From 443cef5f1ca60d2d4a6ed4f6289d5aa65e6caabb Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 2 Aug 2026 15:07:46 -0400 Subject: [PATCH 1/9] Add shareable facilitator training report (scholarships + trainees) Turns the manually-maintained per-training summary spreadsheet into a reusable partial: scholarship dollars and award counts (funded vs unfunded) plus trainee counts, one column per facilitator training, grouped by year. Trainee counts split into "2-Day" (scheduled) vs "On-Demand" totals via a new event.on_demand flag. Rendered on the cross-event revenue page and a new standalone, shareable report page (/events/facilitator_training_report). Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 1 + app/controllers/events_controller.rb | 18 ++- app/models/event.rb | 4 + app/policies/event_policy.rb | 7 + app/services/event_dashboard.rb | 11 ++ app/services/facilitator_training_report.rb | 73 ++++++++++ .../_facilitator_training_report.html.erb | 125 ++++++++++++++++++ app/views/events/_form.html.erb | 1 + .../facilitator_training_report.html.erb | 20 +++ app/views/events/statistics.html.erb | 14 ++ config/routes.rb | 1 + .../20260802185720_add_on_demand_to_events.rb | 14 ++ db/schema.rb | 1 + spec/requests/events_spec.rb | 33 +++++ spec/routing/events_routing_spec.rb | 4 + spec/services/event_dashboard_spec.rb | 31 +++++ .../facilitator_training_report_spec.rb | 109 +++++++++++++++ spec/views/page_bg_class_alignment_spec.rb | 1 + 18 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 app/services/facilitator_training_report.rb create mode 100644 app/views/events/_facilitator_training_report.html.erb create mode 100644 app/views/events/facilitator_training_report.html.erb create mode 100644 db/migrate/20260802185720_add_on_demand_to_events.rb create mode 100644 spec/services/facilitator_training_report_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 0830b36dc5..82c61d7b61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,6 +192,7 @@ end - `EventRevenueFigures` — Batch-loads the per-event money components `EventRevenueReport` rows are built from (registration payments/outstanding, funded/unfunded scholarships, discounts, CE paid/outstanding) in a fixed number of grouped queries; mirrors the `EventDashboard` definitions - `EventParticipationReport` — Cross-event participation report grouped by calendar year (unique people trained vs attended seats vs per-status outcome counts, chart series) for the events participation page; sibling of `EventRevenueReport` - `ReportPeriods` — Shared module (included by `EventRevenueReport` and `EventParticipationReport`) resolving the reporting-hub period toggle (this year / last year / all time) to a metric scope + label for the summary cards +- `FacilitatorTrainingReport` — Cross-event summary of facilitator trainings grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventDashboard`) plus trainee counts split into "2-Day" (scheduled) vs "On-Demand" (`event.on_demand?`) totals. Rendered by the shared `events/_facilitator_training_report` partial on the events statistics hub and the standalone `events#facilitator_training_report` page - `ScholarshipApplication` — Gathers one person's scholarship-application answers for an event by field across all their submissions, so answers surface whether captured on a dedicated scholarship form, an embedded registration section, or the registration submission itself (used by the scholarship edit page and the public submission view) - `WorkshopSearchService` — Complex filtering, sorting, pagination with ActionPolicy - `WorkshopFromIdeaService` — Converts WorkshopIdea to Workshop with asset migration diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index f90026197a..c3398baa65 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -38,12 +38,21 @@ def participation end # Events statistics hub: the revenue and participation report summaries side by - # side, each linking to its full report. + # side, each linking to its full report, plus the facilitator-training summary. def statistics authorize! @period = params[:period].presence_in(%w[ this_year last_year all_time ]) || "this_year" @revenue_report = EventRevenueReport.new(report_events(Event.paid)) @participation_report = EventParticipationReport.new(report_events(Event.all)) + @facilitator_report = FacilitatorTrainingReport.new(facilitator_training_events) + end + + # Standalone, shareable summary of scholarship dollars/counts and trainee counts + # across facilitator trainings, grouped by year. Admin-only (aggregates money + # across every event), the same report partial the statistics hub embeds. + def facilitator_training_report + authorize! + @report = FacilitatorTrainingReport.new(facilitator_training_events) end def new @@ -687,6 +696,13 @@ def set_event @event = Event.find(params[:id]) end + # Facilitator-training events for the summary report, ordered by start date so + # columns read left-to-right chronologically within each year. Passed as plain + # records — the report decorates each for its column label/date range. + def facilitator_training_events + Event.facilitator_trainings.order(:start_date) + end + def event_params authorized(params.require(:event)) end diff --git a/app/models/event.rb b/app/models/event.rb index 952cc07aac..5abd1ec7f7 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -93,6 +93,10 @@ def remote_search_label # start_date is a date column, so compare against a date — a Time would be cast # to midnight and drop events starting today. scope :upcoming, -> { where("start_date >= ?", Date.current) } + # On-demand (self-paced) events, as opposed to scheduled multi-day sessions. + # Splits the facilitator-training report's "2-Day" vs "On-Demand" trainee totals. + scope :on_demand, -> { where(on_demand: true) } + scope :scheduled, -> { where(on_demand: false) } # Events that charge a registration fee (cost_cents may be nil for free ones). scope :paid, -> { where("cost_cents > 0") } # Events whose start date falls in the given calendar year. Keyed off the year diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index a520fc27de..825cd63a81 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -31,6 +31,12 @@ def statistics? admin? end + # The facilitator-training report aggregates scholarship money and trainee + # counts across every training, so it's admin-only like the revenue report. + def facilitator_training_report? + admin? + end + def show? return true if admin? @@ -160,6 +166,7 @@ def google_analytics? :pre_title, :pre_date_text, :facilitator_training, + :on_demand, :featured, :start_date, :start_date_date, :start_date_time, :end_date, :end_date_date, :end_date_time, diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index 50ce24da97..702f7a949d 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -103,6 +103,17 @@ def unfunded_scholarship_cents scholarships.where(grant_id: nil).sum(:amount_cents) end + # Number of scholarship awards, split the same way as the dollar figures: a + # scholarship is funded when a grant backs it, unfunded otherwise. Together + # these sum to the event's total scholarship award count. + def funded_scholarship_count + scholarships.where.not(grant_id: nil).count + end + + def unfunded_scholarship_count + scholarships.where(grant_id: nil).count + end + def scholarship_recipient_count scholarships.distinct.count(:recipient_id) end diff --git a/app/services/facilitator_training_report.rb b/app/services/facilitator_training_report.rb new file mode 100644 index 0000000000..addea76f6b --- /dev/null +++ b/app/services/facilitator_training_report.rb @@ -0,0 +1,73 @@ +# Cross-event summary of a year's facilitator trainings: scholarship dollars and +# award counts (funded vs unfunded) plus trainee counts, one column per training. +# Funded vs unfunded follows the same convention as everywhere else — a +# scholarship is funded when a grant backs it — sourced from EventDashboard so +# the definition lives in one place. +# +# Trainee counts split by delivery format: scheduled multi-day sessions total +# under "2-Day" and self-paced ones (event.on_demand?) under "On-Demand". +# +# Give it a collection of facilitator-training events; it groups them by calendar +# year, newest first, each year a table's worth of columns with its own totals. +class FacilitatorTrainingReport + # One facilitator-training event's column. Delegates the funded/unfunded splits + # to EventDashboard so the money and count conventions can't drift. + Column = Struct.new(:event, :dashboard, keyword_init: true) do + def funded_cents = dashboard.funded_scholarship_cents + def unfunded_cents = dashboard.unfunded_scholarship_cents + def scholarship_cents = funded_cents + unfunded_cents + + def funded_count = dashboard.funded_scholarship_count + def unfunded_count = dashboard.unfunded_scholarship_count + def scholarship_count = funded_count + unfunded_count + + def trainee_count = dashboard.registrant_count + + def on_demand? = event.on_demand? + + def label = event.decorate.compact_label + def date_label = event.start_date? ? event.decorate.short_date_range : nil + end + + # The figures summed across a year's columns for the totals row. + SUMMABLE = %i[ + funded_cents unfunded_cents scholarship_cents + funded_count unfunded_count scholarship_count trainee_count + ].freeze + + # One calendar year of facilitator trainings, with its columns and totals. + YearGroup = Struct.new(:year, :columns, keyword_init: true) do + SUMMABLE.each do |attribute| + define_method(attribute) { columns.sum(&attribute) } + end + + # Trainee totals split by delivery format — these sum to trainee_count. + def two_day_trainee_count = columns.reject(&:on_demand?).sum(&:trainee_count) + def on_demand_trainee_count = columns.select(&:on_demand?).sum(&:trainee_count) + end + + def initialize(events) + @events = events + end + + def any? + years.any? + end + + # Calendar-year groups, newest first. Events without a start date fall under a + # nil year that sorts last. + def years + @years ||= @events + .group_by { |event| event.start_date&.year } + .map { |year, year_events| YearGroup.new(year: year, columns: columns_for(year_events)) } + .sort_by { |group| [ group.year ? 0 : 1, -(group.year || 0) ] } + end + + private + + def columns_for(events) + events + .sort_by { |event| event.start_date || Time.zone.at(0) } + .map { |event| Column.new(event: event, dashboard: EventDashboard.new(event)) } + end +end diff --git a/app/views/events/_facilitator_training_report.html.erb b/app/views/events/_facilitator_training_report.html.erb new file mode 100644 index 0000000000..60c86715fb --- /dev/null +++ b/app/views/events/_facilitator_training_report.html.erb @@ -0,0 +1,125 @@ +<%# Shareable facilitator-training summary: scholarship dollars and award counts + (funded vs unfunded) plus trainee counts, one column per training, grouped by + year. Pass `report:` (a FacilitatorTrainingReport). Rendered on the event + revenue page and the standalone facilitator training report page. %> +<% if report.any? %> +
+ <% report.years.each do |group| %> + <% events = group.columns %> +
+
+

<%= group.year || "Undated" %> facilitator trainings

+ <%= pluralize(events.size, "training") %> +
+ +
+ + <%# ---- $ of Scholarships ---- %> + + + + <% events.each do |column| %> + + <% end %> + + + + + + <% events.each do |column| %> + + <% end %> + + + + <% events.each do |column| %> + + <% end %> + + + + <% events.each do |column| %> + + <% end %> + + + + + + + <%# ---- # of Scholarships ---- %> + + + <% events.each do |column| %> + + <% end %> + + + + <% events.each do |column| %> + + <% end %> + + + + <% events.each do |column| %> + + <% end %> + + + + <% events.each do |column| %> + + <% end %> + + + + + + + <%# ---- # of Trainees ---- %> + + + <% events.each do |column| %> + + <% end %> + + + + <% events.each do |column| %> + + <% end %> + + + + + + + + + + + + + + +
$ of scholarships + <%= column.label %> + <% if column.date_label %> + <%= column.date_label %> + <% end %> +
Total funded<%= dollars_from_cents(column.funded_cents) %>
Total unfunded<%= dollars_from_cents(column.unfunded_cents) %>
Total<%= dollars_from_cents(column.scholarship_cents) %>
Overall total<%= dollars_from_cents(group.scholarship_cents) %>
# of scholarships<%= column.label %>
Total # funded<%= column.funded_count %>
Total # unfunded<%= column.unfunded_count %>
Total #<%= column.scholarship_count %>
Overall # total<%= group.scholarship_count %>
# of trainees + <%= column.label %> + <% if column.on_demand? %> + On-demand + <% end %> +
Trainees<%= column.trainee_count %>
2-Day total<%= group.two_day_trainee_count %>
On-Demand total<%= group.on_demand_trainee_count %>
Overall total<%= group.trainee_count %>
+
+
+ <% end %> +
+<% else %> +
+ No facilitator trainings yet — mark an event as a facilitator training to see it here. +
+<% end %> diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 042b3c7ed2..64e459e744 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -31,6 +31,7 @@ placeholder: "e.g. TOS205" } %> <%= f.input :facilitator_training, as: :boolean, label: "Facilitator training event", wrapper_html: { class: "mb-0!" } %> + <%= f.input :on_demand, as: :boolean, label: "On-demand (self-paced)", hint: "Counts under \"On-Demand\" instead of \"2-Day\" in the facilitator training report", wrapper_html: { class: "mb-0!" } %> <%= f.input :pre_title, diff --git a/app/views/events/facilitator_training_report.html.erb b/app/views/events/facilitator_training_report.html.erb new file mode 100644 index 0000000000..844918077c --- /dev/null +++ b/app/views/events/facilitator_training_report.html.erb @@ -0,0 +1,20 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% content_for(:full_width, true) %> + +
+
+
+ <%= link_to "← Events statistics", statistics_events_path(return_to: params[:return_to]), + class: "text-sm font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline" %> +
+ +
+

Facilitator training report

+

+ Scholarship dollars and awards (funded vs unfunded) plus trainee counts, one column per training, grouped by year. +

+
+ + <%= render "facilitator_training_report", report: @report %> +
+
diff --git a/app/views/events/statistics.html.erb b/app/views/events/statistics.html.erb index 64c9c62741..4ead945458 100644 --- a/app/views/events/statistics.html.erb +++ b/app/views/events/statistics.html.erb @@ -42,5 +42,19 @@ <%= render "events/participation_summary", report: @participation_report, period: @participation_report.period_scope(@period) %> + + <%# Scholarship + trainee summary across facilitator trainings, grouped by + year. The same partial the standalone report page renders. %> +
+
+
+

Facilitator training summary

+

Scholarship dollars and awards (funded vs unfunded) plus trainee counts, by training.

