summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/beds_controller.rb66
-rw-r--r--app/controllers/dashboard_controller.rb33
-rw-r--r--app/controllers/fertilizer_products_controller.rb70
-rw-r--r--app/controllers/fertilizers_controller.rb70
-rw-r--r--app/controllers/nutrient_measurement_controller.rb4
-rw-r--r--app/controllers/nutrient_measurements_controller.rb26
-rw-r--r--app/controllers/nutrient_profiles_controller.rb (renamed from app/controllers/crops_controller.rb)2
-rw-r--r--app/controllers/rafts_controller.rb29
-rw-r--r--app/controllers/recipes_controller.rb8
-rw-r--r--app/controllers/targets_controller.rb68
-rw-r--r--app/helpers/application_helper.rb2
-rw-r--r--app/helpers/beds_helper.rb2
-rw-r--r--app/helpers/dashboard_helper.rb8
-rw-r--r--app/helpers/nutrients_helper.rb21
-rw-r--r--app/helpers/recipes_helper.rb16
-rw-r--r--app/helpers/targets_helper.rb2
-rw-r--r--app/javascript/application.js2
-rw-r--r--app/javascript/controllers/application.js9
-rw-r--r--app/javascript/controllers/hello_controller.js7
-rw-r--r--app/javascript/controllers/index.js4
-rw-r--r--app/models/bed.rb5
-rw-r--r--app/models/crop.rb4
-rw-r--r--app/models/fertilizer_component.rb3
-rw-r--r--app/models/fertilizer_composition.rb6
-rw-r--r--app/models/fertilizer_product.rb7
-rw-r--r--app/models/nutrient.rb7
-rw-r--r--app/models/nutrient_measurement.rb14
-rw-r--r--app/models/nutrient_profile.rb13
-rw-r--r--app/models/raft.rb5
-rw-r--r--app/models/target.rb42
-rw-r--r--app/models/target_allocation.rb7
-rw-r--r--app/services/fertilizer_recipe_calculator.rb136
-rw-r--r--app/services/target_nutrient_calculator.rb33
-rw-r--r--app/views/dashboard/_nutrient_measurements.html.erb22
-rw-r--r--app/views/dashboard/_nutrient_measurements_table.html.erb18
-rw-r--r--app/views/dashboard/_nutrient_target_table.html.erb74
-rw-r--r--app/views/dashboard/_raft_allocation.html.erb2
-rw-r--r--app/views/dashboard/_target_table.html.erb3
-rw-r--r--app/views/dashboard/index.html.erb8
-rw-r--r--app/views/layouts/application.html.erb5
-rw-r--r--app/views/nutrient_measurement/index.html.erb27
-rw-r--r--app/views/nutrient_measurements/_form.html.erb49
-rw-r--r--app/views/nutrient_measurements/index.html.erb41
-rw-r--r--app/views/nutrient_measurements/new.html.erb11
-rw-r--r--app/views/targets/create.html.erb2
-rw-r--r--app/views/targets/edit.html.erb2
-rw-r--r--app/views/targets/index.html.erb68
-rw-r--r--app/views/targets/new.html.erb85
-rw-r--r--app/views/targets/show.html.erb3
-rw-r--r--app/views/targets/update.html.erb2
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.&nbsp;
+ <%= 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>
Copyright 2019--2025 Marius PETER