Skip to content

Commit ecafd13

Browse files
maebealeclaude
andcommitted
Add per-event dashboard with drill-in registrant filters
Event admins had no at-a-glance view of an event's registration and financial health — counts, money received/outstanding, scholarships, and who is represented all required reading the registrant table row by row. - Dashboard at GET /events/:id/dashboard (admin/owner), backed by an EventDashboard service that aggregates the metrics in a few queries. - Money reads as an equation — grand total = registration fees + scholarships + cont-ed — each addend split into paid/completed vs outstanding, expanding to the registrants behind the figure. - Headcounts: registrants (+ inactive), organizations, sectors, states; each expands to its list and links to the matching filtered Manage list. - Manage gains filters + scopes: payment status, scholarship task status, state, state-scoped county ("CA - Los Angeles"), sector, and registrant selection. - Continuing-education fees are stubbed to $0 until their migration lands. - Seed scholarships/payments/allocations for paid dev events so the dashboard shows real numbers locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 78fc0f8 commit ecafd13

18 files changed

Lines changed: 1617 additions & 15 deletions

AGENTS.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,22 +48,22 @@ This codebase (Rails 8.1)
4848

4949
| Directory | Purpose | Count |
5050
|---|---|---|
51-
| `app/models/` | ActiveRecord models | ~66 files |
52-
| `app/services/` | Service objects for complex logic | ~21 files |
51+
| `app/models/` | ActiveRecord models | ~73 files |
52+
| `app/services/` | Service objects for complex logic | ~23 files |
5353
| `app/jobs/` | SolidQueue background jobs | 3 files |
54-
| `app/models/concerns/` | Shared model modules | 12 concerns |
54+
| `app/models/concerns/` | Shared model modules | 13 concerns |
5555

5656
### Presentation
5757

5858
| Directory | Purpose | Count |
5959
|---|---|---|
60-
| `app/controllers/` | Rails controllers (admin/, events/) | ~63 files |
61-
| `app/views/` | ERB templates | ~465 files |
62-
| `app/decorators/` | Draper decorators for view logic | ~36 files |
63-
| `app/policies/` | ActionPolicy authorization rules | ~44 files |
60+
| `app/controllers/` | Rails controllers (admin/, events/) | ~69 files |
61+
| `app/views/` | ERB templates | ~504 files |
62+
| `app/decorators/` | Draper decorators for view logic | ~37 files |
63+
| `app/policies/` | ActionPolicy authorization rules | ~49 files |
6464
| `app/presenters/` | Presentation objects | 1 file |
6565
| `app/helpers/` | View helpers | ~19 files |
66-
| `app/mailers/` | ActionMailer classes | 6 files |
66+
| `app/mailers/` | ActionMailer classes | 5 files |
6767
| `app/inputs/` | Custom SimpleForm inputs | 1 file |
6868

6969
### Frontend
@@ -172,6 +172,7 @@ end
172172

173173
### Business Logic
174174

175+
- `EventDashboard` — Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total)
175176
- `WorkshopSearchService` — Complex filtering, sorting, pagination with ActionPolicy
176177
- `WorkshopFromIdeaService` — Converts WorkshopIdea to Workshop with asset migration
177178
- `WorkshopVariationFromIdeaService` — Variation creation from ideas

app/controllers/events_controller.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ class EventsController < ApplicationController
22
include AhoyTracking, TagAssignable
33
skip_before_action :authenticate_user!, only: [ :index, :show ]
44
skip_before_action :verify_authenticity_token, only: [ :preview ]
5-
before_action :set_event, only: %i[ show edit update destroy preview manage preview_reminder send_reminder copy_registration_form ]
5+
before_action :set_event, only: %i[ show edit update destroy preview dashboard manage preview_reminder send_reminder copy_registration_form ]
66

77
def index
88
authorize!
@@ -35,6 +35,12 @@ def preview
3535
render :show
3636
end
3737