+
+ <%= link_to "Open report →", facilitator_training_report_events_path(return_to: params[:return_to]), + class: "text-sm font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline whitespace-nowrap" %> +
+ <%= render "facilitator_training_report", report: @facilitator_report %> +
diff --git a/config/routes.rb b/config/routes.rb index d74564fde3..da08d006b7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -148,6 +148,7 @@ get :revenue get :participation get :statistics + get :facilitator_training_report end member do get :dashboard diff --git a/db/migrate/20260802185720_add_on_demand_to_events.rb b/db/migrate/20260802185720_add_on_demand_to_events.rb new file mode 100644 index 0000000000..66babd3ab0 --- /dev/null +++ b/db/migrate/20260802185720_add_on_demand_to_events.rb @@ -0,0 +1,14 @@ +class AddOnDemandToEvents < ActiveRecord::Migration[8.0] + # Flags a facilitator-training event as on-demand (self-paced) rather than a + # scheduled multi-day session. Used by the facilitator-training report to split + # trainee counts into "2-Day" vs "On-Demand" totals. Defaults false so existing + # events read as scheduled sessions. + def up + return if column_exists?(:events, :on_demand) + add_column :events, :on_demand, :boolean, default: false, null: false + end + + def down + remove_column :events, :on_demand, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 62a0080d14..bb7111784a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -553,6 +553,7 @@ t.string "hint_times" t.boolean "inactive", default: true, null: false t.integer "location_id" + t.boolean "on_demand", default: false, null: false t.string "pre_date_text" t.string "pre_title" t.boolean "public_registration_enabled", default: false, null: false diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 7b1b97fba6..28056c4438 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -329,6 +329,14 @@ def add_ce_registrant(target_event) expect(response.body).to include(revenue_events_path, participation_events_path) end + it "embeds the facilitator training summary, linking to its full report" do + sign_in admin + get statistics_events_path + expect(response.body).to include("Facilitator training summary") + expect(response.body).to include("$ of scholarships", "# of trainees") + expect(response.body).to include(facilitator_training_report_events_path) + end + it "carries the active filters into the full report links" do sign_in admin get statistics_events_path(period: "all_time", event_type: "trainings") @@ -367,6 +375,31 @@ def add_ce_registrant(target_event) end end + describe "GET /facilitator_training_report" do + let!(:training) { create(:event, title: "TAC 261", facilitator_training: true, cost_cents: 10_000, start_date: Date.new(2026, 5, 1)) } + let!(:webinar) { create(:event, title: "Paid webinar", facilitator_training: false, cost_cents: 5_000, start_date: Date.new(2026, 5, 1)) } + + context "as admin" do + it "renders the report with only facilitator trainings as columns" do + sign_in admin + get facilitator_training_report_events_path + expect(response).to have_http_status(:ok) + expect(response.body).to include("Facilitator training report") + expect(response.body).to include("$ of scholarships", "# of scholarships", "# of trainees") + expect(response.body).to include("TAC 261") + expect(response.body).not_to include("Paid webinar") + end + end + + context "as non-admin" do + it "redirects" do + sign_in user + get facilitator_training_report_events_path + expect(response).to redirect_to(root_path) + end + end + end + describe "GET /sample_ticket" do context "as admin" do before { sign_in admin } diff --git a/spec/routing/events_routing_spec.rb b/spec/routing/events_routing_spec.rb index a40250e199..f2091fcdce 100644 --- a/spec/routing/events_routing_spec.rb +++ b/spec/routing/events_routing_spec.rb @@ -5,5 +5,9 @@ it "routes to #dashboard" do expect(get: "/events/1/dashboard").to route_to("events#dashboard", id: "1") end + + it "routes to #facilitator_training_report" do + expect(get: "/events/facilitator_training_report").to route_to("events#facilitator_training_report") + end end end diff --git a/spec/services/event_dashboard_spec.rb b/spec/services/event_dashboard_spec.rb index a2fe476a0d..69b64707aa 100644 --- a/spec/services/event_dashboard_spec.rb +++ b/spec/services/event_dashboard_spec.rb @@ -1050,4 +1050,35 @@ def opt_in(person, text:) end end end + + describe "scholarship funded/unfunded counts" do + let(:event) { create(:event, cost_cents: 50_000) } + let(:person1) { create(:person) } + let(:person2) { create(:person) } + let(:person3) { create(:person) } + + before do + reg1 = create(:event_registration, event: event, registrant: person1, status: "registered") + reg2 = create(:event_registration, event: event, registrant: person2, status: "registered") + + grant_backed = create(:scholarship, recipient: person1, amount_cents: 4_000, grant: create(:grant)) + create(:allocation, source: grant_backed, allocatable: reg1, amount: 4_000) + + comped = create(:scholarship, recipient: person2, amount_cents: 2_000, grant: nil) + create(:allocation, source: comped, allocatable: reg2, amount: 2_000) + + # A scholarship on a cancelled registration must be ignored by both counts. + cancelled = create(:event_registration, event: event, registrant: person3, status: "cancelled") + ignored = create(:scholarship, recipient: person3, amount_cents: 3_000, grant: create(:grant)) + create(:allocation, source: ignored, allocatable: cancelled, amount: 3_000) + end + + it "counts grant-backed scholarships as funded" do + expect(dashboard.funded_scholarship_count).to eq(1) + end + + it "counts grant-free scholarships as unfunded" do + expect(dashboard.unfunded_scholarship_count).to eq(1) + end + end end diff --git a/spec/services/facilitator_training_report_spec.rb b/spec/services/facilitator_training_report_spec.rb new file mode 100644 index 0000000000..c424d5459d --- /dev/null +++ b/spec/services/facilitator_training_report_spec.rb @@ -0,0 +1,109 @@ +require "rails_helper" + +RSpec.describe FacilitatorTrainingReport do + describe "per-event columns" do + subject(:report) { described_class.new([ event ]) } + + let(:event) { create(:event, facilitator_training: true, cost_cents: 50_000, start_date: Date.new(2025, 3, 1)) } + let(:person1) { create(:person) } + let(:person2) { create(:person) } + + let!(:reg1) { create(:event_registration, event: event, registrant: person1, status: "registered") } + let!(:reg2) { create(:event_registration, event: event, registrant: person2, status: "registered") } + + before do + # A cancelled registration whose trainee count and scholarship must be ignored. + cancelled = create(:event_registration, event: event, registrant: create(:person), status: "cancelled") + ignored = create(:scholarship, recipient: cancelled.registrant, amount_cents: 9_999, grant: create(:grant)) + create(:allocation, source: ignored, allocatable: cancelled, amount: 9_999) + + funded = create(:scholarship, recipient: person1, amount_cents: 4_000, grant: create(:grant)) + create(:allocation, source: funded, allocatable: reg1, amount: 4_000) + + unfunded = create(:scholarship, recipient: person2, amount_cents: 2_000, grant: nil) + create(:allocation, source: unfunded, allocatable: reg2, amount: 2_000) + end + + let(:column) { report.years.first.columns.first } + + it "splits scholarship dollars into funded vs unfunded" do + expect(column.funded_cents).to eq(4_000) + expect(column.unfunded_cents).to eq(2_000) + expect(column.scholarship_cents).to eq(6_000) + end + + it "splits scholarship award counts into funded vs unfunded" do + expect(column.funded_count).to eq(1) + expect(column.unfunded_count).to eq(1) + expect(column.scholarship_count).to eq(2) + end + + it "counts only active registrations as trainees" do + expect(column.trainee_count).to eq(2) + end + + it "labels the column from the event" do + expect(column.label).to eq(event.decorate.compact_label) + end + end + + describe "trainee format split" do + let(:scheduled) { create(:event, facilitator_training: true, on_demand: false, start_date: Date.new(2025, 3, 1)) } + let(:on_demand) { create(:event, facilitator_training: true, on_demand: true, start_date: Date.new(2025, 7, 1)) } + + subject(:report) { described_class.new([ scheduled, on_demand ]) } + + before do + 2.times { create(:event_registration, event: scheduled, registrant: create(:person), status: "registered") } + 3.times { create(:event_registration, event: on_demand, registrant: create(:person), status: "registered") } + end + + let(:group) { report.years.first } + + it "totals scheduled trainees under 2-Day and self-paced under On-Demand" do + expect(group.two_day_trainee_count).to eq(2) + expect(group.on_demand_trainee_count).to eq(3) + end + + it "sums both formats into the overall trainee total" do + expect(group.trainee_count).to eq(5) + end + end + + describe "grouping and totals" do + let(:e2024) { create(:event, facilitator_training: true, start_date: Date.new(2024, 5, 1)) } + let(:e2025a) { create(:event, facilitator_training: true, cost_cents: 50_000, start_date: Date.new(2025, 3, 1)) } + let(:e2025b) { create(:event, facilitator_training: true, start_date: Date.new(2025, 11, 1)) } + + subject(:report) { described_class.new([ e2025b, e2024, e2025a ]) } + + before do + person = create(:person) + reg = create(:event_registration, event: e2025a, registrant: person, status: "registered") + funded = create(:scholarship, recipient: person, amount_cents: 5_000, grant: create(:grant)) + create(:allocation, source: funded, allocatable: reg, amount: 5_000) + end + + it "groups by calendar year, newest first" do + expect(report.years.map(&:year)).to eq([ 2025, 2024 ]) + end + + it "orders each year's columns by start date" do + expect(report.years.first.columns.map(&:event)).to eq([ e2025a, e2025b ]) + end + + it "sums scholarship dollars across a year's columns" do + expect(report.years.first.scholarship_cents).to eq(5_000) + end + end + + describe "#any?" do + it "is false with no events" do + expect(described_class.new([]).any?).to be(false) + end + + it "is true with at least one event" do + expect(described_class.new([ create(:event, facilitator_training: true) ]).any?).to be(true) + end + end +end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 4eceafb764..5e9db4b640 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -116,6 +116,7 @@ "app/views/events/revenue.html.erb" => "admin-only bg-blue-100", "app/views/events/participation.html.erb" => "admin-only bg-blue-100", "app/views/events/statistics.html.erb" => "admin-only bg-blue-100", + "app/views/events/facilitator_training_report.html.erb" => "admin-only bg-blue-100", "app/views/events/preview_reminder.html.erb" => "admin-only bg-blue-100", "app/views/events/confirm_reminder.html.erb" => "admin-only bg-blue-100", "app/views/event_registrations/index.html.erb" => "admin-only bg-blue-100", From 4ac08ba60894000a5312da4ec4a93499559c5315 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 11:12:28 -0400 Subject: [PATCH 2/9] Rework into an Events scholarships statistics page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes scholarships a third statistics report alongside revenue and participation (linked from the hub as a summary card), instead of a facilitator-training report embedded in the hub: - Report per facilitator training, grouped by year, with the shared time-period + event filters plus an abbreviation substring search. - Counts scholarship $ and # (awards), funded vs unfunded, and attended trainees split Training (scheduled) vs On-demand. - Funded/unfunded now treats a grant the org donated to itself (AWBW, via new Organization.awbw) as unfunded/subsidy — applied app-wide in EventDashboard and EventRevenueFigures, so revenue agrees. - Drops the unused on_demand/scheduled scopes (kept the column + flag). Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- app/controllers/events_controller.rb | 48 +++--- app/models/event.rb | 4 - app/models/organization.rb | 8 + app/policies/event_policy.rb | 6 +- app/services/event_dashboard.rb | 38 +++-- app/services/event_revenue_figures.rb | 14 +- app/services/event_scholarship_report.rb | 126 +++++++++++++++ app/services/facilitator_training_report.rb | 73 --------- .../events/_abbreviation_filter.html.erb | 9 ++ app/views/events/_form.html.erb | 2 +- .../events/_scholarship_summary.html.erb | 44 ++++++ ...html.erb => _scholarships_report.html.erb} | 26 ++-- .../facilitator_training_report.html.erb | 20 --- app/views/events/scholarships.html.erb | 86 ++++++++++ app/views/events/statistics.html.erb | 25 +-- config/routes.rb | 2 +- spec/models/organization_spec.rb | 15 ++ spec/requests/events_spec.rb | 33 ++-- spec/routing/events_routing_spec.rb | 4 +- spec/services/event_dashboard_spec.rb | 27 ++-- spec/services/event_revenue_figures_spec.rb | 5 +- .../services/event_scholarship_report_spec.rb | 147 ++++++++++++++++++ .../facilitator_training_report_spec.rb | 109 ------------- spec/views/page_bg_class_alignment_spec.rb | 2 +- 25 files changed, 568 insertions(+), 307 deletions(-) create mode 100644 app/services/event_scholarship_report.rb delete mode 100644 app/services/facilitator_training_report.rb create mode 100644 app/views/events/_abbreviation_filter.html.erb create mode 100644 app/views/events/_scholarship_summary.html.erb rename app/views/events/{_facilitator_training_report.html.erb => _scholarships_report.html.erb} (89%) delete mode 100644 app/views/events/facilitator_training_report.html.erb create mode 100644 app/views/events/scholarships.html.erb create mode 100644 spec/services/event_scholarship_report_spec.rb delete mode 100644 spec/services/facilitator_training_report_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 82c61d7b61..e84e1d8bae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,7 +192,7 @@ end - `EventRevenueFigures` — Batch-loads the per-event money components `EventRevenueReport` rows are built from (registration payments/outstanding, funded/unfunded scholarships, discounts, CE paid/outstanding) in a fixed number of grouped queries; mirrors the `EventDashboard` definitions - `EventParticipationReport` — Cross-event participation report grouped by calendar year (unique people trained vs attended seats vs per-status outcome counts, chart series) for the events participation page; sibling of `EventRevenueReport` - `ReportPeriods` — Shared module (included by `EventRevenueReport` and `EventParticipationReport`) resolving the reporting-hub period toggle (this year / last year / all time) to a metric scope + label for the summary cards -- `FacilitatorTrainingReport` — Cross-event summary of facilitator trainings grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventDashboard`) plus trainee counts split into "2-Day" (scheduled) vs "On-Demand" (`event.on_demand?`) totals. Rendered by the shared `events/_facilitator_training_report` partial on the events statistics hub and the standalone `events#facilitator_training_report` page +- `EventScholarshipReport` — Cross-event scholarship report grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventDashboard`) per facilitator training, plus an attended-trainee count split into "Training" (scheduled) vs "On-demand" (`event.on_demand?`). Sibling of `EventRevenueReport`/`EventParticipationReport` (includes `ReportPeriods`); powers the `events#scholarships` report page and the statistics-hub scholarship summary card - `ScholarshipApplication` — Gathers one person's scholarship-application answers for an event by field across all their submissions, so answers surface whether captured on a dedicated scholarship form, an embedded registration section, or the registration submission itself (used by the scholarship edit page and the public submission view) - `WorkshopSearchService` — Complex filtering, sorting, pagination with ActionPolicy - `WorkshopFromIdeaService` — Converts WorkshopIdea to Workshop with asset migration diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index c3398baa65..e7e85a9ee8 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -3,7 +3,7 @@ class EventsController < ApplicationController skip_before_action :authenticate_user!, only: [ :index, :show, :staff ] skip_before_action :verify_authenticity_token, only: [ :preview ] before_action :set_event, only: %i[ show edit update destroy preview dashboard sample_ticket background registrants onboarding staff edit_staff update_staff recipients preview_reminder confirm_reminder send_reminder copy_registration_form ] - before_action :set_report_filters, only: %i[ revenue participation statistics ] + before_action :set_report_filters, only: %i[ revenue participation statistics scholarships ] def index authorize! @@ -37,22 +37,24 @@ def participation @report = EventParticipationReport.new(events, featured_year: selected_year) end - # Events statistics hub: the revenue and participation report summaries side by - # side, each linking to its full report, plus the facilitator-training summary. + # Events statistics hub: the revenue, participation and scholarship report + # summaries side by side, each linking to its full report. def statistics authorize! @period = params[:period].presence_in(%w[ this_year last_year all_time ]) || "this_year" @revenue_report = EventRevenueReport.new(report_events(Event.paid)) @participation_report = EventParticipationReport.new(report_events(Event.all)) - @facilitator_report = FacilitatorTrainingReport.new(facilitator_training_events) + @scholarship_report = EventScholarshipReport.new(report_events(Event.facilitator_trainings)) end - # Standalone, shareable summary of scholarship dollars/counts and trainee counts - # across facilitator trainings, grouped by year. Admin-only (aggregates money - # across every event), the same report partial the statistics hub embeds. - def facilitator_training_report + # Cross-event scholarship report: scholarship dollars and award counts (funded + # vs unfunded) per facilitator training, grouped by year, with an attended- + # trainee count split into Training vs On-demand. Sibling of the revenue and + # participation reports; admin-only. + def scholarships authorize! - @report = FacilitatorTrainingReport.new(facilitator_training_events) + events, selected_year = filtered_report_events(Event.facilitator_trainings) + @report = EventScholarshipReport.new(events, featured_year: selected_year) end def new @@ -457,15 +459,20 @@ def staff_update_return_path end end - # Shared filter state for the revenue/participation/statistics report pages: the - # event-type and specific-event filters, plus the event list for the Event - # dropdown. + # Shared filter state for the revenue/participation/statistics/scholarships + # report pages: the event-type, specific-event and abbreviation-search filters, + # plus the event list for the Event dropdown. def set_report_filters @event_type = params[:event_type].presence_in(%w[ trainings other ]) @filter_event = Event.find_by(id: params[:event_id]) if params[:event_id].present? - # The revenue report only covers paid events, so its Event dropdown lists only - # those; the others list every event. - dropdown_scope = action_name == "revenue" ? Event.paid : Event.all + @event_abbreviation = params[:abbreviation].presence + # The Event dropdown lists the report's own universe: paid events for revenue, + # facilitator trainings for scholarships, every event otherwise. + dropdown_scope = case action_name + when "revenue" then Event.paid + when "scholarships" then Event.facilitator_trainings + else Event.all + end @filter_events = dropdown_scope.order(start_date: :desc) end @@ -492,11 +499,13 @@ def report_events(base) scoped_report_base(base).order(start_date: :desc).map(&:decorate) end - # Narrows `base` by the event-type and specific-event filters. + # Narrows `base` by the event-type, specific-event and abbreviation-search + # filters. def scoped_report_base(base) base = base.facilitator_trainings if @event_type == "trainings" base = base.where(facilitator_training: false) if @event_type == "other" base = base.where(id: @filter_event.id) if @filter_event + base = base.where("events.abbreviation LIKE ?", "%#{Event.sanitize_sql_like(@event_abbreviation)}%") if @event_abbreviation base end @@ -696,13 +705,6 @@ def set_event @event = Event.find(params[:id]) end - # Facilitator-training events for the summary report, ordered by start date so - # columns read left-to-right chronologically within each year. Passed as plain - # records — the report decorates each for its column label/date range. - def facilitator_training_events - Event.facilitator_trainings.order(:start_date) - end - def event_params authorized(params.require(:event)) end diff --git a/app/models/event.rb b/app/models/event.rb index 5abd1ec7f7..952cc07aac 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -93,10 +93,6 @@ def remote_search_label # start_date is a date column, so compare against a date — a Time would be cast # to midnight and drop events starting today. scope :upcoming, -> { where("start_date >= ?", Date.current) } - # On-demand (self-paced) events, as opposed to scheduled multi-day sessions. - # Splits the facilitator-training report's "2-Day" vs "On-Demand" trainee totals. - scope :on_demand, -> { where(on_demand: true) } - scope :scheduled, -> { where(on_demand: false) } # Events that charge a registration fee (cost_cents may be nil for free ones). scope :paid, -> { where("cost_cents > 0") } # Events whose start date falls in the given calendar year. Keyed off the year diff --git a/app/models/organization.rb b/app/models/organization.rb index 007a9c03c7..e1a7db3a04 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -40,6 +40,14 @@ class Organization < ApplicationRecord AGENCY_TYPE_OTHER = "Other" AGENCY_TYPES = [ "501c3/nonprofit", "For-profit", "Government agency", AGENCY_TYPE_OTHER ].freeze + # The organization that runs this app. A grant it donates is the org funding + # itself, so reports count it as subsidy (unfunded), not external funding. + # Identified by name via ORGANIZATION_NAME — the only marker available today. + # Not memoized: the record can be created mid-process (seeds, tests). + def self.awbw + find_by(name: ENV.fetch("ORGANIZATION_NAME", "A Window Between Worlds")) + end + # Validations validates :logo, content_type: %w[image/png image/jpeg image/webp], diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index 825cd63a81..0c23915d1b 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -31,9 +31,9 @@ def statistics? admin? end - # The facilitator-training report aggregates scholarship money and trainee - # counts across every training, so it's admin-only like the revenue report. - def facilitator_training_report? + # The scholarship report aggregates scholarship money and award counts across + # every training, so it's admin-only like the revenue report. + def scholarships? admin? end diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index 702f7a949d..f8fe52aff3 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -90,28 +90,28 @@ def scholarship_total_cents scholarships.sum(:amount_cents) end - # Scholarship dollars drawn from a funder/grant — money a grant pays toward - # registration cost, so it counts as revenue. Paired with + # Scholarship dollars drawn from an EXTERNAL funder/grant — money a grant pays + # toward registration cost, so it counts as revenue. Paired with # unfunded_scholarship_cents, these sum to scholarship_total_cents. def funded_scholarship_cents - scholarships.where.not(grant_id: nil).sum(:amount_cents) + funded_scholarships.sum(:amount_cents) end - # Scholarship dollars awarded without a grant behind them — cost the org comps - # directly, so no money actually changes hands. + # Scholarship dollars the org comps from its own pocket: awards with no grant, + # plus awards from a grant the org donated to itself (AWBW) — that's subsidy, + # not external funding. def unfunded_scholarship_cents - scholarships.where(grant_id: nil).sum(:amount_cents) + unfunded_scholarships.sum(:amount_cents) end - # Number of scholarship awards, split the same way as the dollar figures: a - # scholarship is funded when a grant backs it, unfunded otherwise. Together - # these sum to the event's total scholarship award count. + # Number of scholarship awards, split the same way as the dollar figures. + # Together these sum to the event's total scholarship award count. def funded_scholarship_count - scholarships.where.not(grant_id: nil).count + funded_scholarships.count end def unfunded_scholarship_count - scholarships.where(grant_id: nil).count + unfunded_scholarships.count end def scholarship_recipient_count @@ -1108,6 +1108,22 @@ def scholarships .where(allocations: { allocatable_type: "EventRegistration", allocatable_id: active_registration_ids }) end + # Externally funded = backed by a grant whose donor isn't the org itself. + def funded_scholarships + scholarships.where.not(grant_id: [ nil, *awbw_grant_ids ]) + end + + # Org-subsidized = no grant, or a grant the org (AWBW) donated to itself. + def unfunded_scholarships + scholarships.where(grant_id: [ nil, *awbw_grant_ids ]) + end + + # Ids of grants the org donated to itself; empty when the AWBW org isn't on + # file, collapsing the split back to grant-present vs grant-absent. + def awbw_grant_ids + @awbw_grant_ids ||= Grant.where(donor: Organization.awbw).ids + end + # [ [ organization_id, registrant_id ], ... ] from the organizations linked on # each active registration (the registration-time snapshot only, not later # affiliations) — the basis for grouping registrants by their org's city. diff --git a/app/services/event_revenue_figures.rb b/app/services/event_revenue_figures.rb index 0b390edcb1..552e864807 100644 --- a/app/services/event_revenue_figures.rb +++ b/app/services/event_revenue_figures.rb @@ -43,6 +43,16 @@ def for(event) private + # A grant counts as external funding only when it exists and the org didn't + # donate it to itself (AWBW) — matching EventDashboard#funded_scholarships. + def external_grant?(grant_id) + grant_id.present? && !awbw_grant_ids.include?(grant_id) + end + + def awbw_grant_ids + @awbw_grant_ids ||= Grant.where(donor: Organization.awbw).ids.to_set + end + def figures_by_event_id @figures_by_event_id ||= @events.to_h { |event| [ event.id, build(event) ] } end @@ -56,8 +66,8 @@ def build(event) Figures.new( registration_payments_cents: registration_ids.sum { |id| registration_allocations[[ id, "Payment" ]].to_i }, registration_outstanding_cents: registration_ids.sum { |id| [ cost_cents - registration_allocated_total[id], 0 ].max }, - funded_scholarship_cents: scholarships.sum { |grant_id, amount_cents| grant_id ? amount_cents : 0 }, - unfunded_scholarship_cents: scholarships.sum { |grant_id, amount_cents| grant_id ? 0 : amount_cents }, + funded_scholarship_cents: scholarships.sum { |grant_id, amount_cents| external_grant?(grant_id) ? amount_cents : 0 }, + unfunded_scholarship_cents: scholarships.sum { |grant_id, amount_cents| external_grant?(grant_id) ? 0 : amount_cents }, discount_cents: registration_ids.sum { |id| registration_allocations[[ id, "Discount" ]].to_i } + ce_rows.sum { |ce_id, _cost| ce_allocations[[ ce_id, "Discount" ]].to_i }, ce_paid_cents: ce_rows.sum { |ce_id, _cost| ce_allocations[[ ce_id, "Payment" ]].to_i }, diff --git a/app/services/event_scholarship_report.rb b/app/services/event_scholarship_report.rb new file mode 100644 index 0000000000..9128a30040 --- /dev/null +++ b/app/services/event_scholarship_report.rb @@ -0,0 +1,126 @@ +# Scholarship report: scholarship dollars and award counts (funded vs unfunded) +# per facilitator training, grouped by calendar year. The sibling of +# EventRevenueReport / EventParticipationReport — same year-grouped shape and +# statistics-hub period card, but counting scholarships. +# +# Funded vs unfunded follows the app-wide convention (see EventDashboard): +# funded = backed by an external grant; unfunded = no grant, or a grant the org +# (AWBW) donated to itself. Alongside the money it carries a trainee headcount — +# people who ATTENDED — split by delivery format: scheduled sessions total under +# "Training" and self-paced ones (event.on_demand?) under "On-demand". +# +# Give it a collection of (decorated) facilitator-training events. +class EventScholarshipReport + # One training's column. Sources every figure from EventDashboard so the + # funded/unfunded and attendance conventions can't drift from the dashboard. + Column = Struct.new(:event, :dashboard, keyword_init: true) do + def funded_cents = dashboard.funded_scholarship_cents + def unfunded_cents = dashboard.unfunded_scholarship_cents + def scholarship_cents = funded_cents + unfunded_cents + + def funded_count = dashboard.funded_scholarship_count + def unfunded_count = dashboard.unfunded_scholarship_count + def scholarship_count = funded_count + unfunded_count + + # Trainees who fully attended (registration status "attended"). + def attended_count = dashboard.attendance_count_for("attended") + + def on_demand? = event.on_demand? + def label = event.compact_label + def date_label = event.start_date? ? event.short_date_range : nil + def year = event.start_date&.year + end + + # The additive figures — summed across a year's columns for its totals, and + # across every column for the all-time total. + SUMMABLE = %i[ + funded_cents unfunded_cents scholarship_cents + funded_count unfunded_count scholarship_count attended_count + ].freeze + + module Aggregates + SUMMABLE.each do |attribute| + define_method(attribute) { columns.sum(&attribute) } + end + + # Attendance split by delivery format — these sum to attended_count. + def training_attended_count = columns.reject(&:on_demand?).sum(&:attended_count) + def on_demand_attended_count = columns.select(&:on_demand?).sum(&:attended_count) + end + + # One calendar year of trainings, with its columns and totals. + YearGroup = Struct.new(:year, :columns, :in_progress, keyword_init: true) do + include Aggregates + end + + include Aggregates + include ReportPeriods + + def initialize(events, current_year: Date.current.year, featured_year: nil) + @events = events.to_a + @current_year = current_year + @featured_year_value = featured_year || current_year + end + + def columns + @columns ||= @events.map { |event| Column.new(event: event, dashboard: EventDashboard.new(event)) } + end + + def any? + columns.any? + end + + # Calendar-year groups, newest first. Trainings without a start date fall under + # a nil year that sorts last; each year's columns read chronologically. + def years + @years ||= columns + .group_by(&:year) + .map { |year, year_columns| YearGroup.new(year: year, columns: sorted(year_columns), in_progress: year == @current_year) } + .sort_by { |group| [ group.year ? 0 : 1, -(group.year || 0) ] } + end + + # The year whose figures lead the KPI strip: the filtered/navigated-from year, + # else the current year, falling back to the most recent year present. + def featured_year + years_by_value[@featured_year_value] || years.first + end + + # The most recent year-group strictly older than the featured one, for a + # year-over-year delta. Nil when there's nothing older to compare against. + def prior_year + return nil unless featured_year&.year + years.find { |group| group.year && group.year < featured_year.year } + end + + # Stacked-column series by year, oldest to newest, in dollars — funded vs + # unfunded scholarship money, for the hub card's mini chart. + def chart_series + ascending = years.reject { |group| group.year.nil? }.reverse + { + "Funded" => :funded_cents, + "Unfunded" => :unfunded_cents + }.map do |name, attribute| + { name: name, data: ascending.map { |group| [ group.year.to_s, to_dollars(group.public_send(attribute)) ] } } + end + end + + private + + # A zeroed year group for a period with no trainings, so the summary card + # renders 0 rather than blank. + def empty_year_group(year) + YearGroup.new(year: year, columns: [], in_progress: false) + end + + def years_by_value + @years_by_value ||= years.index_by(&:year) + end + + def sorted(year_columns) + year_columns.sort_by { |column| column.event.start_date || Time.zone.at(0) } + end + + def to_dollars(cents) + (cents / 100.0).round(2) + end +end diff --git a/app/services/facilitator_training_report.rb b/app/services/facilitator_training_report.rb deleted file mode 100644 index addea76f6b..0000000000 --- a/app/services/facilitator_training_report.rb +++ /dev/null @@ -1,73 +0,0 @@ -# Cross-event summary of a year's facilitator trainings: scholarship dollars and -# award counts (funded vs unfunded) plus trainee counts, one column per training. -# Funded vs unfunded follows the same convention as everywhere else — a -# scholarship is funded when a grant backs it — sourced from EventDashboard so -# the definition lives in one place. -# -# Trainee counts split by delivery format: scheduled multi-day sessions total -# under "2-Day" and self-paced ones (event.on_demand?) under "On-Demand". -# -# Give it a collection of facilitator-training events; it groups them by calendar -# year, newest first, each year a table's worth of columns with its own totals. -class FacilitatorTrainingReport - # One facilitator-training event's column. Delegates the funded/unfunded splits - # to EventDashboard so the money and count conventions can't drift. - Column = Struct.new(:event, :dashboard, keyword_init: true) do - def funded_cents = dashboard.funded_scholarship_cents - def unfunded_cents = dashboard.unfunded_scholarship_cents - def scholarship_cents = funded_cents + unfunded_cents - - def funded_count = dashboard.funded_scholarship_count - def unfunded_count = dashboard.unfunded_scholarship_count - def scholarship_count = funded_count + unfunded_count - - def trainee_count = dashboard.registrant_count - - def on_demand? = event.on_demand? - - def label = event.decorate.compact_label - def date_label = event.start_date? ? event.decorate.short_date_range : nil - end - - # The figures summed across a year's columns for the totals row. - SUMMABLE = %i[ - funded_cents unfunded_cents scholarship_cents - funded_count unfunded_count scholarship_count trainee_count - ].freeze - - # One calendar year of facilitator trainings, with its columns and totals. - YearGroup = Struct.new(:year, :columns, keyword_init: true) do - SUMMABLE.each do |attribute| - define_method(attribute) { columns.sum(&attribute) } - end - - # Trainee totals split by delivery format — these sum to trainee_count. - def two_day_trainee_count = columns.reject(&:on_demand?).sum(&:trainee_count) - def on_demand_trainee_count = columns.select(&:on_demand?).sum(&:trainee_count) - end - - def initialize(events) - @events = events - end - - def any? - years.any? - end - - # Calendar-year groups, newest first. Events without a start date fall under a - # nil year that sorts last. - def years - @years ||= @events - .group_by { |event| event.start_date&.year } - .map { |year, year_events| YearGroup.new(year: year, columns: columns_for(year_events)) } - .sort_by { |group| [ group.year ? 0 : 1, -(group.year || 0) ] } - end - - private - - def columns_for(events) - events - .sort_by { |event| event.start_date || Time.zone.at(0) } - .map { |event| Column.new(event: event, dashboard: EventDashboard.new(event)) } - end -end diff --git a/app/views/events/_abbreviation_filter.html.erb b/app/views/events/_abbreviation_filter.html.erb new file mode 100644 index 0000000000..349d31ddc4 --- /dev/null +++ b/app/views/events/_abbreviation_filter.html.erb @@ -0,0 +1,9 @@ +<%# Abbreviation substring search shared by the report forms. Submits the form on + change (blur/Enter), matching the auto-submitting selects beside it. %> +
+ + <%= text_field_tag :abbreviation, params[:abbreviation], + placeholder: "e.g. TOS", + class: "max-w-[10rem] px-3 py-2 rounded-md border-gray-300 shadow-sm text-sm text-gray-700", + onchange: "this.form.requestSubmit()" %> +
diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 64e459e744..4f8e054415 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -31,7 +31,7 @@ placeholder: "e.g. TOS205" } %> <%= f.input :facilitator_training, as: :boolean, label: "Facilitator training event", wrapper_html: { class: "mb-0!" } %> - <%= f.input :on_demand, as: :boolean, label: "On-demand (self-paced)", hint: "Counts under \"On-Demand\" instead of \"2-Day\" in the facilitator training report", wrapper_html: { class: "mb-0!" } %> + <%= f.input :on_demand, as: :boolean, label: "On-demand (self-paced)", hint: "Trainees count under \"On-demand\" instead of \"Training\" in the scholarships report", wrapper_html: { class: "mb-0!" } %> <%= f.input :pre_title, diff --git a/app/views/events/_scholarship_summary.html.erb b/app/views/events/_scholarship_summary.html.erb new file mode 100644 index 0000000000..b02da2eb24 --- /dev/null +++ b/app/views/events/_scholarship_summary.html.erb @@ -0,0 +1,44 @@ +<%# Compact scholarship headline for the statistics hub, linking to the full + report. Expects `report` (an EventScholarshipReport) and `period` (its + resolved PeriodScope: #label, #year and #metrics). %> +
+
+

