From 7831ae8106e23fc17d22968028de8e52376bc0d1 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 22 Jun 2026 08:16:44 -0400 Subject: [PATCH] Show allocated/amount-due stats on grant-funded scholarship edit Grant-funded scholarships had no event, so they lost the at-a-glance budget stats that event-funded scholarships show. Surface the pending scholarship amount split across "Allocated" and "Amount due" so staff can see disbursement status without saving, mirroring the event flow. Co-Authored-By: Claude Opus 4.8 --- .../scholarship_preview_controller.js | 38 ++++++++++++++++++- app/views/scholarships/_form.html.erb | 38 +++++++++++++++++-- spec/requests/scholarships_spec.rb | 16 ++++++++ 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/app/frontend/javascript/controllers/scholarship_preview_controller.js b/app/frontend/javascript/controllers/scholarship_preview_controller.js index 5c937d58c7..7d35b7d732 100644 --- a/app/frontend/javascript/controllers/scholarship_preview_controller.js +++ b/app/frontend/javascript/controllers/scholarship_preview_controller.js @@ -3,9 +3,16 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="scholarship-preview" // Live-previews the "Still owed" stat and the scholarship-amount allocation // as the user edits the amount — before saving. +// +// Grant-funded scholarships have no event/registration to allocate against, so +// instead the pending award is split live across "Allocated" (counts once the +// recipient's tasks are completed) and "Amount due" (what's still owed them). export default class extends Controller { - static targets = ["amount", "owed", "amountBox", "allocationStrip", "allocatedHint"] - static values = { eventCost: Number, otherAllocated: Number } + static targets = [ + "amount", "owed", "amountBox", "allocationStrip", "allocatedHint", + "completed", "grantAmount", "grantAllocated", "grantDue" + ] + static values = { eventCost: Number, otherAllocated: Number, grantFunded: Boolean } connect() { this.update() @@ -17,6 +24,7 @@ export default class extends Controller { this.renderOwed(allocatedCents) this.renderAllocation(allocatedCents) + this.renderGrantSplit(allocatedCents) } renderOwed(allocatedCents) { @@ -53,6 +61,32 @@ export default class extends Controller { } } + // Grant-funded only: the award counts as allocated once tasks are completed; + // whatever isn't allocated is still due to the recipient. + renderGrantSplit(amountCents) { + if (!this.grantFundedValue) return + + const completed = this.hasCompletedTarget && this.completedTarget.checked + const allocatedCents = completed ? amountCents : 0 + const dueCents = Math.max(amountCents - allocatedCents, 0) + + if (this.hasGrantAmountTarget) { + this.grantAmountTarget.textContent = this.formatDollars(amountCents) + } + + if (this.hasGrantAllocatedTarget) { + this.grantAllocatedTarget.textContent = this.formatDollars(allocatedCents) + } + + if (this.hasGrantDueTarget) { + const paid = dueCents <= 0 + const cls = paid ? "border-green-200 bg-green-50 text-green-700" : "border-amber-200 bg-amber-50 text-amber-700" + const icon = paid ? "fa-circle-check" : "fa-circle-exclamation" + const label = paid ? "Paid" : `${this.formatDollars(dueCents)} due` + this.grantDueTarget.innerHTML = `${label}` + } + } + formatDollars(cents) { const whole = cents % 100 === 0 return `$${(cents / 100).toLocaleString("en-US", { minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: 2 })}` diff --git a/app/views/scholarships/_form.html.erb b/app/views/scholarships/_form.html.erb index 2b2102909b..83b6f9e8f6 100644 --- a/app/views/scholarships/_form.html.erb +++ b/app/views/scholarships/_form.html.erb @@ -7,8 +7,9 @@ <%= render "shared/errors", resource: f.object if f.object.errors.any? %>
+ data-scholarship-preview-event-cost-value="<%= @allocatable.respond_to?(:event) ? @allocatable.event.cost_cents.to_i : 0 %>" + data-scholarship-preview-other-allocated-value="<%= @allocatable.respond_to?(:event) ? (@allocatable.allocations_sum.to_i - f.object.allocation&.amount.to_i) : 0 %>" + data-scholarship-preview-grant-funded-value="<%= grant_funded %>">
@@ -76,6 +77,37 @@
<% end %> + <%# Grant-funded scholarships have no event to owe against, so mirror the event + stat grid with the award split across "Allocated" (counts once tasks are + completed) and "Amount due" (what's still owed the recipient). + scholarship_preview_controller keeps these in sync as the amount and the + tasks toggle change. %> + <% if grant_funded %> + <% amount_cents = f.object.amount_cents.to_i %> + <% allocated_cents = f.object.tasks_completed? ? amount_cents : 0 %> + <% due_cents = [ amount_cents - allocated_cents, 0 ].max %> + <% paid = due_cents <= 0 %> +
+
+
Scholarship amount
+
<%= dollars_from_cents(amount_cents) %>
+
+
+
Allocated
+
<%= dollars_from_cents(allocated_cents) %>
+
+
+
Amount due
+
+ +
+
+
+ <% end %> + <%# With a registration, the amount + tasks-completed toggle live in the stat grid above. Without one (grant-funded or standalone) they live here instead. %> <% unless @allocatable.respond_to?(:event) %> @@ -103,7 +135,7 @@
<%= f.label :tasks_completed, "Tasks completed", class: "block text-sm font-medium text-gray-700 mb-1" %>