diff options
Diffstat (limited to 'app')
50 files changed, 578 insertions, 575 deletions
diff --git a/app/controllers/beds_controller.rb b/app/controllers/beds_controller.rb deleted file mode 100644 index 2e4d94c..0000000 --- a/app/controllers/beds_controller.rb +++ /dev/null @@ -1,66 +0,0 @@ -class BedsController < ApplicationController - before_action :set_bed, only: %i[ edit update ] - before_action :get_crops, only: %i[ index edit update ] - - def index - @beds = Bed.all - end - - def edit - end - - def update - if @bed.update(bed_params) - redirect_to beds_path, notice: "Bed #{@bed.id} successfully updated." - else - render :edit, status: :unprocessable_entity - end - end - - def bulk_assign_crops - crop = Crop.find(params[:crop_id]) - Raft.update_all(crop_id: crop.id) - redirect_back fallback_location: root_path, notice: "All rafts set to #{crop.name}." - end - - - def reset_seed_crops - # mirrors seed logic - tomatoes = Crop.find_by!(name: "tomatoes") - hot_peppers = Crop.find_by!(name: "hot peppers") - chives = Crop.find_by!(name: "chives") - italian_basil = Crop.find_by!(name: "italian basil") - cabbage_chinese = Crop.find_by!(name: "cabbage, chinese") - lettuce = Crop.find_by!(name: "lettuce") - - Bed.includes(:rafts).find_each do |bed| - default_crop = case bed.location - when 1..2 then tomatoes - when 3 then hot_peppers - when 4 then chives - when 5 then italian_basil - when 6..7 then cabbage_chinese - else lettuce - end - bed.rafts.update_all(crop_id: default_crop.id) - end - redirect_back fallback_location: root_path, notice: "Raft crops reset to default seed layout." - end - - private - - def set_bed - @bed = Bed.find(params[:id]) - end - - def get_crops - @crops = Crop.order(:name) - end - - def bed_params - params.require(:bed).permit( - :location, - rafts_attributes: %i[id crop_id] - ) - end -end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 4e9d560..a315eda 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,17 +1,31 @@ class DashboardController < ApplicationController def index # Raft allocation by crop type - @raft_data = raft_data_series + # @raft_data = raft_data_series + + @nutrient_profiles = NutrientProfile.order(:name) # Nutrient target table - @latest_measurement = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first - @target = TargetNutrientCalculator.call + # @latest_measurement = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first + # @target = TargetNutrientCalculator.call # Measurement history table @measurements = NutrientMeasurement.order(measured_on: :desc).limit(10) + @npk_measurement_data = NutrientMeasurement.data_series_for(:nno3, :p, :k) + @ammonium_measurement_data = NutrientMeasurement.data_series_for(:nnh4) + + @weighted = Target.first.weighted_requirements # => { "nno3"=>..., "p"=>..., ... } - @npk_measurement_data = measurement_data_series(:nno3, :p, :k) - @ammonium_measurement_data = measurement_data_series(:nnh4) + last = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first + @latest_measurements = {} + + if last + # Use the same keys as NutrientProfile to keep naming consistent. + keys = (NutrientProfile::NUTRIENT_KEYS rescue []).map(&:to_s) + keys.each do |k| + @latest_measurements[k] = last.send(k) if last.respond_to?(k) + end + end end private @@ -33,13 +47,4 @@ class DashboardController < ApplicationController unassigned, assigned = data_series.partition { |s| s[:name].casecmp("unassigned").zero? } assigned + unassigned end - - def measurement_data_series(*nutrients) - nutrients.map do |formula| - { name: Nutrient.find_by!(formula:).name, - data: NutrientMeasurement - .order(:measured_on) - .pluck(:measured_on, formula) } - end - end end diff --git a/app/controllers/fertilizer_products_controller.rb b/app/controllers/fertilizer_products_controller.rb deleted file mode 100644 index ff0d945..0000000 --- a/app/controllers/fertilizer_products_controller.rb +++ /dev/null @@ -1,70 +0,0 @@ -class FertilizerProductsController < ApplicationController - before_action :set_fertilizer_product, only: %i[ show edit update destroy ] - - # GET /fertilizer_products or /fertilizer_products.json - def index - @fertilizer_products = FertilizerProduct.all - end - - # GET /fertilizer_products/1 or /fertilizer_products/1.json - def show - end - - # GET /fertilizer_products/new - def new - @fertilizer_product = FertilizerProduct.new - end - - # GET /fertilizer_products/1/edit - def edit - end - - # POST /fertilizer_products or /fertilizer_products.json - def create - @fertilizer_product = FertilizerProduct.new(fertilizer_product_params) - - respond_to do |format| - if @fertilizer_product.save - format.html { redirect_to @fertilizer_product, notice: "Fertilizer product was successfully created." } - format.json { render :show, status: :created, location: @fertilizer_product } - else - format.html { render :new, status: :unprocessable_entity } - format.json { render json: @fertilizer_product.errors, status: :unprocessable_entity } - end - end - end - - # PATCH/PUT /fertilizer_products/1 or /fertilizer_products/1.json - def update - respond_to do |format| - if @fertilizer_product.update(fertilizer_product_params) - format.html { redirect_to @fertilizer_product, notice: "Fertilizer product was successfully updated.", status: :see_other } - format.json { render :show, status: :ok, location: @fertilizer_product } - else - format.html { render :edit, status: :unprocessable_entity } - format.json { render json: @fertilizer_product.errors, status: :unprocessable_entity } - end - end - end - - # DELETE /fertilizer_products/1 or /fertilizer_products/1.json - def destroy - @fertilizer_product.destroy! - - respond_to do |format| - format.html { redirect_to fertilizer_products_path, notice: "Fertilizer product was successfully destroyed.", status: :see_other } - format.json { head :no_content } - end - end - - private - # Use callbacks to share common setup or constraints between actions. - def set_fertilizer_product - @fertilizer_product = FertilizerProduct.find(params.expect(:id)) - end - - # Only allow a list of trusted parameters through. - def fertilizer_product_params - params.expect(fertilizer_product: [ :name, :purity ]) - end -end diff --git a/app/controllers/fertilizers_controller.rb b/app/controllers/fertilizers_controller.rb deleted file mode 100644 index 040c462..0000000 --- a/app/controllers/fertilizers_controller.rb +++ /dev/null @@ -1,70 +0,0 @@ -class FertilizersController < ApplicationController - before_action :set_fertilizer, only: %i[ show edit update destroy ] - - # GET /fertilizers or /fertilizers.json - def index - @fertilizers = Fertilizer.all - end - - # GET /fertilizers/1 or /fertilizers/1.json - def show - end - - # GET /fertilizers/new - def new - @fertilizer = Fertilizer.new - end - - # GET /fertilizers/1/edit - def edit - end - - # POST /fertilizers or /fertilizers.json - def create - @fertilizer = Fertilizer.new(fertilizer_params) - - respond_to do |format| - if @fertilizer.save - format.html { redirect_to @fertilizer, notice: "Fertilizer was successfully created." } - format.json { render :show, status: :created, location: @fertilizer } - else - format.html { render :new, status: :unprocessable_entity } - format.json { render json: @fertilizer.errors, status: :unprocessable_entity } - end - end - end - - # PATCH/PUT /fertilizers/1 or /fertilizers/1.json - def update - respond_to do |format| - if @fertilizer.update(fertilizer_params) - format.html { redirect_to @fertilizer, notice: "Fertilizer was successfully updated.", status: :see_other } - format.json { render :show, status: :ok, location: @fertilizer } - else - format.html { render :edit, status: :unprocessable_entity } - format.json { render json: @fertilizer.errors, status: :unprocessable_entity } - end - end - end - - # DELETE /fertilizers/1 or /fertilizers/1.json - def destroy - @fertilizer.destroy! - - respond_to do |format| - format.html { redirect_to fertilizers_path, notice: "Fertilizer was successfully destroyed.", status: :see_other } - format.json { head :no_content } - end - end - - private - # Use callbacks to share common setup or constraints between actions. - def set_fertilizer - @fertilizer = Fertilizer.find(params.expect(:id)) - end - - # Only allow a list of trusted parameters through. - def fertilizer_params - params.expect(fertilizer: [ :name, :formula, :nno3, :nnh4, :p, :k, :ca, :mg, :s, :na, :cl, :si, :fe, :zn, :b, :mn, :cu, :mo ]) - end -end diff --git a/app/controllers/nutrient_measurement_controller.rb b/app/controllers/nutrient_measurement_controller.rb deleted file mode 100644 index 6d26bfa..0000000 --- a/app/controllers/nutrient_measurement_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -class NutrientMeasurementController < ApplicationController - def index - end -end diff --git a/app/controllers/nutrient_measurements_controller.rb b/app/controllers/nutrient_measurements_controller.rb new file mode 100644 index 0000000..71ca9e4 --- /dev/null +++ b/app/controllers/nutrient_measurements_controller.rb @@ -0,0 +1,26 @@ +class NutrientMeasurementsController < ApplicationController + def index + @nutrient_measurements = NutrientMeasurement.order(measured_on: :desc) + @npk_measurement_data = NutrientMeasurement.data_series_for(:nno3, :p, :k) + end + + def new + @nutrient_measurement = NutrientMeasurement.new(measured_on: Date.today) + end + + def create + @measurement = NutrientMeasurement.new(nutrient_measurement_params) + if @measurement.save + redirect_to @measurement, notice: "Relevé enregistré." + else + render :new, status: :unprocessable_entity + end + end + + private + + def nutrient_measurement_params + permitted = [ :measured_on ] + NutrientMeasurement::NUTRIENT_FIELDS + params.require(:nutrient_measurement).permit(*permitted) + end +end diff --git a/app/controllers/crops_controller.rb b/app/controllers/nutrient_profiles_controller.rb index 951d380..99489d5 100644 --- a/app/controllers/crops_controller.rb +++ b/app/controllers/nutrient_profiles_controller.rb @@ -1,4 +1,4 @@ -class CropsController < ApplicationController +class NutrientProfilesController < ApplicationController before_action :set_crop, only: %i[ show edit update destroy ] def index diff --git a/app/controllers/rafts_controller.rb b/app/controllers/rafts_controller.rb deleted file mode 100644 index c1c8a72..0000000 --- a/app/controllers/rafts_controller.rb +++ /dev/null @@ -1,29 +0,0 @@ -class RaftsController < ApplicationController - before_action :set_raft, only: %i[ edit update ] - before_action :set_crop, only: %i[ edit update ] - - def edit - end - - def update - if @raft.update(raft_params) - redirect_to beds_path, notice: "Raft #{@raft.id} successfully updated." - else - render :edit, status: :unprocessable_entity - end - end - - private - - def set_raft - @raft = Raft.find(params[:id]) - end - - def set_crop - @crops = Crop.order(:name) - end - - def raft_params - params.require(:raft).permit(:location, :bed_id, :crop_id) - end -end diff --git a/app/controllers/recipes_controller.rb b/app/controllers/recipes_controller.rb deleted file mode 100644 index bcc29e7..0000000 --- a/app/controllers/recipes_controller.rb +++ /dev/null @@ -1,8 +0,0 @@ -class RecipesController < ApplicationController - def show - @latest = NutrientMeasurement.order(:measured_on).last || NutrientMeasurement.new - @target = TargetNutrientCalculator.call - volume = (params[:volume].presence || 100_000).to_i - @recipe = FertilizerRecipeCalculator.call(@latest, @target, water_volume_l: volume) - end -end diff --git a/app/controllers/targets_controller.rb b/app/controllers/targets_controller.rb new file mode 100644 index 0000000..a11ddd7 --- /dev/null +++ b/app/controllers/targets_controller.rb @@ -0,0 +1,68 @@ +class TargetsController < ApplicationController + before_action :set_target, only: %i[show edit update destroy] + + def index + @targets = Target.order(:name) + end + + def new + @target = Target.new(name: "Cible #{Date.today + 1.month}") + seed_allocations + end + + def create + @target = Target.new(target_params) + if @target.save + redirect_to @target, notice: "Cible enregistrée." + else + seed_allocations if @target.target_allocations.blank? + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @target.update(target_params) + redirect_to @target, notice: "Cible mise à jour." + else + render :edit, status: :unprocessable_entity + end + end + + def show + @weighted = @target.weighted_requirements # => { "nno3"=>..., "p"=>..., ... } + + last = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first + @latest_measurements = {} + + if last + # Use the same keys as NutrientProfile to keep naming consistent. + keys = (NutrientProfile::NUTRIENT_KEYS rescue []).map(&:to_s) + keys.each do |k| + @latest_measurements[k] = last.send(k) if last.respond_to?(k) + end + end + end + + private + + def set_target + @target = Target.find(params[:id]) + end + + def seed_allocations + existing_ids = @target.target_allocations.map(&:nutrient_profile_id).compact + (NutrientProfile.order(:name).pluck(:id) - existing_ids).each do |np_id| + @target.target_allocations.build(nutrient_profile_id: np_id, percentage: 12.5) + end + end + + def target_params + params.require(:target).permit( + :name, + target_allocations_attributes: [ :id, :nutrient_profile_id, :percentage, :_destroy ] + ) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb deleted file mode 100644 index de6be79..0000000 --- a/app/helpers/application_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module ApplicationHelper -end diff --git a/app/helpers/beds_helper.rb b/app/helpers/beds_helper.rb deleted file mode 100644 index 2f3e2cb..0000000 --- a/app/helpers/beds_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module BedsHelper -end diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb deleted file mode 100644 index 5d64d17..0000000 --- a/app/helpers/dashboard_helper.rb +++ /dev/null @@ -1,8 +0,0 @@ -module DashboardHelper - def fmt2(v) = number_with_precision(v, precision: 2) - - # Total nitrogen (NO3-N + NH4-N) - def total_n(measurement) - (measurement.nno3.to_f) + (measurement.nnh4.to_f) - end -end diff --git a/app/helpers/nutrients_helper.rb b/app/helpers/nutrients_helper.rb deleted file mode 100644 index de98dc3..0000000 --- a/app/helpers/nutrients_helper.rb +++ /dev/null @@ -1,21 +0,0 @@ -module NutrientsHelper - NUTRIENTS = %i[nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4].freeze - - def percent_delta(measured, target) - return 0.0 if target.to_f.zero? - ((measured.to_f - target.to_f) / target.to_f) * 100.0 - end - - def fmt(v) - number_with_precision(v, precision: 2, strip_insignificant_zeros: true) - end - - def delta_badge_class(delta) - d = delta.abs - case d - when d < 0 then "bg-info" - when 0..5 then "bg-secondary" - else "bg-warning" - end - end -end diff --git a/app/helpers/recipes_helper.rb b/app/helpers/recipes_helper.rb deleted file mode 100644 index 02cb1a7..0000000 --- a/app/helpers/recipes_helper.rb +++ /dev/null @@ -1,16 +0,0 @@ -module RecipesHelper - def commercial_name_for(component) - # Try to find a FertilizerProduct via a join, but gracefully fallback. - if defined?(FertilizerProduct) && defined?(FertilizerComposition) - prod = FertilizerProduct.joins(:fertilizer_compositions) - .where(fertilizer_compositions: { fertilizer_component_id: component.id }) - .first - return prod.name if prod&.name.present? - end - component.name.presence || component.formula - end - - def fmt_kg(v) - number_with_precision(v.to_f, precision: 2, strip_insignificant_zeros: true) - end -end diff --git a/app/helpers/targets_helper.rb b/app/helpers/targets_helper.rb new file mode 100644 index 0000000..8484878 --- /dev/null +++ b/app/helpers/targets_helper.rb @@ -0,0 +1,2 @@ +module TargetsHelper +end diff --git a/app/javascript/application.js b/app/javascript/application.js index beff742..0d7b494 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1 +1,3 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "@hotwired/turbo-rails" +import "controllers" diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js new file mode 100644 index 0000000..1213e85 --- /dev/null +++ b/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js new file mode 100644 index 0000000..5975c07 --- /dev/null +++ b/app/javascript/controllers/hello_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.element.textContent = "Hello World!" + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 0000000..1156bf8 --- /dev/null +++ b/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) diff --git a/app/models/bed.rb b/app/models/bed.rb deleted file mode 100644 index d41afe6..0000000 --- a/app/models/bed.rb +++ /dev/null @@ -1,5 +0,0 @@ -class Bed < ApplicationRecord - has_many :rafts, -> { order(:location) }, dependent: :destroy - accepts_nested_attributes_for :rafts - validates :location, presence: true, uniqueness: true -end diff --git a/app/models/crop.rb b/app/models/crop.rb deleted file mode 100644 index b0f168d..0000000 --- a/app/models/crop.rb +++ /dev/null @@ -1,4 +0,0 @@ -class Crop < ApplicationRecord - has_many :rafts - enum :crop_type, { leafy: 0, fruit: 1, herb: 2 } -end diff --git a/app/models/fertilizer_component.rb b/app/models/fertilizer_component.rb deleted file mode 100644 index 701ae9b..0000000 --- a/app/models/fertilizer_component.rb +++ /dev/null @@ -1,3 +0,0 @@ -class FertilizerComponent < ApplicationRecord - validates :name, presence: true -end diff --git a/app/models/fertilizer_composition.rb b/app/models/fertilizer_composition.rb deleted file mode 100644 index cf2bb93..0000000 --- a/app/models/fertilizer_composition.rb +++ /dev/null @@ -1,6 +0,0 @@ -class FertilizerComposition < ApplicationRecord - belongs_to :fertilizer_product - belongs_to :fertilizer_component - - validates :percent_w, numericality: { greater_than: 0, less_than_or_equal_to: 100 } -end diff --git a/app/models/fertilizer_product.rb b/app/models/fertilizer_product.rb deleted file mode 100644 index e41316b..0000000 --- a/app/models/fertilizer_product.rb +++ /dev/null @@ -1,7 +0,0 @@ -class FertilizerProduct < ApplicationRecord - has_many :fertilizer_compositions, dependent: :destroy - has_many :fertilizer_components, through: :fertilizer_compositions - - validates :name, presence: true, uniqueness: true - validates :purity, numericality: { greater_than: 0, less_than_or_equal_to: 100 } -end diff --git a/app/models/nutrient.rb b/app/models/nutrient.rb deleted file mode 100644 index c584668..0000000 --- a/app/models/nutrient.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Nutrient < ApplicationRecord - validates :formula, presence: true, uniqueness: true - validates :name, presence: true - - before_update { raise ActiveRecord::ReadOnlyRecord } - before_destroy { raise ActiveRecord::ReadOnlyRecord } -end diff --git a/app/models/nutrient_measurement.rb b/app/models/nutrient_measurement.rb index f1d6d5b..1139af7 100644 --- a/app/models/nutrient_measurement.rb +++ b/app/models/nutrient_measurement.rb @@ -1,4 +1,18 @@ class NutrientMeasurement < ApplicationRecord + NUTRIENT_FIELDS = %i[ + nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4 + ].freeze + validates :measured_on, presence: true validates :measured_on, uniqueness: true + + def self.data_series_for(*nutrients) + nutrients.map do |formula| + { name: formula, data: self.order(:measured_on).pluck(:measured_on, formula) } + end + end + + def self.nutrient_fields + NUTRIENT_FIELDS + end end diff --git a/app/models/nutrient_profile.rb b/app/models/nutrient_profile.rb new file mode 100644 index 0000000..0610855 --- /dev/null +++ b/app/models/nutrient_profile.rb @@ -0,0 +1,13 @@ +class NutrientProfile < ApplicationRecord + # Align these keys with your schema columns (per your schema.txt) + NUTRIENT_KEYS = %i[ + nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4 + ].freeze + + # Returns a Hash of nutrient => numeric requirement (nil kept; caller can skip nils) + def requirements_hash + attributes + .slice(*NUTRIENT_KEYS.map(&:to_s)) # only nutrient columns + .transform_keys(&:to_s) + end +end diff --git a/app/models/raft.rb b/app/models/raft.rb deleted file mode 100644 index 3fe5928..0000000 --- a/app/models/raft.rb +++ /dev/null @@ -1,5 +0,0 @@ -class Raft < ApplicationRecord - belongs_to :bed - belongs_to :crop, optional: true - validates :location, presence: true, uniqueness: { scope: :bed_id } -end diff --git a/app/models/target.rb b/app/models/target.rb new file mode 100644 index 0000000..9211e74 --- /dev/null +++ b/app/models/target.rb @@ -0,0 +1,42 @@ +# app/models/target.rb +class Target < ApplicationRecord + has_many :target_allocations, dependent: :destroy + has_many :nutrient_profiles, through: :target_allocations + + accepts_nested_attributes_for :target_allocations, allow_destroy: true + # validate :percentages_sum_to_100 + + def weighted_requirements + totals = Hash.new(0.0) + denom = 100.0 + + target_allocations.includes(:nutrient_profile).each do |alloc| + profile = alloc.nutrient_profile + next unless profile + + weight = (alloc.percentage || 0).to_f / denom + next if weight <= 0 + + # Prefer the helper, but gracefully fall back to slicing attributes. + reqs = if profile.respond_to?(:requirements_hash) + profile.requirements_hash + else + profile.attributes.slice(*NutrientProfile::NUTRIENT_KEYS.map(&:to_s)) + end + + reqs.each do |nutrient_key, value| + next if value.nil? + totals[nutrient_key.to_s] += value.to_f * weight + end + end + + totals + end + + private + + # def percentages_sum_to_100 + # sum = target_allocations.sum { |a| a.percentage.to_f } + # errors.add(:base, "La somme des pourcentages doit être égale à 100%") unless (sum - 100.0).abs <= 0.1 + # end +end diff --git a/app/models/target_allocation.rb b/app/models/target_allocation.rb new file mode 100644 index 0000000..6aa1dcb --- /dev/null +++ b/app/models/target_allocation.rb @@ -0,0 +1,7 @@ +class TargetAllocation < ApplicationRecord + belongs_to :target + belongs_to :nutrient_profile + + validates :percentage, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 } + validates :nutrient_profile_id, uniqueness: { scope: :target_id } +end diff --git a/app/services/fertilizer_recipe_calculator.rb b/app/services/fertilizer_recipe_calculator.rb deleted file mode 100644 index 28ba8aa..0000000 --- a/app/services/fertilizer_recipe_calculator.rb +++ /dev/null @@ -1,136 +0,0 @@ -class FertilizerRecipeCalculator - NUTRIENTS = %i[nno3 p k ca mg s nnh4].freeze - - def self.call(latest, target, water_volume_l: 100_000) - new(latest, target, water_volume_l).call - end - - def initialize(latest, target, water_volume_l) - @latest = latest || NutrientMeasurement.new - @target = target || NutrientMeasurement.new - @vol_l = water_volume_l.to_f - end - - # Returns a Hash { FertilizerComponent => qty_kg } - def call - deltas_mg_per_l = NUTRIENTS.index_with do |k| - [ (value(@target, k) - value(@latest, k)), 0 ].max - end - - # mg/L * L = mg -> kg - needed_kg = deltas_mg_per_l.transform_values { |mg_l| mg_l * @vol_l / 1_000_000.0 } - - recipe = Hash.new(0.0) - - # 1) P via MAP or DAP (accounts NH4-N) - if (p_need = needed_kg[:p]) > 0 - map = fc_by_formula("MAP") || fc_by_name_like("monoammonium phosphate") - dap = fc_by_formula("DAP") || fc_by_name_like("diammonium phosphate") - carrier = [ map, dap ].compact.find { |c| positive?(c.p) } - if carrier - kg = kg_for(p_need, carrier.p) - recipe[carrier] += kg - needed_kg[:nnh4] = [ needed_kg[:nnh4] - kg * pct(carrier.nnh4), 0 ].max if positive?(carrier.nnh4) - needed_kg[:p] = 0 - end - end - - # 2) NO3-N via KNO3 then Ca(NO3)2 (accounts K/Ca) - if (n_need = needed_kg[:nno3]) > 0 - kno3 = fc_by_formula("KNO3") || fc_by_name_like("potassium nitrate") - can = fc_by_formula("Ca(NO3)2") || fc_by_name_like("calcium nitrate") - - if kno3&.nno3.to_f > 0 - kg = kg_for(n_need, kno3.nno3) - recipe[kno3] += kg - needed_kg[:k] = [ needed_kg[:k] - kg * pct(kno3.k), 0 ].max - n_need -= kg * pct(kno3.nno3) - end - - if n_need > 0 && can&.nno3.to_f > 0 - kg = kg_for(n_need, can.nno3) - recipe[can] += kg - needed_kg[:ca] = [ needed_kg[:ca] - kg * pct(can.ca), 0 ].max - n_need = 0 - end - - needed_kg[:nno3] = [ n_need, 0 ].max - end - - # 3) K via K2SO4 (accounts S) - if (k_need = needed_kg[:k]) > 0 - sop = fc_by_formula("K2SO4") || fc_by_name_like("potassium sulfate") - if sop&.k.to_f > 0 - kg = kg_for(k_need, sop.k) - recipe[sop] += kg - needed_kg[:s] = [ needed_kg[:s] - kg * pct(sop.s), 0 ].max - needed_kg[:k] = 0 - end - end - - # 4) Ca via Ca(NO3)2 (accounts NO3-N) - if (ca_need = needed_kg[:ca]) > 0 - can = recipe.keys.find { |c| norm_formula(c) == "ca(no3)2" } || - fc_by_formula("Ca(NO3)2") || fc_by_name_like("calcium nitrate") - if can&.ca.to_f > 0 - kg = kg_for(ca_need, can.ca) - recipe[can] += kg - needed_kg[:nno3] = [ needed_kg[:nno3] - kg * pct(can.nno3), 0 ].max - needed_kg[:ca] = 0 - end - end - - # 5) Mg via MgSO4 (accounts S) - if (mg_need = needed_kg[:mg]) > 0 - mgs = fc_by_formula("MgSO4") || fc_by_name_like("magnesium sulfate") - if mgs&.mg.to_f > 0 - kg = kg_for(mg_need, mgs.mg) - recipe[mgs] += kg - needed_kg[:s] = [ needed_kg[:s] - kg * pct(mgs.s), 0 ].max - needed_kg[:mg] = 0 - end - end - - # 6) S via K2SO4 or MgSO4 (whichever we already used or find) - if (s_need = needed_kg[:s]) > 0 - sop = recipe.keys.find { |c| norm_formula(c) == "k2so4" } || - fc_by_formula("K2SO4") || fc_by_name_like("potassium sulfate") - mgs = recipe.keys.find { |c| norm_formula(c) == "mgso4" } || - fc_by_formula("MgSO4") || fc_by_name_like("magnesium sulfate") - carrier = [ sop, mgs ].compact.find { |c| positive?(c.s) } - if carrier - kg = kg_for(s_need, carrier.s) - recipe[carrier] += kg - needed_kg[:k] = [ needed_kg[:k] - kg * pct(carrier.k), 0 ].max if positive?(carrier.k) - needed_kg[:mg] = [ needed_kg[:mg] - kg * pct(carrier.mg), 0 ].max if positive?(carrier.mg) - needed_kg[:s] = 0 - end - end - - recipe.delete_if { |_c, kg| kg < 0.01 } - recipe - end - - private - - def value(obj, key) = obj.public_send(key).to_f - - def fc_by_formula(formula) - FertilizerComponent.where("LOWER(formula) = ?", formula.to_s.downcase).first - end - - def fc_by_name_like(name) - FertilizerComponent.where("LOWER(name) LIKE ?", "%#{name.downcase}%").first - end - - def kg_for(need_kg_element, percent_in_product) - return 0.0 unless positive?(percent_in_product) - need_kg_element / pct(percent_in_product) - end - - def pct(v) = v.to_f / 100.0 - - def positive?(v) = v.to_f > 0.0 - - def norm_formula(c) = c.formula.to_s.downcase.gsub(/\s+/, "") -end diff --git a/app/services/target_nutrient_calculator.rb b/app/services/target_nutrient_calculator.rb deleted file mode 100644 index e6cd378..0000000 --- a/app/services/target_nutrient_calculator.rb +++ /dev/null @@ -1,33 +0,0 @@ -class TargetNutrientCalculator - # Derive nutrient columns from the NutrientMeasurement table - NUTRIENT_COLUMNS = (NutrientMeasurement.column_names - %w[id measured_on created_at updated_at]) - .map!(&:to_sym) - .freeze - - # Returns an unsaved NutrientMeasurement with target concentrations (e.g., mg/L) - def self.call - rafts = Raft.includes(:crop).where.not(crop_id: nil) - total = rafts.count - return empty_measurement if total.zero? - - sums = Hash.new(0.0) - - rafts.each do |raft| - NUTRIENT_COLUMNS.each do |col| - v = raft.crop.public_send(col) - sums[col] += v.to_f if v - end - end - - targets = sums.transform_values { |s| s / total } - NutrientMeasurement.new({ measured_on: Date.current }.merge(targets)) - end - - private - - def empty_measurement - NutrientMeasurement.new( - { measured_on: Date.current }.merge(NUTRIENT_COLUMNS.index_with { 0.0 }) - ) - end -end diff --git a/app/views/dashboard/_nutrient_measurements.html.erb b/app/views/dashboard/_nutrient_measurements.html.erb deleted file mode 100644 index bc63a60..0000000 --- a/app/views/dashboard/_nutrient_measurements.html.erb +++ /dev/null @@ -1,22 +0,0 @@ -<div class="card shadow mb-4"> - <div class="card-header d-flex justify-content-between align-items-center"> - <h5 class="mb-0">Nutrient Measurements</h5> - <div class="btn-group"> - <%#= link_to "Add new measurement", editor_rafts_path, class: "btn btn-sm btn-primary" %> - <%#= link_to "View all", editor_rafts_path, class: "btn btn-sm btn-secondary" %> - </div> - </div> - - <div class="card-body p-0"> - <div class="container mb-3"> - <%= line_chart @npk_measurement_data, - title: "NPK", - ytitle: "Concentration (mg/L)" %> - </div> - <div class="container mb-3"> - <%= line_chart @ammonium_measurement_data, - title: "Ammonium", - ytitle: "Concentration (mg/L)" %> - </div> - </div> -</div> diff --git a/app/views/dashboard/_nutrient_measurements_table.html.erb b/app/views/dashboard/_nutrient_measurements_table.html.erb new file mode 100644 index 0000000..82ea6ff --- /dev/null +++ b/app/views/dashboard/_nutrient_measurements_table.html.erb @@ -0,0 +1,18 @@ +<div class="card shadow my-3"> + <div class="card-header d-flex justify-content-between align-items-center"> + <h4 class="mb-0">Relevé des Nutriments</h4> + <div class="btn-group"> + <%= link_to "Ajouter un relevé", new_nutrient_measurement_path, class: "btn btn-sm btn-primary" %> + <%= link_to "Liste des relevés", nutrient_measurements_path, class: "btn btn-sm btn-secondary" %> + </div> + </div> + + <div class="card-body"> + <%= line_chart @npk_measurement_data, + title: "NPK", + ytitle: "Concentration (mg/L)" %> + <%= line_chart @ammonium_measurement_data, + title: "Ammonium", + ytitle: "Concentration (mg/L)" %> + </div> +</div> diff --git a/app/views/dashboard/_nutrient_target_table.html.erb b/app/views/dashboard/_nutrient_target_table.html.erb new file mode 100644 index 0000000..7cf294c --- /dev/null +++ b/app/views/dashboard/_nutrient_target_table.html.erb @@ -0,0 +1,74 @@ +<div class="card shadow my-3"> + <div class="card-header d-flex justify-content-between align-items-center"> + <h4 class="mb-0">Complémentation</h4> + <div class="btn-group"> + <%= link_to "Nouvelle cible", new_target_path, class: "btn btn-sm btn-primary" %> + <%= link_to "Voir la recette", root_path, class: "btn btn-sm btn-secondary" %> + </div> + </div> + + <div class="card-body p-0"> + <div class="table-responsive"> + <table class="table table-sm table-striped table-hover align-middle mb-0"> + <thead class="table-light"> + <tr> + <th>Nutriment</th> + <th class="text-end">Relevé</th> + <th class="text-end">Cible</th> + <th class="text-end">Delta</th> + </tr> + </thead> + <tbody> + <% wr = @weighted || {} %> + <% lm = @latest_measurements || {} %> + + <% keys = (wr.keys + lm.keys).map(&:to_s).uniq.sort %> + <% keys.each do |nut| %> + <% measured = lm[nut] %> + <% target = wr[nut] %> + <% delta = (measured.to_f - target.to_f) if measured || target %> + <tr> + <td class="fw-semibold"><%= nut.upcase %></td> + + <td class="text-end"> + <% if measured.nil? %> + <span class="text-muted">—</span> + <% else %> + <%= number_with_precision(measured, precision: 2) %> + <% end %> + </td> + + <td class="text-end"> + <% if target.nil? %> + <span class="text-muted">—</span> + <% else %> + <%= number_with_precision(target, precision: 2) %> + <% end %> + </td> + + <td class="text-end"> + <% if measured.nil? && target.nil? %> + <span class="text-muted">—</span> + <% else %> + <% badge = + if delta.nil? + "text-bg-secondary" + elsif delta.abs <= 0.01 + "text-bg-success" + elsif delta > 0 + "text-bg-warning" + else + "text-bg-danger" + end %> + <span class="badge <%= badge %>"> + <%= number_with_precision(delta.to_f, precision: 2) %> + </span> + <% end %> + </td> + </tr> + <% end %> + </tbody> + </table> + </div> + </div> +</div> diff --git a/app/views/dashboard/_raft_allocation.html.erb b/app/views/dashboard/_raft_allocation.html.erb index a2128ea..1c9ef9a 100644 --- a/app/views/dashboard/_raft_allocation.html.erb +++ b/app/views/dashboard/_raft_allocation.html.erb @@ -1,7 +1,7 @@ <div class="card shadow mb-4"> <div class="card-header d-flex justify-content-between align-items-center"> <h5 class="mb-0">Crop Allocation</h5> - <%= link_to "Edit allocation", beds_path, class: "btn btn-sm btn-primary" %> + <%#= link_to "Edit allocation", beds_path, class: "btn btn-sm btn-primary" %> </div> <%= bar_chart @raft_data, stacked: true %> diff --git a/app/views/dashboard/_target_table.html.erb b/app/views/dashboard/_target_table.html.erb index b8f7e66..b1553dc 100644 --- a/app/views/dashboard/_target_table.html.erb +++ b/app/views/dashboard/_target_table.html.erb @@ -9,8 +9,7 @@ <thead class="table-light"> <tr> <th scope="col" class="text-nowrap">Nutrient</th> - <th scope="col" class="text-end"> Latest (mg/L) - </th> + <th scope="col" class="text-end"> Latest (mg/L)</th> <th scope="col" class="text-end">Target (mg/L)</th> <th scope="col" class="text-end">Δ %</th> </tr> diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index b1b2d87..a954e4c 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -1,7 +1,9 @@ <h1 class="display-1">Ferti</h1> -<%= render "raft_allocation" %> +<%= render "nutrient_target_table", nutrient_profiles: @nutrient_profiles %> -<%= render "target_table" %> +<%#= render "raft_allocation" %> -<%= render "nutrient_measurements" %> +<%#= render "target_table" %> + +<%= render "nutrient_measurements_table" %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index bf61c9d..dc61670 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -14,8 +14,8 @@ <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> <link rel="icon" href="/icon.png" type="image/png"> - <link rel="icon" href="/icon.svg" type="image/svg+xml"> - <link rel="apple-touch-icon" href="/icon.png"> + <!-- <link rel="icon" href="/icon.svg" type="image/svg+xml"> --> + <!-- <link rel="apple-touch-icon" href="/icon.png"> --> <%# Includes all stylesheet files in app/assets/stylesheets %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> @@ -25,7 +25,6 @@ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chartkick@5"></script> - <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script> <%= javascript_importmap_tags %> diff --git a/app/views/nutrient_measurement/index.html.erb b/app/views/nutrient_measurement/index.html.erb deleted file mode 100644 index d9f522e..0000000 --- a/app/views/nutrient_measurement/index.html.erb +++ /dev/null @@ -1,27 +0,0 @@ -<h1>NutrientMeasurement#index</h1> -<p>Find me in app/views/nutrient_measurement/index.html.erb</p> - -<div class="table-responsive"> - <table class="table table-sm table-striped table-hover align-middle table-nutrient mb-0"> - <thead class="table-light"> - <tr> - <th>Date</th> - <th class="numeric" title="Total N = NO₃‑N + NH₄‑N">N (total)</th> - <th class="numeric">P</th> - <th class="numeric">K</th> - <th class="numeric" title="Ammonia nitrogen">NH₄‑N</th> - </tr> - </thead> - <tbody> - <% @measurements.each do |m| %> - <tr> - <td><%= l(m.measured_on) %></td> - <td class="numeric"><%= fmt2(total_n(m)) %></td> - <td class="numeric"><%= fmt2(m.p) %></td> - <td class="numeric"><%= fmt2(m.k) %></td> - <td class="numeric"><%= fmt2(m.nnh4) %></td> - </tr> - <% end %> - </tbody> - </table> -</div> diff --git a/app/views/nutrient_measurements/_form.html.erb b/app/views/nutrient_measurements/_form.html.erb new file mode 100644 index 0000000..9689c57 --- /dev/null +++ b/app/views/nutrient_measurements/_form.html.erb @@ -0,0 +1,49 @@ +<%= form_with(model: nutrient_measurement) do |form| %> + <% if nutrient_measurement.errors.any? %> + <div class="alert alert-danger"> + <p class="mb-1"><strong><%= pluralize(nutrient_measurement.errors.count, "erreur") %></strong> empêchent l’enregistrement :</p> + <ul class="mb-0"> + <% nutrient_measurement.errors.full_messages.each do |msg| %> + <li><%= msg %></li> + <% end %> + </ul> + </div> + <% end %> + + <div class="mb-3"> + <%= form.label :measured_on, "Date du relevé", class: "form-label" %> + <%= form.date_field :measured_on, class: "form-control", required: true %> + </div> + + <h2 class="h6 mt-4 mb-2">Concentrations de nutriments — laisser vide si non mesuré</h2> + + <div class="row g-2"> + <% # You can reorganize into macros/micros if you prefer %> + <% labels = { + nno3: "Nitrate (N-NO₃)", p: "Phosphore (P)", k: "Potassium (K)", + ca: "Calcium (Ca)", mg: "Magnésium (Mg)", s: "Soufre (S)", + na: "Sodium (Na)", cl: "Chlore (Cl)", si: "Silicium (Si)", + fe: "Fer (Fe)", zn: "Zinc (Zn)", b: "Bore (B)", + mn: "Manganèse (Mn)", cu: "Cuivre (Cu)", mo: "Molybdène (Mo)", + nnh4: "Ammonium (N-NH₄)" + } %> + + <% NutrientMeasurement::NUTRIENT_FIELDS.each do |field| %> + <div class="col-6 col-md-3"> + <div class="input-group"> + <%= form.number_field field, + class: "form-control", + placeholder: "—", + step: "0.01", + min: "0" %> + <span class="input-group-text">mg/L</span> + </div> + <label class="form-label d-block small text-muted mt-1"><%= labels[field] %></label> + </div> + <% end %> + </div> + + <div> + <%= form.submit "Ajouter le relevé", class: "btn btn-primary" %> + </div> +<% end %> diff --git a/app/views/nutrient_measurements/index.html.erb b/app/views/nutrient_measurements/index.html.erb new file mode 100644 index 0000000..c01d0cc --- /dev/null +++ b/app/views/nutrient_measurements/index.html.erb @@ -0,0 +1,41 @@ +<% content_for :title, "Liste des Relevé" %> + +<h1 class="display-1">Liste des Relevés</h1> + +<div class="d-flex justify-content-between align-items-center mb-3"> + <div class="btn-group"> + <%= link_to "Nouvelle mesure", new_nutrient_measurement_path, class: "btn btn-primary" %> + <%= link_to "Retour", root_path, class: "btn btn-outline-secondary" %> + </div> +</div> + +<div class="table-responsive"> + <table class="table table-sm table-striped table-hover align-middle table-nutrient mb-0"> + <thead class="table-light"> + <tr> + <th>Date</th> + <th class="text-end" title="Total N = NO₃-N + NH₄-N">N (total)</th> + <th class="text-end">P</th> + <th class="text-end">K</th> + <th class="text-end" title="Ammonia nitrogen">NH₄-N</th> + </tr> + </thead> + <tbody> + <% @nutrient_measurements.each do |m| %> + <tr> + <td><%= l(m.measured_on) %></td> + <td class="text-end"> + <%= number_with_precision(m.nno3.to_f + m.nnh4.to_f, precision: 2) if m.nno3 || m.nnh4 %> + </td> + <td class="text-end"><%= number_with_precision(m.p, precision: 2) if m.p %></td> + <td class="text-end"><%= number_with_precision(m.k, precision: 2) if m.k %></td> + <td class="text-end"><%= number_with_precision(m.nnh4, precision: 2) if m.nnh4 %></td> + </tr> + <% end %> + </tbody> + </table> +</div> + +<%= line_chart @npk_measurement_data, + title: "NPK", + ytitle: "Concentration (mg/L)" %> diff --git a/app/views/nutrient_measurements/new.html.erb b/app/views/nutrient_measurements/new.html.erb new file mode 100644 index 0000000..82a913e --- /dev/null +++ b/app/views/nutrient_measurements/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "Ajouter un Relevé" %> + +<h1 class="display-1">Ajouter un Relevé</h1> + +<%= render "form", nutrient_measurement: @nutrient_measurement %> + +<br> + +<div> + <%= link_to "Retour", root_path, class: "btn btn-secondary" %> +</div> diff --git a/app/views/targets/create.html.erb b/app/views/targets/create.html.erb new file mode 100644 index 0000000..51e2782 --- /dev/null +++ b/app/views/targets/create.html.erb @@ -0,0 +1,2 @@ +<h1>Targets#create</h1> +<p>Find me in app/views/targets/create.html.erb</p> diff --git a/app/views/targets/edit.html.erb b/app/views/targets/edit.html.erb new file mode 100644 index 0000000..849bf7f --- /dev/null +++ b/app/views/targets/edit.html.erb @@ -0,0 +1,2 @@ +<h1>Targets#edit</h1> +<p>Find me in app/views/targets/edit.html.erb</p> diff --git a/app/views/targets/index.html.erb b/app/views/targets/index.html.erb new file mode 100644 index 0000000..b552038 --- /dev/null +++ b/app/views/targets/index.html.erb @@ -0,0 +1,68 @@ +<h1 class="display-1">Cibles</h1> + +<div class="btn-group my-3"> + <%= link_to "Nouvelle Cible", new_target_path, class: "btn btn-primary" %> +</div> + +<div class="table-responsive"> + <table class="table table-sm table-striped table-hover align-middle mb-0"> + <thead class="table-light"> + <tr> + <th>Nom</th> + <th>Répartition</tr> + <th class="text-end" style="width: 140px;">Total %</th> + <th class="text-nowrap" style="width: 190px;">Créé le</th> + <th class="text-end" style="width: 180px;">Actions</th> + </tr> + </thead> + <tbody> + <% if @targets.present? %> + <% @targets.each do |t| %> + <% sum_pct = t.target_allocations.sum { |a| a.percentage.to_f } %> + <% badge_class = (sum_pct - 100.0).abs <= 0.01 ? "bg-success" : "bg-danger" %> + <tr> + <td class="fw-semibold"> + <%= link_to t.name.presence || "Objectif ##{t.id}", t %> + </td> + <td> + <% if t.target_allocations.empty? %> + <span class="text-muted">Aucune répartition définie</span> + <% else %> + <ul class="list-unstyled mb-0 d-flex flex-wrap gap-2"> + <% t.target_allocations.each do |a| %> + <li class="badge text-bg-light border"> + <%= a.nutrient_profile&.name || "Profil ##{a.nutrient_profile_id}" %> + — <%= number_with_precision(a.percentage.to_f, precision: 2) %>% + </li> + <% end %> + </ul> + <% end %> + </td> + <td class="text-end"> + <span class="badge <%= badge_class %>"> + <%= number_with_precision(sum_pct, precision: 2) %>% + </span> + </td> + <td class="text-nowrap"> + <%= l(t.created_at, format: :short) %> + </td> + <td class="text-end text-nowrap"> + <%= link_to "Voir", t, class: "btn btn-outline-secondary btn-sm" %> + <%= link_to "Modifier", edit_target_path(t), class: "btn btn-outline-primary btn-sm" %> + <%# FIXME: Doesn't work. %> + <%= link_to "Supprimer", t, class: "btn btn-outline-danger btn-sm", + data: { turbo_method: :delete, turbo_confirm: "Supprimer cet objectif ?" } %> + </td> + </tr> + <% end %> + <% else %> + <tr> + <td colspan="5" class="text-center py-4 text-muted"> + Aucun objectif pour le moment. + <%= link_to "Créer le premier", new_target_path %>. + </td> + </tr> + <% end %> + </tbody> + </table> +</div> diff --git a/app/views/targets/new.html.erb b/app/views/targets/new.html.erb new file mode 100644 index 0000000..42cb7bd --- /dev/null +++ b/app/views/targets/new.html.erb @@ -0,0 +1,85 @@ +<% content_for :title, "Ajouter une Cible" %> + +<h1 class="display-1">Ajouter une Cible</h1> + +<%= form_with(model: @target) do |f| %> + <div class="card shadow-sm"> + <div class="card-header"> + <%= f.text_field :name, class: "form-control", placeholder: "Nom de la cible" %> + </div> + + <div class="card-body p-0"> + <div class="table-responsive"> + <table class="table table-hover align-middle mb-0"> + <thead class="table-light"> + <tr> + <th>Profil</th> + <th class="text-end" style="width: 180px;">Proportion</th> + </tr> + </thead> + <tbody id="alloc-table-body"> + <%= f.fields_for :target_allocations do |af| %> + <% np = af.object.nutrient_profile %> + <tr> + <td> + <%= af.hidden_field :nutrient_profile_id %> + <strong><%= np&.name.capitalize || "Profil ##{af.object.nutrient_profile_id}" %></strong> + </td> + <td class="text-end"> + <div class="input-group input-group-sm" style="max-width: 160px; margin-left:auto;"> + <%= af.number_field :percentage, + in: 0..100, step: 0.5, + class: "form-control text-end alloc-input", + placeholder: "0.0", + data: { action: "input->alloc#sum" } %> + <span class="input-group-text">%</span> + </div> + </td> + </tr> + <% end %> + </tbody> + <tfoot> + <tr> + <td class="small text-muted">Ajustez chaque pourcentage pour totaliser 100%.</td> + <td class="text-end"> + <span class="badge bg-secondary" id="alloc-total">Total : 0%</span> + </td> + </tr> + </tfoot> + </table> + </div> + </div> + + <div class="card-footer d-flex gap-2 justify-content-end"> + <div class="btn-group"> + <%= f.submit "Enregistrer l’objectif", class: "btn btn-primary", id: "submit-btn" %> + <%= link_to "Annuler", targets_path, class: "btn btn-secondary" %> + </div> + </div> + </div> +<% end %> + +<script> + // Lightweight client-side sum check (no Stimulus required). + document.addEventListener("turbo:load", initAllocSum); + document.addEventListener("DOMContentLoaded", initAllocSum); + + function initAllocSum() { + const inputs = document.querySelectorAll(".alloc-input"); + const totalBadge = document.getElementById("alloc-total"); + const submitBtn = document.getElementById("submit-btn"); + if (!inputs.length || !totalBadge) return; + + function updateTotal() { + let sum = 0; + inputs.forEach(i => sum += parseFloat(i.value || "0")); + const rounded = Math.round(sum * 100) / 100; + totalBadge.textContent = `Total : ${rounded}%`; + totalBadge.className = "badge " + (Math.abs(rounded - 100) < 0.01 ? "bg-success" : "bg-danger"); + if (submitBtn) submitBtn.disabled = !(Math.abs(rounded - 100) < 0.01); + } + + inputs.forEach(i => i.addEventListener("input", updateTotal)); + updateTotal(); + } +</script> diff --git a/app/views/targets/show.html.erb b/app/views/targets/show.html.erb new file mode 100644 index 0000000..1525609 --- /dev/null +++ b/app/views/targets/show.html.erb @@ -0,0 +1,3 @@ +<h1>Targets#show</h1> + +<%# TODO: add table comparing this target with the most recent measurement. %> diff --git a/app/views/targets/update.html.erb b/app/views/targets/update.html.erb new file mode 100644 index 0000000..a39287c --- /dev/null +++ b/app/views/targets/update.html.erb @@ -0,0 +1,2 @@ +<h1>Targets#update</h1> +<p>Find me in app/views/targets/update.html.erb</p> |