Scholarships

+ <%= link_to "Full report →", scholarships_events_path(statistics_to_report_params), + class: "text-xs font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline" %> +
+ + <% if report.any? %> + <% metrics = period.metrics %> +
<%= period.label %>
+
+
+
Funded
+
<%= dollars_from_cents(metrics.funded_cents) %>
+
<%= pluralize(metrics.funded_count, "award") %>
+
+
+
Unfunded
+
<%= dollars_from_cents(metrics.unfunded_cents) %>
+
<%= pluralize(metrics.unfunded_count, "award") %>
+
+
+
Total awarded
+
<%= dollars_from_cents(metrics.scholarship_cents) %>
+
<%= pluralize(metrics.scholarship_count, "award") %>
+
+
+
Attended
+
<%= number_with_delimiter(metrics.attended_count) %>
+
<%= metrics.training_attended_count %> training · <%= metrics.on_demand_attended_count %> on-demand
+
+
+
+ <%= column_chart report.chart_series, stacked: true, prefix: "$", thousands: ",", + colors: [ "#059669", "#dc2626" ], height: "120px", legend: false, + library: { borderRadius: 3 } %> +
+ <% else %> +

No facilitator trainings yet.

+ <% end %> +
diff --git a/app/views/events/_facilitator_training_report.html.erb b/app/views/events/_scholarships_report.html.erb similarity index 89% rename from app/views/events/_facilitator_training_report.html.erb rename to app/views/events/_scholarships_report.html.erb index 60c86715fb..5b58d62e85 100644 --- a/app/views/events/_facilitator_training_report.html.erb +++ b/app/views/events/_scholarships_report.html.erb @@ -1,7 +1,7 @@ -<%# Shareable facilitator-training summary: scholarship dollars and award counts - (funded vs unfunded) plus trainee counts, one column per training, grouped by - year. Pass `report:` (a FacilitatorTrainingReport). Rendered on the event - revenue page and the standalone facilitator training report page. %> +<%# Scholarship summary: dollars and award counts (funded vs unfunded) plus an + attended-trainee count, one column per facilitator training, grouped by year. + Pass `report:` (an EventScholarshipReport). Rendered on the scholarships + report page. %> <% if report.any? %>
<% report.years.each do |group| %> @@ -82,9 +82,9 @@ <%= group.scholarship_count %> - <%# ---- # of Trainees ---- %> + <%# ---- # of Trainees (attended) ---- %> - # of trainees + # of trainees attended <% events.each do |column| %> <%= column.label %> @@ -95,22 +95,22 @@ <% end %> - Trainees + Attended <% events.each do |column| %> - <%= column.trainee_count %> + <%= column.attended_count %> <% end %> - 2-Day total - <%= group.two_day_trainee_count %> + Training + <%= group.training_attended_count %> - On-Demand total - <%= group.on_demand_trainee_count %> + On-demand + <%= group.on_demand_attended_count %> Overall total - <%= group.trainee_count %> + <%= group.attended_count %> diff --git a/app/views/events/facilitator_training_report.html.erb b/app/views/events/facilitator_training_report.html.erb deleted file mode 100644 index 844918077c..0000000000 --- a/app/views/events/facilitator_training_report.html.erb +++ /dev/null @@ -1,20 +0,0 @@ -<% content_for(:page_bg_class, "admin-only bg-blue-100") %> -<% content_for(:full_width, true) %> - -
-
-
- <%= link_to "← Events statistics", statistics_events_path(return_to: params[:return_to]), - class: "text-sm font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline" %> -
- -
-

Facilitator training report

-

- Scholarship dollars and awards (funded vs unfunded) plus trainee counts, one column per training, grouped by year. -

-
- - <%= render "facilitator_training_report", report: @report %> -
-
diff --git a/app/views/events/scholarships.html.erb b/app/views/events/scholarships.html.erb new file mode 100644 index 0000000000..53ac3ffe20 --- /dev/null +++ b/app/views/events/scholarships.html.erb @@ -0,0 +1,86 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% content_for(:full_width, true) %> + +
+
+
+ <% if params[:return_to] == "dashboard" && params[:event_id].present? %> + <%= link_to "← Dashboard", dashboard_event_path(params[:event_id]), class: "text-sm text-gray-500 hover:text-gray-700" %> + <% else %> + <%= link_to "← Events statistics", statistics_events_path(report_to_statistics_params), class: "text-sm text-gray-500 hover:text-gray-700" %> + <% end %> +
+ +
+

Events scholarships

+

+ Scholarship dollars and awards (funded vs unfunded) per facilitator training, by year, with trainees who attended. +

+
+ + <%# Ahoy-style filters — time period, specific training, and abbreviation + search — each auto-submitting a GET form so the report reshapes on change. %> + <%= form_with url: scholarships_events_path, method: :get, local: true, + class: "flex flex-wrap items-end gap-4 mb-8" do %> + <%= hidden_field_tag :return_to, params[:return_to] %> + <%= render "time_period_filter" %> + <%= render "event_filter", all_label: "All trainings" %> + <%= render "abbreviation_filter" %> + <% end %> + + <% if @report.any? %> + <% featured = @report.featured_year %> + <% prior = @report.prior_year %> + + <%# Headline figures for the featured year: funded vs unfunded scholarship + money and award counts, plus attended trainees. %> +
+
+

