summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMarius Peter <dev@marius-peter.com>2025-09-08 21:21:56 +0200
committerMarius Peter <dev@marius-peter.com>2025-09-08 21:21:56 +0200
commit7116826b854188604e21e2a613ac6672b6fd81f3 (patch)
tree33150bf2e04e69b8e1fa7d37901d2643b1955534
parent8ba568ae0ebe715b5da453681eb141886f1977a8 (diff)
Create Target and nutrient target table on dashboard.
-rw-r--r--app/controllers/dashboard_controller.rb27
-rw-r--r--app/controllers/nutrient_measurements_controller.rb26
-rw-r--r--app/controllers/targets_controller.rb68
-rw-r--r--app/helpers/targets_helper.rb2
-rw-r--r--app/models/nutrient_measurement.rb14
-rw-r--r--app/models/nutrient_profile.rb11
-rw-r--r--app/models/target.rb42
-rw-r--r--app/models/target_allocation.rb7
-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_profile_allocator.html.erb187
-rw-r--r--app/views/dashboard/_nutrient_target_table.html.erb74
-rw-r--r--app/views/dashboard/_target_table.html.erb3
-rw-r--r--app/views/dashboard/index.html.erb4
-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
-rw-r--r--config/routes.rb1
-rw-r--r--db/migrate/20250908181137_create_targets.rb9
-rw-r--r--db/migrate/20250908181147_create_target_allocations.rb11
-rw-r--r--db/schema.rb20
-rw-r--r--db/seeds/NutrientProfile.rb2
-rw-r--r--test/controllers/targets_controller_test.rb33
-rw-r--r--test/fixtures/target_allocations.yml11
-rw-r--r--test/fixtures/targets.yml7
-rw-r--r--test/models/target_allocation_test.rb7
-rw-r--r--test/models/target_test.rb7
34 files changed, 649 insertions, 254 deletions
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index 6f323a9..a315eda 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -10,10 +10,22 @@ class DashboardController < ApplicationController
# @target = TargetNutrientCalculator.call
# Measurement history table
- # @measurements = NutrientMeasurement.order(measured_on: :desc).limit(10)
+ @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)
- # @npk_measurement_data = measurement_data_series(:nno3, :p, :k)
- # @ammonium_measurement_data = measurement_data_series(:nnh4)
+ @weighted = Target.first.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
@@ -35,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/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/targets_controller.rb b/app/controllers/targets_controller.rb
new file mode 100644
index 0000000..60d3523
--- /dev/null
+++ b/app/controllers/targets_controller.rb
@@ -0,0 +1,68 @@
+class TargetsController < ApplicationController
+ before_action :set_target, only: %i[show edit update]
+
+ 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/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/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
index 22f2704..0610855 100644
--- a/app/models/nutrient_profile.rb
+++ b/app/models/nutrient_profile.rb
@@ -1,2 +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/target.rb b/app/models/target.rb
new file mode 100644
index 0000000..3063b42
--- /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.reject(&:marked_for_destruction?).sum { |a| a.percentage.to_f }
+ errors.add(:base, "La somme des pourcentages doit être égale à 100%") unless (sum - 100.0).abs <= 0.01
+ 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/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_profile_allocator.html.erb b/app/views/dashboard/_nutrient_profile_allocator.html.erb
deleted file mode 100644
index d402ace..0000000
--- a/app/views/dashboard/_nutrient_profile_allocator.html.erb
+++ /dev/null
@@ -1,187 +0,0 @@
-<%# Props: nutrient_profiles: ActiveRecord::Relation<NutrientProfile> %>
-<%# Fallback if controller didn't set @nutrient_profiles yet %>
-<% profiles = (local_assigns[:nutrient_profiles] || []).presence || [] %>
-
-<%# We'll render a form purely for structure (no real submit yet) %>
-<%= form_with url: "#", method: :post, local: true, html: { id: "np-mix-form", "data-controller": "np-mix" } do %>
- <div class="card shadow">
- <div class="card-body">
-
- <div class="d-flex justify-content-between align-items-center mb-2">
- <div class="small text-muted">
- Choisissez des <strong>profils de croissance</strong> et répartissez-les pour totaliser <strong>100%</strong>.
- </div>
- <div>
- Somme&nbsp;: <span id="np-mix-sum" class="badge bg-secondary">0%</span>
- </div>
- </div>
-
- <div id="np-mix-rows" class="vstack gap-2">
- <%# Rows are injected by JS from the template below, including defaults %>
- </div>
-
- <div class="mt-3 d-flex gap-2">
- <button type="button" class="btn btn-outline-primary" id="np-mix-add">
- + Ajouter un profil
- </button>
-
- <%# Placeholder "save" button for later backend wiring; disabled until total == 100 %>
- <button type="submit" class="btn btn-success ms-auto" id="np-mix-save" disabled>
- Enregistrer (à venir)
- </button>
- </div>
- </div>
- </div>
-
- <%# --- Hidden template for a single row --- %>
- <template id="np-mix-row-template">
- <div class="np-mix-row d-flex align-items-center gap-2 border rounded p-2">
- <button type="button" class="btn btn-outline-danger btn-sm np-mix-delete" aria-label="Supprimer la ligne">
- Suppr.
- </button>
-
- <div class="flex-grow-1">
- <select name="mix[items][][profile_id]" class="form-select form-select-sm np-mix-select" required>
- <% if profiles.any? %>
- <% profiles.each do |p| %>
- <option value="<%= p.id %>"><%= p.name %></option>
- <% end %>
- <% else %>
- <%# If no collection provided yet, at least show placeholders to demo the UI %>
- <option value="">-- Sélectionner un profil --</option>
- <option value="gen-croissance">Générique croissance</option>
- <option value="tomate-cycle">Tomate (cycle entier)</option>
- <option value="gen-floraison">Générique floraison</option>
- <% end %>
- </select>
- </div>
-
- <div class="input-group input-group-sm" style="max-width: 140px;">
- <input type="number"
- name="mix[items][][percentage]"
- class="form-control text-end np-mix-percent"
- min="0" max="100" step="1" value="0" required>
- <span class="input-group-text">%</span>
- </div>
- </div>
- </template>
-
- <%# --- Defaults to inject on load --- %>
- <script type="application/json" id="np-mix-defaults">
- {
- "items": [
- { "name": "G\u00E9n\u00E9rique croissance", "percent": 50 },
- { "name": "Tomate (cycle entier)", "percent": 30 },
- { "name": "G\u00E9n\u00E9rique floraison", "percent": 20 }
- ]
- }
- </script>
-
- <%# --- Tiny inline JS to keep this self-contained (no Stimulus required) --- %>
- <script>
- (() => {
- const rowsContainer = document.getElementById('np-mix-rows');
- const addBtn = document.getElementById('np-mix-add');
- const saveBtn = document.getElementById('np-mix-save');
- const sumBadge = document.getElementById('np-mix-sum');
- const tpl = document.getElementById('np-mix-row-template');
- const defaultsJSON = document.getElementById('np-mix-defaults')?.textContent || "{}";
- const defaults = JSON.parse(defaultsJSON);
-
- function currentSum() {
- return Array.from(rowsContainer.querySelectorAll('.np-mix-percent'))
- .reduce((acc, el) => acc + (parseFloat(el.value) || 0), 0);
- }
-
- function refreshSum() {
- const sum = currentSum();
- sumBadge.textContent = `${sum}%`;
- sumBadge.classList.remove('bg-secondary','bg-danger','bg-success','bg-warning');
-
- if (sum === 100) {
- sumBadge.classList.add('bg-success');
- saveBtn?.removeAttribute('disabled');
- } else if (sum > 100) {
- sumBadge.classList.add('bg-danger');
- saveBtn?.setAttribute('disabled', 'disabled');
- } else {
- sumBadge.classList.add('bg-warning');
- saveBtn?.setAttribute('disabled', 'disabled');
- }
- }
-
- function setSelectByName(selectEl, targetName) {
- // Try to match by visible name; fall back to first option.
- const options = Array.from(selectEl.options);
- const found = options.find(o => o.text.trim().toLowerCase() === String(targetName || '').trim().toLowerCase());
- if (found) {
- selectEl.value = found.value;
- }
- }
-
- function installRow({ name = null, percent = 0 } = {}) {
- const node = tpl.content.firstElementChild.cloneNode(true);
-
- // Hook up events
- node.querySelector('.np-mix-delete').addEventListener('click', () => {
- node.remove();
- refreshSum();
- });
-
- const selectEl = node.querySelector('.np-mix-select');
- const percentEl = node.querySelector('.np-mix-percent');
-
- // Default selection (by name) and percent
- if (name) setSelectByName(selectEl, name);
- percentEl.value = percent;
-
- // Input events
- selectEl.addEventListener('change', () => { /* reserved for later linkage */ });
- percentEl.addEventListener('input', () => {
- // Clamp and refresh
- let v = parseFloat(percentEl.value);
- if (isNaN(v)) v = 0;
- v = Math.max(0, Math.min(100, Math.round(v)));
- percentEl.value = v;
- refreshSum();
- });
-
- rowsContainer.appendChild(node);
- }
-
- // Init with three defaults
- const items = (defaults && defaults.items) ? defaults.items : [];
- if (items.length) {
- items.forEach(it => installRow({ name: it.name, percent: it.percent }));
- } else {
- // Fallback: create three blank rows
- for (let i = 0; i < 3; i++) installRow();
- }
- refreshSum();
-
- // Add new blank row
- addBtn.addEventListener('click', () => {
- installRow({ name: null, percent: 0 });
- refreshSum();
- // Scroll to the new row on mobile for better UX
- rowsContainer.lastElementChild?.scrollIntoView({ behavior: 'smooth', block: 'center' });
- });
-
- // Prevent real submit for now (frontend only)
- document.getElementById('np-mix-form')?.addEventListener('submit', (e) => {
- e.preventDefault();
- // Later: wire to Turbo/JSON post. For now just a gentle nudge.
- saveBtn.textContent = 'Enregistrer (backend à venir)';
- saveBtn.blur();
- });
- })();
- </script>
-
- <style>
- /* Small touch targets & tidy spacing on mobile */
- @media (max-width: 576px) {
- .np-mix-row { padding: .5rem; }
- .np-mix-row .btn { padding: .25rem .5rem; }
- }
- </style>
-<% end %>
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/_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 b8f9145..a954e4c 100644
--- a/app/views/dashboard/index.html.erb
+++ b/app/views/dashboard/index.html.erb
@@ -1,9 +1,9 @@
<h1 class="display-1">Ferti</h1>
-<%= render "nutrient_profile_allocator", nutrient_profiles: @nutrient_profiles %>
+<%= render "nutrient_target_table", nutrient_profiles: @nutrient_profiles %>
<%#= render "raft_allocation" %>
<%#= render "target_table" %>
-<%#= render "nutrient_measurements" %>
+<%= render "nutrient_measurements_table" %>
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>
diff --git a/config/routes.rb b/config/routes.rb
index 50950d4..d5cbeaf 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -13,6 +13,7 @@ Rails.application.routes.draw do
# end
# resources :fertilizer_products
+ resources :targets
resources :nutrient_profiles
resources :nutrient_measurements
diff --git a/db/migrate/20250908181137_create_targets.rb b/db/migrate/20250908181137_create_targets.rb
new file mode 100644
index 0000000..9144e50
--- /dev/null
+++ b/db/migrate/20250908181137_create_targets.rb
@@ -0,0 +1,9 @@
+class CreateTargets < ActiveRecord::Migration[8.0]
+ def change
+ create_table :targets do |t|
+ t.string :name
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20250908181147_create_target_allocations.rb b/db/migrate/20250908181147_create_target_allocations.rb
new file mode 100644
index 0000000..9141bd3
--- /dev/null
+++ b/db/migrate/20250908181147_create_target_allocations.rb
@@ -0,0 +1,11 @@
+class CreateTargetAllocations < ActiveRecord::Migration[8.0]
+ def change
+ create_table :target_allocations do |t|
+ t.references :target, null: false, foreign_key: true
+ t.references :nutrient_profile, null: false, foreign_key: true
+ t.decimal :percentage
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index feb2fd0..d201f5a 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2025_09_01_112954) do
+ActiveRecord::Schema[8.0].define(version: 2025_09_08_181147) do
create_table "beds", force: :cascade do |t|
t.integer "location", null: false
t.datetime "created_at", null: false
@@ -122,8 +122,26 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_01_112954) do
t.index ["crop_nutrient_need_id"], name: "index_rafts_on_crop_nutrient_need_id"
end
+ create_table "target_allocations", force: :cascade do |t|
+ t.integer "target_id", null: false
+ t.integer "nutrient_profile_id", null: false
+ t.decimal "percentage"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["nutrient_profile_id"], name: "index_target_allocations_on_nutrient_profile_id"
+ t.index ["target_id"], name: "index_target_allocations_on_target_id"
+ end
+
+ create_table "targets", force: :cascade do |t|
+ t.string "name"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
add_foreign_key "fertilizer_compositions", "fertilizer_components"
add_foreign_key "fertilizer_compositions", "fertilizer_products"
add_foreign_key "rafts", "beds"
add_foreign_key "rafts", "nutrient_profiles", column: "crop_nutrient_need_id"
+ add_foreign_key "target_allocations", "nutrient_profiles"
+ add_foreign_key "target_allocations", "targets"
end
diff --git a/db/seeds/NutrientProfile.rb b/db/seeds/NutrientProfile.rb
index 04e16cc..0c3c152 100644
--- a/db/seeds/NutrientProfile.rb
+++ b/db/seeds/NutrientProfile.rb
@@ -101,7 +101,7 @@
b: 0.11,
mn: 0.11,
cu: 0.03,
- mo: 0.01 },
+ mo: 0.01 }
].each do |profile|
NutrientProfile.find_or_create_by!(name: profile[:name]) do |p|
p.attributes = profile
diff --git a/test/controllers/targets_controller_test.rb b/test/controllers/targets_controller_test.rb
new file mode 100644
index 0000000..911e3ae
--- /dev/null
+++ b/test/controllers/targets_controller_test.rb
@@ -0,0 +1,33 @@
+require "test_helper"
+
+class TargetsControllerTest < ActionDispatch::IntegrationTest
+ test "should get index" do
+ get targets_index_url
+ assert_response :success
+ end
+
+ test "should get new" do
+ get targets_new_url
+ assert_response :success
+ end
+
+ test "should get create" do
+ get targets_create_url
+ assert_response :success
+ end
+
+ test "should get edit" do
+ get targets_edit_url
+ assert_response :success
+ end
+
+ test "should get update" do
+ get targets_update_url
+ assert_response :success
+ end
+
+ test "should get show" do
+ get targets_show_url
+ assert_response :success
+ end
+end
diff --git a/test/fixtures/target_allocations.yml b/test/fixtures/target_allocations.yml
new file mode 100644
index 0000000..0ba49ee
--- /dev/null
+++ b/test/fixtures/target_allocations.yml
@@ -0,0 +1,11 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+one:
+ target: one
+ nutrient_profile: one
+ percentage: 9.99
+
+two:
+ target: two
+ nutrient_profile: two
+ percentage: 9.99
diff --git a/test/fixtures/targets.yml b/test/fixtures/targets.yml
new file mode 100644
index 0000000..7d41224
--- /dev/null
+++ b/test/fixtures/targets.yml
@@ -0,0 +1,7 @@
+# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+
+one:
+ name: MyString
+
+two:
+ name: MyString
diff --git a/test/models/target_allocation_test.rb b/test/models/target_allocation_test.rb
new file mode 100644
index 0000000..a89cd89
--- /dev/null
+++ b/test/models/target_allocation_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class TargetAllocationTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end
diff --git a/test/models/target_test.rb b/test/models/target_test.rb
new file mode 100644
index 0000000..1d9b332
--- /dev/null
+++ b/test/models/target_test.rb
@@ -0,0 +1,7 @@
+require "test_helper"
+
+class TargetTest < ActiveSupport::TestCase
+ # test "the truth" do
+ # assert true
+ # end
+end
Copyright 2019--2025 Marius PETER