38+
def dashboard
39+
authorize! @event, to: :manage?
40+
@event = @event.decorate
41+
@dashboard = EventDashboard.new(@event)
42+
end
43+
3844
def manage
3945
authorize! @event, to: :manage?
4046
@event = @event.decorate
@@ -43,7 +49,14 @@ def manage
4349
.joins(:registrant)
4450
scope = scope.keyword(params[:keyword]) if params[:keyword].present?
4551
scope = scope.attendance_status(params[:attendance_status]) if params[:attendance_status].present?
52+
scope = scope.payment_status(params[:payment_status]) if params[:payment_status].present?
53+
scope = scope.scholarship_status(params[:scholarship]) if params[:scholarship].present?
54+
scope = scope.registrant_ids(params[:registrant_ids]) if params[:registrant_ids].present?
55+
scope = scope.registrant_state(params[:state]) if params[:state].present?
56+
scope = scope.registrant_county(params[:county]) if params[:county].present?
57+
scope = scope.registrant_sector(params[:sector]) if params[:sector].present?
4658
@event_registrations = scope.order(Arel.sql("people.first_name, people.last_name"))
59+
@dashboard = EventDashboard.new(@event)
4760

4861
emails = @event_registrations.map { |r| r.registrant.preferred_email&.downcase }.compact
4962
@duplicate_emails = emails.tally.select { |_, count| count > 1 }.keys.to_set

app/decorators/event_decorator.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,22 @@ def date
2121
start_date.strftime("%B %d, %Y")
2222
end
2323

24+
# Weekday-prefixed date range (e.g. "Thu-Fri, Jan 1-2, 2026") that collapses the
25+
# year — and the month/weekday where possible — so nothing repeats unnecessarily.
26+
def date_range
27+
s = start_date.in_time_zone(Time.zone)
28+
e = (end_date || start_date).in_time_zone(Time.zone)
29+
return s.strftime("%a, %b %-d, %Y") if s.to_date == e.to_date
30+
31+
if s.year == e.year && s.month == e.month
32+
"#{s.strftime('%a')}-#{e.strftime('%a')}, #{s.strftime('%b')} #{s.strftime('%-d')}-#{e.strftime('%-d')}, #{s.strftime('%Y')}"
33+
elsif s.year == e.year
34+
"#{s.strftime('%a, %b %-d')} - #{e.strftime('%a, %b %-d')}, #{s.strftime('%Y')}"
35+
else
36+
"#{s.strftime('%a, %b %-d, %Y')} - #{e.strftime('%a, %b %-d, %Y')}"
37+
end
38+
end
39+
2440
def detail(length: nil)
2541
length ? description&.truncate(length) : description
2642
end