<%= featured.year || "All trainings" %>

+ <% if featured.in_progress %> + In progress + <% end %> +
+
+
+
Funded
+
<%= dollars_from_cents(featured.funded_cents) %>
+
external grants
+
+
+
Unfunded
+
<%= dollars_from_cents(featured.unfunded_cents) %>
+
no grant or AWBW
+
+
+
Total awarded
+
<%= dollars_from_cents(featured.scholarship_cents) %>
+
funded + unfunded
+
+
+
Awards
+
<%= number_with_delimiter(featured.scholarship_count) %>
+
<%= featured.funded_count %> funded · <%= featured.unfunded_count %> unfunded
+
+
+
Attended
+
<%= number_with_delimiter(featured.attended_count) %>
+
<%= featured.training_attended_count %> training · <%= featured.on_demand_attended_count %> on-demand
+
+
+
+ + <%= render "scholarships_report", report: @report %> + +

+ Funded = backed by an external grant. + Unfunded = no grant, or a grant A Window Between Worlds donated to itself (org subsidy). + Attended counts trainees whose registration is marked attended. +

+ <% else %> +
+ No facilitator trainings match these filters yet. +
+ <% end %> +
+
diff --git a/app/views/events/statistics.html.erb b/app/views/events/statistics.html.erb index 4ead945458..21513f214b 100644 --- a/app/views/events/statistics.html.erb +++ b/app/views/events/statistics.html.erb @@ -34,27 +34,10 @@ <%= render "event_filter" %> <% end %> -
-
- <%= render "events/revenue_summary", report: @revenue_report, period: @revenue_report.period_scope(@period) %> -
-
- <%= render "events/participation_summary", report: @participation_report, period: @participation_report.period_scope(@period) %> -
+
+ <%= render "events/revenue_summary", report: @revenue_report, period: @revenue_report.period_scope(@period) %> + <%= render "events/participation_summary", report: @participation_report, period: @participation_report.period_scope(@period) %> + <%= render "events/scholarship_summary", report: @scholarship_report, period: @scholarship_report.period_scope(@period) %>
- - <%# Scholarship + trainee summary across facilitator trainings, grouped by - year. The same partial the standalone report page renders. %> -
-
-
-

Facilitator training summary

-

Scholarship dollars and awards (funded vs unfunded) plus trainee counts, by training.

-
- <%= link_to "Open report →", facilitator_training_report_events_path(return_to: params[:return_to]), - class: "text-sm font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline whitespace-nowrap" %> -
- <%= render "facilitator_training_report", report: @facilitator_report %> -
diff --git a/config/routes.rb b/config/routes.rb index da08d006b7..387611272f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -148,7 +148,7 @@ get :revenue get :participation get :statistics - get :facilitator_training_report + get :scholarships end member do get :dashboard diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 75391fd1b2..ea6a79d0d7 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -364,4 +364,19 @@ expect(Organization.program_statuses_by_id([ org.id ])).to eq(org.id => :new) end end + + describe ".awbw" do + it "finds the org named by ORGANIZATION_NAME" do + awbw = create(:organization, name: ENV.fetch("ORGANIZATION_NAME", "A Window Between Worlds")) + create(:organization, name: "Some Partner Org") + + expect(Organization.awbw).to eq(awbw) + end + + it "is nil when no organization matches" do + create(:organization, name: "Some Partner Org") + + expect(Organization.awbw).to be_nil + end + end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 28056c4438..7addb4c2d1 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -329,12 +329,11 @@ def add_ce_registrant(target_event) expect(response.body).to include(revenue_events_path, participation_events_path) end - it "embeds the facilitator training summary, linking to its full report" do + it "shows the scholarship summary card linking to its full report" do sign_in admin get statistics_events_path - expect(response.body).to include("Facilitator training summary") - expect(response.body).to include("$ of scholarships", "# of trainees") - expect(response.body).to include(facilitator_training_report_events_path) + expect(response.body).to include("Scholarships") + expect(response.body).to include(scholarships_events_path) end it "carries the active filters into the full report links" do @@ -375,26 +374,36 @@ def add_ce_registrant(target_event) end end - describe "GET /facilitator_training_report" do - let!(:training) { create(:event, title: "TAC 261", facilitator_training: true, cost_cents: 10_000, start_date: Date.new(2026, 5, 1)) } + describe "GET /scholarships" do + let!(:training) { create(:event, title: "TAC 261", abbreviation: "TAC261", facilitator_training: true, cost_cents: 10_000, start_date: Date.new(2026, 5, 1)) } + let!(:on_demand) { create(:event, title: "Self-paced training", abbreviation: "OND100", facilitator_training: true, on_demand: true, start_date: Date.new(2026, 6, 1)) } let!(:webinar) { create(:event, title: "Paid webinar", facilitator_training: false, cost_cents: 5_000, start_date: Date.new(2026, 5, 1)) } context "as admin" do - it "renders the report with only facilitator trainings as columns" do + it "renders the report with only facilitator trainings" do sign_in admin - get facilitator_training_report_events_path + get scholarships_events_path expect(response).to have_http_status(:ok) - expect(response.body).to include("Facilitator training report") - expect(response.body).to include("$ of scholarships", "# of scholarships", "# of trainees") - expect(response.body).to include("TAC 261") + expect(response.body).to include("Events scholarships") + expect(response.body).to include("$ of scholarships", "# of scholarships", "# of trainees attended") + expect(response.body).to include("TAC 261", "Self-paced training") expect(response.body).not_to include("Paid webinar") end + + it "narrows to trainings whose abbreviation matches the search" do + sign_in admin + get scholarships_events_path(abbreviation: "TAC") + # Abbreviations head the report columns; the excluded training's is absent + # (its title still appears in the always-full Event dropdown). + expect(response.body).to include("TAC261") + expect(response.body).not_to include("OND100") + end end context "as non-admin" do it "redirects" do sign_in user - get facilitator_training_report_events_path + get scholarships_events_path expect(response).to redirect_to(root_path) end end diff --git a/spec/routing/events_routing_spec.rb b/spec/routing/events_routing_spec.rb index f2091fcdce..12207e2e18 100644 --- a/spec/routing/events_routing_spec.rb +++ b/spec/routing/events_routing_spec.rb @@ -6,8 +6,8 @@ expect(get: "/events/1/dashboard").to route_to("events#dashboard", id: "1") end - it "routes to #facilitator_training_report" do - expect(get: "/events/facilitator_training_report").to route_to("events#facilitator_training_report") + it "routes to #scholarships" do + expect(get: "/events/scholarships").to route_to("events#scholarships") end end end diff --git a/spec/services/event_dashboard_spec.rb b/spec/services/event_dashboard_spec.rb index 69b64707aa..1782ffff48 100644 --- a/spec/services/event_dashboard_spec.rb +++ b/spec/services/event_dashboard_spec.rb @@ -1051,34 +1051,43 @@ def opt_in(person, text:) end end - describe "scholarship funded/unfunded counts" do + describe "scholarship funded/unfunded split" do let(:event) { create(:event, cost_cents: 50_000) } let(:person1) { create(:person) } let(:person2) { create(:person) } let(:person3) { create(:person) } + let(:person4) { create(:person) } before do reg1 = create(:event_registration, event: event, registrant: person1, status: "registered") reg2 = create(:event_registration, event: event, registrant: person2, status: "registered") + reg4 = create(:event_registration, event: event, registrant: person4, status: "registered") - grant_backed = create(:scholarship, recipient: person1, amount_cents: 4_000, grant: create(:grant)) - create(:allocation, source: grant_backed, allocatable: reg1, amount: 4_000) + external = create(:scholarship, recipient: person1, amount_cents: 4_000, grant: create(:grant)) + create(:allocation, source: external, allocatable: reg1, amount: 4_000) comped = create(:scholarship, recipient: person2, amount_cents: 2_000, grant: nil) create(:allocation, source: comped, allocatable: reg2, amount: 2_000) - # A scholarship on a cancelled registration must be ignored by both counts. + # A grant the org donated to itself is subsidy, so it counts as UNFUNDED. + awbw = create(:organization, name: "A Window Between Worlds") + awbw_award = create(:scholarship, recipient: person4, amount_cents: 1_000, grant: create(:grant, donor: awbw)) + create(:allocation, source: awbw_award, allocatable: reg4, amount: 1_000) + + # A scholarship on a cancelled registration must be ignored everywhere. cancelled = create(:event_registration, event: event, registrant: person3, status: "cancelled") - ignored = create(:scholarship, recipient: person3, amount_cents: 3_000, grant: create(:grant)) - create(:allocation, source: ignored, allocatable: cancelled, amount: 3_000) + ignored = create(:scholarship, recipient: person3, amount_cents: 9_000, grant: create(:grant)) + create(:allocation, source: ignored, allocatable: cancelled, amount: 9_000) end - it "counts grant-backed scholarships as funded" do + it "counts only externally grant-backed scholarships as funded" do expect(dashboard.funded_scholarship_count).to eq(1) + expect(dashboard.funded_scholarship_cents).to eq(4_000) end - it "counts grant-free scholarships as unfunded" do - expect(dashboard.unfunded_scholarship_count).to eq(1) + it "counts grant-free and AWBW-donated scholarships as unfunded" do + expect(dashboard.unfunded_scholarship_count).to eq(2) + expect(dashboard.unfunded_scholarship_cents).to eq(3_000) end end end diff --git a/spec/services/event_revenue_figures_spec.rb b/spec/services/event_revenue_figures_spec.rb index 6f6d637253..19cc57fcb9 100644 --- a/spec/services/event_revenue_figures_spec.rb +++ b/spec/services/event_revenue_figures_spec.rb @@ -90,6 +90,9 @@ events.each { |e| loader.for(e) } end - expect(queries).to eq(5) + # 5 batch component queries + 2 constant queries that classify AWBW-donated + # grants as subsidy (the AWBW org lookup and its grant ids), regardless of + # how many events are loaded. + expect(queries).to eq(7) end end diff --git a/spec/services/event_scholarship_report_spec.rb b/spec/services/event_scholarship_report_spec.rb new file mode 100644 index 0000000000..04fd6a0be6 --- /dev/null +++ b/spec/services/event_scholarship_report_spec.rb @@ -0,0 +1,147 @@ +require "rails_helper" + +RSpec.describe EventScholarshipReport do + # The report reads (decorated) events, mirroring what the controller passes. + def report_for(events, **opts) + described_class.new(events.map(&:decorate), **opts) + end + + describe "per-training columns" do + subject(:report) { report_for([ event ]) } + + let(:event) { create(:event, facilitator_training: true, cost_cents: 50_000, start_date: Date.new(2025, 3, 1)) } + let(:person1) { create(:person) } + let(:person2) { create(:person) } + let(:person3) { create(:person) } + + let!(:reg1) { create(:event_registration, event: event, registrant: person1, status: "attended") } + let!(:reg2) { create(:event_registration, event: event, registrant: person2, status: "attended") } + let!(:reg3) { create(:event_registration, event: event, registrant: person3, status: "registered") } + + before do + external = create(:scholarship, recipient: person1, amount_cents: 4_000, grant: create(:grant)) + create(:allocation, source: external, allocatable: reg1, amount: 4_000) + + comped = create(:scholarship, recipient: person2, amount_cents: 2_000, grant: nil) + create(:allocation, source: comped, allocatable: reg2, amount: 2_000) + end + + let(:column) { report.years.first.columns.first } + + it "splits scholarship dollars into funded (external grant) vs unfunded" do + expect(column.funded_cents).to eq(4_000) + expect(column.unfunded_cents).to eq(2_000) + expect(column.scholarship_cents).to eq(6_000) + end + + it "splits scholarship award counts into funded vs unfunded" do + expect(column.funded_count).to eq(1) + expect(column.unfunded_count).to eq(1) + expect(column.scholarship_count).to eq(2) + end + + it "counts only attended registrations as trainees" do + # reg1 + reg2 are attended; reg3 is only registered. + expect(column.attended_count).to eq(2) + end + + it "labels the column from the event" do + expect(column.label).to eq(event.decorate.compact_label) + end + end + + describe "AWBW-donated grants count as unfunded" do + subject(:report) { report_for([ event ]) } + + let(:event) { create(:event, facilitator_training: true, cost_cents: 50_000, start_date: Date.new(2025, 3, 1)) } + let(:person) { create(:person) } + let!(:reg) { create(:event_registration, event: event, registrant: person, status: "attended") } + + before do + awbw = create(:organization, name: "A Window Between Worlds") + awbw_grant = create(:grant, donor: awbw) + award = create(:scholarship, recipient: person, amount_cents: 3_000, grant: awbw_grant) + create(:allocation, source: award, allocatable: reg, amount: 3_000) + end + + let(:column) { report.years.first.columns.first } + + it "treats a grant the org donated to itself as unfunded, not funded" do + expect(column.funded_cents).to eq(0) + expect(column.unfunded_cents).to eq(3_000) + expect(column.funded_count).to eq(0) + expect(column.unfunded_count).to eq(1) + end + end + + describe "attendance split by delivery format" do + let(:scheduled) { create(:event, facilitator_training: true, on_demand: false, start_date: Date.new(2025, 3, 1)) } + let(:on_demand) { create(:event, facilitator_training: true, on_demand: true, start_date: Date.new(2025, 7, 1)) } + + subject(:report) { report_for([ scheduled, on_demand ]) } + + before do + 2.times { create(:event_registration, event: scheduled, registrant: create(:person), status: "attended") } + create(:event_registration, event: scheduled, registrant: create(:person), status: "no_show") + 3.times { create(:event_registration, event: on_demand, registrant: create(:person), status: "attended") } + end + + let(:group) { report.years.first } + + it "totals attended trainees under Training (scheduled) vs On-demand" do + expect(group.training_attended_count).to eq(2) + expect(group.on_demand_attended_count).to eq(3) + end + + it "sums both formats into the overall attended total (no-shows excluded)" do + expect(group.attended_count).to eq(5) + end + end + + describe "grouping, totals, and featured year" do + let(:e2024) { create(:event, facilitator_training: true, start_date: Date.new(2024, 5, 1)) } + let(:e2025a) { create(:event, facilitator_training: true, cost_cents: 50_000, start_date: Date.new(2025, 3, 1)) } + let(:e2025b) { create(:event, facilitator_training: true, start_date: Date.new(2025, 11, 1)) } + + subject(:report) { report_for([ e2025b, e2024, e2025a ], featured_year: 2025) } + + before do + person = create(:person) + reg = create(:event_registration, event: e2025a, registrant: person, status: "attended") + award = create(:scholarship, recipient: person, amount_cents: 5_000, grant: create(:grant)) + create(:allocation, source: award, allocatable: reg, amount: 5_000) + end + + it "groups by calendar year, newest first" do + expect(report.years.map(&:year)).to eq([ 2025, 2024 ]) + end + + it "orders each year's columns by start date" do + expect(report.years.first.columns.map(&:event)).to eq([ e2025a, e2025b ]) + end + + it "sums scholarship dollars across a year's columns" do + expect(report.years.first.scholarship_cents).to eq(5_000) + end + + it "leads with the requested featured year" do + expect(report.featured_year.year).to eq(2025) + expect(report.prior_year.year).to eq(2024) + end + + it "resolves an all-time period to the whole report" do + period = report.period_scope("all_time") + expect(period.metrics.scholarship_cents).to eq(5_000) + end + end + + describe "#any?" do + it "is false with no events" do + expect(described_class.new([]).any?).to be(false) + end + + it "is true with at least one event" do + expect(report_for([ create(:event, facilitator_training: true) ]).any?).to be(true) + end + end +end diff --git a/spec/services/facilitator_training_report_spec.rb b/spec/services/facilitator_training_report_spec.rb deleted file mode 100644 index c424d5459d..0000000000 --- a/spec/services/facilitator_training_report_spec.rb +++ /dev/null @@ -1,109 +0,0 @@ -require "rails_helper" - -RSpec.describe FacilitatorTrainingReport do - describe "per-event columns" do - subject(:report) { described_class.new([ event ]) } - - let(:event) { create(:event, facilitator_training: true, cost_cents: 50_000, start_date: Date.new(2025, 3, 1)) } - let(:person1) { create(:person) } - let(:person2) { create(:person) } - - let!(:reg1) { create(:event_registration, event: event, registrant: person1, status: "registered") } - let!(:reg2) { create(:event_registration, event: event, registrant: person2, status: "registered") } - - before do - # A cancelled registration whose trainee count and scholarship must be ignored. - cancelled = create(:event_registration, event: event, registrant: create(:person), status: "cancelled") - ignored = create(:scholarship, recipient: cancelled.registrant, amount_cents: 9_999, grant: create(:grant)) - create(:allocation, source: ignored, allocatable: cancelled, amount: 9_999) - - funded = create(:scholarship, recipient: person1, amount_cents: 4_000, grant: create(:grant)) - create(:allocation, source: funded, allocatable: reg1, amount: 4_000) - - unfunded = create(:scholarship, recipient: person2, amount_cents: 2_000, grant: nil) - create(:allocation, source: unfunded, allocatable: reg2, amount: 2_000) - end - - let(:column) { report.years.first.columns.first } - - it "splits scholarship dollars into funded vs unfunded" do - expect(column.funded_cents).to eq(4_000) - expect(column.unfunded_cents).to eq(2_000) - expect(column.scholarship_cents).to eq(6_000) - end - - it "splits scholarship award counts into funded vs unfunded" do - expect(column.funded_count).to eq(1) - expect(column.unfunded_count).to eq(1) - expect(column.scholarship_count).to eq(2) - end - - it "counts only active registrations as trainees" do - expect(column.trainee_count).to eq(2) - end - - it "labels the column from the event" do - expect(column.label).to eq(event.decorate.compact_label) - end - end - - describe "trainee format split" do - let(:scheduled) { create(:event, facilitator_training: true, on_demand: false, start_date: Date.new(2025, 3, 1)) } - let(:on_demand) { create(:event, facilitator_training: true, on_demand: true, start_date: Date.new(2025, 7, 1)) } - - subject(:report) { described_class.new([ scheduled, on_demand ]) } - - before do - 2.times { create(:event_registration, event: scheduled, registrant: create(:person), status: "registered") } - 3.times { create(:event_registration, event: on_demand, registrant: create(:person), status: "registered") } - end - - let(:group) { report.years.first } - - it "totals scheduled trainees under 2-Day and self-paced under On-Demand" do - expect(group.two_day_trainee_count).to eq(2) - expect(group.on_demand_trainee_count).to eq(3) - end - - it "sums both formats into the overall trainee total" do - expect(group.trainee_count).to eq(5) - end - end - - describe "grouping and totals" do - let(:e2024) { create(:event, facilitator_training: true, start_date: Date.new(2024, 5, 1)) } - let(:e2025a) { create(:event, facilitator_training: true, cost_cents: 50_000, start_date: Date.new(2025, 3, 1)) } - let(:e2025b) { create(:event, facilitator_training: true, start_date: Date.new(2025, 11, 1)) } - - subject(:report) { described_class.new([ e2025b, e2024, e2025a ]) } - - before do - person = create(:person) - reg = create(:event_registration, event: e2025a, registrant: person, status: "registered") - funded = create(:scholarship, recipient: person, amount_cents: 5_000, grant: create(:grant)) - create(:allocation, source: funded, allocatable: reg, amount: 5_000) - end - - it "groups by calendar year, newest first" do - expect(report.years.map(&:year)).to eq([ 2025, 2024 ]) - end - - it "orders each year's columns by start date" do - expect(report.years.first.columns.map(&:event)).to eq([ e2025a, e2025b ]) - end - - it "sums scholarship dollars across a year's columns" do - expect(report.years.first.scholarship_cents).to eq(5_000) - end - end - - describe "#any?" do - it "is false with no events" do - expect(described_class.new([]).any?).to be(false) - end - - it "is true with at least one event" do - expect(described_class.new([ create(:event, facilitator_training: true) ]).any?).to be(true) - end - end -end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 5e9db4b640..7ab87a9e44 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -116,7 +116,7 @@ "app/views/events/revenue.html.erb" => "admin-only bg-blue-100", "app/views/events/participation.html.erb" => "admin-only bg-blue-100", "app/views/events/statistics.html.erb" => "admin-only bg-blue-100", - "app/views/events/facilitator_training_report.html.erb" => "admin-only bg-blue-100", + "app/views/events/scholarships.html.erb" => "admin-only bg-blue-100", "app/views/events/preview_reminder.html.erb" => "admin-only bg-blue-100", "app/views/events/confirm_reminder.html.erb" => "admin-only bg-blue-100", "app/views/event_registrations/index.html.erb" => "admin-only bg-blue-100", From 8b03294be07980c2e25b70acaca736dac3d605dc Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 11:36:50 -0400 Subject: [PATCH 3/9] Polish scholarship report + index rows + edit header - Brand the scholarships report to the fuchsia domain theme (icon + label per section) instead of ad-hoc rose/amber/yellow; wrap long training titles to two lines then truncate. - Scholarships index: move Status left of Training; make the whole recipient row navigate to the scholarship edit page (clickable-row Stimulus controller), keeping the name link for keyboard access. - Edit-page header: render the recipient as a profile button. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/clickable_row_controller.js | 16 ++++++ app/frontend/javascript/controllers/index.js | 3 ++ .../events/_scholarships_report.html.erb | 50 ++++++++++++------- app/views/scholarships/_grant_group.html.erb | 2 +- .../scholarships/_recipient_row.html.erb | 8 ++- app/views/shared/_event_page_header.html.erb | 10 ++-- 6 files changed, 64 insertions(+), 25 deletions(-) create mode 100644 app/frontend/javascript/controllers/clickable_row_controller.js diff --git a/app/frontend/javascript/controllers/clickable_row_controller.js b/app/frontend/javascript/controllers/clickable_row_controller.js new file mode 100644 index 0000000000..ae4f61bae8 --- /dev/null +++ b/app/frontend/javascript/controllers/clickable_row_controller.js @@ -0,0 +1,16 @@ +import { Controller } from "@hotwired/stimulus" +import { Turbo } from "@hotwired/turbo-rails" + +// Makes a whole element (e.g. a table row) navigate to a URL on click, while +// leaving nested interactive elements working normally — a click on a link, +// button, input, or anything opted out with data-clickable-row-ignore is left +// alone. Modifier-clicks (new tab) fall through to the row's own link. +export default class extends Controller { + static values = { url: String } + + navigate(event) { + if (event.target.closest("a, button, input, label, select, summary, [data-clickable-row-ignore]")) return + if (event.metaKey || event.ctrlKey || event.shiftKey) return + Turbo.visit(this.urlValue) + } +} diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 346eda3b17..b0734f9fbf 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -39,6 +39,9 @@ application.register("ce-license-picker", CeLicensePickerController) import ChipSelectController from "./chip_select_controller" application.register("chip-select", ChipSelectController) +import ClickableRowController from "./clickable_row_controller" +application.register("clickable-row", ClickableRowController) + import CocoonController from "./cocoon_controller" application.register("cocoon", CocoonController) diff --git a/app/views/events/_scholarships_report.html.erb b/app/views/events/_scholarships_report.html.erb index 5b58d62e85..505f4f46c4 100644 --- a/app/views/events/_scholarships_report.html.erb +++ b/app/views/events/_scholarships_report.html.erb @@ -1,26 +1,32 @@ <%# Scholarship summary: dollars and award counts (funded vs unfunded) plus an attended-trainee count, one column per facilitator training, grouped by year. - Pass `report:` (an EventScholarshipReport). Rendered on the scholarships - report page. %> + Pass `report:` (an EventScholarshipReport). Branded to the scholarship domain + theme (fuchsia); the three sections read by their icon + label, not by hue. %> +<% header_cell = "text-right font-semibold px-3 py-2 border border-gray-200 align-bottom min-w-[9rem] max-w-[15rem]" %> <% if report.any? %>
<% report.years.each do |group| %> <% events = group.columns %> -
-
-

