Create Target and nutrient target table on dashboard.

Commit
7116826b854188604e21e2a613ac6672b6fd81f3
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
app/controllers/dashboard_controller.rb
index 6f323a91..a315eda5 100644..100644
@@ -10,10 +10,22 @@
10 10 # @target = TargetNutrientCalculator.call
11 11
12 12 # Measurement history table
13 Removed: # @measurements = NutrientMeasurement.order(measured_on: :desc).limit(10)
13 Added: @measurements = NutrientMeasurement.order(measured_on: :desc).limit(10)
14 Added: @npk_measurement_data = NutrientMeasurement.data_series_for(:nno3, :p, :k)
15 Added: @ammonium_measurement_data = NutrientMeasurement.data_series_for(:nnh4)
14 16
15 Removed: # @npk_measurement_data = measurement_data_series(:nno3, :p, :k)
16 Removed: # @ammonium_measurement_data = measurement_data_series(:nnh4)
17 Added: @weighted = Target.first.weighted_requirements # => { "nno3"=>..., "p"=>..., ... }
18 Added:
19 Added: last = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first
20 Added: @latest_measurements = {}
21 Added:
22 Added: if last
23 Added: # Use the same keys as NutrientProfile to keep naming consistent.
24 Added: keys = (NutrientProfile::NUTRIENT_KEYS rescue []).map(&:to_s)
25 Added: keys.each do |k|
26 Added: @latest_measurements[k] = last.send(k) if last.respond_to?(k)
27 Added: end
28 Added: end
17 29 end
18 30
19 31 private
@@ -34,14 +46,5 @@
34 46
35 47 unassigned, assigned = data_series.partition { |s| s[:name].casecmp("unassigned").zero? }
36 48 assigned + unassigned
37 Removed: end
38 Removed:
39 Removed: def measurement_data_series(*nutrients)
40 Removed: nutrients.map do |formula|
41 Removed: { name: Nutrient.find_by!(formula:).name,
42 Removed: data: NutrientMeasurement
43 Removed: .order(:measured_on)
44 Removed: .pluck(:measured_on, formula) }
45 Removed: end
46 49 end
47 50 end
app/controllers/nutrient_measurements_controller.rb
index 00000000..71ca9e4f 000000..100644
@@ -0,0 +1,26 @@
1 Added: class NutrientMeasurementsController < ApplicationController
2 Added: def index
3 Added: @nutrient_measurements = NutrientMeasurement.order(measured_on: :desc)
4 Added: @npk_measurement_data = NutrientMeasurement.data_series_for(:nno3, :p, :k)
5 Added: end
6 Added:
7 Added: def new
8 Added: @nutrient_measurement = NutrientMeasurement.new(measured_on: Date.today)
9 Added: end
10 Added:
11 Added: def create
12 Added: @measurement = NutrientMeasurement.new(nutrient_measurement_params)
13 Added: if @measurement.save
14 Added: redirect_to @measurement, notice: "Relevé enregistré."
15 Added: else
16 Added: render :new, status: :unprocessable_entity
17 Added: end
18 Added: end
19 Added:
20 Added: private
21 Added:
22 Added: def nutrient_measurement_params
23 Added: permitted = [ :measured_on ] + NutrientMeasurement::NUTRIENT_FIELDS
24 Added: params.require(:nutrient_measurement).permit(*permitted)
25 Added: end
26 Added: end
app/controllers/targets_controller.rb
index 00000000..60d3523d 000000..100644
@@ -0,0 +1,68 @@
1 Added: class TargetsController < ApplicationController
2 Added: before_action :set_target, only: %i[show edit update]
3 Added:
4 Added: def index
5 Added: @targets = Target.order(:name)
6 Added: end
7 Added:
8 Added: def new
9 Added: @target = Target.new(name: "Cible #{Date.today + 1.month}")
10 Added: seed_allocations
11 Added: end
12 Added:
13 Added: def create
14 Added: @target = Target.new(target_params)
15 Added: if @target.save
16 Added: redirect_to @target, notice: "Cible enregistrée."
17 Added: else
18 Added: seed_allocations if @target.target_allocations.blank?
19 Added: render :new, status: :unprocessable_entity
20 Added: end
21 Added: end
22 Added:
23 Added: def edit
24 Added: end
25 Added:
26 Added: def update
27 Added: if @target.update(target_params)
28 Added: redirect_to @target, notice: "Cible mise à jour."
29 Added: else
30 Added: render :edit, status: :unprocessable_entity
31 Added: end
32 Added: end
33 Added:
34 Added: def show
35 Added: @weighted = @target.weighted_requirements # => { "nno3"=>..., "p"=>..., ... }
36 Added:
37 Added: last = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first
38 Added: @latest_measurements = {}
39 Added:
40 Added: if last
41 Added: # Use the same keys as NutrientProfile to keep naming consistent.
42 Added: keys = (NutrientProfile::NUTRIENT_KEYS rescue []).map(&:to_s)
43 Added: keys.each do |k|
44 Added: @latest_measurements[k] = last.send(k) if last.respond_to?(k)
45 Added: end
46 Added: end
47 Added: end
48 Added:
49 Added: private
50 Added:
51 Added: def set_target
52 Added: @target = Target.find(params[:id])
53 Added: end
54 Added:
55 Added: def seed_allocations
56 Added: existing_ids = @target.target_allocations.map(&:nutrient_profile_id).compact
57 Added: (NutrientProfile.order(:name).pluck(:id) - existing_ids).each do |np_id|
58 Added: @target.target_allocations.build(nutrient_profile_id: np_id, percentage: 12.5)
59 Added: end
60 Added: end
61 Added:
62 Added: def target_params
63 Added: params.require(:target).permit(
64 Added: :name,
65 Added: target_allocations_attributes: [ :id, :nutrient_profile_id, :percentage, :_destroy ]
66 Added: )
67 Added: end
68 Added: end
app/helpers/targets_helper.rb
index 00000000..8484878b 000000..100644
@@ -0,0 +1,2 @@
1 Added: module TargetsHelper
2 Added: end
app/models/nutrient_measurement.rb
index f1d6d5b5..1139af78 100644..100644
@@ -1,4 +1,18 @@
1 1 class NutrientMeasurement < ApplicationRecord
2 Added: NUTRIENT_FIELDS = %i[
3 Added: nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4
4 Added: ].freeze
5 Added:
2 6 validates :measured_on, presence: true
3 7 validates :measured_on, uniqueness: true
8 Added:
9 Added: def self.data_series_for(*nutrients)
10 Added: nutrients.map do |formula|
11 Added: { name: formula, data: self.order(:measured_on).pluck(:measured_on, formula) }
12 Added: end
13 Added: end
14 Added:
15 Added: def self.nutrient_fields
16 Added: NUTRIENT_FIELDS
17 Added: end
4 18 end
app/models/nutrient_profile.rb
index 22f27047..0610855a 100644..100644
@@ -1,2 +1,13 @@
1 1 class NutrientProfile < ApplicationRecord
2 Added: # Align these keys with your schema columns (per your schema.txt)
3 Added: NUTRIENT_KEYS = %i[
4 Added: nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4
5 Added: ].freeze
6 Added:
7 Added: # Returns a Hash of nutrient => numeric requirement (nil kept; caller can skip nils)
8 Added: def requirements_hash
9 Added: attributes
10 Added: .slice(*NUTRIENT_KEYS.map(&:to_s)) # only nutrient columns
11 Added: .transform_keys(&:to_s)
12 Added: end
2 13 end
app/models/target.rb
index 00000000..3063b425 000000..100644
@@ -0,0 +1,42 @@
1 Added: # app/models/target.rb
2 Added: class Target < ApplicationRecord
3 Added: has_many :target_allocations, dependent: :destroy
4 Added: has_many :nutrient_profiles, through: :target_allocations
5 Added:
6 Added: accepts_nested_attributes_for :target_allocations, allow_destroy: true
7 Added: validate :percentages_sum_to_100
8 Added:
9 Added: def weighted_requirements
10 Added: totals = Hash.new(0.0)
11 Added: denom = 100.0
12 Added:
13 Added: target_allocations.includes(:nutrient_profile).each do |alloc|
14 Added: profile = alloc.nutrient_profile
15 Added: next unless profile
16 Added:
17 Added: weight = (alloc.percentage || 0).to_f / denom
18 Added: next if weight <= 0
19 Added:
20 Added: # Prefer the helper, but gracefully fall back to slicing attributes.
21 Added: reqs = if profile.respond_to?(:requirements_hash)
22 Added: profile.requirements_hash
23 Added: else
24 Added: profile.attributes.slice(*NutrientProfile::NUTRIENT_KEYS.map(&:to_s))
25 Added: end
26 Added:
27 Added: reqs.each do |nutrient_key, value|
28 Added: next if value.nil?
29 Added: totals[nutrient_key.to_s] += value.to_f * weight
30 Added: end
31 Added: end
32 Added:
33 Added: totals
34 Added: end
35 Added:
36 Added: private
37 Added:
38 Added: def percentages_sum_to_100
39 Added: sum = target_allocations.reject(&:marked_for_destruction?).sum { |a| a.percentage.to_f }
40 Added: errors.add(:base, "La somme des pourcentages doit être égale à 100%") unless (sum - 100.0).abs <= 0.01
41 Added: end
42 Added: end
app/models/target_allocation.rb
index 00000000..6aa1dcb0 000000..100644
@@ -0,0 +1,7 @@
1 Added: class TargetAllocation < ApplicationRecord
2 Added: belongs_to :target
3 Added: belongs_to :nutrient_profile
4 Added:
5 Added: validates :percentage, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
6 Added: validates :nutrient_profile_id, uniqueness: { scope: :target_id }
7 Added: end
app/views/dashboard/_nutrient_measurements.html.erb
index bc63a607..00000000 100644..000000
@@ -1,22 +0,0 @@
1 Removed: <div class="card shadow mb-4">
2 Removed: <div class="card-header d-flex justify-content-between align-items-center">
3 Removed: <h5 class="mb-0">Nutrient Measurements</h5>
4 Removed: <div class="btn-group">
5 Removed: <%#= link_to "Add new measurement", editor_rafts_path, class: "btn btn-sm btn-primary" %>
6 Removed: <%#= link_to "View all", editor_rafts_path, class: "btn btn-sm btn-secondary" %>
7 Removed: </div>
8 Removed: </div>
9 Removed:
10 Removed: <div class="card-body p-0">
11 Removed: <div class="container mb-3">
12 Removed: <%= line_chart @npk_measurement_data,
13 Removed: title: "NPK",
14 Removed: ytitle: "Concentration (mg/L)" %>
15 Removed: </div>
16 Removed: <div class="container mb-3">
17 Removed: <%= line_chart @ammonium_measurement_data,
18 Removed: title: "Ammonium",
19 Removed: ytitle: "Concentration (mg/L)" %>
20 Removed: </div>
21 Removed: </div>
22 Removed: </div>
app/views/dashboard/_nutrient_measurements_table.html.erb
index 00000000..82ea6ff4 000000..100644
@@ -0,0 +1,18 @@
1 Added: <div class="card shadow my-3">
2 Added: <div class="card-header d-flex justify-content-between align-items-center">
3 Added: <h4 class="mb-0">Relevé des Nutriments</h4>
4 Added: <div class="btn-group">
5 Added: <%= link_to "Ajouter un relevé", new_nutrient_measurement_path, class: "btn btn-sm btn-primary" %>
6 Added: <%= link_to "Liste des relevés", nutrient_measurements_path, class: "btn btn-sm btn-secondary" %>
7 Added: </div>
8 Added: </div>
9 Added:
10 Added: <div class="card-body">
11 Added: <%= line_chart @npk_measurement_data,
12 Added: title: "NPK",
13 Added: ytitle: "Concentration (mg/L)" %>
14 Added: <%= line_chart @ammonium_measurement_data,
15 Added: title: "Ammonium",
16 Added: ytitle: "Concentration (mg/L)" %>
17 Added: </div>
18 Added: </div>
app/views/dashboard/_nutrient_profile_allocator.html.erb
index d402aceb..00000000 100644..000000
@@ -1,187 +0,0 @@
1 Removed: <%# Props: nutrient_profiles: ActiveRecord::Relation<NutrientProfile> %>
2 Removed: <%# Fallback if controller didn't set @nutrient_profiles yet %>
3 Removed: <% profiles = (local_assigns[:nutrient_profiles] || []).presence || [] %>
4 Removed:
5 Removed: <%# We'll render a form purely for structure (no real submit yet) %>
6 Removed: <%= form_with url: "#", method: :post, local: true, html: { id: "np-mix-form", "data-controller": "np-mix" } do %>
7 Removed: <div class="card shadow">
8 Removed: <div class="card-body">
9 Removed:
10 Removed: <div class="d-flex justify-content-between align-items-center mb-2">
11 Removed: <div class="small text-muted">
12 Removed: Choisissez des <strong>profils de croissance</strong> et répartissez-les pour totaliser <strong>100%</strong>.
13 Removed: </div>
14 Removed: <div>
15 Removed: Somme&nbsp;: <span id="np-mix-sum" class="badge bg-secondary">0%</span>
16 Removed: </div>
17 Removed: </div>
18 Removed:
19 Removed: <div id="np-mix-rows" class="vstack gap-2">
20 Removed: <%# Rows are injected by JS from the template below, including defaults %>
21 Removed: </div>
22 Removed:
23 Removed: <div class="mt-3 d-flex gap-2">
24 Removed: <button type="button" class="btn btn-outline-primary" id="np-mix-add">
25 Removed: + Ajouter un profil
26 Removed: </button>
27 Removed:
28 Removed: <%# Placeholder "save" button for later backend wiring; disabled until total == 100 %>
29 Removed: <button type="submit" class="btn btn-success ms-auto" id="np-mix-save" disabled>
30 Removed: Enregistrer (à venir)
31 Removed: </button>
32 Removed: </div>
33 Removed: </div>
34 Removed: </div>
35 Removed:
36 Removed: <%# --- Hidden template for a single row --- %>
37 Removed: <template id="np-mix-row-template">
38 Removed: <div class="np-mix-row d-flex align-items-center gap-2 border rounded p-2">
39 Removed: <button type="button" class="btn btn-outline-danger btn-sm np-mix-delete" aria-label="Supprimer la ligne">
40 Removed: Suppr.
41 Removed: </button>
42 Removed:
43 Removed: <div class="flex-grow-1">
44 Removed: <select name="mix[items][][profile_id]" class="form-select form-select-sm np-mix-select" required>
45 Removed: <% if profiles.any? %>
46 Removed: <% profiles.each do |p| %>
47 Removed: <option value="<%= p.id %>"><%= p.name %></option>
48 Removed: <% end %>
49 Removed: <% else %>
50 Removed: <%# If no collection provided yet, at least show placeholders to demo the UI %>
51 Removed: <option value="">-- Sélectionner un profil --</option>
52 Removed: <option value="gen-croissance">Générique croissance</option>
53 Removed: <option value="tomate-cycle">Tomate (cycle entier)</option>
54 Removed: <option value="gen-floraison">Générique floraison</option>
55 Removed: <% end %>
56 Removed: </select>
57 Removed: </div>
58 Removed:
59 Removed: <div class="input-group input-group-sm" style="max-width: 140px;">
60 Removed: <input type="number"
61 Removed: name="mix[items][][percentage]"
62 Removed: class="form-control text-end np-mix-percent"
63 Removed: min="0" max="100" step="1" value="0" required>
64 Removed: <span class="input-group-text">%</span>
65 Removed: </div>
66 Removed: </div>
67 Removed: </template>
68 Removed:
69 Removed: <%# --- Defaults to inject on load --- %>
70 Removed: <script type="application/json" id="np-mix-defaults">
71 Removed: {
72 Removed: "items": [
73 Removed: { "name": "G\u00E9n\u00E9rique croissance", "percent": 50 },
74 Removed: { "name": "Tomate (cycle entier)", "percent": 30 },
75 Removed: { "name": "G\u00E9n\u00E9rique floraison", "percent": 20 }
76 Removed: ]
77 Removed: }
78 Removed: </script>
79 Removed:
80 Removed: <%# --- Tiny inline JS to keep this self-contained (no Stimulus required) --- %>
81 Removed: <script>
82 Removed: (() => {
83 Removed: const rowsContainer = document.getElementById('np-mix-rows');
84 Removed: const addBtn = document.getElementById('np-mix-add');
85 Removed: const saveBtn = document.getElementById('np-mix-save');
86 Removed: const sumBadge = document.getElementById('np-mix-sum');
87 Removed: const tpl = document.getElementById('np-mix-row-template');
88 Removed: const defaultsJSON = document.getElementById('np-mix-defaults')?.textContent || "{}";
89 Removed: const defaults = JSON.parse(defaultsJSON);
90 Removed:
91 Removed: function currentSum() {
92 Removed: return Array.from(rowsContainer.querySelectorAll('.np-mix-percent'))
93 Removed: .reduce((acc, el) => acc + (parseFloat(el.value) || 0), 0);
94 Removed: }
95 Removed:
96 Removed: function refreshSum() {
97 Removed: const sum = currentSum();
98 Removed: sumBadge.textContent = `${sum}%`;
99 Removed: sumBadge.classList.remove('bg-secondary','bg-danger','bg-success','bg-warning');
100 Removed:
101 Removed: if (sum === 100) {
102 Removed: sumBadge.classList.add('bg-success');
103 Removed: saveBtn?.removeAttribute('disabled');
104 Removed: } else if (sum > 100) {
105 Removed: sumBadge.classList.add('bg-danger');
106 Removed: saveBtn?.setAttribute('disabled', 'disabled');
107 Removed: } else {
108 Removed: sumBadge.classList.add('bg-warning');
109 Removed: saveBtn?.setAttribute('disabled', 'disabled');
110 Removed: }
111 Removed: }
112 Removed:
113 Removed: function setSelectByName(selectEl, targetName) {
114 Removed: // Try to match by visible name; fall back to first option.
115 Removed: const options = Array.from(selectEl.options);
116 Removed: const found = options.find(o => o.text.trim().toLowerCase() === String(targetName || '').trim().toLowerCase());
117 Removed: if (found) {
118 Removed: selectEl.value = found.value;
119 Removed: }
120 Removed: }
121 Removed:
122 Removed: function installRow({ name = null, percent = 0 } = {}) {
123 Removed: const node = tpl.content.firstElementChild.cloneNode(true);
124 Removed:
125 Removed: // Hook up events
126 Removed: node.querySelector('.np-mix-delete').addEventListener('click', () => {
127 Removed: node.remove();
128 Removed: refreshSum();
129 Removed: });
130 Removed:
131 Removed: const selectEl = node.querySelector('.np-mix-select');
132 Removed: const percentEl = node.querySelector('.np-mix-percent');
133 Removed:
134 Removed: // Default selection (by name) and percent
135 Removed: if (name) setSelectByName(selectEl, name);
136 Removed: percentEl.value = percent;
137 Removed:
138 Removed: // Input events
139 Removed: selectEl.addEventListener('change', () => { /* reserved for later linkage */ });
140 Removed: percentEl.addEventListener('input', () => {
141 Removed: // Clamp and refresh
142 Removed: let v = parseFloat(percentEl.value);
143 Removed: if (isNaN(v)) v = 0;
144 Removed: v = Math.max(0, Math.min(100, Math.round(v)));
145 Removed: percentEl.value = v;
146 Removed: refreshSum();
147 Removed: });
148 Removed:
149 Removed: rowsContainer.appendChild(node);
150 Removed: }
151 Removed:
152 Removed: // Init with three defaults
153 Removed: const items = (defaults && defaults.items) ? defaults.items : [];
154 Removed: if (items.length) {
155 Removed: items.forEach(it => installRow({ name: it.name, percent: it.percent }));
156 Removed: } else {
157 Removed: // Fallback: create three blank rows
158 Removed: for (let i = 0; i < 3; i++) installRow();
159 Removed: }
160 Removed: refreshSum();
161 Removed:
162 Removed: // Add new blank row
163 Removed: addBtn.addEventListener('click', () => {
164 Removed: installRow({ name: null, percent: 0 });
165 Removed: refreshSum();
166 Removed: // Scroll to the new row on mobile for better UX
167 Removed: rowsContainer.lastElementChild?.scrollIntoView({ behavior: 'smooth', block: 'center' });
168 Removed: });
169 Removed:
170 Removed: // Prevent real submit for now (frontend only)
171 Removed: document.getElementById('np-mix-form')?.addEventListener('submit', (e) => {
172 Removed: e.preventDefault();
173 Removed: // Later: wire to Turbo/JSON post. For now just a gentle nudge.
174 Removed: saveBtn.textContent = 'Enregistrer (backend à venir)';
175 Removed: saveBtn.blur();
176 Removed: });
177 Removed: })();
178 Removed: </script>
179 Removed:
180 Removed: <style>
181 Removed: /* Small touch targets & tidy spacing on mobile */
182 Removed: @media (max-width: 576px) {
183 Removed: .np-mix-row { padding: .5rem; }
184 Removed: .np-mix-row .btn { padding: .25rem .5rem; }
185 Removed: }
186 Removed: </style>
187 Removed: <% end %>
app/views/dashboard/_nutrient_target_table.html.erb
index 00000000..7cf294c0 000000..100644
@@ -0,0 +1,74 @@
1 Added: <div class="card shadow my-3">
2 Added: <div class="card-header d-flex justify-content-between align-items-center">
3 Added: <h4 class="mb-0">Complémentation</h4>
4 Added: <div class="btn-group">
5 Added: <%= link_to "Nouvelle cible", new_target_path, class: "btn btn-sm btn-primary" %>
6 Added: <%= link_to "Voir la recette", root_path, class: "btn btn-sm btn-secondary" %>
7 Added: </div>
8 Added: </div>
9 Added:
10 Added: <div class="card-body p-0">
11 Added: <div class="table-responsive">
12 Added: <table class="table table-sm table-striped table-hover align-middle mb-0">
13 Added: <thead class="table-light">
14 Added: <tr>
15 Added: <th>Nutriment</th>
16 Added: <th class="text-end">Relevé</th>
17 Added: <th class="text-end">Cible</th>
18 Added: <th class="text-end">Delta</th>
19 Added: </tr>
20 Added: </thead>
21 Added: <tbody>
22 Added: <% wr = @weighted || {} %>
23 Added: <% lm = @latest_measurements || {} %>
24 Added:
25 Added: <% keys = (wr.keys + lm.keys).map(&:to_s).uniq.sort %>
26 Added: <% keys.each do |nut| %>
27 Added: <% measured = lm[nut] %>
28 Added: <% target = wr[nut] %>
29 Added: <% delta = (measured.to_f - target.to_f) if measured || target %>
30 Added: <tr>
31 Added: <td class="fw-semibold"><%= nut.upcase %></td>
32 Added:
33 Added: <td class="text-end">
34 Added: <% if measured.nil? %>
35 Added: <span class="text-muted">—</span>
36 Added: <% else %>
37 Added: <%= number_with_precision(measured, precision: 2) %>
38 Added: <% end %>
39 Added: </td>
40 Added:
41 Added: <td class="text-end">
42 Added: <% if target.nil? %>
43 Added: <span class="text-muted">—</span>
44 Added: <% else %>
45 Added: <%= number_with_precision(target, precision: 2) %>
46 Added: <% end %>
47 Added: </td>
48 Added:
49 Added: <td class="text-end">
50 Added: <% if measured.nil? && target.nil? %>
51 Added: <span class="text-muted">—</span>
52 Added: <% else %>
53 Added: <% badge =
54 Added: if delta.nil?
55 Added: "text-bg-secondary"
56 Added: elsif delta.abs <= 0.01
57 Added: "text-bg-success"
58 Added: elsif delta > 0
59 Added: "text-bg-warning"
60 Added: else
61 Added: "text-bg-danger"
62 Added: end %>
63 Added: <span class="badge <%= badge %>">
64 Added: <%= number_with_precision(delta.to_f, precision: 2) %>
65 Added: </span>
66 Added: <% end %>
67 Added: </td>
68 Added: </tr>
69 Added: <% end %>
70 Added: </tbody>
71 Added: </table>
72 Added: </div>
73 Added: </div>
74 Added: </div>
app/views/dashboard/_target_table.html.erb
index b8f7e664..b1553dcf 100644..100644
@@ -9,8 +9,7 @@
9 9 <thead class="table-light">
10 10 <tr>
11 11 <th scope="col" class="text-nowrap">Nutrient</th>
12 Removed: <th scope="col" class="text-end"> Latest (mg/L)
13 Removed: </th>
12 Added: <th scope="col" class="text-end"> Latest (mg/L)</th>
14 13 <th scope="col" class="text-end">Target (mg/L)</th>
15 14 <th scope="col" class="text-end">Δ %</th>
16 15 </tr>
app/views/dashboard/index.html.erb
index b8f91452..a954e4c1 100644..100644
@@ -1,9 +1,9 @@
1 1 <h1 class="display-1">Ferti</h1>
2 2
3 Removed: <%= render "nutrient_profile_allocator", nutrient_profiles: @nutrient_profiles %>
3 Added: <%= render "nutrient_target_table", nutrient_profiles: @nutrient_profiles %>
4 4
5 5 <%#= render "raft_allocation" %>
6 6
7 7 <%#= render "target_table" %>
8 8
9 Removed: <%#= render "nutrient_measurements" %>
9 Added: <%= render "nutrient_measurements_table" %>
app/views/nutrient_measurement/index.html.erb
index d9f522ec..00000000 100644..000000
@@ -1,27 +0,0 @@
1 Removed: <h1>NutrientMeasurement#index</h1>
2 Removed: <p>Find me in app/views/nutrient_measurement/index.html.erb</p>
3 Removed:
4 Removed: <div class="table-responsive">
5 Removed: <table class="table table-sm table-striped table-hover align-middle table-nutrient mb-0">
6 Removed: <thead class="table-light">
7 Removed: <tr>
8 Removed: <th>Date</th>
9 Removed: <th class="numeric" title="Total N = NO₃‑N + NH₄‑N">N (total)</th>
10 Removed: <th class="numeric">P</th>
11 Removed: <th class="numeric">K</th>
12 Removed: <th class="numeric" title="Ammonia nitrogen">NH₄‑N</th>
13 Removed: </tr>
14 Removed: </thead>
15 Removed: <tbody>
16 Removed: <% @measurements.each do |m| %>
17 Removed: <tr>
18 Removed: <td><%= l(m.measured_on) %></td>
19 Removed: <td class="numeric"><%= fmt2(total_n(m)) %></td>
20 Removed: <td class="numeric"><%= fmt2(m.p) %></td>
21 Removed: <td class="numeric"><%= fmt2(m.k) %></td>
22 Removed: <td class="numeric"><%= fmt2(m.nnh4) %></td>
23 Removed: </tr>
24 Removed: <% end %>
25 Removed: </tbody>
26 Removed: </table>
27 Removed: </div>
app/views/nutrient_measurements/_form.html.erb
index 00000000..9689c573 000000..100644
@@ -0,0 +1,49 @@
1 Added: <%= form_with(model: nutrient_measurement) do |form| %>
2 Added: <% if nutrient_measurement.errors.any? %>
3 Added: <div class="alert alert-danger">
4 Added: <p class="mb-1"><strong><%= pluralize(nutrient_measurement.errors.count, "erreur") %></strong> empêchent l’enregistrement :</p>
5 Added: <ul class="mb-0">
6 Added: <% nutrient_measurement.errors.full_messages.each do |msg| %>
7 Added: <li><%= msg %></li>
8 Added: <% end %>
9 Added: </ul>
10 Added: </div>
11 Added: <% end %>
12 Added:
13 Added: <div class="mb-3">
14 Added: <%= form.label :measured_on, "Date du relevé", class: "form-label" %>
15 Added: <%= form.date_field :measured_on, class: "form-control", required: true %>
16 Added: </div>
17 Added:
18 Added: <h2 class="h6 mt-4 mb-2">Concentrations de nutriments — laisser vide si non mesuré</h2>
19 Added:
20 Added: <div class="row g-2">
21 Added: <% # You can reorganize into macros/micros if you prefer %>
22 Added: <% labels = {
23 Added: nno3: "Nitrate (N-NO₃)", p: "Phosphore (P)", k: "Potassium (K)",
24 Added: ca: "Calcium (Ca)", mg: "Magnésium (Mg)", s: "Soufre (S)",
25 Added: na: "Sodium (Na)", cl: "Chlore (Cl)", si: "Silicium (Si)",
26 Added: fe: "Fer (Fe)", zn: "Zinc (Zn)", b: "Bore (B)",
27 Added: mn: "Manganèse (Mn)", cu: "Cuivre (Cu)", mo: "Molybdène (Mo)",
28 Added: nnh4: "Ammonium (N-NH₄)"
29 Added: } %>
30 Added:
31 Added: <% NutrientMeasurement::NUTRIENT_FIELDS.each do |field| %>
32 Added: <div class="col-6 col-md-3">
33 Added: <div class="input-group">
34 Added: <%= form.number_field field,
35 Added: class: "form-control",
36 Added: placeholder: "—",
37 Added: step: "0.01",
38 Added: min: "0" %>
39 Added: <span class="input-group-text">mg/L</span>
40 Added: </div>
41 Added: <label class="form-label d-block small text-muted mt-1"><%= labels[field] %></label>
42 Added: </div>
43 Added: <% end %>
44 Added: </div>
45 Added:
46 Added: <div>
47 Added: <%= form.submit "Ajouter le relevé", class: "btn btn-primary" %>
48 Added: </div>
49 Added: <% end %>
app/views/nutrient_measurements/index.html.erb
index 00000000..c01d0cc8 000000..100644
@@ -0,0 +1,41 @@
1 Added: <% content_for :title, "Liste des Relevé" %>
2 Added:
3 Added: <h1 class="display-1">Liste des Relevés</h1>
4 Added:
5 Added: <div class="d-flex justify-content-between align-items-center mb-3">
6 Added: <div class="btn-group">
7 Added: <%= link_to "Nouvelle mesure", new_nutrient_measurement_path, class: "btn btn-primary" %>
8 Added: <%= link_to "Retour", root_path, class: "btn btn-outline-secondary" %>
9 Added: </div>
10 Added: </div>
11 Added:
12 Added: <div class="table-responsive">
13 Added: <table class="table table-sm table-striped table-hover align-middle table-nutrient mb-0">
14 Added: <thead class="table-light">
15 Added: <tr>
16 Added: <th>Date</th>
17 Added: <th class="text-end" title="Total N = NO₃-N + NH₄-N">N (total)</th>
18 Added: <th class="text-end">P</th>
19 Added: <th class="text-end">K</th>
20 Added: <th class="text-end" title="Ammonia nitrogen">NH₄-N</th>
21 Added: </tr>
22 Added: </thead>
23 Added: <tbody>
24 Added: <% @nutrient_measurements.each do |m| %>
25 Added: <tr>
26 Added: <td><%= l(m.measured_on) %></td>
27 Added: <td class="text-end">
28 Added: <%= number_with_precision(m.nno3.to_f + m.nnh4.to_f, precision: 2) if m.nno3 || m.nnh4 %>
29 Added: </td>
30 Added: <td class="text-end"><%= number_with_precision(m.p, precision: 2) if m.p %></td>
31 Added: <td class="text-end"><%= number_with_precision(m.k, precision: 2) if m.k %></td>
32 Added: <td class="text-end"><%= number_with_precision(m.nnh4, precision: 2) if m.nnh4 %></td>
33 Added: </tr>
34 Added: <% end %>
35 Added: </tbody>
36 Added: </table>
37 Added: </div>
38 Added:
39 Added: <%= line_chart @npk_measurement_data,
40 Added: title: "NPK",
41 Added: ytitle: "Concentration (mg/L)" %>
app/views/nutrient_measurements/new.html.erb
index 00000000..82a913ef 000000..100644
@@ -0,0 +1,11 @@
1 Added: <% content_for :title, "Ajouter un Relevé" %>
2 Added:
3 Added: <h1 class="display-1">Ajouter un Relevé</h1>
4 Added:
5 Added: <%= render "form", nutrient_measurement: @nutrient_measurement %>
6 Added:
7 Added: <br>
8 Added:
9 Added: <div>
10 Added: <%= link_to "Retour", root_path, class: "btn btn-secondary" %>
11 Added: </div>
app/views/targets/create.html.erb
index 00000000..51e27829 000000..100644
@@ -0,0 +1,2 @@
1 Added: <h1>Targets#create</h1>
2 Added: <p>Find me in app/views/targets/create.html.erb</p>
app/views/targets/edit.html.erb
index 00000000..849bf7f2 000000..100644
@@ -0,0 +1,2 @@
1 Added: <h1>Targets#edit</h1>
2 Added: <p>Find me in app/views/targets/edit.html.erb</p>
app/views/targets/index.html.erb
index 00000000..b5520380 000000..100644
@@ -0,0 +1,68 @@
1 Added: <h1 class="display-1">Cibles</h1>
2 Added:
3 Added: <div class="btn-group my-3">
4 Added: <%= link_to "Nouvelle Cible", new_target_path, class: "btn btn-primary" %>
5 Added: </div>
6 Added:
7 Added: <div class="table-responsive">
8 Added: <table class="table table-sm table-striped table-hover align-middle mb-0">
9 Added: <thead class="table-light">
10 Added: <tr>
11 Added: <th>Nom</th>
12 Added: <th>Répartition</tr>
13 Added: <th class="text-end" style="width: 140px;">Total %</th>
14 Added: <th class="text-nowrap" style="width: 190px;">Créé le</th>
15 Added: <th class="text-end" style="width: 180px;">Actions</th>
16 Added: </tr>
17 Added: </thead>
18 Added: <tbody>
19 Added: <% if @targets.present? %>
20 Added: <% @targets.each do |t| %>
21 Added: <% sum_pct = t.target_allocations.sum { |a| a.percentage.to_f } %>
22 Added: <% badge_class = (sum_pct - 100.0).abs <= 0.01 ? "bg-success" : "bg-danger" %>
23 Added: <tr>
24 Added: <td class="fw-semibold">
25 Added: <%= link_to t.name.presence || "Objectif ##{t.id}", t %>
26 Added: </td>
27 Added: <td>
28 Added: <% if t.target_allocations.empty? %>
29 Added: <span class="text-muted">Aucune répartition définie</span>
30 Added: <% else %>
31 Added: <ul class="list-unstyled mb-0 d-flex flex-wrap gap-2">
32 Added: <% t.target_allocations.each do |a| %>
33 Added: <li class="badge text-bg-light border">
34 Added: <%= a.nutrient_profile&.name || "Profil ##{a.nutrient_profile_id}" %>
35 Added: — <%= number_with_precision(a.percentage.to_f, precision: 2) %>%
36 Added: </li>
37 Added: <% end %>
38 Added: </ul>
39 Added: <% end %>
40 Added: </td>
41 Added: <td class="text-end">
42 Added: <span class="badge <%= badge_class %>">
43 Added: <%= number_with_precision(sum_pct, precision: 2) %>%
44 Added: </span>
45 Added: </td>
46 Added: <td class="text-nowrap">
47 Added: <%= l(t.created_at, format: :short) %>
48 Added: </td>
49 Added: <td class="text-end text-nowrap">
50 Added: <%= link_to "Voir", t, class: "btn btn-outline-secondary btn-sm" %>
51 Added: <%= link_to "Modifier", edit_target_path(t), class: "btn btn-outline-primary btn-sm" %>
52 Added: <%# FIXME: Doesn't work. %>
53 Added: <%= link_to "Supprimer", t, class: "btn btn-outline-danger btn-sm",
54 Added: data: { turbo_method: :delete, turbo_confirm: "Supprimer cet objectif ?" } %>
55 Added: </td>
56 Added: </tr>
57 Added: <% end %>
58 Added: <% else %>
59 Added: <tr>
60 Added: <td colspan="5" class="text-center py-4 text-muted">
61 Added: Aucun objectif pour le moment.&nbsp;
62 Added: <%= link_to "Créer le premier", new_target_path %>.
63 Added: </td>
64 Added: </tr>
65 Added: <% end %>
66 Added: </tbody>
67 Added: </table>
68 Added: </div>
app/views/targets/new.html.erb
index 00000000..42cb7bd9 000000..100644
@@ -0,0 +1,85 @@
1 Added: <% content_for :title, "Ajouter une Cible" %>
2 Added:
3 Added: <h1 class="display-1">Ajouter une Cible</h1>
4 Added:
5 Added: <%= form_with(model: @target) do |f| %>
6 Added: <div class="card shadow-sm">
7 Added: <div class="card-header">
8 Added: <%= f.text_field :name, class: "form-control", placeholder: "Nom de la cible" %>
9 Added: </div>
10 Added:
11 Added: <div class="card-body p-0">
12 Added: <div class="table-responsive">
13 Added: <table class="table table-hover align-middle mb-0">
14 Added: <thead class="table-light">
15 Added: <tr>
16 Added: <th>Profil</th>
17 Added: <th class="text-end" style="width: 180px;">Proportion</th>
18 Added: </tr>
19 Added: </thead>
20 Added: <tbody id="alloc-table-body">
21 Added: <%= f.fields_for :target_allocations do |af| %>
22 Added: <% np = af.object.nutrient_profile %>
23 Added: <tr>
24 Added: <td>
25 Added: <%= af.hidden_field :nutrient_profile_id %>
26 Added: <strong><%= np&.name.capitalize || "Profil ##{af.object.nutrient_profile_id}" %></strong>
27 Added: </td>
28 Added: <td class="text-end">
29 Added: <div class="input-group input-group-sm" style="max-width: 160px; margin-left:auto;">
30 Added: <%= af.number_field :percentage,
31 Added: in: 0..100, step: 0.5,
32 Added: class: "form-control text-end alloc-input",
33 Added: placeholder: "0.0",
34 Added: data: { action: "input->alloc#sum" } %>
35 Added: <span class="input-group-text">%</span>
36 Added: </div>
37 Added: </td>
38 Added: </tr>
39 Added: <% end %>
40 Added: </tbody>
41 Added: <tfoot>
42 Added: <tr>
43 Added: <td class="small text-muted">Ajustez chaque pourcentage pour totaliser 100%.</td>
44 Added: <td class="text-end">
45 Added: <span class="badge bg-secondary" id="alloc-total">Total : 0%</span>
46 Added: </td>
47 Added: </tr>
48 Added: </tfoot>
49 Added: </table>
50 Added: </div>
51 Added: </div>
52 Added:
53 Added: <div class="card-footer d-flex gap-2 justify-content-end">
54 Added: <div class="btn-group">
55 Added: <%= f.submit "Enregistrer l’objectif", class: "btn btn-primary", id: "submit-btn" %>
56 Added: <%= link_to "Annuler", targets_path, class: "btn btn-secondary" %>
57 Added: </div>
58 Added: </div>
59 Added: </div>
60 Added: <% end %>
61 Added:
62 Added: <script>
63 Added: // Lightweight client-side sum check (no Stimulus required).
64 Added: document.addEventListener("turbo:load", initAllocSum);
65 Added: document.addEventListener("DOMContentLoaded", initAllocSum);
66 Added:
67 Added: function initAllocSum() {
68 Added: const inputs = document.querySelectorAll(".alloc-input");
69 Added: const totalBadge = document.getElementById("alloc-total");
70 Added: const submitBtn = document.getElementById("submit-btn");
71 Added: if (!inputs.length || !totalBadge) return;
72 Added:
73 Added: function updateTotal() {
74 Added: let sum = 0;
75 Added: inputs.forEach(i => sum += parseFloat(i.value || "0"));
76 Added: const rounded = Math.round(sum * 100) / 100;
77 Added: totalBadge.textContent = `Total : ${rounded}%`;
78 Added: totalBadge.className = "badge " + (Math.abs(rounded - 100) < 0.01 ? "bg-success" : "bg-danger");
79 Added: if (submitBtn) submitBtn.disabled = !(Math.abs(rounded - 100) < 0.01);
80 Added: }
81 Added:
82 Added: inputs.forEach(i => i.addEventListener("input", updateTotal));
83 Added: updateTotal();
84 Added: }
85 Added: </script>
app/views/targets/show.html.erb
index 00000000..1525609a 000000..100644
@@ -0,0 +1,3 @@
1 Added: <h1>Targets#show</h1>
2 Added:
3 Added: <%# TODO: add table comparing this target with the most recent measurement. %>
app/views/targets/update.html.erb
index 00000000..a39287cf 000000..100644
@@ -0,0 +1,2 @@
1 Added: <h1>Targets#update</h1>
2 Added: <p>Find me in app/views/targets/update.html.erb</p>
config/routes.rb
index 50950d48..d5cbeaf4 100644..100644
@@ -13,6 +13,7 @@
13 13 # end
14 14
15 15 # resources :fertilizer_products
16 Added: resources :targets
16 17 resources :nutrient_profiles
17 18 resources :nutrient_measurements
18 19
db/migrate/20250908181137_create_targets.rb
index 00000000..9144e50c 000000..100644
@@ -0,0 +1,9 @@
1 Added: class CreateTargets < ActiveRecord::Migration[8.0]
2 Added: def change
3 Added: create_table :targets do |t|
4 Added: t.string :name
5 Added:
6 Added: t.timestamps
7 Added: end
8 Added: end
9 Added: end
db/migrate/20250908181147_create_target_allocations.rb
index 00000000..9141bd3c 000000..100644
@@ -0,0 +1,11 @@
1 Added: class CreateTargetAllocations < ActiveRecord::Migration[8.0]
2 Added: def change
3 Added: create_table :target_allocations do |t|
4 Added: t.references :target, null: false, foreign_key: true
5 Added: t.references :nutrient_profile, null: false, foreign_key: true
6 Added: t.decimal :percentage
7 Added:
8 Added: t.timestamps
9 Added: end
10 Added: end
11 Added: end
db/schema.rb
index feb2fd03..d201f5a2 100644..100644
@@ -10,7 +10,7 @@
10 10 #
11 11 # It's strongly recommended that you check this file into your version control system.
12 12
13 Removed: ActiveRecord::Schema[8.0].define(version: 2025_09_01_112954) do
13 Added: ActiveRecord::Schema[8.0].define(version: 2025_09_08_181147) do
14 14 create_table "beds", force: :cascade do |t|
15 15 t.integer "location", null: false
16 16 t.datetime "created_at", null: false
@@ -122,8 +122,26 @@
122 122 t.index ["crop_nutrient_need_id"], name: "index_rafts_on_crop_nutrient_need_id"
123 123 end
124 124
125 Added: create_table "target_allocations", force: :cascade do |t|
126 Added: t.integer "target_id", null: false
127 Added: t.integer "nutrient_profile_id", null: false
128 Added: t.decimal "percentage"
129 Added: t.datetime "created_at", null: false
130 Added: t.datetime "updated_at", null: false
131 Added: t.index ["nutrient_profile_id"], name: "index_target_allocations_on_nutrient_profile_id"
132 Added: t.index ["target_id"], name: "index_target_allocations_on_target_id"
133 Added: end
134 Added:
135 Added: create_table "targets", force: :cascade do |t|
136 Added: t.string "name"
137 Added: t.datetime "created_at", null: false
138 Added: t.datetime "updated_at", null: false
139 Added: end
140 Added:
125 141 add_foreign_key "fertilizer_compositions", "fertilizer_components"
126 142 add_foreign_key "fertilizer_compositions", "fertilizer_products"
127 143 add_foreign_key "rafts", "beds"
128 144 add_foreign_key "rafts", "nutrient_profiles", column: "crop_nutrient_need_id"
145 Added: add_foreign_key "target_allocations", "nutrient_profiles"
146 Added: add_foreign_key "target_allocations", "targets"
129 147 end
db/seeds/NutrientProfile.rb
index 04e16cc0..0c3c1528 100644..100644
@@ -101,7 +101,7 @@
101 101 b: 0.11,
102 102 mn: 0.11,
103 103 cu: 0.03,
104 Removed: mo: 0.01 },
104 Added: mo: 0.01 }
105 105 ].each do |profile|
106 106 NutrientProfile.find_or_create_by!(name: profile[:name]) do |p|
107 107 p.attributes = profile
test/controllers/targets_controller_test.rb
index 00000000..911e3ae9 000000..100644
@@ -0,0 +1,33 @@
1 Added: require "test_helper"
2 Added:
3 Added: class TargetsControllerTest < ActionDispatch::IntegrationTest
4 Added: test "should get index" do
5 Added: get targets_index_url
6 Added: assert_response :success
7 Added: end
8 Added:
9 Added: test "should get new" do
10 Added: get targets_new_url
11 Added: assert_response :success
12 Added: end
13 Added:
14 Added: test "should get create" do
15 Added: get targets_create_url
16 Added: assert_response :success
17 Added: end
18 Added:
19 Added: test "should get edit" do
20 Added: get targets_edit_url
21 Added: assert_response :success
22 Added: end
23 Added:
24 Added: test "should get update" do
25 Added: get targets_update_url
26 Added: assert_response :success
27 Added: end
28 Added:
29 Added: test "should get show" do
30 Added: get targets_show_url
31 Added: assert_response :success
32 Added: end
33 Added: end
test/fixtures/target_allocations.yml
index 00000000..0ba49eef 000000..100644
@@ -0,0 +1,11 @@
1 Added: # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
2 Added:
3 Added: one:
4 Added: target: one
5 Added: nutrient_profile: one
6 Added: percentage: 9.99
7 Added:
8 Added: two:
9 Added: target: two
10 Added: nutrient_profile: two
11 Added: percentage: 9.99
test/fixtures/targets.yml
index 00000000..7d412240 000000..100644
@@ -0,0 +1,7 @@
1 Added: # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
2 Added:
3 Added: one:
4 Added: name: MyString
5 Added:
6 Added: two:
7 Added: name: MyString
test/models/target_allocation_test.rb
index 00000000..a89cd896 000000..100644
@@ -0,0 +1,7 @@
1 Added: require "test_helper"
2 Added:
3 Added: class TargetAllocationTest < ActiveSupport::TestCase
4 Added: # test "the truth" do
5 Added: # assert true
6 Added: # end
7 Added: end
test/models/target_test.rb
index 00000000..1d9b3326 000000..100644
@@ -0,0 +1,7 @@
1 Added: require "test_helper"
2 Added:
3 Added: class TargetTest < ActiveSupport::TestCase
4 Added: # test "the truth" do
5 Added: # assert true
6 Added: # end
7 Added: end