app/models/event_registration.rb

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,89 @@ class EventRegistration < ApplicationRecord
3535
OR LOWER(REPLACE(people.last_name, ' ', '')) LIKE :name", name: "%#{registrant_name}%") }
3636
scope :event_title, ->(event_title) { joins(:event).where("LOWER(events.title LIKE ?)", "%#{event_title}%") }
3737
scope :active, -> { where(status: ACTIVE_STATUSES) }
38+
scope :registrant_ids, ->(ids) { where(registrant_id: ids.to_s.split("-").map(&:to_i)) }
3839
scope :attendance_status, ->(status) { where(status: status) }
40+
scope :registrant_state, ->(state) {
41+
joins(registrant: :addresses)
42+
.where(addresses: { inactive: false, state: state })
43+
.distinct
44+
}
45+
# Accepts "STATE|County" (from the manage filter) to scope to a county within
46+
# a specific state, or a bare county name for backward compatibility.
47+
scope :registrant_county, ->(value) {
48+
state, county = value.to_s.include?("|") ? value.split("|", 2) : [ nil, value.to_s ]
49+
next none if county.blank?
50+
conditions = { inactive: false, county: county }
51+
conditions[:state] = state if state.present?
52+
joins(registrant: :addresses).where(addresses: conditions).distinct
53+
}
54+
scope :registrant_sector, ->(sector_id) {
55+
joins(registrant: :sectorable_items)
56+
.where(sectorable_items: { sector_id: sector_id })
57+
.distinct
58+
}
59+
scope :with_scholarship, -> {
60+
where(<<~SQL.squish)
61+
EXISTS (
62+
SELECT 1 FROM allocations
63+
WHERE allocations.allocatable_type = 'EventRegistration'
64+
AND allocations.allocatable_id = event_registrations.id
65+
AND allocations.source_type = 'Scholarship'
66+
)
67+
SQL
68+
}
69+
scope :scholarship_tasks_completed, -> { with_scholarship_where_tasks(true) }
70+
scope :scholarship_tasks_incomplete, -> { with_scholarship_where_tasks(false) }
71+
scope :with_scholarship_where_tasks, ->(completed) {
72+
where(<<~SQL.squish, completed)
73+
EXISTS (
74+
SELECT 1 FROM allocations
75+
INNER JOIN scholarships ON scholarships.id = allocations.source_id
76+
WHERE allocations.allocatable_type = 'EventRegistration'
77+
AND allocations.allocatable_id = event_registrations.id
78+
AND allocations.source_type = 'Scholarship'
79+
AND scholarships.tasks_completed = ?
80+
)
81+
SQL
82+
}
83+
scope :scholarship_status, ->(value) {
84+
case value
85+
when "yes" then with_scholarship
86+
when "complete" then scholarship_tasks_completed
87+
when "incomplete" then scholarship_tasks_incomplete
88+
end
89+
}
90+
scope :paid_in_full, -> {
91+
where(<<~SQL.squish)
92+
COALESCE((
93+
SELECT SUM(allocations.amount) FROM allocations
94+
WHERE allocations.allocatable_type = 'EventRegistration'
95+
AND allocations.allocatable_id = event_registrations.id
96+
), 0) >= COALESCE((
97+
SELECT events.cost_cents FROM events WHERE events.id = event_registrations.event_id
98+
), 0)
99+
SQL
100+
}
101+
scope :not_paid_in_full, -> {
102+
where(<<~SQL.squish)
103+
COALESCE((
104+
SELECT events.cost_cents FROM events WHERE events.id = event_registrations.event_id
105+
), 0) > 0
106+
AND COALESCE((
107+
SELECT SUM(allocations.amount) FROM allocations
108+
WHERE allocations.allocatable_type = 'EventRegistration'
109+
AND allocations.allocatable_id = event_registrations.id
110+
), 0) < COALESCE((
111+
SELECT events.cost_cents FROM events WHERE events.id = event_registrations.event_id
112+
), 0)
113+
SQL
114+
}
115+
scope :payment_status, ->(value) {
116+
case value
117+
when "paid" then paid_in_full
118+
when "unpaid" then not_paid_in_full
119+
end
120+
}
39121
scope :keyword, ->(term) {
40122
return none if term.blank?
41123

app/models/sector.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class Sector < ApplicationRecord
4343
# Scopes
4444
scope :sector_name, ->(sector_name) {
4545
sector_name.present? ? where("sectors.name LIKE ?", "%#{sector_name}%") : all }
46+
scope :sector_ids, ->(ids) { where(id: ids.to_s.split("-").map(&:to_i)) }
4647
scope :has_taggings, -> { joins(:sectorable_items).distinct }
4748
scope :has_published_taggings, -> {
4849
subqueries = Tag::TAGGABLE_META.map do |_key, data|
@@ -60,6 +61,7 @@ class Sector < ApplicationRecord
6061
scope :filter_scope, ->(params) do
6162
filtered = self.all
6263
filtered = filtered.sector_name(params[:sector_name])
64+
filtered = filtered.sector_ids(params[:sector_ids]) if params[:sector_ids].present?
6365
filtered = filtered.published if params[:published] == "true"
6466
filtered = filtered.where(published: false) if params[:published] == "false"
6567
filtered

0 commit comments

Comments
 (0)