<%= group.year || "Undated" %> facilitator trainings

+
+
+

+ + <%= group.year || "Undated" %> facilitator trainings +

<%= pluralize(events.size, "training") %>
- <%# ---- $ of Scholarships ---- %> + <%# ---- $ of scholarships ---- %> - - + + <% events.each do |column| %> - - <%# ---- # of Scholarships ---- %> - - + <%# ---- # of scholarships ---- %> + + <% events.each do |column| %> - + <% end %> @@ -82,12 +92,14 @@ - <%# ---- # of Trainees (attended) ---- %> - - + <%# ---- # of trainees attended ---- %> + + <% events.each do |column| %> - - + diff --git a/app/views/scholarships/_recipient_row.html.erb b/app/views/scholarships/_recipient_row.html.erb index cf6f8361a9..e74fb07a6a 100644 --- a/app/views/scholarships/_recipient_row.html.erb +++ b/app/views/scholarships/_recipient_row.html.erb @@ -1,11 +1,14 @@ <% scholarship = scholarship.decorate %> - +<%# The whole row navigates to the scholarship edit page (clickable-row); the + recipient link stays a real link for keyboard/screen-reader access. %> + - + +<%# The whole row links to the scholarship edit page with no JS: the row is the + positioned ancestor and the recipient link's stretched ::before covers it, so + a click anywhere in the row follows that one (screen-reader-friendly) link. %> + From e1cc49d6e04a04b064ec4d94a9d44d8cbc82c8ed Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 5 Aug 2026 08:12:47 -0400 Subject: [PATCH 6/9] Match the app's stretched-link convention (after: not before:) The clickable-row idiom in _payment_history / link_organization uses after:absolute after:inset-0; align the scholarship row to it. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/scholarships/_recipient_row.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/scholarships/_recipient_row.html.erb b/app/views/scholarships/_recipient_row.html.erb index c7955b4dc7..bc23c1d58c 100644 --- a/app/views/scholarships/_recipient_row.html.erb +++ b/app/views/scholarships/_recipient_row.html.erb @@ -4,7 +4,7 @@ a click anywhere in the row follows that one (screen-reader-friendly) link. %> From f3cb98556c76b9e0ad2ec8d9cc41bc0862c86371 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 5 Aug 2026 11:11:32 -0400 Subject: [PATCH 7/9] Redesign scholarships report: one card, row-per-training, wider pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-stacked-table layout repeated the event columns for every metric, which was hard to read. Rework into one row per facilitator training so the figures scan left-to-right, and make the report reshape to the task: - One card for the whole report; years become subheader/subtotal rows (with an all-time total) instead of a separate card each, so "All time" reads as a single table. Card title switches to "All facilitator trainings" for all-time. - A layout toggle (?view=) swaps separate #/$ columns for combined count-over- dollars cells, server-driven so the choice is shareable. - All-time no longer pins the KPI headline to the current year — it aggregates every training ("All trainings"). - "Registrants attended" (was "Attended") clarifies it counts all attendees, not just recipients; the per-training numbers link into the registrants roster (attended filter), with a matching eyebrow back to the report. - One "Event search" box matches abbreviation OR title (was abbreviation-only). - Statistics + revenue/participation/scholarships pages widened to match the event manage pages. - Seed prior-year trainings so the year grouping and all-time have real data. - Document the table view-toggle patterns (segmented nav + sliders) in .impeccable.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .impeccable.md | 81 ++++++++++ app/controllers/events_controller.rb | 11 +- app/controllers/scholarships_controller.rb | 16 +- app/services/event_scholarship_report.rb | 16 +- .../events/_abbreviation_filter.html.erb | 9 -- .../events/_event_search_filter.html.erb | 10 ++ app/views/events/_report_filters.html.erb | 2 +- app/views/events/_scholarship_kpis.html.erb | 2 +- .../events/_scholarships_report.html.erb | 141 ++---------------- ...scholarships_report_combined_cell.html.erb | 7 + .../_scholarships_report_figures.html.erb | 29 ++++ .../_scholarships_report_table.html.erb | 98 ++++++++++++ .../_scholarships_report_toggle.html.erb | 13 ++ app/views/events/participation.html.erb | 3 +- app/views/events/registrants.html.erb | 2 + app/views/events/revenue.html.erb | 3 +- app/views/events/scholarships.html.erb | 9 +- app/views/events/statistics.html.erb | 3 +- db/seeds/dev/scholarships.rb | 58 +++++++ spec/requests/events_spec.rb | 20 ++- .../services/event_scholarship_report_spec.rb | 7 + 21 files changed, 377 insertions(+), 163 deletions(-) create mode 100644 .impeccable.md delete mode 100644 app/views/events/_abbreviation_filter.html.erb create mode 100644 app/views/events/_event_search_filter.html.erb create mode 100644 app/views/events/_scholarships_report_combined_cell.html.erb create mode 100644 app/views/events/_scholarships_report_figures.html.erb create mode 100644 app/views/events/_scholarships_report_table.html.erb create mode 100644 app/views/events/_scholarships_report_toggle.html.erb diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 0000000000..a40ad3e71f --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,81 @@ +# impeccable — project design notes + +Context and reusable UI patterns for design work in this app (Rails + Tailwind + +Stimulus + Turbo). Read this before building or reshaping UI so new work matches +the house patterns instead of reinventing them. + +## Design Context + +> Not yet captured. Audience, brand voice, and theme can't be inferred from the +> codebase — run `/impeccable teach` to fill this section in before doing +> open-ended design work. The pattern library below is safe to use regardless. + +## House UI patterns + +### Table view toggles (+ the sliders) + +When a table needs to reshape what it shows without leaving the page, we use two +complementary controls, both showcased together in the **events registrants +toolbar** (`app/views/events/_registrants_results.html.erb`) — the best example +to copy from. + +**1. Segmented display toggle** — swaps what the table shows on a reload: +*which rows* (a filter over the dataset) or *which layout variant* renders. +Server-driven: each segment is a `link_to` inside a pill `
$ of scholarships
+ $ of scholarships + - <%= column.label %> + + <%= column.label %> <% if column.date_label %> <%= column.date_label %> <% end %> @@ -52,11 +58,15 @@ <%= dollars_from_cents(group.scholarship_cents) %>
# of scholarships
+ # of scholarships + <%= column.label %> + <%= column.label %> +
<%= group.scholarship_count %>
# of trainees attended
+ # of trainees attended + - <%= column.label %> + + <%= column.label %> <% if column.on_demand? %> On-demand <% end %> diff --git a/app/views/scholarships/_grant_group.html.erb b/app/views/scholarships/_grant_group.html.erb index 95a75f5c02..73abc80873 100644 --- a/app/views/scholarships/_grant_group.html.erb +++ b/app/views/scholarships/_grant_group.html.erb @@ -35,8 +35,8 @@ Recipient Program LocationTraining StatusTraining Amount Tasks
<%= link_to scholarship.recipient_name, edit_scholarship_path(scholarship), class: "font-medium text-blue-700 hover:text-blue-900 hover:underline" %> <%= scholarship.program_name %> <%= scholarship.program_location %><%= scholarship.training_label %> <% if scholarship.program_status == "—" %> @@ -13,6 +16,7 @@ <%= scholarship.program_status %> <% end %> <%= scholarship.training_label %> <%= scholarship.amount %> <% if scholarship.tasks_completed? %> diff --git a/app/views/shared/_event_page_header.html.erb b/app/views/shared/_event_page_header.html.erb index 7ebb76d95b..7df4d9ed53 100644 --- a/app/views/shared/_event_page_header.html.erb +++ b/app/views/shared/_event_page_header.html.erb @@ -29,9 +29,13 @@ <% end %> <% if person.present? %> -

- <%= link_to person.full_name, person_path(person), class: "hover:underline" %> -

