diff options
Diffstat (limited to 'app')
31 files changed, 228 insertions, 516 deletions
diff --git a/app/controllers/beds_controller.rb b/app/controllers/beds_controller.rb deleted file mode 100644 index 2e4d94c..0000000 --- a/app/controllers/beds_controller.rb +++ /dev/null @@ -1,66 +0,0 @@ -class BedsController < ApplicationController - before_action :set_bed, only: %i[ edit update ] - before_action :get_crops, only: %i[ index edit update ] - - def index - @beds = Bed.all - end - - def edit - end - - def update - if @bed.update(bed_params) - redirect_to beds_path, notice: "Bed #{@bed.id} successfully updated." - else - render :edit, status: :unprocessable_entity - end - end - - def bulk_assign_crops - crop = Crop.find(params[:crop_id]) - Raft.update_all(crop_id: crop.id) - redirect_back fallback_location: root_path, notice: "All rafts set to #{crop.name}." - end - - - def reset_seed_crops - # mirrors seed logic - tomatoes = Crop.find_by!(name: "tomatoes") - hot_peppers = Crop.find_by!(name: "hot peppers") - chives = Crop.find_by!(name: "chives") - italian_basil = Crop.find_by!(name: "italian basil") - cabbage_chinese = Crop.find_by!(name: "cabbage, chinese") - lettuce = Crop.find_by!(name: "lettuce") - - Bed.includes(:rafts).find_each do |bed| - default_crop = case bed.location - when 1..2 then tomatoes - when 3 then hot_peppers - when 4 then chives - when 5 then italian_basil - when 6..7 then cabbage_chinese - else lettuce - end - bed.rafts.update_all(crop_id: default_crop.id) - end - redirect_back fallback_location: root_path, notice: "Raft crops reset to default seed layout." - end - - private - - def set_bed - @bed = Bed.find(params[:id]) - end - - def get_crops - @crops = Crop.order(:name) - end - - def bed_params - params.require(:bed).permit( - :location, - rafts_attributes: %i[id crop_id] - ) - end -end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 4e9d560..6f323a9 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,17 +1,19 @@ class DashboardController < ApplicationController def index # Raft allocation by crop type - @raft_data = raft_data_series + # @raft_data = raft_data_series + + @nutrient_profiles = NutrientProfile.order(:name) # Nutrient target table - @latest_measurement = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first - @target = TargetNutrientCalculator.call + # @latest_measurement = NutrientMeasurement.order(measured_on: :desc, created_at: :desc).first + # @target = TargetNutrientCalculator.call # Measurement history table - @measurements = NutrientMeasurement.order(measured_on: :desc).limit(10) + # @measurements = NutrientMeasurement.order(measured_on: :desc).limit(10) - @npk_measurement_data = measurement_data_series(:nno3, :p, :k) - @ammonium_measurement_data = measurement_data_series(:nnh4) + # @npk_measurement_data = measurement_data_series(:nno3, :p, :k) + # @ammonium_measurement_data = measurement_data_series(:nnh4) end private diff --git a/app/controllers/fertilizer_products_controller.rb b/app/controllers/fertilizer_products_controller.rb deleted file mode 100644 index ff0d945..0000000 --- a/app/controllers/fertilizer_products_controller.rb +++ /dev/null @@ -1,70 +0,0 @@ -class FertilizerProductsController < ApplicationController - before_action :set_fertilizer_product, only: %i[ show edit update destroy ] - - # GET /fertilizer_products or /fertilizer_products.json - def index - @fertilizer_products = FertilizerProduct.all - end - - # GET /fertilizer_products/1 or /fertilizer_products/1.json - def show - end - - # GET /fertilizer_products/new - def new - @fertilizer_product = FertilizerProduct.new - end - - # GET /fertilizer_products/1/edit - def edit - end - - # POST /fertilizer_products or /fertilizer_products.json - def create - @fertilizer_product = FertilizerProduct.new(fertilizer_product_params) - - respond_to do |format| - if @fertilizer_product.save - format.html { redirect_to @fertilizer_product, notice: "Fertilizer product was successfully created." } - format.json { render :show, status: :created, location: @fertilizer_product } - else - format.html { render :new, status: :unprocessable_entity } - format.json { render json: @fertilizer_product.errors, status: :unprocessable_entity } - end - end - end - - # PATCH/PUT /fertilizer_products/1 or /fertilizer_products/1.json - def update - respond_to do |format| - if @fertilizer_product.update(fertilizer_product_params) - format.html { redirect_to @fertilizer_product, notice: "Fertilizer product was successfully updated.", status: :see_other } - format.json { render :show, status: :ok, location: @fertilizer_product } - else - format.html { render :edit, status: :unprocessable_entity } - format.json { render json: @fertilizer_product.errors, status: :unprocessable_entity } - end - end - end - - # DELETE /fertilizer_products/1 or /fertilizer_products/1.json - def destroy - @fertilizer_product.destroy! - - respond_to do |format| - format.html { redirect_to fertilizer_products_path, notice: "Fertilizer product was successfully destroyed.", status: :see_other } - format.json { head :no_content } - end - end - - private - # Use callbacks to share common setup or constraints between actions. - def set_fertilizer_product - @fertilizer_product = FertilizerProduct.find(params.expect(:id)) - end - - # Only allow a list of trusted parameters through. - def fertilizer_product_params - params.expect(fertilizer_product: [ :name, :purity ]) - end -end diff --git a/app/controllers/fertilizers_controller.rb b/app/controllers/fertilizers_controller.rb deleted file mode 100644 index 040c462..0000000 --- a/app/controllers/fertilizers_controller.rb +++ /dev/null @@ -1,70 +0,0 @@ -class FertilizersController < ApplicationController - before_action :set_fertilizer, only: %i[ show edit update destroy ] - - # GET /fertilizers or /fertilizers.json - def index - @fertilizers = Fertilizer.all - end - - # GET /fertilizers/1 or /fertilizers/1.json - def show - end - - # GET /fertilizers/new - def new - @fertilizer = Fertilizer.new - end - - # GET /fertilizers/1/edit - def edit - end - - # POST /fertilizers or /fertilizers.json - def create - @fertilizer = Fertilizer.new(fertilizer_params) - - respond_to do |format| - if @fertilizer.save - format.html { redirect_to @fertilizer, notice: "Fertilizer was successfully created." } - format.json { render :show, status: :created, location: @fertilizer } - else - format.html { render :new, status: :unprocessable_entity } - format.json { render json: @fertilizer.errors, status: :unprocessable_entity } - end - end - end - - # PATCH/PUT /fertilizers/1 or /fertilizers/1.json - def update - respond_to do |format| - if @fertilizer.update(fertilizer_params) - format.html { redirect_to @fertilizer, notice: "Fertilizer was successfully updated.", status: :see_other } - format.json { render :show, status: :ok, location: @fertilizer } - else - format.html { render :edit, status: :unprocessable_entity } - format.json { render json: @fertilizer.errors, status: :unprocessable_entity } - end - end - end - - # DELETE /fertilizers/1 or /fertilizers/1.json - def destroy - @fertilizer.destroy! - - respond_to do |format| - format.html { redirect_to fertilizers_path, notice: "Fertilizer was successfully destroyed.", status: :see_other } - format.json { head :no_content } - end - end - - private - # Use callbacks to share common setup or constraints between actions. - def set_fertilizer - @fertilizer = Fertilizer.find(params.expect(:id)) - end - - # Only allow a list of trusted parameters through. - def fertilizer_params - params.expect(fertilizer: [ :name, :formula, :nno3, :nnh4, :p, :k, :ca, :mg, :s, :na, :cl, :si, :fe, :zn, :b, :mn, :cu, :mo ]) - end -end diff --git a/app/controllers/nutrient_measurement_controller.rb b/app/controllers/nutrient_measurement_controller.rb deleted file mode 100644 index 6d26bfa..0000000 --- a/app/controllers/nutrient_measurement_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -class NutrientMeasurementController < ApplicationController - def index - end -end diff --git a/app/controllers/crops_controller.rb b/app/controllers/nutrient_profiles_controller.rb index 951d380..99489d5 100644 --- a/app/controllers/crops_controller.rb +++ b/app/controllers/nutrient_profiles_controller.rb @@ -1,4 +1,4 @@ -class CropsController < ApplicationController +class NutrientProfilesController < ApplicationController before_action :set_crop, only: %i[ show edit update destroy ] def index diff --git a/app/controllers/rafts_controller.rb b/app/controllers/rafts_controller.rb deleted file mode 100644 index c1c8a72..0000000 --- a/app/controllers/rafts_controller.rb +++ /dev/null @@ -1,29 +0,0 @@ -class RaftsController < ApplicationController - before_action :set_raft, only: %i[ edit update ] - before_action :set_crop, only: %i[ edit update ] - - def edit - end - - def update - if @raft.update(raft_params) - redirect_to beds_path, notice: "Raft #{@raft.id} successfully updated." - else - render :edit, status: :unprocessable_entity - end - end - - private - - def set_raft - @raft = Raft.find(params[:id]) - end - - def set_crop - @crops = Crop.order(:name) - end - - def raft_params - params.require(:raft).permit(:location, :bed_id, :crop_id) - end -end diff --git a/app/controllers/recipes_controller.rb b/app/controllers/recipes_controller.rb deleted file mode 100644 index bcc29e7..0000000 --- a/app/controllers/recipes_controller.rb +++ /dev/null @@ -1,8 +0,0 @@ -class RecipesController < ApplicationController - def show - @latest = NutrientMeasurement.order(:measured_on).last || NutrientMeasurement.new - @target = TargetNutrientCalculator.call - volume = (params[:volume].presence || 100_000).to_i - @recipe = FertilizerRecipeCalculator.call(@latest, @target, water_volume_l: volume) - end -end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb deleted file mode 100644 index de6be79..0000000 --- a/app/helpers/application_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module ApplicationHelper -end diff --git a/app/helpers/beds_helper.rb b/app/helpers/beds_helper.rb deleted file mode 100644 index 2f3e2cb..0000000 --- a/app/helpers/beds_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module BedsHelper -end diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb deleted file mode 100644 index 5d64d17..0000000 --- a/app/helpers/dashboard_helper.rb +++ /dev/null @@ -1,8 +0,0 @@ -module DashboardHelper - def fmt2(v) = number_with_precision(v, precision: 2) - - # Total nitrogen (NO3-N + NH4-N) - def total_n(measurement) - (measurement.nno3.to_f) + (measurement.nnh4.to_f) - end -end diff --git a/app/helpers/nutrients_helper.rb b/app/helpers/nutrients_helper.rb deleted file mode 100644 index de98dc3..0000000 --- a/app/helpers/nutrients_helper.rb +++ /dev/null @@ -1,21 +0,0 @@ -module NutrientsHelper - NUTRIENTS = %i[nno3 p k ca mg s na cl si fe zn b mn cu mo nnh4].freeze - - def percent_delta(measured, target) - return 0.0 if target.to_f.zero? - ((measured.to_f - target.to_f) / target.to_f) * 100.0 - end - - def fmt(v) - number_with_precision(v, precision: 2, strip_insignificant_zeros: true) - end - - def delta_badge_class(delta) - d = delta.abs - case d - when d < 0 then "bg-info" - when 0..5 then "bg-secondary" - else "bg-warning" - end - end -end diff --git a/app/helpers/recipes_helper.rb b/app/helpers/recipes_helper.rb deleted file mode 100644 index 02cb1a7..0000000 --- a/app/helpers/recipes_helper.rb +++ /dev/null @@ -1,16 +0,0 @@ -module RecipesHelper - def commercial_name_for(component) - # Try to find a FertilizerProduct via a join, but gracefully fallback. - if defined?(FertilizerProduct) && defined?(FertilizerComposition) - prod = FertilizerProduct.joins(:fertilizer_compositions) - .where(fertilizer_compositions: { fertilizer_component_id: component.id }) - .first - return prod.name if prod&.name.present? - end - component.name.presence || component.formula - end - - def fmt_kg(v) - number_with_precision(v.to_f, precision: 2, strip_insignificant_zeros: true) - end -end diff --git a/app/javascript/application.js b/app/javascript/application.js index beff742..0d7b494 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1 +1,3 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "@hotwired/turbo-rails" +import "controllers" diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js new file mode 100644 index 0000000..1213e85 --- /dev/null +++ b/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js new file mode 100644 index 0000000..5975c07 --- /dev/null +++ b/app/javascript/controllers/hello_controller.js @@ -0,0 +1,7 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.element.textContent = "Hello World!" + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 0000000..1156bf8 --- /dev/null +++ b/app/javascript/controllers/index.js @@ -0,0 +1,4 @@ +// Import and register all your controllers from the importmap via controllers/**/*_controller +import { application } from "controllers/application" +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) diff --git a/app/models/bed.rb b/app/models/bed.rb deleted file mode 100644 index d41afe6..0000000 --- a/app/models/bed.rb +++ /dev/null @@ -1,5 +0,0 @@ -class Bed < ApplicationRecord - has_many :rafts, -> { order(:location) }, dependent: :destroy - accepts_nested_attributes_for :rafts - validates :location, presence: true, uniqueness: true -end diff --git a/app/models/crop.rb b/app/models/crop.rb deleted file mode 100644 index b0f168d..0000000 --- a/app/models/crop.rb +++ /dev/null @@ -1,4 +0,0 @@ -class Crop < ApplicationRecord - has_many :rafts - enum :crop_type, { leafy: 0, fruit: 1, herb: 2 } -end diff --git a/app/models/fertilizer_component.rb b/app/models/fertilizer_component.rb deleted file mode 100644 index 701ae9b..0000000 --- a/app/models/fertilizer_component.rb +++ /dev/null @@ -1,3 +0,0 @@ -class FertilizerComponent < ApplicationRecord - validates :name, presence: true -end diff --git a/app/models/fertilizer_composition.rb b/app/models/fertilizer_composition.rb deleted file mode 100644 index cf2bb93..0000000 --- a/app/models/fertilizer_composition.rb +++ /dev/null @@ -1,6 +0,0 @@ -class FertilizerComposition < ApplicationRecord - belongs_to :fertilizer_product - belongs_to :fertilizer_component - - validates :percent_w, numericality: { greater_than: 0, less_than_or_equal_to: 100 } -end diff --git a/app/models/fertilizer_product.rb b/app/models/fertilizer_product.rb deleted file mode 100644 index e41316b..0000000 --- a/app/models/fertilizer_product.rb +++ /dev/null @@ -1,7 +0,0 @@ -class FertilizerProduct < ApplicationRecord - has_many :fertilizer_compositions, dependent: :destroy - has_many :fertilizer_components, through: :fertilizer_compositions - - validates :name, presence: true, uniqueness: true - validates :purity, numericality: { greater_than: 0, less_than_or_equal_to: 100 } -end diff --git a/app/models/nutrient.rb b/app/models/nutrient.rb deleted file mode 100644 index c584668..0000000 --- a/app/models/nutrient.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Nutrient < ApplicationRecord - validates :formula, presence: true, uniqueness: true - validates :name, presence: true - - before_update { raise ActiveRecord::ReadOnlyRecord } - before_destroy { raise ActiveRecord::ReadOnlyRecord } -end diff --git a/app/models/nutrient_profile.rb b/app/models/nutrient_profile.rb new file mode 100644 index 0000000..22f2704 --- /dev/null +++ b/app/models/nutrient_profile.rb @@ -0,0 +1,2 @@ +class NutrientProfile < ApplicationRecord +end diff --git a/app/models/raft.rb b/app/models/raft.rb deleted file mode 100644 index 3fe5928..0000000 --- a/app/models/raft.rb +++ /dev/null @@ -1,5 +0,0 @@ -class Raft < ApplicationRecord - belongs_to :bed - belongs_to :crop, optional: true - validates :location, presence: true, uniqueness: { scope: :bed_id } -end diff --git a/app/services/fertilizer_recipe_calculator.rb b/app/services/fertilizer_recipe_calculator.rb deleted file mode 100644 index 28ba8aa..0000000 --- a/app/services/fertilizer_recipe_calculator.rb +++ /dev/null @@ -1,136 +0,0 @@ -class FertilizerRecipeCalculator - NUTRIENTS = %i[nno3 p k ca mg s nnh4].freeze - - def self.call(latest, target, water_volume_l: 100_000) - new(latest, target, water_volume_l).call - end - - def initialize(latest, target, water_volume_l) - @latest = latest || NutrientMeasurement.new - @target = target || NutrientMeasurement.new - @vol_l = water_volume_l.to_f - end - - # Returns a Hash { FertilizerComponent => qty_kg } - def call - deltas_mg_per_l = NUTRIENTS.index_with do |k| - [ (value(@target, k) - value(@latest, k)), 0 ].max - end - - # mg/L * L = mg -> kg - needed_kg = deltas_mg_per_l.transform_values { |mg_l| mg_l * @vol_l / 1_000_000.0 } - - recipe = Hash.new(0.0) - - # 1) P via MAP or DAP (accounts NH4-N) - if (p_need = needed_kg[:p]) > 0 - map = fc_by_formula("MAP") || fc_by_name_like("monoammonium phosphate") - dap = fc_by_formula("DAP") || fc_by_name_like("diammonium phosphate") - carrier = [ map, dap ].compact.find { |c| positive?(c.p) } - if carrier - kg = kg_for(p_need, carrier.p) - recipe[carrier] += kg - needed_kg[:nnh4] = [ needed_kg[:nnh4] - kg * pct(carrier.nnh4), 0 ].max if positive?(carrier.nnh4) - needed_kg[:p] = 0 - end - end - - # 2) NO3-N via KNO3 then Ca(NO3)2 (accounts K/Ca) - if (n_need = needed_kg[:nno3]) > 0 - kno3 = fc_by_formula("KNO3") || fc_by_name_like("potassium nitrate") - can = fc_by_formula("Ca(NO3)2") || fc_by_name_like("calcium nitrate") - - if kno3&.nno3.to_f > 0 - kg = kg_for(n_need, kno3.nno3) - recipe[kno3] += kg - needed_kg[:k] = [ needed_kg[:k] - kg * pct(kno3.k), 0 ].max - n_need -= kg * pct(kno3.nno3) - end - - if n_need > 0 && can&.nno3.to_f > 0 - kg = kg_for(n_need, can.nno3) - recipe[can] += kg - needed_kg[:ca] = [ needed_kg[:ca] - kg * pct(can.ca), 0 ].max - n_need = 0 - end - - needed_kg[:nno3] = [ n_need, 0 ].max - end - - # 3) K via K2SO4 (accounts S) - if (k_need = needed_kg[:k]) > 0 - sop = fc_by_formula("K2SO4") || fc_by_name_like("potassium sulfate") - if sop&.k.to_f > 0 - kg = kg_for(k_need, sop.k) - recipe[sop] += kg - needed_kg[:s] = [ needed_kg[:s] - kg * pct(sop.s), 0 ].max - needed_kg[:k] = 0 - end - end - - # 4) Ca via Ca(NO3)2 (accounts NO3-N) - if (ca_need = needed_kg[:ca]) > 0 - can = recipe.keys.find { |c| norm_formula(c) == "ca(no3)2" } || - fc_by_formula("Ca(NO3)2") || fc_by_name_like("calcium nitrate") - if can&.ca.to_f > 0 - kg = kg_for(ca_need, can.ca) - recipe[can] += kg - needed_kg[:nno3] = [ needed_kg[:nno3] - kg * pct(can.nno3), 0 ].max - needed_kg[:ca] = 0 - end - end - - # 5) Mg via MgSO4 (accounts S) - if (mg_need = needed_kg[:mg]) > 0 - mgs = fc_by_formula("MgSO4") || fc_by_name_like("magnesium sulfate") - if mgs&.mg.to_f > 0 - kg = kg_for(mg_need, mgs.mg) - recipe[mgs] += kg - needed_kg[:s] = [ needed_kg[:s] - kg * pct(mgs.s), 0 ].max - needed_kg[:mg] = 0 - end - end - - # 6) S via K2SO4 or MgSO4 (whichever we already used or find) - if (s_need = needed_kg[:s]) > 0 - sop = recipe.keys.find { |c| norm_formula(c) == "k2so4" } || - fc_by_formula("K2SO4") || fc_by_name_like("potassium sulfate") - mgs = recipe.keys.find { |c| norm_formula(c) == "mgso4" } || - fc_by_formula("MgSO4") || fc_by_name_like("magnesium sulfate") - carrier = [ sop, mgs ].compact.find { |c| positive?(c.s) } - if carrier - kg = kg_for(s_need, carrier.s) - recipe[carrier] += kg - needed_kg[:k] = [ needed_kg[:k] - kg * pct(carrier.k), 0 ].max if positive?(carrier.k) - needed_kg[:mg] = [ needed_kg[:mg] - kg * pct(carrier.mg), 0 ].max if positive?(carrier.mg) - needed_kg[:s] = 0 - end - end - - recipe.delete_if { |_c, kg| kg < 0.01 } - recipe - end - - private - - def value(obj, key) = obj.public_send(key).to_f - - def fc_by_formula(formula) - FertilizerComponent.where("LOWER(formula) = ?", formula.to_s.downcase).first - end - - def fc_by_name_like(name) - FertilizerComponent.where("LOWER(name) LIKE ?", "%#{name.downcase}%").first - end - - def kg_for(need_kg_element, percent_in_product) - return 0.0 unless positive?(percent_in_product) - need_kg_element / pct(percent_in_product) - end - - def pct(v) = v.to_f / 100.0 - - def positive?(v) = v.to_f > 0.0 - - def norm_formula(c) = c.formula.to_s.downcase.gsub(/\s+/, "") -end diff --git a/app/services/target_nutrient_calculator.rb b/app/services/target_nutrient_calculator.rb deleted file mode 100644 index e6cd378..0000000 --- a/app/services/target_nutrient_calculator.rb +++ /dev/null @@ -1,33 +0,0 @@ -class TargetNutrientCalculator - # Derive nutrient columns from the NutrientMeasurement table - NUTRIENT_COLUMNS = (NutrientMeasurement.column_names - %w[id measured_on created_at updated_at]) - .map!(&:to_sym) - .freeze - - # Returns an unsaved NutrientMeasurement with target concentrations (e.g., mg/L) - def self.call - rafts = Raft.includes(:crop).where.not(crop_id: nil) - total = rafts.count - return empty_measurement if total.zero? - - sums = Hash.new(0.0) - - rafts.each do |raft| - NUTRIENT_COLUMNS.each do |col| - v = raft.crop.public_send(col) - sums[col] += v.to_f if v - end - end - - targets = sums.transform_values { |s| s / total } - NutrientMeasurement.new({ measured_on: Date.current }.merge(targets)) - end - - private - - def empty_measurement - NutrientMeasurement.new( - { measured_on: Date.current }.merge(NUTRIENT_COLUMNS.index_with { 0.0 }) - ) - end -end diff --git a/app/views/dashboard/_nutrient_profile_allocator.html.erb b/app/views/dashboard/_nutrient_profile_allocator.html.erb new file mode 100644 index 0000000..d402ace --- /dev/null +++ b/app/views/dashboard/_nutrient_profile_allocator.html.erb @@ -0,0 +1,187 @@ +<%# 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 : <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/_raft_allocation.html.erb b/app/views/dashboard/_raft_allocation.html.erb index a2128ea..1c9ef9a 100644 --- a/app/views/dashboard/_raft_allocation.html.erb +++ b/app/views/dashboard/_raft_allocation.html.erb @@ -1,7 +1,7 @@ <div class="card shadow mb-4"> <div class="card-header d-flex justify-content-between align-items-center"> <h5 class="mb-0">Crop Allocation</h5> - <%= link_to "Edit allocation", beds_path, class: "btn btn-sm btn-primary" %> + <%#= link_to "Edit allocation", beds_path, class: "btn btn-sm btn-primary" %> </div> <%= bar_chart @raft_data, stacked: true %> diff --git a/app/views/dashboard/index.html.erb b/app/views/dashboard/index.html.erb index b1b2d87..b8f9145 100644 --- a/app/views/dashboard/index.html.erb +++ b/app/views/dashboard/index.html.erb @@ -1,7 +1,9 @@ <h1 class="display-1">Ferti</h1> -<%= render "raft_allocation" %> +<%= render "nutrient_profile_allocator", nutrient_profiles: @nutrient_profiles %> -<%= render "target_table" %> +<%#= render "raft_allocation" %> -<%= render "nutrient_measurements" %> +<%#= render "target_table" %> + +<%#= render "nutrient_measurements" %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index bf61c9d..dc61670 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -14,8 +14,8 @@ <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> <link rel="icon" href="/icon.png" type="image/png"> - <link rel="icon" href="/icon.svg" type="image/svg+xml"> - <link rel="apple-touch-icon" href="/icon.png"> + <!-- <link rel="icon" href="/icon.svg" type="image/svg+xml"> --> + <!-- <link rel="apple-touch-icon" href="/icon.png"> --> <%# Includes all stylesheet files in app/assets/stylesheets %> <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %> @@ -25,7 +25,6 @@ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chartkick@5"></script> - <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script> <%= javascript_importmap_tags %> |