Last commit.

Commit
fa77a691ce0cc8941fe470a762f352b27f4f0563
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 a315eda5..fc8cbd84 100644..100644
@@ -1,50 +1,11 @@
1 1 class DashboardController < ApplicationController
2 2 def index
3 Removed: # Raft allocation by crop type
4 Removed: # @raft_data = raft_data_series
3 Added: @latest_measurement = NutrientMeasurement.order(measured_on: :desc).first
4 Added: @latest_target = Target.order(created_at: :desc).first
5 5
6 Removed: @nutrient_profiles = NutrientProfile.order(:name)
7 Removed:
8 Removed: # Nutrient target table
9 Removed: # @latest_measurement = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first
10 Removed: # @target = TargetNutrientCalculator.call
11 Removed:
12 6 # Measurement history table
13 7 @measurements = NutrientMeasurement.order(measured_on: :desc).limit(10)
14 8 @npk_measurement_data = NutrientMeasurement.data_series_for(:nno3, :p, :k)
15 9 @ammonium_measurement_data = NutrientMeasurement.data_series_for(:nnh4)
16 Removed:
17 Removed: @weighted = Target.first.weighted_requirements # => { "nno3"=>..., "p"=>..., ... }
18 Removed:
19 Removed: last = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first
20 Removed: @latest_measurements = {}
21 Removed:
22 Removed: if last
23 Removed: # Use the same keys as NutrientProfile to keep naming consistent.
24 Removed: keys = (NutrientProfile::NUTRIENT_KEYS rescue []).map(&:to_s)
25 Removed: keys.each do |k|
26 Removed: @latest_measurements[k] = last.send(k) if last.respond_to?(k)
27 Removed: end
28 Removed: end
29 Removed: end
30 Removed:
31 Removed: private
32 Removed:
33 Removed: def raft_data_series
34 Removed: data_series = []
35 Removed:
36 Removed: counts = Raft.left_outer_joins(:crop)
37 Removed: .group("crops.name", "crops.crop_type")
38 Removed: .count
39 Removed:
40 Removed: counts.each do |(crop_name, crop_type), count|
41 Removed: name = (crop_name || "unassigned").titleize
42 Removed: type = (crop_type || "unassigned").titleize
43 Removed: data = { type => count }
44 Removed: data_series << { name:, data: }
45 Removed: end
46 Removed:
47 Removed: unassigned, assigned = data_series.partition { |s| s[:name].casecmp("unassigned").zero? }
48 Removed: assigned + unassigned
49 10 end
50 11 end
app/controllers/targets_controller.rb
index a11ddd7a..679cb753 100644..100644
@@ -6,16 +6,24 @@
6 6 end
7 7
8 8 def new
9 Removed: @target = Target.new(name: "Cible #{Date.today + 1.month}")
10 Removed: seed_allocations
9 Added: @target = Target.new
10 Added: @nutrient_profiles = NutrientProfile.order(:name)
11 Added: # Build one allocation per profile so each appears as a row
12 Added: @nutrient_profiles.each do |np|
13 Added: @target.target_allocations.build(nutrient_profile: np, percentage: 12.5)
14 Added: end
11 15 end
12 16
13 17 def create
14 18 @target = Target.new(target_params)
15 19 if @target.save
16 Removed: redirect_to @target, notice: "Cible enregistrée."
20 Added: redirect_to @target, notice: "Cible créée."
17 21 else
18 Removed: seed_allocations if @target.target_allocations.blank?
22 Added: # Rebuild rows for any profiles missing (e.g., after validation errors)
23 Added: existing_ids = @target.target_allocations.map(&:nutrient_profile_id)
24 Added: (NutrientProfile.where.not(id: existing_ids)).order(:name).each do |np|
25 Added: @target.target_allocations.build(nutrient_profile: np, percentage: 0)
26 Added: end
19 27 render :new, status: :unprocessable_entity
20 28 end
21 29 end
@@ -31,18 +39,12 @@
31 39 end
32 40 end
33 41
34 Removed: def show
35 Removed: @weighted = @target.weighted_requirements # => { "nno3"=>..., "p"=>..., ... }
36 Removed:
37 Removed: last = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first
38 Removed: @latest_measurements = {}
39 Removed:
40 Removed: if last
41 Removed: # Use the same keys as NutrientProfile to keep naming consistent.
42 Removed: keys = (NutrientProfile::NUTRIENT_KEYS rescue []).map(&:to_s)
43 Removed: keys.each do |k|
44 Removed: @latest_measurements[k] = last.send(k) if last.respond_to?(k)
45 Removed: end
42 Added: def destroy
43 Added: @target = Target.find(params[:id])
44 Added: @target.destroy
45 Added: respond_to do |format|
46 Added: format.turbo_stream { render turbo_stream: turbo_stream.remove(dom_id(@target)) }
47 Added: format.html { redirect_to targets_path, notice: "Cible supprimé." }
46 48 end
47 49 end
48 50
@@ -50,13 +52,6 @@
50 52
51 53 def set_target
52 54 @target = Target.find(params[:id])
53 Removed: end
54 Removed:
55 Removed: def seed_allocations
56 Removed: existing_ids = @target.target_allocations.map(&:nutrient_profile_id).compact
57 Removed: (NutrientProfile.order(:name).pluck(:id) - existing_ids).each do |np_id|
58 Removed: @target.target_allocations.build(nutrient_profile_id: np_id, percentage: 12.5)
59 Removed: end
60 55 end
61 56
62 57 def target_params
app/helpers/targets_helper.rb
index 8484878b..00000000 100644..000000
@@ -1,2 +0,0 @@
1 Removed: module TargetsHelper
2 Removed: end
app/models/concerns/nutrient_vector.rb
index 00000000..ba4f28a9 000000..100644
@@ -0,0 +1,55 @@
1 Added: module NutrientVector
2 Added: extend ActiveSupport::Concern
3 Added:
4 Added: NUTRIENT_KEYS = %i[
5 Added: nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4
6 Added: ].freeze
7 Added:
8 Added: included do
9 Added: # Define simple readers (nno3, p, k, ...) that read from #nutrient_values
10 Added: NUTRIENT_KEYS.each do |k|
11 Added: define_method(k) { nutrient_values[k] }
12 Added: end
13 Added: end
14 Added:
15 Added: # Hash-like access
16 Added: def [](key)
17 Added: key = key.to_sym
18 Added: return nil unless NUTRIENT_KEYS.include?(key)
19 Added: public_send(key)
20 Added: end
21 Added:
22 Added: def keys = NUTRIENT_KEYS
23 Added:
24 Added: # Returns a copy to avoid accidental mutation
25 Added: def to_h
26 Added: NUTRIENT_KEYS.index_with { |k| public_send(k) }
27 Added: end
28 Added:
29 Added: # Iterate over pairs
30 Added: def each_pair
31 Added: return enum_for(:each_pair) unless block_given?
32 Added: NUTRIENT_KEYS.each { |k| yield k, public_send(k) }
33 Added: end
34 Added:
35 Added: # Simple difference (self - other), useful for “how far from target?”
36 Added: def delta_against(other)
37 Added: NUTRIENT_KEYS.index_with { |k| (public_send(k).to_f) - (other.public_send(k).to_f) }
38 Added: end
39 Added:
40 Added: # Percent difference relative to other (e.g., measurement vs target)
41 Added: # Returns 0 when both are 0, and nil when target is 0 but measurement isn’t.
42 Added: def percent_diff_against(other)
43 Added: NUTRIENT_KEYS.index_with do |k|
44 Added: a = public_send(k).to_f
45 Added: b = other.public_send(k).to_f
46 Added: if b.zero? && a.zero?
47 Added: 0.0
48 Added: elsif b.zero?
49 Added: nil
50 Added: else
51 Added: ((a - b) / b) * 100.0
52 Added: end
53 Added: end
54 Added: end
55 Added: end
app/models/nutrient.rb
index 00000000..08400861 000000..100644
@@ -0,0 +1,4 @@
1 Added: class Nutrient < ApplicationRecord
2 Added: validates :formula, presence: true, uniqueness: true
3 Added: validates :name, presence: true
4 Added: end
app/models/nutrient_measurement.rb
index 1139af78..b56c5ed6 100644..100644
@@ -1,7 +1,5 @@
1 1 class NutrientMeasurement < ApplicationRecord
2 Removed: NUTRIENT_FIELDS = %i[
3 Removed: nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4
4 Removed: ].freeze
2 Added: include NutrientVector
5 3
6 4 validates :measured_on, presence: true
7 5 validates :measured_on, uniqueness: true
@@ -12,7 +10,11 @@
12 10 end
13 11 end
14 12
15 Removed: def self.nutrient_fields
16 Removed: NUTRIENT_FIELDS
13 Added: private
14 Added:
15 Added: def nutrient_values
16 Added: @nutrient_values ||= NutrientVector::NUTRIENT_KEYS.index_with do |k|
17 Added: read_attribute(k).to_f
18 Added: end
17 19 end
18 20 end
app/models/target.rb
index 9211e747..2efd3b25 100644..100644
@@ -1,42 +1,53 @@
1 Removed: # app/models/target.rb
2 1 class Target < ApplicationRecord
3 Removed: has_many :target_allocations, dependent: :destroy
4 Removed: has_many :nutrient_profiles, through: :target_allocations
2 Added: include NutrientVector
5 3
6 Removed: accepts_nested_attributes_for :target_allocations, allow_destroy: true
7 Removed: # validate :percentages_sum_to_100
4 Added: has_many :target_allocations, inverse_of: :target, dependent: :destroy
8 5
9 Removed: def weighted_requirements
10 Removed: totals = Hash.new(0.0)
11 Removed: denom = 100.0
6 Added: after_initialize :set_defaults, if: :new_record?
12 7
13 Removed: target_allocations.includes(:nutrient_profile).each do |alloc|
14 Removed: profile = alloc.nutrient_profile
15 Removed: next unless profile
8 Added: validate :allocations_sum_to_100
16 9
17 Removed: weight = (alloc.percentage || 0).to_f / denom
18 Removed: next if weight <= 0
10 Added: # Recompute when allocations change; keep it simple (memoized per instance)
11 Added: def recompute_nutrients!
12 Added: @nutrient_values = nil
13 Added: nutrient_values
14 Added: end
19 15
20 Removed: # Prefer the helper, but gracefully fall back to slicing attributes.
21 Removed: reqs = if profile.respond_to?(:requirements_hash)
22 Removed: profile.requirements_hash
23 Removed: else
24 Removed: profile.attributes.slice(*NutrientProfile::NUTRIENT_KEYS.map(&:to_s))
25 Removed: end
16 Added: private
26 17
27 Removed: reqs.each do |nutrient_key, value|
28 Removed: next if value.nil?
29 Removed: totals[nutrient_key.to_s] += value.to_f * weight
18 Added: def nutrient_values
19 Added: @nutrient_values ||= get_nutrient_values
20 Added: end
21 Added:
22 Added: def get_nutrient_values
23 Added: sums = Hash.new(0.0)
24 Added: allocs = target_allocations.includes(:nutrient_profile)
25 Added:
26 Added: allocs.each do |alloc|
27 Added: weight = alloc.percentage.to_f / 100.0
28 Added: profile = alloc.nutrient_profile
29 Added: NutrientVector::NUTRIENT_KEYS.each do |k|
30 Added: sums[k] += profile.public_send(k).to_f * weight
30 31 end
31 32 end
32 33
33 Removed: totals
34 Added: # Ensure all keys exist, even when there are no allocations
35 Added: NutrientVector::NUTRIENT_KEYS.each { |k| sums[k] ||= 0.0 }
36 Added: sums.freeze
34 37 end
35 38
36 Removed: private
39 Added: def set_defaults
40 Added: self.name ||= "Cible #{Date.today + 1.month}"
41 Added: if target_allocations.empty?
42 Added: nutrient_profiles = NutrientProfile.limit(3)
43 Added: nutrient_profiles.each do |profile|
44 Added: target_allocations.build(nutrient_profile: profile, percentage: 0)
45 Added: end
46 Added: end
47 Added: end
37 48
38 Removed: # def percentages_sum_to_100
39 Removed: # sum = target_allocations.sum { |a| a.percentage.to_f }
40 Removed: # errors.add(:base, "La somme des pourcentages doit être égale à 100%") unless (sum - 100.0).abs <= 0.1
41 Removed: # end
49 Added: def allocations_sum_to_100
50 Added: sum = target_allocations.reject(&:marked_for_destruction?).sum { |a| a.percentage.to_f }
51 Added: errors.add(:base, "La somme des pourcentages doit être 100%") unless (sum - 100).abs < 0.01
52 Added: end
42 53 end
app/models/target_allocation.rb
index 6aa1dcb0..5e971a0a 100644..100644
@@ -1,7 +1,6 @@
1 1 class TargetAllocation < ApplicationRecord
2 Removed: belongs_to :target
2 Added: belongs_to :target, inverse_of: :target_allocations, touch: true
3 3 belongs_to :nutrient_profile
4 4
5 5 validates :percentage, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
6 Removed: validates :nutrient_profile_id, uniqueness: { scope: :target_id }
7 6 end
app/views/dashboard/_nutrient_measurements_table.html.erb
index 82ea6ff4..4d516bdf 100644..100644
@@ -2,8 +2,8 @@
2 2 <div class="card-header d-flex justify-content-between align-items-center">
3 3 <h4 class="mb-0">Relevé des Nutriments</h4>
4 4 <div class="btn-group">
5 Removed: <%= link_to "Ajouter un relevé", new_nutrient_measurement_path, class: "btn btn-sm btn-primary" %>
6 Removed: <%= link_to "Liste des relevés", nutrient_measurements_path, class: "btn btn-sm btn-secondary" %>
5 Added: <%= link_to "Ajouter un relevé", new_nutrient_measurement_path, class: "btn btn-sm btn-outline-primary" %>
6 Added: <%= link_to "Liste des relevés", nutrient_measurements_path, class: "btn btn-sm btn-outline-secondary" %>
7 7 </div>
8 8 </div>
9 9
app/views/dashboard/_nutrient_target_table.html.erb
index 7cf294c0..006d262b 100644..100644
@@ -2,40 +2,33 @@
2 2 <div class="card-header d-flex justify-content-between align-items-center">
3 3 <h4 class="mb-0">Complémentation</h4>
4 4 <div class="btn-group">
5 Removed: <%= link_to "Nouvelle cible", new_target_path, class: "btn btn-sm btn-primary" %>
6 Removed: <%= link_to "Voir la recette", root_path, class: "btn btn-sm btn-secondary" %>
5 Added: <%= link_to "Nouvelle cible", new_target_path, class: "btn btn-sm btn-outline-primary" %>
6 Added: <%= link_to "Liste des cibles", targets_path, class: "btn btn-sm btn-outline-secondary" %>
7 Added: <%= link_to "Voir la recette", root_path, class: "btn btn-sm btn-outline-secondary" %>
7 8 </div>
8 9 </div>
9 10
10 11 <div class="card-body p-0">
11 12 <div class="table-responsive">
12 Removed: <table class="table table-sm table-striped table-hover align-middle mb-0">
13 Added: <table class="table table-striped table-hover align-middle mb-0">
13 14 <thead class="table-light">
14 15 <tr>
15 16 <th>Nutriment</th>
16 17 <th class="text-end">Relevé</th>
17 Removed: <th class="text-end">Cible</th>
18 Removed: <th class="text-end">Delta</th>
18 Added: <th class="text-end"><%= @latest_target&.name || "Cible" %></th>
19 Added: <th class="text-end">Delta (%)</th>
19 20 </tr>
20 21 </thead>
21 22 <tbody>
22 Removed: <% wr = @weighted || {} %>
23 Removed: <% lm = @latest_measurements || {} %>
23 Added: <% Nutrient.all.each do |nutrient| %>
24 Added: <% measured = @latest_measurement.send(nutrient.formula.downcase) %>
25 Added: <% target = @latest_target.send(nutrient.formula.downcase) %>
26 Added: <% delta = (target.to_f - measured.to_f) / 100.0 if measured || target %>
24 27
25 Removed: <% keys = (wr.keys + lm.keys).map(&:to_s).uniq.sort %>
26 Removed: <% keys.each do |nut| %>
27 Removed: <% measured = lm[nut] %>
28 Removed: <% target = wr[nut] %>
29 Removed: <% delta = (measured.to_f - target.to_f) if measured || target %>
30 28 <tr>
31 Removed: <td class="fw-semibold"><%= nut.upcase %></td>
32 Removed:
29 Added: <td class="fw-semibold"><%= nutrient.formula %></td>
33 30 <td class="text-end">
34 Removed: <% if measured.nil? %>
35 Removed: <span class="text-muted">—</span>
36 Removed: <% else %>
37 Removed: <%= number_with_precision(measured, precision: 2) %>
38 Removed: <% end %>
31 Added: <%= measured.present? ? number_with_precision(measured, precision: 2) : — %>
39 32 </td>
40 33
41 34 <td class="text-end">
@@ -50,7 +43,7 @@
50 43 <% if measured.nil? && target.nil? %>
51 44 <span class="text-muted">—</span>
52 45 <% else %>
53 Removed: <% badge =
46 Added: <% badge_class =
54 47 if delta.nil?
55 48 "text-bg-secondary"
56 49 elsif delta.abs <= 0.01
@@ -60,7 +53,7 @@
60 53 else
61 54 "text-bg-danger"
62 55 end %>
63 Removed: <span class="badge <%= badge %>">
56 Added: <span class="badge <%= badge_class %>">
64 57 <%= number_with_precision(delta.to_f, precision: 2) %>
65 58 </span>
66 59 <% end %>
app/views/dashboard/_raft_allocation.html.erb
index 1c9ef9ab..00000000 100644..000000
@@ -1,9 +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">Crop Allocation</h5>
4 Removed: <%#= link_to "Edit allocation", beds_path, class: "btn btn-sm btn-primary" %>
5 Removed: </div>
6 Removed:
7 Removed: <%= bar_chart @raft_data, stacked: true %>
8 Removed: </div>
9 Removed:
app/views/dashboard/_target_table.html.erb
index b1553dcf..00000000 100644..000000
@@ -1,39 +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">Target nutrient concentrations</h5>
4 Removed: <%= link_to "Get Ferti© recipe", ferti_recipe_path, class: "btn btn-sm btn-primary" %>
5 Removed: </div>
6 Removed:
7 Removed: <div class="table-responsive">
8 Removed: <table class="table table-sm align-middle mb-0">
9 Removed: <thead class="table-light">
10 Removed: <tr>
11 Removed: <th scope="col" class="text-nowrap">Nutrient</th>
12 Removed: <th scope="col" class="text-end"> Latest (mg/L)</th>
13 Removed: <th scope="col" class="text-end">Target (mg/L)</th>
14 Removed: <th scope="col" class="text-end">Δ %</th>
15 Removed: </tr>
16 Removed: </thead>
17 Removed: <tbody>
18 Removed: <% NutrientsHelper::NUTRIENTS.each do |n| %>
19 Removed: <% latest = @latest_measurement[n] || 0 %>
20 Removed: <% target = @target[n] || 0 %>
21 Removed: <% delta = target - latest %>
22 Removed: <tr>
23 Removed: <th scope="row" class="text-nowrap"><%= n.upcase %></th>
24 Removed: <td class="text-end"><%= fmt(latest) %></td>
25 Removed: <td class="text-end"><%= fmt(target) %></td>
26 Removed: <td class="text-end">
27 Removed: <span class="badge <%= delta_badge_class(delta) %>">
28 Removed: <%= fmt(delta) %>%
29 Removed: </span>
30 Removed: </td>
31 Removed: </tr>
32 Removed: <% end %>
33 Removed: </tbody>
34 Removed: </table>
35 Removed: </div>
36 Removed: <div class="card-footer">
37 Removed: Latest measurement: <%= @latest_measurement.measured_on || "none yet" %>
38 Removed: </div>
39 Removed: </div>
app/views/dashboard/index.html.erb
index a954e4c1..50fab18c 100644..100644
@@ -2,8 +2,4 @@
2 2
3 3 <%= render "nutrient_target_table", nutrient_profiles: @nutrient_profiles %>
4 4
5 Removed: <%#= render "raft_allocation" %>
6 Removed:
7 Removed: <%#= render "target_table" %>
8 Removed:
9 5 <%= render "nutrient_measurements_table" %>
app/views/targets/index.html.erb
index b5520380..e536debb 100644..100644
@@ -5,31 +5,29 @@
5 5 </div>
6 6
7 7 <div class="table-responsive">
8 Removed: <table class="table table-sm table-striped table-hover align-middle mb-0">
8 Added: <table class="table table-hover align-middle">
9 9 <thead class="table-light">
10 10 <tr>
11 11 <th>Nom</th>
12 Removed: <th>Répartition</tr>
13 Removed: <th class="text-end" style="width: 140px;">Total %</th>
14 Removed: <th class="text-nowrap" style="width: 190px;">Créé le</th>
15 Removed: <th class="text-end" style="width: 180px;">Actions</th>
12 Added: <th>Répartition</th>
13 Added: <th>Date de Création</th>
16 14 </tr>
17 15 </thead>
18 16 <tbody>
19 17 <% if @targets.present? %>
20 Removed: <% @targets.each do |t| %>
21 Removed: <% sum_pct = t.target_allocations.sum { |a| a.percentage.to_f } %>
18 Added: <% @targets.each do |target| %>
19 Added: <% sum_pct = target.allocations.sum { |a| a.percentage.to_f } %>
22 20 <% badge_class = (sum_pct - 100.0).abs <= 0.01 ? "bg-success" : "bg-danger" %>
23 21 <tr>
24 22 <td class="fw-semibold">
25 Removed: <%= link_to t.name.presence || "Objectif ##{t.id}", t %>
23 Added: <%= link_to target.name.presence || "Cible ##{t.id}", target %>
26 24 </td>
27 25 <td>
28 Removed: <% if t.target_allocations.empty? %>
26 Added: <% if target.allocations.empty? %>
29 27 <span class="text-muted">Aucune répartition définie</span>
30 28 <% else %>
31 Removed: <ul class="list-unstyled mb-0 d-flex flex-wrap gap-2">
32 Removed: <% t.target_allocations.each do |a| %>
29 Added: <ul class="list-unstyled mb-0 d-flex flex-wrap gap-3">
30 Added: <% target.allocations.each do |a| %>
33 31 <li class="badge text-bg-light border">
34 32 <%= a.nutrient_profile&.name || "Profil ##{a.nutrient_profile_id}" %>
35 33 — <%= number_with_precision(a.percentage.to_f, precision: 2) %>%
@@ -38,20 +36,8 @@
38 36 </ul>
39 37 <% end %>
40 38 </td>
41 Removed: <td class="text-end">
42 Removed: <span class="badge <%= badge_class %>">
43 Removed: <%= number_with_precision(sum_pct, precision: 2) %>%
44 Removed: </span>
45 Removed: </td>
46 39 <td class="text-nowrap">
47 Removed: <%= l(t.created_at, format: :short) %>
48 Removed: </td>
49 Removed: <td class="text-end text-nowrap">
50 Removed: <%= link_to "Voir", t, class: "btn btn-outline-secondary btn-sm" %>
51 Removed: <%= link_to "Modifier", edit_target_path(t), class: "btn btn-outline-primary btn-sm" %>
52 Removed: <%# FIXME: Doesn't work. %>
53 Removed: <%= link_to "Supprimer", t, class: "btn btn-outline-danger btn-sm",
54 Removed: data: { turbo_method: :delete, turbo_confirm: "Supprimer cet objectif ?" } %>
40 Added: <%= l(target.created_at, format: :short) %>
55 41 </td>
56 42 </tr>
57 43 <% end %>
app/views/targets/new.html.erb
index 42cb7bd9..e69de29b 100644..100644
@@ -1,85 +0,0 @@
1 Removed: <% content_for :title, "Ajouter une Cible" %>
2 Removed:
3 Removed: <h1 class="display-1">Ajouter une Cible</h1>
4 Removed:
5 Removed: <%= form_with(model: @target) do |f| %>
6 Removed: <div class="card shadow-sm">
7 Removed: <div class="card-header">
8 Removed: <%= f.text_field :name, class: "form-control", placeholder: "Nom de la cible" %>
9 Removed: </div>
10 Removed:
11 Removed: <div class="card-body p-0">
12 Removed: <div class="table-responsive">
13 Removed: <table class="table table-hover align-middle mb-0">
14 Removed: <thead class="table-light">
15 Removed: <tr>
16 Removed: <th>Profil</th>
17 Removed: <th class="text-end" style="width: 180px;">Proportion</th>
18 Removed: </tr>
19 Removed: </thead>
20 Removed: <tbody id="alloc-table-body">
21 Removed: <%= f.fields_for :target_allocations do |af| %>
22 Removed: <% np = af.object.nutrient_profile %>
23 Removed: <tr>
24 Removed: <td>
25 Removed: <%= af.hidden_field :nutrient_profile_id %>
26 Removed: <strong><%= np&.name.capitalize || "Profil ##{af.object.nutrient_profile_id}" %></strong>
27 Removed: </td>
28 Removed: <td class="text-end">
29 Removed: <div class="input-group input-group-sm" style="max-width: 160px; margin-left:auto;">
30 Removed: <%= af.number_field :percentage,
31 Removed: in: 0..100, step: 0.5,
32 Removed: class: "form-control text-end alloc-input",
33 Removed: placeholder: "0.0",
34 Removed: data: { action: "input->alloc#sum" } %>
35 Removed: <span class="input-group-text">%</span>
36 Removed: </div>
37 Removed: </td>
38 Removed: </tr>
39 Removed: <% end %>
40 Removed: </tbody>
41 Removed: <tfoot>
42 Removed: <tr>
43 Removed: <td class="small text-muted">Ajustez chaque pourcentage pour totaliser 100%.</td>
44 Removed: <td class="text-end">
45 Removed: <span class="badge bg-secondary" id="alloc-total">Total : 0%</span>
46 Removed: </td>
47 Removed: </tr>
48 Removed: </tfoot>
49 Removed: </table>
50 Removed: </div>
51 Removed: </div>
52 Removed:
53 Removed: <div class="card-footer d-flex gap-2 justify-content-end">
54 Removed: <div class="btn-group">
55 Removed: <%= f.submit "Enregistrer l’objectif", class: "btn btn-primary", id: "submit-btn" %>
56 Removed: <%= link_to "Annuler", targets_path, class: "btn btn-secondary" %>
57 Removed: </div>
58 Removed: </div>
59 Removed: </div>
60 Removed: <% end %>
61 Removed:
62 Removed: <script>
63 Removed: // Lightweight client-side sum check (no Stimulus required).
64 Removed: document.addEventListener("turbo:load", initAllocSum);
65 Removed: document.addEventListener("DOMContentLoaded", initAllocSum);
66 Removed:
67 Removed: function initAllocSum() {
68 Removed: const inputs = document.querySelectorAll(".alloc-input");
69 Removed: const totalBadge = document.getElementById("alloc-total");
70 Removed: const submitBtn = document.getElementById("submit-btn");
71 Removed: if (!inputs.length || !totalBadge) return;
72 Removed:
73 Removed: function updateTotal() {
74 Removed: let sum = 0;
75 Removed: inputs.forEach(i => sum += parseFloat(i.value || "0"));
76 Removed: const rounded = Math.round(sum * 100) / 100;
77 Removed: totalBadge.textContent = `Total : ${rounded}%`;
78 Removed: totalBadge.className = "badge " + (Math.abs(rounded - 100) < 0.01 ? "bg-success" : "bg-danger");
79 Removed: if (submitBtn) submitBtn.disabled = !(Math.abs(rounded - 100) < 0.01);
80 Removed: }
81 Removed:
82 Removed: inputs.forEach(i => i.addEventListener("input", updateTotal));
83 Removed: updateTotal();
84 Removed: }
85 Removed: </script>
db/migrate/20250820175727_create_nutrients.rb
index d0543752..503df3f5 100644..100644
@@ -3,8 +3,6 @@
3 3 create_table :nutrients do |t|
4 4 t.string :formula
5 5 t.string :name
6 Removed:
7 Removed: t.timestamps
8 6 end
9 7 end
10 8 end
db/migrate/20250823142743_create_beds.rb
index 3c23ad71..00000000 100644..000000
@@ -1,11 +0,0 @@
1 Removed: class CreateBeds < ActiveRecord::Migration[8.0]
2 Removed: def change
3 Removed: create_table :beds do |t|
4 Removed: t.integer :location, null: false
5 Removed:
6 Removed: t.timestamps
7 Removed: end
8 Removed:
9 Removed: add_index :beds, :location, unique: true
10 Removed: end
11 Removed: end
db/migrate/20250823142858_create_rafts.rb
index 6b7f0060..00000000 100644..000000
@@ -1,13 +0,0 @@
1 Removed: class CreateRafts < ActiveRecord::Migration[8.0]
2 Removed: def change
3 Removed: create_table :rafts do |t|
4 Removed: t.references :bed, null: false, foreign_key: true
5 Removed: t.integer :location, null: false
6 Removed: t.references :crop, null: true, foreign_key: true
7 Removed:
8 Removed: t.timestamps
9 Removed: end
10 Removed:
11 Removed: add_index :rafts, [ :bed_id, :location ], unique: true
12 Removed: end
13 Removed: end
db/migrate/20250901112954_rename_crops_to_nutrient_profiles.rb
index 5b46df19..42732045 100644..100644
@@ -1,10 +1,6 @@
1 1 class RenameCropsToNutrientProfiles < ActiveRecord::Migration[8.0]
2 2 def change
3 3 rename_table :crops, :nutrient_profiles
4 Removed:
5 Removed: rename_column :rafts, :crop_id, :crop_nutrient_need_id
6 Removed: add_index :rafts, :crop_nutrient_need_id unless index_exists?(:rafts, :crop_nutrient_need_id)
7 Removed:
8 4 remove_column :nutrient_profiles, :crop_type
9 5 end
10 6 end
db/schema.rb
index d201f5a2..0fe55a61 100644..100644
@@ -11,13 +11,6 @@
11 11 # It's strongly recommended that you check this file into your version control system.
12 12
13 13 ActiveRecord::Schema[8.0].define(version: 2025_09_08_181147) do
14 Removed: create_table "beds", force: :cascade do |t|
15 Removed: t.integer "location", null: false
16 Removed: t.datetime "created_at", null: false
17 Removed: t.datetime "updated_at", null: false
18 Removed: t.index ["location"], name: "index_beds_on_location", unique: true
19 Removed: end
20 Removed:
21 14 create_table "fertilizer_components", force: :cascade do |t|
22 15 t.string "name"
23 16 t.string "formula"
@@ -107,21 +100,8 @@
107 100 create_table "nutrients", force: :cascade do |t|
108 101 t.string "formula"
109 102 t.string "name"
110 Removed: t.datetime "created_at", null: false
111 Removed: t.datetime "updated_at", null: false
112 103 end
113 104
114 Removed: create_table "rafts", force: :cascade do |t|
115 Removed: t.integer "bed_id", null: false
116 Removed: t.integer "location", null: false
117 Removed: t.integer "crop_nutrient_need_id"
118 Removed: t.datetime "created_at", null: false
119 Removed: t.datetime "updated_at", null: false
120 Removed: t.index ["bed_id", "location"], name: "index_rafts_on_bed_id_and_location", unique: true
121 Removed: t.index ["bed_id"], name: "index_rafts_on_bed_id"
122 Removed: t.index ["crop_nutrient_need_id"], name: "index_rafts_on_crop_nutrient_need_id"
123 Removed: end
124 Removed:
125 105 create_table "target_allocations", force: :cascade do |t|
126 106 t.integer "target_id", null: false
127 107 t.integer "nutrient_profile_id", null: false
@@ -140,8 +120,6 @@
140 120
141 121 add_foreign_key "fertilizer_compositions", "fertilizer_components"
142 122 add_foreign_key "fertilizer_compositions", "fertilizer_products"
143 Removed: add_foreign_key "rafts", "beds"
144 Removed: add_foreign_key "rafts", "nutrient_profiles", column: "crop_nutrient_need_id"
145 123 add_foreign_key "target_allocations", "nutrient_profiles"
146 124 add_foreign_key "target_allocations", "targets"
147 125 end
db/seeds.rb
index d5f046df..78810e30 100644..100644
@@ -8,7 +8,8 @@
8 8 # MovieGenre.find_or_create_by!(name: genre_name)
9 9 # end
10 10
11 Removed: MODELS = [ NutrientMeasurement,
11 Added: MODELS = [ Nutrient,
12 Added: NutrientMeasurement,
12 13 NutrientProfile,
13 14 Target
14 15 ].freeze
db/seeds/Nutrient.rb
index 00000000..0b29cab4 000000..100644
@@ -0,0 +1,22 @@
1 Added: nutrients = [
2 Added: { formula: "NNO3", name: "Nitrate" },
3 Added: { formula: "NNH4", name: "Ammonium" },
4 Added: { formula: "P", name: "Phosphorus" },
5 Added: { formula: "K", name: "Potassium" },
6 Added: { formula: "Ca", name: "Calcium" },
7 Added: { formula: "Mg", name: "Magnesium" },
8 Added: { formula: "S", name: "Sulfur" },
9 Added: { formula: "Na", name: "Sodium" },
10 Added: { formula: "Cl", name: "Chloride" },
11 Added: { formula: "Si", name: "Silicon" },
12 Added: { formula: "Fe", name: "Iron" },
13 Added: { formula: "Zn", name: "Zinc" },
14 Added: { formula: "B", name: "Boron" },
15 Added: { formula: "Mn", name: "Manganese" },
16 Added: { formula: "Cu", name: "Copper" },
17 Added: { formula: "Mo", name: "Molybdenum" }
18 Added: ]
19 Added:
20 Added: nutrients.each do |attrs|
21 Added: Nutrient.find_or_create_by!(attrs)
22 Added: end
db/seeds/NutrientProfile.rb
index 0c3c1528..c9d80b31 100644..100644
@@ -1,4 +1,5 @@
1 Removed: [ { name: "formule moyenne générale",
1 Added: profiles = [
2 Added: { name: "formule moyenne générale",
2 3 nno3: 160.00,
3 4 p: 30.00,
4 5 k: 230.00,
@@ -102,7 +103,9 @@
102 103 mn: 0.11,
103 104 cu: 0.03,
104 105 mo: 0.01 }
105 Removed: ].each do |profile|
106 Added: ]
107 Added:
108 Added: profiles.each do |profile|
106 109 NutrientProfile.find_or_create_by!(name: profile[:name]) do |p|
107 110 p.attributes = profile
108 111 end
db/seeds/Target.rb
index 790fef77..bce02908 100644..100644
@@ -10,7 +10,7 @@
10 10 missing = wanted.keys - profiles.keys
11 11 raise "Missing NutrientProfile(s): #{missing.join(', ')}" if missing.any?
12 12
13 Removed: target = Target.find_or_create_by!(name: "Objectif par défaut")
13 Added: target = Target.find_or_create_by!(name: "Cible par défaut")
14 14
15 15 target.target_allocations.destroy_all
16 16 wanted.each do |name, pct|