+
+ <%= link_to person_path(person), + class: "inline-flex items-center gap-1.5 rounded-full border border-gray-300 bg-white px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-50 transition" do %> + + <%= person.full_name %> + <% end %> +
<% end %> <% if local_assigns[:trailing].present? %> From c7f188eae9149b2d7a4a8c91c8df1ca60659b11e Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 4 Aug 2026 11:54:12 -0400 Subject: [PATCH 4/9] Share scholarship report + filters (incl. funder) across both pages - Add the same report filters (time period, training, abbreviation) to the /scholarships index, plus a new funder filter on both the index and the events scholarship report, via a remote-select over people/orgs and a shared _report_filters partial. - Surface the scholarship summary (KPI strip, extracted to a partial) at the top of /scholarships. - Funder scoping runs through the data: Scholarship.from_funder/for_events scopes, an EventDashboard scholarship_donor: option, and a funder-aware EventScholarshipReport, so both the list and the report reflect it. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 3 +- app/controllers/events_controller.rb | 4 +- app/controllers/scholarships_controller.rb | 76 +++++++++++++++---- app/models/scholarship.rb | 23 ++++++ app/services/event_dashboard.rb | 23 +++++- app/services/event_scholarship_report.rb | 5 +- app/views/events/_funder_filter.html.erb | 15 ++++ app/views/events/_report_filters.html.erb | 8 ++ app/views/events/_scholarship_kpis.html.erb | 40 ++++++++++ app/views/events/scholarships.html.erb | 45 +---------- app/views/scholarships/index.html.erb | 17 +++++ spec/models/scholarship_spec.rb | 33 ++++++++ spec/requests/events_spec.rb | 13 ++++ spec/requests/scholarships_spec.rb | 24 ++++++ .../services/event_scholarship_report_spec.rb | 30 ++++++++ 15 files changed, 295 insertions(+), 64 deletions(-) create mode 100644 app/views/events/_funder_filter.html.erb create mode 100644 app/views/events/_report_filters.html.erb create mode 100644 app/views/events/_scholarship_kpis.html.erb diff --git a/AGENTS.md b/AGENTS.md index e84e1d8bae..2d327cc6f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ This codebase (Rails 8.1) | Directory | Purpose | |---|---| | `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) | -| `app/frontend/javascript/controllers/` | Stimulus controllers (76) | +| `app/frontend/javascript/controllers/` | Stimulus controllers (77) | | `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) | | `app/frontend/stylesheets/` | Tailwind CSS and component styles | @@ -304,6 +304,7 @@ end - `autosave` — Auto-save form state - `carousel` — Swiper-based carousels - `ce_license_picker` — Fill the CE license type/number/state/expiry fields from the picked license (or clear them for a new one) +- `clickable_row` — Makes a whole element (e.g. a table row) navigate to a URL on click, leaving nested links/buttons and modifier-clicks alone (scholarships index rows → edit) - `cocoon` — Nested form handling (cocoon gem) - `collection` — Filter form auto-submit with debounce - `column_toggle` — Toggle table column visibility diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index e7e85a9ee8..443cd1ef68 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -54,7 +54,7 @@ def statistics def scholarships authorize! events, selected_year = filtered_report_events(Event.facilitator_trainings) - @report = EventScholarshipReport.new(events, featured_year: selected_year) + @report = EventScholarshipReport.new(events, featured_year: selected_year, funder: @filter_funder) end def new @@ -466,6 +466,7 @@ def set_report_filters @event_type = params[:event_type].presence_in(%w[ trainings other ]) @filter_event = Event.find_by(id: params[:event_id]) if params[:event_id].present? @event_abbreviation = params[:abbreviation].presence + @filter_funder = GlobalID::Locator.locate_signed(params[:funder_sgid]) if params[:funder_sgid].present? # The Event dropdown lists the report's own universe: paid events for revenue, # facilitator trainings for scholarships, every event otherwise. dropdown_scope = case action_name @@ -506,6 +507,7 @@ def scoped_report_base(base) base = base.where(facilitator_training: false) if @event_type == "other" base = base.where(id: @filter_event.id) if @filter_event base = base.where("events.abbreviation LIKE ?", "%#{Event.sanitize_sql_like(@event_abbreviation)}%") if @event_abbreviation + base = base.where(id: Scholarship.from_funder(@filter_funder).event_ids) if @filter_funder base end diff --git a/app/controllers/scholarships_controller.rb b/app/controllers/scholarships_controller.rb index 7cdda55792..22f44fb6ec 100644 --- a/app/controllers/scholarships_controller.rb +++ b/app/controllers/scholarships_controller.rb @@ -4,21 +4,11 @@ class ScholarshipsController < ApplicationController def index authorize! Scholarship - # Eager-load everything the grid derives so each row's funder, program, - # location, training, and status cells add no per-row queries: - # * grant → donor for the funder grouping; - # * recipient → affiliations → organization → addresses for program/location/status; - # * recipient → event_registrations → event for the attended-training column. - scholarships = authorized_scope(Scholarship.all).includes( - { grant: :donor }, - { recipient: [ { affiliations: { organization: :addresses } }, { event_registrations: :event } ] } - ) - if params[:recipient_id].present? - scholarships = scholarships.where(recipient_id: params[:recipient_id]) - @recipient = Person.find_by(id: params[:recipient_id]) - end + set_report_filter_state + scholarships = filtered_scholarships @funder_groups = ScholarshipsGrouping.new(scholarships).funder_groups @scholarships_count = scholarships.size + @scholarship_report = EventScholarshipReport.new(report_training_events, featured_year: @selected_year, funder: @filter_funder) end def show @@ -120,6 +110,66 @@ def toggle_tasks private + # Filter state for the shared report filter partials (time period, event, + # abbreviation, funder). The Event dropdown and year options list facilitator + # trainings, matching the events scholarship report. + def set_report_filter_state + @filter_event = Event.find_by(id: params[:event_id]) if params[:event_id].present? + @event_abbreviation = params[:abbreviation].presence + @filter_funder = GlobalID::Locator.locate_signed(params[:funder_sgid]) if params[:funder_sgid].present? + @filter_events = Event.facilitator_trainings.order(start_date: :desc) + @year_options = Event.facilitator_trainings + .where.not(start_date: nil) + .distinct + .pluck(Arel.sql("YEAR(start_date)")) + .sort + .reverse + @time_period = params[:time_period].presence || "all_time" + @selected_year = @time_period == "this_year" ? Date.current.year : Integer(@time_period, exception: false) + end + + # The scholarship list, narrowed by recipient, funder, and the event-centric + # filters (which resolve to the events a scholarship was awarded at). + def filtered_scholarships + # Eager-load everything the grid derives so each row's funder, program, + # location, training, and status cells add no per-row queries. + scope = authorized_scope(Scholarship.all).includes( + { grant: :donor }, + { recipient: [ { affiliations: { organization: :addresses } }, { event_registrations: :event } ] } + ) + if params[:recipient_id].present? + scope = scope.where(recipient_id: params[:recipient_id]) + @recipient = Person.find_by(id: params[:recipient_id]) + end + scope = scope.from_funder(@filter_funder) if @filter_funder + event_ids = filter_event_ids + scope = scope.for_events(event_ids) if event_ids + scope + end + + # Event ids matching the year / specific-event / abbreviation filters, or nil + # when none are active (so the list isn't restricted by event). + def filter_event_ids + return unless @selected_year || @filter_event || @event_abbreviation + scoped_events.select(:id) + end + + # Facilitator trainings for the summary report at the top of the index, scoped + # by the same filters (year / event / abbreviation / funder), decorated. + def report_training_events + events = scoped_events(Event.facilitator_trainings) + events = events.where(id: Scholarship.from_funder(@filter_funder).event_ids) if @filter_funder + events.order(start_date: :desc).map(&:decorate) + end + + # Applies the year / specific-event / abbreviation filters to an event scope. + def scoped_events(base = Event.all) + base = base.in_year(@selected_year) if @selected_year + base = base.where(id: @filter_event.id) if @filter_event + base = base.where("events.abbreviation LIKE ?", "%#{Event.sanitize_sql_like(@event_abbreviation)}%") if @event_abbreviation + base + end + def set_scholarship @scholarship = Scholarship.find(params[:id]) end diff --git a/app/models/scholarship.rb b/app/models/scholarship.rb index f22a63b88c..fcc50b2f00 100644 --- a/app/models/scholarship.rb +++ b/app/models/scholarship.rb @@ -18,6 +18,29 @@ class Scholarship < ApplicationRecord scope :completed, -> { where(tasks_completed: true) } scope :agreement_signed, -> { where.not(agreement_signed_at: nil) } + # Scholarships from grants a given donor (Person/Organization) gave — the + # "funder" filter. A blank donor matches nothing. + scope :from_funder, ->(donor) { where(grant_id: Grant.where(donor: donor).select(:id)) } + + # Scholarships awarded at the given events, via the allocation → event + # registration chain (a scholarship's allocation is on an EventRegistration). + scope :for_events, ->(event_ids) { + registration_ids = EventRegistration.where(event_id: event_ids).select(:id) + source_ids = Allocation + .where(allocatable_type: "EventRegistration", allocatable_id: registration_ids, source_type: "Scholarship") + .select(:source_id) + where(id: source_ids) + } + + # Ids of events this relation's scholarships were awarded at — for narrowing an + # event report to trainings a funder actually scholarshipped. + def self.event_ids + registration_ids = Allocation + .where(allocatable_type: "EventRegistration", source_type: "Scholarship", source_id: all.select(:id)) + .select(:allocatable_id) + EventRegistration.where(id: registration_ids).distinct.pluck(:event_id) + end + # The agreement is signed when a signed-at timestamp is present — a single # source of truth. `agreement_signed` reads/writes as a virtual boolean so the # admin form checkbox and strong params keep working, stamping or clearing the diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index f8fe52aff3..f59950f835 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -1,6 +1,11 @@ class EventDashboard - def initialize(event) + # scholarship_donor: when set, every scholarship figure (funded/unfunded cents + # and counts, totals, recipients) is scoped to grants that donor gave — for the + # funder-filtered scholarship report. Attendance/registration figures are + # unaffected. Default nil = every scholarship, as before. + def initialize(event, scholarship_donor: nil) @event = event + @scholarship_donor = scholarship_donor end attr_reader :event @@ -1103,9 +1108,19 @@ def bulk_payments end def scholarships - @scholarships ||= Scholarship - .joins(:allocation) - .where(allocations: { allocatable_type: "EventRegistration", allocatable_id: active_registration_ids }) + @scholarships ||= begin + scope = Scholarship + .joins(:allocation) + .where(allocations: { allocatable_type: "EventRegistration", allocatable_id: active_registration_ids }) + scope = scope.where(grant_id: donor_grant_ids) if @scholarship_donor + scope + end + end + + # Ids of grants the scoped donor gave — used to narrow scholarships to one + # funder. Empty (so no scholarships match) when the donor gave none. + def donor_grant_ids + @donor_grant_ids ||= Grant.where(donor: @scholarship_donor).ids end # Externally funded = backed by a grant whose donor isn't the org itself. diff --git a/app/services/event_scholarship_report.rb b/app/services/event_scholarship_report.rb index 9128a30040..0858827bc6 100644 --- a/app/services/event_scholarship_report.rb +++ b/app/services/event_scholarship_report.rb @@ -56,14 +56,15 @@ def on_demand_attended_count = columns.select(&:on_demand?).sum(&:attended_count include Aggregates include ReportPeriods - def initialize(events, current_year: Date.current.year, featured_year: nil) + def initialize(events, current_year: Date.current.year, featured_year: nil, funder: nil) @events = events.to_a @current_year = current_year @featured_year_value = featured_year || current_year + @funder = funder end def columns - @columns ||= @events.map { |event| Column.new(event: event, dashboard: EventDashboard.new(event)) } + @columns ||= @events.map { |event| Column.new(event: event, dashboard: EventDashboard.new(event, scholarship_donor: @funder)) } end def any? diff --git a/app/views/events/_funder_filter.html.erb b/app/views/events/_funder_filter.html.erb new file mode 100644 index 0000000000..2153c82133 --- /dev/null +++ b/app/views/events/_funder_filter.html.erb @@ -0,0 +1,15 @@ +<%# Funder filter shared by the scholarship report forms: a remote-select over + people and organizations (grant donors), bound to the signed global id in + `funder_sgid`. Seeded with just the current funder's option so the value + shows without preloading everyone; the rest arrive via the remote search. + Submits the form on change, matching the sibling selects. %> +<% funder_label = @filter_funder&.compound_search_label %> +
+ + <%= select_tag :funder_sgid, + options_for_select(funder_label ? [ [ funder_label[:label], funder_label[:id] ] ] : [], funder_label&.dig(:id)), + include_blank: "All funders", + class: "min-w-[13rem] max-w-[16rem] bg-white px-3 py-2 rounded-md border-gray-300 shadow-sm text-sm text-gray-700", + data: { controller: "remote-select", remote_select_model_value: "person_or_organization" }, + onchange: "this.form.requestSubmit()" %> +
diff --git a/app/views/events/_report_filters.html.erb b/app/views/events/_report_filters.html.erb new file mode 100644 index 0000000000..3dd2400461 --- /dev/null +++ b/app/views/events/_report_filters.html.erb @@ -0,0 +1,8 @@ +<%# Shared scholarship-report filter controls: time period, specific training, + abbreviation search, and funder. Rendered inside a GET form on both the + events scholarship report and the /scholarships index. `event_all_label` + overrides the Event dropdown's blank option. %> +<%= render "events/time_period_filter" %> +<%= render "events/event_filter", all_label: local_assigns.fetch(:event_all_label, "All trainings") %> +<%= render "events/abbreviation_filter" %> +<%= render "events/funder_filter" %> diff --git a/app/views/events/_scholarship_kpis.html.erb b/app/views/events/_scholarship_kpis.html.erb new file mode 100644 index 0000000000..80d4567397 --- /dev/null +++ b/app/views/events/_scholarship_kpis.html.erb @@ -0,0 +1,40 @@ +<%# Featured-year headline for the scholarship report: funded vs unfunded money + and award counts, plus attended trainees. Pass `report:` (an + EventScholarshipReport). Shared by the events scholarship report and the + /scholarships index. %> +<% featured = report.featured_year %> +
+
+

<%= featured.year || "All trainings" %>

+ <% if featured.in_progress %> + In progress + <% end %> +
+
+
+
Funded
+
<%= dollars_from_cents(featured.funded_cents) %>
+
external grants
+
+
+
Unfunded
+
<%= dollars_from_cents(featured.unfunded_cents) %>
+
no grant or AWBW
+
+
+
Total awarded
+
<%= dollars_from_cents(featured.scholarship_cents) %>
+
funded + unfunded
+
+
+
Awards
+
<%= number_with_delimiter(featured.scholarship_count) %>
+
<%= featured.funded_count %> funded · <%= featured.unfunded_count %> unfunded
+
+
+
Attended
+
<%= number_with_delimiter(featured.attended_count) %>
+
<%= featured.training_attended_count %> training · <%= featured.on_demand_attended_count %> on-demand
+
+
+
diff --git a/app/views/events/scholarships.html.erb b/app/views/events/scholarships.html.erb index 53ac3ffe20..227b15d565 100644 --- a/app/views/events/scholarships.html.erb +++ b/app/views/events/scholarships.html.erb @@ -23,52 +23,11 @@ <%= form_with url: scholarships_events_path, method: :get, local: true, class: "flex flex-wrap items-end gap-4 mb-8" do %> <%= hidden_field_tag :return_to, params[:return_to] %> - <%= render "time_period_filter" %> - <%= render "event_filter", all_label: "All trainings" %> - <%= render "abbreviation_filter" %> + <%= render "report_filters", event_all_label: "All trainings" %> <% end %> <% if @report.any? %> - <% featured = @report.featured_year %> - <% prior = @report.prior_year %> - - <%# Headline figures for the featured year: funded vs unfunded scholarship - money and award counts, plus attended trainees. %> -
-
-

<%= featured.year || "All trainings" %>

- <% if featured.in_progress %> - In progress - <% end %> -
-
-
-
Funded
-
<%= dollars_from_cents(featured.funded_cents) %>
-
external grants
-
-
-
Unfunded
-
<%= dollars_from_cents(featured.unfunded_cents) %>
-
no grant or AWBW
-
-
-
Total awarded
-
<%= dollars_from_cents(featured.scholarship_cents) %>
-
funded + unfunded
-
-
-
Awards
-
<%= number_with_delimiter(featured.scholarship_count) %>
-
<%= featured.funded_count %> funded · <%= featured.unfunded_count %> unfunded
-
-
-
Attended
-
<%= number_with_delimiter(featured.attended_count) %>
-
<%= featured.training_attended_count %> training · <%= featured.on_demand_attended_count %> on-demand
-
-
-
+ <%= render "scholarship_kpis", report: @report %> <%= render "scholarships_report", report: @report %> diff --git a/app/views/scholarships/index.html.erb b/app/views/scholarships/index.html.erb index d14f56bbcd..abd0704398 100644 --- a/app/views/scholarships/index.html.erb +++ b/app/views/scholarships/index.html.erb @@ -24,6 +24,23 @@ + <%# Shared scholarship-report filters: time period, training, abbreviation, funder. %> + <%= form_with url: scholarships_path, method: :get, local: true, class: "flex flex-wrap items-end gap-4 mb-6" do %> + <% if params[:recipient_id].present? %><%= hidden_field_tag :recipient_id, params[:recipient_id] %><% end %> + <%= render "events/report_filters" %> + <% end %> + + <%# Scholarship summary for the trainings in scope, linking to the full report. %> + <% if @scholarship_report.any? %> +
+
+

Summary

