diff --git a/Gemfile.lock b/Gemfile.lock index 2d3f618..3402f94 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -465,6 +465,7 @@ PLATFORMS arm-linux-musl arm64-darwin-23 arm64-darwin-24 + arm64-darwin-25 x86_64-linux x86_64-linux-gnu x86_64-linux-musl diff --git a/app/javascript/controllers/one_time_amount_controller.js b/app/javascript/controllers/one_time_amount_controller.js new file mode 100644 index 0000000..a635c20 --- /dev/null +++ b/app/javascript/controllers/one_time_amount_controller.js @@ -0,0 +1,33 @@ +import { Controller } from "@hotwired/stimulus" + +const currency = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, +}) + +// Keeps a one-time giving amount within the scenario's remaining giving budget. +// Sets a custom-validity message so the browser blocks submission (via native +// constraint validation, regardless of number-input support) and shows a +// friendly error without closing the modal. +export default class extends Controller { + static values = { max: Number } + + connect() { + this.element.addEventListener("input", this.validate) + this.validate() + } + + disconnect() { + this.element.removeEventListener("input", this.validate) + } + + validate = () => { + const overMaxValue = this.element.value !== "" && Number(this.element.value) > this.maxValue + this.element.setCustomValidity(overMaxValue ? this.message : "") + } + + get message() { + return `Enter an amount of ${currency.format(this.maxValue)} or less — that's your remaining one-time giving budget.` + } +} \ No newline at end of file diff --git a/app/models/allocation/one_time.rb b/app/models/allocation/one_time.rb index 2df868a..d81eee1 100644 --- a/app/models/allocation/one_time.rb +++ b/app/models/allocation/one_time.rb @@ -19,14 +19,36 @@ def share_percentage (amount.to_i / total.to_f * 100).round end + # The most this allocation can be set to while staying within the scenario's + # total giving budget. Returns nil when no budget is set (the server imposes + # no cap there either). Never drops below the allocation's own amount so + # pre-existing over-allocated data stays editable. Used by the view helper so + # the slider cap and the server validator stay in sync. + def max_amount(scenario = self.scenario) + remaining = budget_remaining(scenario) + return if remaining.nil? + + [ remaining, amount.to_i ].max + end + private def within_total_giving_amount return if amount.blank? || scenario&.total_giving_amount.blank? - others = scenario.one_time_allocations.where.not(id: id).sum(:amount) - if others + amount > scenario.total_giving_amount + remaining = budget_remaining(scenario) + return if remaining.nil? + + if amount > remaining + others = scenario.total_giving_amount - remaining errors.add(:amount, "would bring one-time giving to #{others + amount}, over the total giving amount of #{scenario.total_giving_amount.to_i}") end end + + def budget_remaining(scenario = self.scenario) + return nil if scenario.blank? || scenario.total_giving_amount.blank? + + others = scenario.one_time_allocations.where.not(id: id).sum(:amount) + scenario.total_giving_amount - others + end end diff --git a/app/views/scenarios/_allocation_modal.html.erb b/app/views/scenarios/_allocation_modal.html.erb index b8e094f..3dfd9b8 100644 --- a/app/views/scenarios/_allocation_modal.html.erb +++ b/app/views/scenarios/_allocation_modal.html.erb @@ -26,13 +26,14 @@ class: "allocation-slider block mt-2" %> <% else %> + <% max_amount = allocation.max_amount(scenario) if klass == Allocation::OneTime %>