+ <%= link_to "Full report →", scholarships_events_path, class: "text-sm font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline" %> +
+ <%= render "events/scholarship_kpis", report: @scholarship_report %> +
+ <% end %> + <%= render "shared/filtered_to", record: @recipient, clear_path: scholarships_path %> <% if @funder_groups.any? %>
diff --git a/spec/models/scholarship_spec.rb b/spec/models/scholarship_spec.rb index fd257af3cc..0d106a7e7d 100644 --- a/spec/models/scholarship_spec.rb +++ b/spec/models/scholarship_spec.rb @@ -116,4 +116,37 @@ expect(scholarship.reload.agreement_signed_at).to be_within(1.second).of(original) end end + + describe "report filter scopes" do + let(:event) { create(:event, cost_cents: 50_000) } + let(:funder) { create(:organization, name: "Community Trust") } + let(:person1) { create(:person) } + let(:person2) { create(:person) } + + let!(:from_funder) do + reg = create(:event_registration, event: event, registrant: person1, status: "attended") + scholarship = create(:scholarship, recipient: person1, amount_cents: 4_000, grant: create(:grant, donor: funder)) + create(:allocation, source: scholarship, allocatable: reg, amount: 4_000) + scholarship + end + + let!(:other) do + reg = create(:event_registration, event: create(:event, cost_cents: 50_000), registrant: person2, status: "attended") + scholarship = create(:scholarship, recipient: person2, amount_cents: 2_000, grant: create(:grant)) + create(:allocation, source: scholarship, allocatable: reg, amount: 2_000) + scholarship + end + + it ".from_funder returns only scholarships whose grant that donor gave" do + expect(Scholarship.from_funder(funder)).to contain_exactly(from_funder) + end + + it ".for_events returns only scholarships awarded at the given events" do + expect(Scholarship.for_events([ event.id ])).to contain_exactly(from_funder) + end + + it ".event_ids returns the events the scholarships were awarded at" do + expect(Scholarship.from_funder(funder).event_ids).to contain_exactly(event.id) + end + end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 7addb4c2d1..9eb399e75f 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -398,6 +398,19 @@ def add_ce_registrant(target_event) expect(response.body).to include("TAC261") expect(response.body).not_to include("OND100") end + + it "narrows to trainings a selected funder scholarshipped" do + funder = create(:organization, name: "Community Trust") + person = create(:person) + reg = create(:event_registration, event: training, registrant: person, status: "attended") + award = create(:scholarship, recipient: person, amount_cents: 4_000, grant: create(:grant, donor: funder)) + create(:allocation, source: award, allocatable: reg, amount: 4_000) + + sign_in admin + get scholarships_events_path(funder_sgid: funder.to_signed_global_id.to_s) + expect(response.body).to include("TAC261") + expect(response.body).not_to include("OND100") + end end context "as non-admin" do diff --git a/spec/requests/scholarships_spec.rb b/spec/requests/scholarships_spec.rb index 451ba6429c..94c3612b62 100644 --- a/spec/requests/scholarships_spec.rb +++ b/spec/requests/scholarships_spec.rb @@ -383,6 +383,30 @@ expect(response.body).to include("Jane Doe") end + it "renders the shared report filters and the scholarship summary" do + training = create(:event, facilitator_training: true, cost_cents: 50_000, start_date: Date.current) + recipient = create(:person) + reg = create(:event_registration, event: training, registrant: recipient, status: "attended") + award = create(:scholarship, recipient: recipient, amount_cents: 4_000, grant: create(:grant)) + create(:allocation, source: award, allocatable: reg, amount: 4_000) + + get scholarships_path + expect(response.body).to include("Time period", "Abbreviation", "Funder") + expect(response.body).to include("Summary", "Total awarded") + end + + it "narrows the list to a selected funder" do + keep = create(:organization, name: "Keep Foundation") + drop = create(:organization, name: "Drop Foundation") + create(:scholarship, grant: create(:grant, donor: keep), recipient: create(:person, first_name: "Kept", last_name: "One")) + create(:scholarship, grant: create(:grant, donor: drop), recipient: create(:person, first_name: "Dropped", last_name: "Two")) + + get scholarships_path(funder_sgid: keep.to_signed_global_id.to_s) + + expect(response.body).to include("Kept One") + expect(response.body).not_to include("Dropped Two") + end + it "links a grant group's grant back to the scholarship index via from_scholarships" do grant = create(:grant, name: "Marisla") create(:scholarship, grant: grant) diff --git a/spec/services/event_scholarship_report_spec.rb b/spec/services/event_scholarship_report_spec.rb index 04fd6a0be6..36f0994ee3 100644 --- a/spec/services/event_scholarship_report_spec.rb +++ b/spec/services/event_scholarship_report_spec.rb @@ -74,6 +74,36 @@ def report_for(events, **opts) end end + describe "funder scoping" do + let(:event) { create(:event, facilitator_training: true, cost_cents: 50_000, start_date: Date.new(2025, 3, 1)) } + let(:funder) { create(:organization, name: "Community Trust") } + let(:person1) { create(:person) } + let(:person2) { create(:person) } + + before do + reg1 = create(:event_registration, event: event, registrant: person1, status: "attended") + reg2 = create(:event_registration, event: event, registrant: person2, status: "attended") + + from_funder = create(:scholarship, recipient: person1, amount_cents: 4_000, grant: create(:grant, donor: funder)) + create(:allocation, source: from_funder, allocatable: reg1, amount: 4_000) + + other = create(:scholarship, recipient: person2, amount_cents: 2_000, grant: create(:grant)) + create(:allocation, source: other, allocatable: reg2, amount: 2_000) + end + + it "scopes scholarship figures to the given funder" do + report = described_class.new([ event.decorate ], funder: funder) + column = report.years.first.columns.first + expect(column.scholarship_cents).to eq(4_000) + expect(column.scholarship_count).to eq(1) + end + + it "counts every funder's scholarships when unscoped" do + report = described_class.new([ event.decorate ]) + expect(report.years.first.columns.first.scholarship_cents).to eq(6_000) + end + end + describe "attendance split by delivery format" do let(:scheduled) { create(:event, facilitator_training: true, on_demand: false, start_date: Date.new(2025, 3, 1)) } let(:on_demand) { create(:event, facilitator_training: true, on_demand: true, start_date: Date.new(2025, 7, 1)) } From f9efd30ae625d16271353191a296b606ee91b3c0 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 5 Aug 2026 08:09:17 -0400 Subject: [PATCH 5/9] Use a stretched link for the clickable scholarship row Replace the clickable-row Stimulus controller with a no-JS stretched link: the row is the positioned ancestor and the recipient link's ::before covers it, so clicking anywhere follows that one link. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 3 +-- .../controllers/clickable_row_controller.js | 16 ---------------- app/frontend/javascript/controllers/index.js | 3 --- app/views/scholarships/_recipient_row.html.erb | 11 +++++------ 4 files changed, 6 insertions(+), 27 deletions(-) delete mode 100644 app/frontend/javascript/controllers/clickable_row_controller.js diff --git a/AGENTS.md b/AGENTS.md index 2d327cc6f4..e84e1d8bae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ This codebase (Rails 8.1) | Directory | Purpose | |---|---| | `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) | -| `app/frontend/javascript/controllers/` | Stimulus controllers (77) | +| `app/frontend/javascript/controllers/` | Stimulus controllers (76) | | `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) | | `app/frontend/stylesheets/` | Tailwind CSS and component styles | @@ -304,7 +304,6 @@ end - `autosave` — Auto-save form state - `carousel` — Swiper-based carousels - `ce_license_picker` — Fill the CE license type/number/state/expiry fields from the picked license (or clear them for a new one) -- `clickable_row` — Makes a whole element (e.g. a table row) navigate to a URL on click, leaving nested links/buttons and modifier-clicks alone (scholarships index rows → edit) - `cocoon` — Nested form handling (cocoon gem) - `collection` — Filter form auto-submit with debounce - `column_toggle` — Toggle table column visibility diff --git a/app/frontend/javascript/controllers/clickable_row_controller.js b/app/frontend/javascript/controllers/clickable_row_controller.js deleted file mode 100644 index ae4f61bae8..0000000000 --- a/app/frontend/javascript/controllers/clickable_row_controller.js +++ /dev/null @@ -1,16 +0,0 @@ -import { Controller } from "@hotwired/stimulus" -import { Turbo } from "@hotwired/turbo-rails" - -// Makes a whole element (e.g. a table row) navigate to a URL on click, while -// leaving nested interactive elements working normally — a click on a link, -// button, input, or anything opted out with data-clickable-row-ignore is left -// alone. Modifier-clicks (new tab) fall through to the row's own link. -export default class extends Controller { - static values = { url: String } - - navigate(event) { - if (event.target.closest("a, button, input, label, select, summary, [data-clickable-row-ignore]")) return - if (event.metaKey || event.ctrlKey || event.shiftKey) return - Turbo.visit(this.urlValue) - } -} diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index b0734f9fbf..346eda3b17 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -39,9 +39,6 @@ application.register("ce-license-picker", CeLicensePickerController) import ChipSelectController from "./chip_select_controller" application.register("chip-select", ChipSelectController) -import ClickableRowController from "./clickable_row_controller" -application.register("clickable-row", ClickableRowController) - import CocoonController from "./cocoon_controller" application.register("cocoon", CocoonController) diff --git a/app/views/scholarships/_recipient_row.html.erb b/app/views/scholarships/_recipient_row.html.erb index e74fb07a6a..c7955b4dc7 100644 --- a/app/views/scholarships/_recipient_row.html.erb +++ b/app/views/scholarships/_recipient_row.html.erb @@ -1,11 +1,10 @@ <% scholarship = scholarship.decorate %> -<%# The whole row navigates to the scholarship edit page (clickable-row); the - recipient link stays a real link for keyboard/screen-reader access. %> -
- <%= link_to scholarship.recipient_name, edit_scholarship_path(scholarship), class: "font-medium text-blue-700 hover:text-blue-900 hover:underline" %> + <%= link_to scholarship.recipient_name, edit_scholarship_path(scholarship), class: "font-medium text-blue-700 hover:text-blue-900 hover:underline before:absolute before:inset-0 before:content-['']" %> <%= scholarship.program_name %> <%= scholarship.program_location %>
- <%= link_to scholarship.recipient_name, edit_scholarship_path(scholarship), class: "font-medium text-blue-700 hover:text-blue-900 hover:underline before:absolute before:inset-0 before:content-['']" %> + <%= link_to scholarship.recipient_name, edit_scholarship_path(scholarship), class: "font-medium text-blue-700 hover:text-blue-900 hover:underline after:absolute after:inset-0 after:content-['']" %> <%= scholarship.program_name %> <%= scholarship.program_location %>
- <%# ---- $ of scholarships ---- %> - - - - <% events.each do |column| %> - - <% end %> - - - - - - <% events.each do |column| %> - - <% end %> - - - - <% events.each do |column| %> - - <% end %> - - - - <% events.each do |column| %> - - <% end %> - - - - - - - <%# ---- # of scholarships ---- %> - - - <% events.each do |column| %> - - <% end %> - - - - <% events.each do |column| %> - - <% end %> - - - - <% events.each do |column| %> - - <% end %> - - - - <% events.each do |column| %> - - <% end %> - - - - - - - <%# ---- # of trainees attended ---- %> - - - <% events.each do |column| %> - - <% end %> - - - - <% events.each do |column| %> - - <% end %> - - - - - - - - - - - - - - -
- $ of scholarships - - <%= column.label %> - <% if column.date_label %> - <%= column.date_label %> - <% end %> -
Total funded<%= dollars_from_cents(column.funded_cents) %>
Total unfunded<%= dollars_from_cents(column.unfunded_cents) %>
Total<%= dollars_from_cents(column.scholarship_cents) %>
Overall total<%= dollars_from_cents(group.scholarship_cents) %>
- # of scholarships - - <%= column.label %> -
Total # funded<%= column.funded_count %>
Total # unfunded<%= column.unfunded_count %>
Total #<%= column.scholarship_count %>
Overall # total<%= group.scholarship_count %>
- # of trainees attended - - <%= column.label %> - <% if column.on_demand? %> - On-demand - <% end %> -
Attended<%= column.attended_count %>
Training<%= group.training_attended_count %>
On-demand<%= group.on_demand_attended_count %>
Overall total<%= group.attended_count %>
-
-
- <% end %> -
+ <%= render "scholarships_report_table", + report: report, + combined: params[:view] == "combined", + all_time: @time_period.blank? || @time_period == "all_time" %> <% else %>
No facilitator trainings yet — mark an event as a facilitator training to see it here. diff --git a/app/views/events/_scholarships_report_combined_cell.html.erb b/app/views/events/_scholarships_report_combined_cell.html.erb new file mode 100644 index 0000000000..af67aed440 --- /dev/null +++ b/app/views/events/_scholarships_report_combined_cell.html.erb @@ -0,0 +1,7 @@ +<%# A single combined scholarship cell: award count stacked over its dollars. + `count`, `cents`; `start:` draws the group's left divider; `emphasis:` bolds + the count for a training's Total column. %> +"> +
"><%= count %>
+
<%= dollars_from_cents(cents) %>
+ diff --git a/app/views/events/_scholarships_report_figures.html.erb b/app/views/events/_scholarships_report_figures.html.erb new file mode 100644 index 0000000000..144ab526ff --- /dev/null +++ b/app/views/events/_scholarships_report_figures.html.erb @@ -0,0 +1,29 @@ +<%# The figure cells for one scholarship report row — a training column, a year + subtotal, or the grand total: registrants attended, then funded/unfunded/total + for counts and dollars (or the combined count-over-dollars cells). Pass + `source:` (responds to attended_count / *_count / *_cents), `combined:`, and + optionally `event:` to link the attended number into the registrants roster + (omit on totals rows, which span multiple events). %> + + <% if event && source.attended_count.positive? %> + <%= link_to source.attended_count, + registrants_event_path(event, attendance_status: "attended", return_to: "scholarships"), + target: "_blank", rel: "noopener", + class: "text-blue-600 hover:text-blue-800 hover:underline", + title: "View attended registrants" %> + <% else %> + <%= source.attended_count %> + <% end %> + +<% if combined %> + <%= render "scholarships_report_combined_cell", count: source.funded_count, cents: source.funded_cents, start: true, emphasis: false %> + <%= render "scholarships_report_combined_cell", count: source.unfunded_count, cents: source.unfunded_cents, start: false, emphasis: false %> + <%= render "scholarships_report_combined_cell", count: source.scholarship_count, cents: source.scholarship_cents, start: false, emphasis: true %> +<% else %> + <%= source.funded_count %> + <%= source.unfunded_count %> + <%= source.scholarship_count %> + <%= dollars_from_cents(source.funded_cents) %> + <%= dollars_from_cents(source.unfunded_cents) %> + <%= dollars_from_cents(source.scholarship_cents) %> +<% end %> diff --git a/app/views/events/_scholarships_report_table.html.erb b/app/views/events/_scholarships_report_table.html.erb new file mode 100644 index 0000000000..ddb53e977f --- /dev/null +++ b/app/views/events/_scholarships_report_table.html.erb @@ -0,0 +1,98 @@ +<%# One scholarship report card: every facilitator training as a row, grouped by + year (a subheader + subtotal per year when more than one year is in scope), + ending in a grand total. `combined:` false → separate "# of scholarships" and + "$ of scholarships" column groups; true → a single count-over-dollars group. + `all_time:` titles the card "All facilitator trainings" (else the single year). + Rendered by _scholarships_report and swapped by the view toggle. Pass + `report:`, `combined:`, `all_time:`. %> +<% years = report.years %> +<% multi_year = years.size > 1 %> +<% col_span = combined ? 5 : 8 %> +
+
+

+ + <%= all_time ? "All facilitator trainings" : "#{years.first.year || "Undated"} facilitator trainings" %> +

+
+ <%= render "scholarships_report_toggle", combined: combined %> + <%= pluralize(report.columns.size, "training") %> +
+
+ +
+ + + + + + <% if combined %> + + <% else %> + + + <% end %> + + + + + + <% unless combined %> + + + + <% end %> + + + + <% years.each do |group| %> + <% if multi_year %> + + + + <% end %> + <% group.columns.each do |column| %> + + + <%= render "scholarships_report_figures", source: column, combined: combined, event: column.event %> + + <% end %> + <% if multi_year %> + + + <%= render "scholarships_report_figures", source: group, combined: combined, event: nil %> + + <% end %> + <% end %> + + + <%= render "scholarships_report_figures", source: report.all_trainings_group, combined: combined, event: nil %> + + +
Training + Registrants attended + + Scholarships + (# / $) + + # of scholarships + + $ of scholarships +
FundedUnfundedTotalFundedUnfundedTotal
+ <%= group.year || "Undated" %> +
+ <%= column.label %> + <% if column.date_label %> + <%= column.date_label %> + <% end %> + <% if column.on_demand? %> + On-demand + <% end %> +
<%= group.year || "Undated" %> total
<%= all_time ? "All-time total" : "Total" %>
+
+
+ Attended breakdown: + <%= report.training_attended_count %> training + · <%= report.on_demand_attended_count %> on-demand +
+
diff --git a/app/views/events/_scholarships_report_toggle.html.erb b/app/views/events/_scholarships_report_toggle.html.erb new file mode 100644 index 0000000000..32e8198d2b --- /dev/null +++ b/app/views/events/_scholarships_report_toggle.html.erb @@ -0,0 +1,13 @@ +<%# Layout toggle for the scholarship report table: separate # and $ columns vs + a combined count-over-dollars cell. Reloads with a `view` param, preserving + the current filters. Pass `combined:` (whether the combined view is active). %> + diff --git a/app/views/events/participation.html.erb b/app/views/events/participation.html.erb index 357c53ae76..c20eca07da 100644 --- a/app/views/events/participation.html.erb +++ b/app/views/events/participation.html.erb @@ -1,7 +1,8 @@ <% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<%# Opt out of the layout's max-w-7xl cap to match the wider event manage pages. %> <% content_for(:full_width, true) %> -
+
<% if params[:return_to] == "dashboard" && params[:event_id].present? %> diff --git a/app/views/events/registrants.html.erb b/app/views/events/registrants.html.erb index c6e3172c2f..8fcbf71418 100644 --- a/app/views/events/registrants.html.erb +++ b/app/views/events/registrants.html.erb @@ -10,6 +10,8 @@
<% if params[:return_to] == "background" %> <%= link_to "← Background", background_event_path(@event, anchor: params[:return_anchor].presence), class: "text-sm text-gray-500 hover:text-gray-700" %> + <% elsif params[:return_to] == "scholarships" %> + <%= link_to "← Events scholarships", scholarships_events_path, class: "text-sm text-gray-500 hover:text-gray-700" %> <% else %> <%= link_to "← Dashboard", dashboard_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> <% end %> diff --git a/app/views/events/revenue.html.erb b/app/views/events/revenue.html.erb index ca782797dd..a5b9d308ca 100644 --- a/app/views/events/revenue.html.erb +++ b/app/views/events/revenue.html.erb @@ -1,7 +1,8 @@ <% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<%# Opt out of the layout's max-w-7xl cap to match the wider event manage pages. %> <% content_for(:full_width, true) %> -
+
<% if params[:return_to] == "dashboard" && params[:event_id].present? %> diff --git a/app/views/events/scholarships.html.erb b/app/views/events/scholarships.html.erb index 227b15d565..8ba07331b5 100644 --- a/app/views/events/scholarships.html.erb +++ b/app/views/events/scholarships.html.erb @@ -1,7 +1,8 @@ <% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<%# Opt out of the layout's max-w-7xl cap to match the wider event manage pages. %> <% content_for(:full_width, true) %> -
+
<% if params[:return_to] == "dashboard" && params[:event_id].present? %> @@ -23,6 +24,7 @@ <%= form_with url: scholarships_events_path, method: :get, local: true, class: "flex flex-wrap items-end gap-4 mb-8" do %> <%= hidden_field_tag :return_to, params[:return_to] %> + <%= hidden_field_tag :view, params[:view] %> <%= render "report_filters", event_all_label: "All trainings" %> <% end %> @@ -33,8 +35,9 @@

Funded = backed by an external grant. - Unfunded = no grant, or a grant A Window Between Worlds donated to itself (org subsidy). - Attended counts trainees whose registration is marked attended. + Unfunded = no grant, or a grant A Window Between Worlds funded (org subsidy). +
+ Attended counts all trainees whose registration is marked attended, including scholarship recipients and people who paid in full.

<% else %>
diff --git a/app/views/events/statistics.html.erb b/app/views/events/statistics.html.erb index 21513f214b..839b4bc876 100644 --- a/app/views/events/statistics.html.erb +++ b/app/views/events/statistics.html.erb @@ -1,7 +1,8 @@ <% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<%# Opt out of the layout's max-w-7xl cap to match the wider event manage pages. %> <% content_for(:full_width, true) %> -
+
<% if params[:return_to] == "events" %> diff --git a/db/seeds/dev/scholarships.rb b/db/seeds/dev/scholarships.rb index 59b9dae3b0..c8fa438756 100644 --- a/db/seeds/dev/scholarships.rb +++ b/db/seeds/dev/scholarships.rb @@ -426,6 +426,64 @@ end end +# --- Prior-year facilitator trainings -------------------------------------- +# Every dev event above sits in the current year, so the events scholarship +# report only ever shows a single year group. Seed a few past-year trainings — +# each with attended trainees and a mix of funded/unfunded awards — so the +# report's year grouping and the "All time" period have real multi-year data, +# and "Registrants attended" exceeds the scholarship count (plain attendees on +# top of recipients). Idempotent: keyed on each training's title. +puts "Creating prior-year facilitator trainings with scholarships…" + +admin_user = User.find_by(email: "umberto.user@example.com") +people_pool = Person.order(:id).to_a +person_cursor = 0 +take_person = -> do + person = people_pool[person_cursor % people_pool.length] + person_cursor += 1 + person +end + +# [ title, abbreviation, start_date, cost_cents, +# [ [ award_cents, grant_funded ], … ] (each an attended recipient), +# plain_attendee_count (attended, no scholarship) ] +prior_year_trainings = [ + [ "Facilitator Training: Trauma-Informed Art (2024)", "TAC24", Date.new(2024, 4, 12), 30_000, + [ [ 30_000, true ], [ 15_000, true ], [ 12_000, false ] ], 4 ], + [ "Facilitator Training: Expressive Arts Intensive (2025)", "TAC25S", Date.new(2025, 3, 8), 32_500, + [ [ 32_500, true ], [ 20_000, false ], [ 10_000, false ] ], 5 ], + [ "Facilitator Training: Community Healing Cohort (2025)", "TAC25F", Date.new(2025, 10, 18), 35_000, + [ [ 35_000, true ], [ 17_500, true ] ], 3 ] +] + +prior_year_trainings.each do |title, abbreviation, start_date, cost_cents, awards, plain_count| + event = Event.find_or_create_by!(title: title) do |e| + e.abbreviation = abbreviation + e.start_date = start_date + e.end_date = start_date + 1.day + e.cost_cents = cost_cents + e.facilitator_training = true + e.published = true + e.created_by = admin_user + end + event.update!(abbreviation: abbreviation, start_date: start_date, end_date: start_date + 1.day, + cost_cents: cost_cents, facilitator_training: true) + + awards.each do |award_cents, grant_funded| + registration = EventRegistration.find_or_create_by!(event: event, registrant: take_person.()) do |reg| + reg.status = "attended" + end + registration.update!(status: "attended") unless registration.status == "attended" + award_scholarship.(registration, amount_cents: award_cents, tasks_completed: true, grant_funded: grant_funded) + end + + plain_count.times do + EventRegistration.find_or_create_by!(event: event, registrant: take_person.()) do |reg| + reg.status = "attended" + end + end +end + # --- Standalone grant-funded scholarships ---------------------------------- # Beyond the event-allocated awards above (which now draw from these grants too), # seed a few standalone grant awards — recipient + grant, no event allocation — diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 9eb399e75f..966f5120bf 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -385,20 +385,36 @@ def add_ce_registrant(target_event) get scholarships_events_path expect(response).to have_http_status(:ok) expect(response.body).to include("Events scholarships") - expect(response.body).to include("$ of scholarships", "# of scholarships", "# of trainees attended") + expect(response.body).to include("$ of scholarships", "# of scholarships", "Attended breakdown") expect(response.body).to include("TAC 261", "Self-paced training") expect(response.body).not_to include("Paid webinar") end + it "renders the combined-cell layout when the view toggle is set" do + sign_in admin + get scholarships_events_path(view: "combined") + # Combined view folds the two count/$ column groups into one; the split + # view's separate headers are gone. + expect(response.body).to include("Events scholarships") + expect(response.body).not_to include("# of scholarships", "$ of scholarships") + end + it "narrows to trainings whose abbreviation matches the search" do sign_in admin - get scholarships_events_path(abbreviation: "TAC") + get scholarships_events_path(search: "TAC") # Abbreviations head the report columns; the excluded training's is absent # (its title still appears in the always-full Event dropdown). expect(response.body).to include("TAC261") expect(response.body).not_to include("OND100") end + it "narrows to trainings whose title matches the search" do + sign_in admin + get scholarships_events_path(search: "Self-paced") + expect(response.body).to include("OND100") + expect(response.body).not_to include("TAC261") + end + it "narrows to trainings a selected funder scholarshipped" do funder = create(:organization, name: "Community Trust") person = create(:person) diff --git a/spec/services/event_scholarship_report_spec.rb b/spec/services/event_scholarship_report_spec.rb index 36f0994ee3..a976d144fc 100644 --- a/spec/services/event_scholarship_report_spec.rb +++ b/spec/services/event_scholarship_report_spec.rb @@ -159,6 +159,13 @@ def report_for(events, **opts) expect(report.prior_year.year).to eq(2024) end + it "leads with an all-trainings aggregate when no year is featured (all-time)" do + all_time = report_for([ e2025b, e2024, e2025a ]) + expect(all_time.featured_year.year).to be_nil + expect(all_time.featured_year.scholarship_cents).to eq(5_000) + expect(all_time.prior_year).to be_nil + end + it "resolves an all-time period to the whole report" do period = report.period_scope("all_time") expect(period.metrics.scholarship_cents).to eq(5_000) From f9f8ff0b0a141c07857c8bfc5be808ba2aed1145 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 5 Aug 2026 11:50:46 -0400 Subject: [PATCH 8/9] Same-tab attended drill-in + all-time headline consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on the scholarships report: - The per-training "Registrants attended" numbers now open the registrants roster in the same tab (attended filter) and the roster's eyebrow returns to the exact report row — scrolled to and highlighted — with the report's filters/toggle restored (registrants-style, via new EventsHelper paths). - Revenue and Participation reports now aggregate every year on "All time" ("All events") instead of pinning the KPI headline to the current year, matching the scholarships fix. - Recipients page gains a "Statistics" link-button next to Grants → the scholarships report. - Statistics-hub revenue Net card: inline the fees/scholarships/subsidy breakdown on the total row so the card stays three rows. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/helpers/events_helper.rb | 33 +++++++++++++++++++ app/services/event_participation_report.rb | 17 ++++++++-- app/services/event_revenue_report.rb | 18 +++++++--- app/views/events/_revenue_summary.html.erb | 11 ++++--- .../_scholarships_report_figures.html.erb | 4 +-- .../_scholarships_report_table.html.erb | 3 +- app/views/events/recipients.html.erb | 6 ++++ app/views/events/registrants.html.erb | 2 +- .../event_participation_report_spec.rb | 6 ++-- spec/services/event_revenue_report_spec.rb | 6 ++-- 10 files changed, 83 insertions(+), 23 deletions(-) diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index cb5085446e..0cb337fe4f 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -25,6 +25,39 @@ def registrants_event_row_path(event_or_id, registration_id) registrants_event_path(event_or_id, anchor: registrant_row_id(registration_id), highlight: registration_id) end + # The scholarships report's filter/toggle state, carried through a drill-in so + # its eyebrow can rebuild the exact view (period, event type/id, search, funder, + # split/combined layout, and the report's own origin) the user came from. + REPORT_FILTER_KEYS = %i[ time_period event_type event_id search funder_sgid view return_to ].freeze + + # Stable anchor id for a training's row on the scholarships report, so the + # registrants eyebrow can scroll to and highlight the row drilled in from. + def training_report_row_id(event_or_id) + id = event_or_id.respond_to?(:id) ? event_or_id.id : event_or_id + "training-row-#{id}" + end + + # Forward: from a scholarships report row into that training's *attended* + # registrants, stamped so the roster's eyebrow returns to the exact row + # (highlight + anchor) with the report's filters/toggle restored. + def attended_registrants_path(event) + registrants_event_path(event, + attendance_status: "attended", + return_to: "scholarships", + return_highlight: event.id, + return_anchor: training_report_row_id(event), + report_filters: params.permit(*REPORT_FILTER_KEYS).to_h.compact_blank) + end + + # Back: the registrants eyebrow's path to the scholarships report, restoring the + # carried filters/toggle and highlighting the row the user drilled in from. + def scholarships_report_return_path + filters = params.fetch(:report_filters, ActionController::Parameters.new).permit(*REPORT_FILTER_KEYS) + scholarships_events_path(**filters.to_h.symbolize_keys, + highlight: params[:return_highlight].presence, + anchor: params[:return_anchor].presence) + end + # Stamp a registrants-page link reached from the background dashboard with the # context its eyebrow needs to send the user back to the exact section they # drilled in from: return_to marks the origin page, return_anchor the section id diff --git a/app/services/event_participation_report.rb b/app/services/event_participation_report.rb index 57c856f121..35128d76f3 100644 --- a/app/services/event_participation_report.rb +++ b/app/services/event_participation_report.rb @@ -91,7 +91,9 @@ def count_other def initialize(events, current_year: Date.current.year, featured_year: nil) @events = events.to_a @current_year = current_year - @featured_year_value = featured_year || current_year + # nil means no specific year is featured (all-time): the headline aggregates + # every event rather than collapsing to the current year. + @featured_year_value = featured_year end def rows @@ -126,12 +128,21 @@ def unique_people @unique_people ||= unique_attended_people end - # The year whose figures lead the KPI strip: the filtered/navigated-from year, - # else the current year, falling back to the most recent year present. + # The group whose figures lead the KPI strip: the filtered/navigated-from year, + # falling back to the most recent year present. When no year is featured + # (all-time), an aggregate of every event so the headline isn't year-scoped. def featured_year + return all_events_group if @featured_year_value.nil? years_by_value[@featured_year_value] || years.first end + # A single group spanning every event, under a nil year so the headline reads + # "All events". unique_people is the distinct all-scope count (not a sum of + # year subtotals). Used as the all-time headline. + def all_events_group + @all_events_group ||= YearGroup.new(year: nil, rows: rows, unique_people: unique_people, in_progress: false) + end + # The most recent year-group strictly older than the featured one, for a # year-over-year delta. Nil when there's nothing older to compare against. def prior_year diff --git a/app/services/event_revenue_report.rb b/app/services/event_revenue_report.rb index d9c98bce42..f7044ed7cd 100644 --- a/app/services/event_revenue_report.rb +++ b/app/services/event_revenue_report.rb @@ -85,7 +85,9 @@ module Summable def initialize(events, current_year: Date.current.year, featured_year: nil) @events = events @current_year = current_year - @featured_year_value = featured_year || current_year + # nil means no specific year is featured (all-time): the headline aggregates + # every event rather than collapsing to the current year. + @featured_year_value = featured_year end def rows @@ -108,13 +110,21 @@ def years .sort_by { |group| [ group.year ? 0 : 1, -(group.year || 0) ] } end - # The year whose figures lead the KPI strip: the year navigated from (the event - # clicked) or the current year. Falls back to the most recent year present when - # that year has no events. + # The group whose figures lead the KPI strip: the year navigated from (the event + # clicked), falling back to the most recent year present. When no year is + # featured (all-time), an aggregate of every event so the headline isn't + # year-scoped. def featured_year + return all_events_group if @featured_year_value.nil? years_by_value[@featured_year_value] || years.first end + # A single group spanning every event, under a nil year so the headline reads + # "All events". Used as the all-time headline. + def all_events_group + @all_events_group ||= YearGroup.new(year: nil, rows: rows, in_progress: false) + end + # The most recent year-group strictly older than the featured one, for a # year-over-year delta. Nil when there's nothing older to compare against. def prior_year diff --git a/app/views/events/_revenue_summary.html.erb b/app/views/events/_revenue_summary.html.erb index 5a40a80740..fa21bc31e6 100644 --- a/app/views/events/_revenue_summary.html.erb +++ b/app/views/events/_revenue_summary.html.erb @@ -12,9 +12,6 @@ <% metrics = period.metrics %>
<%= period.label %>
<% ce_collected_cents = metrics.fees_cents - metrics.registration_payments_cents %> - <% net_parts = [ "Fees" ] - net_parts << "+ #{MoneyFormatter.compact_from_cents(metrics.funded_scholarship_cents, precision: 0)} scholarships" if metrics.funded_scholarship_cents.positive? - net_parts << "− #{MoneyFormatter.compact_from_cents(metrics.org_subsidy_cents, precision: 0)} subsidy" %>
Fees collected
@@ -25,8 +22,12 @@
Net
-
"><%= signed_dollars_from_cents(metrics.net_cents) %>
-
<%= net_parts.join(" ") %>
+
+ "><%= signed_dollars_from_cents(metrics.net_cents) %> + + <%= MoneyFormatter.compact_from_cents(metrics.fees_cents, precision: 0) %> fees<% if metrics.funded_scholarship_cents.positive? %> + <%= MoneyFormatter.compact_from_cents(metrics.funded_scholarship_cents, precision: 0) %> scholarships<% end %> − <%= MoneyFormatter.compact_from_cents(metrics.org_subsidy_cents, precision: 0) %> subsidy + +
Outstanding
diff --git a/app/views/events/_scholarships_report_figures.html.erb b/app/views/events/_scholarships_report_figures.html.erb index 144ab526ff..5e265ea0ed 100644 --- a/app/views/events/_scholarships_report_figures.html.erb +++ b/app/views/events/_scholarships_report_figures.html.erb @@ -6,9 +6,7 @@ (omit on totals rows, which span multiple events). %> <% if event && source.attended_count.positive? %> - <%= link_to source.attended_count, - registrants_event_path(event, attendance_status: "attended", return_to: "scholarships"), - target: "_blank", rel: "noopener", + <%= link_to source.attended_count, attended_registrants_path(event), class: "text-blue-600 hover:text-blue-800 hover:underline", title: "View attended registrants" %> <% else %> diff --git a/app/views/events/_scholarships_report_table.html.erb b/app/views/events/_scholarships_report_table.html.erb index ddb53e977f..cdf29f77b2 100644 --- a/app/views/events/_scholarships_report_table.html.erb +++ b/app/views/events/_scholarships_report_table.html.erb @@ -63,7 +63,8 @@ <% end %> <% group.columns.each do |column| %> - + <% highlighted = params[:highlight].to_s == column.event.id.to_s %> + "> <%= column.label %> <% if column.date_label %> diff --git a/app/views/events/recipients.html.erb b/app/views/events/recipients.html.erb index 9162fdf019..d817bf1d55 100644 --- a/app/views/events/recipients.html.erb +++ b/app/views/events/recipients.html.erb @@ -19,6 +19,12 @@ in their own right-aligned row above the centered title to avoid overlap. %>
+ <% if allowed_to?(:scholarships?, @event) %> + <%= link_to scholarships_events_path, target: "_blank", rel: "noopener", class: "btn btn-utility-outline" do %> + Statistics + + <% end %> + <% end %> <% if allowed_to?(:index?, Grant) %> <%= link_to grants_path, target: "_blank", rel: "noopener", class: "btn btn-utility-outline" do %> Grants diff --git a/app/views/events/registrants.html.erb b/app/views/events/registrants.html.erb index 8fcbf71418..9e6bf77bc3 100644 --- a/app/views/events/registrants.html.erb +++ b/app/views/events/registrants.html.erb @@ -11,7 +11,7 @@ <% if params[:return_to] == "background" %> <%= link_to "← Background", background_event_path(@event, anchor: params[:return_anchor].presence), class: "text-sm text-gray-500 hover:text-gray-700" %> <% elsif params[:return_to] == "scholarships" %> - <%= link_to "← Events scholarships", scholarships_events_path, class: "text-sm text-gray-500 hover:text-gray-700" %> + <%= link_to "← Events scholarships", scholarships_report_return_path, class: "text-sm text-gray-500 hover:text-gray-700" %> <% else %> <%= link_to "← Dashboard", dashboard_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> <% end %> diff --git a/spec/services/event_participation_report_spec.rb b/spec/services/event_participation_report_spec.rb index 66bd426bd4..97267326ff 100644 --- a/spec/services/event_participation_report_spec.rb +++ b/spec/services/event_participation_report_spec.rb @@ -207,9 +207,9 @@ expect(report.years.last.in_progress).to be(false) end - it "features the current year by default, with the next older year as prior" do - expect(report.featured_year.year).to eq(2026) - expect(report.prior_year.year).to eq(2025) + it "aggregates all events when no year is featured (all-time), with no prior delta" do + expect(report.featured_year.year).to be_nil + expect(report.prior_year).to be_nil end it "features an explicit year when given" do diff --git a/spec/services/event_revenue_report_spec.rb b/spec/services/event_revenue_report_spec.rb index 331ee223c9..706ba985c5 100644 --- a/spec/services/event_revenue_report_spec.rb +++ b/spec/services/event_revenue_report_spec.rb @@ -148,9 +148,9 @@ expect(report.years.last.in_progress).to be(false) end - it "features the current year by default, with the next older year as prior" do - expect(report.featured_year.year).to eq(2026) - expect(report.prior_year.year).to eq(2025) + it "aggregates all events when no year is featured (all-time), with no prior delta" do + expect(report.featured_year.year).to be_nil + expect(report.prior_year).to be_nil end it "features an explicit year when given (e.g. the event navigated from)" do From be0f06d7c0571e4abbbbad1ded16abbbdf744bdc Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 5 Aug 2026 20:59:51 -0400 Subject: [PATCH 9/9] Compact revenue Net card note to two lines The amount-laden breakdown wrapped onto a third row, making the Net card taller than its siblings. Replace it with a two-line formula (fees + scholarships, funded added / unfunded subtracted) that stays within three rows. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/events/_revenue_summary.html.erb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/views/events/_revenue_summary.html.erb b/app/views/events/_revenue_summary.html.erb index fa21bc31e6..bb526e12e3 100644 --- a/app/views/events/_revenue_summary.html.erb +++ b/app/views/events/_revenue_summary.html.erb @@ -24,9 +24,7 @@
Net
"><%= signed_dollars_from_cents(metrics.net_cents) %> - - <%= MoneyFormatter.compact_from_cents(metrics.fees_cents, precision: 0) %> fees<% if metrics.funded_scholarship_cents.positive? %> + <%= MoneyFormatter.compact_from_cents(metrics.funded_scholarship_cents, precision: 0) %> scholarships<% end %> − <%= MoneyFormatter.compact_from_cents(metrics.org_subsidy_cents, precision: 0) %> subsidy - + Fees collected +
scholarships (+ funded − unfunded)