diff --git a/AGENTS.md b/AGENTS.md
index cf89eff7a..2d8088b91 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -48,22 +48,22 @@ This codebase (Rails 8.1)
| Directory | Purpose | Count |
|---|---|---|
-| `app/models/` | ActiveRecord models | ~66 files |
-| `app/services/` | Service objects for complex logic | ~21 files |
+| `app/models/` | ActiveRecord models | ~73 files |
+| `app/services/` | Service objects for complex logic | ~23 files |
| `app/jobs/` | SolidQueue background jobs | 3 files |
-| `app/models/concerns/` | Shared model modules | 12 concerns |
+| `app/models/concerns/` | Shared model modules | 13 concerns |
### Presentation
| Directory | Purpose | Count |
|---|---|---|
-| `app/controllers/` | Rails controllers (admin/, events/) | ~63 files |
-| `app/views/` | ERB templates | ~465 files |
-| `app/decorators/` | Draper decorators for view logic | ~36 files |
-| `app/policies/` | ActionPolicy authorization rules | ~44 files |
+| `app/controllers/` | Rails controllers (admin/, events/) | ~69 files |
+| `app/views/` | ERB templates | ~504 files |
+| `app/decorators/` | Draper decorators for view logic | ~37 files |
+| `app/policies/` | ActionPolicy authorization rules | ~49 files |
| `app/presenters/` | Presentation objects | 1 file |
| `app/helpers/` | View helpers | ~19 files |
-| `app/mailers/` | ActionMailer classes | 6 files |
+| `app/mailers/` | ActionMailer classes | 5 files |
| `app/inputs/` | Custom SimpleForm inputs | 1 file |
### Frontend
@@ -172,6 +172,7 @@ end
### Business Logic
+- `EventDashboard` — Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total)
- `WorkshopSearchService` — Complex filtering, sorting, pagination with ActionPolicy
- `WorkshopFromIdeaService` — Converts WorkshopIdea to Workshop with asset migration
- `WorkshopVariationFromIdeaService` — Variation creation from ideas
diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb
index 46fcd6833..6f2459cf6 100644
--- a/app/controllers/events_controller.rb
+++ b/app/controllers/events_controller.rb
@@ -2,7 +2,7 @@ class EventsController < ApplicationController
include AhoyTracking, TagAssignable
skip_before_action :authenticate_user!, only: [ :index, :show ]
skip_before_action :verify_authenticity_token, only: [ :preview ]
- before_action :set_event, only: %i[ show edit update destroy preview manage preview_reminder send_reminder copy_registration_form ]
+ before_action :set_event, only: %i[ show edit update destroy preview dashboard manage preview_reminder send_reminder copy_registration_form ]
def index
authorize!
@@ -35,6 +35,12 @@ def preview
render :show
end
+ def dashboard
+ authorize! @event, to: :manage?
+ @event = @event.decorate
+ @dashboard = EventDashboard.new(@event)
+ end
+
def manage
authorize! @event, to: :manage?
@event = @event.decorate
@@ -43,7 +49,14 @@ def manage
.joins(:registrant)
scope = scope.keyword(params[:keyword]) if params[:keyword].present?
scope = scope.attendance_status(params[:attendance_status]) if params[:attendance_status].present?
+ scope = scope.payment_status(params[:payment_status]) if params[:payment_status].present?
+ scope = scope.scholarship_status(params[:scholarship]) if params[:scholarship].present?
+ scope = scope.registrant_ids(params[:registrant_ids]) if params[:registrant_ids].present?
+ scope = scope.registrant_state(params[:state]) if params[:state].present?
+ scope = scope.registrant_county(params[:county]) if params[:county].present?
+ scope = scope.registrant_sector(params[:sector]) if params[:sector].present?
@event_registrations = scope.order(Arel.sql("people.first_name, people.last_name"))
+ @dashboard = EventDashboard.new(@event)
emails = @event_registrations.map { |r| r.registrant.preferred_email&.downcase }.compact
@duplicate_emails = emails.tally.select { |_, count| count > 1 }.keys.to_set
diff --git a/app/decorators/event_decorator.rb b/app/decorators/event_decorator.rb
index 0f260db02..dcd3b0390 100644
--- a/app/decorators/event_decorator.rb
+++ b/app/decorators/event_decorator.rb
@@ -21,6 +21,22 @@ def date
start_date.strftime("%B %d, %Y")
end
+ # Weekday-prefixed date range (e.g. "Thu-Fri, Jan 1-2, 2026") that collapses the
+ # year — and the month/weekday where possible — so nothing repeats unnecessarily.
+ def date_range
+ s = start_date.in_time_zone(Time.zone)
+ e = (end_date || start_date).in_time_zone(Time.zone)
+ return s.strftime("%a, %b %-d, %Y") if s.to_date == e.to_date
+
+ if s.year == e.year && s.month == e.month
+ "#{s.strftime('%a')}-#{e.strftime('%a')}, #{s.strftime('%b')} #{s.strftime('%-d')}-#{e.strftime('%-d')}, #{s.strftime('%Y')}"
+ elsif s.year == e.year
+ "#{s.strftime('%a, %b %-d')} - #{e.strftime('%a, %b %-d')}, #{s.strftime('%Y')}"
+ else
+ "#{s.strftime('%a, %b %-d, %Y')} - #{e.strftime('%a, %b %-d, %Y')}"
+ end
+ end
+
def detail(length: nil)
length ? description&.truncate(length) : description
end
diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb
index 8b904a0a5..094e27bdc 100644
--- a/app/models/event_registration.rb
+++ b/app/models/event_registration.rb
@@ -35,7 +35,89 @@ class EventRegistration < ApplicationRecord
OR LOWER(REPLACE(people.last_name, ' ', '')) LIKE :name", name: "%#{registrant_name}%") }
scope :event_title, ->(event_title) { joins(:event).where("LOWER(events.title LIKE ?)", "%#{event_title}%") }
scope :active, -> { where(status: ACTIVE_STATUSES) }
+ scope :registrant_ids, ->(ids) { where(registrant_id: ids.to_s.split("-").map(&:to_i)) }
scope :attendance_status, ->(status) { where(status: status) }
+ scope :registrant_state, ->(state) {
+ joins(registrant: :addresses)
+ .where(addresses: { inactive: false, state: state })
+ .distinct
+ }
+ # Accepts "STATE|County" (from the manage filter) to scope to a county within
+ # a specific state, or a bare county name for backward compatibility.
+ scope :registrant_county, ->(value) {
+ state, county = value.to_s.include?("|") ? value.split("|", 2) : [ nil, value.to_s ]
+ next none if county.blank?
+ conditions = { inactive: false, county: county }
+ conditions[:state] = state if state.present?
+ joins(registrant: :addresses).where(addresses: conditions).distinct
+ }
+ scope :registrant_sector, ->(sector_id) {
+ joins(registrant: :sectorable_items)
+ .where(sectorable_items: { sector_id: sector_id })
+ .distinct
+ }
+ scope :with_scholarship, -> {
+ where(<<~SQL.squish)
+ EXISTS (
+ SELECT 1 FROM allocations
+ WHERE allocations.allocatable_type = 'EventRegistration'
+ AND allocations.allocatable_id = event_registrations.id
+ AND allocations.source_type = 'Scholarship'
+ )
+ SQL
+ }
+ scope :scholarship_tasks_completed, -> { with_scholarship_where_tasks(true) }
+ scope :scholarship_tasks_incomplete, -> { with_scholarship_where_tasks(false) }
+ scope :with_scholarship_where_tasks, ->(completed) {
+ where(<<~SQL.squish, completed)
+ EXISTS (
+ SELECT 1 FROM allocations
+ INNER JOIN scholarships ON scholarships.id = allocations.source_id
+ WHERE allocations.allocatable_type = 'EventRegistration'
+ AND allocations.allocatable_id = event_registrations.id
+ AND allocations.source_type = 'Scholarship'
+ AND scholarships.tasks_completed = ?
+ )
+ SQL
+ }
+ scope :scholarship_status, ->(value) {
+ case value
+ when "yes" then with_scholarship
+ when "complete" then scholarship_tasks_completed
+ when "incomplete" then scholarship_tasks_incomplete
+ end
+ }
+ scope :paid_in_full, -> {
+ where(<<~SQL.squish)
+ COALESCE((
+ SELECT SUM(allocations.amount) FROM allocations
+ WHERE allocations.allocatable_type = 'EventRegistration'
+ AND allocations.allocatable_id = event_registrations.id
+ ), 0) >= COALESCE((
+ SELECT events.cost_cents FROM events WHERE events.id = event_registrations.event_id
+ ), 0)
+ SQL
+ }
+ scope :not_paid_in_full, -> {
+ where(<<~SQL.squish)
+ COALESCE((
+ SELECT events.cost_cents FROM events WHERE events.id = event_registrations.event_id
+ ), 0) > 0
+ AND COALESCE((
+ SELECT SUM(allocations.amount) FROM allocations
+ WHERE allocations.allocatable_type = 'EventRegistration'
+ AND allocations.allocatable_id = event_registrations.id
+ ), 0) < COALESCE((
+ SELECT events.cost_cents FROM events WHERE events.id = event_registrations.event_id
+ ), 0)
+ SQL
+ }
+ scope :payment_status, ->(value) {
+ case value
+ when "paid" then paid_in_full
+ when "unpaid" then not_paid_in_full
+ end
+ }
scope :keyword, ->(term) {
return none if term.blank?
diff --git a/app/models/sector.rb b/app/models/sector.rb
index b9cf65ec5..088206171 100644
--- a/app/models/sector.rb
+++ b/app/models/sector.rb
@@ -43,6 +43,7 @@ class Sector < ApplicationRecord
# Scopes
scope :sector_name, ->(sector_name) {
sector_name.present? ? where("sectors.name LIKE ?", "%#{sector_name}%") : all }
+ scope :sector_ids, ->(ids) { where(id: ids.to_s.split("-").map(&:to_i)) }
scope :has_taggings, -> { joins(:sectorable_items).distinct }
scope :has_published_taggings, -> {
subqueries = Tag::TAGGABLE_META.map do |_key, data|
@@ -60,6 +61,7 @@ class Sector < ApplicationRecord
scope :filter_scope, ->(params) do
filtered = self.all
filtered = filtered.sector_name(params[:sector_name])
+ filtered = filtered.sector_ids(params[:sector_ids]) if params[:sector_ids].present?
filtered = filtered.published if params[:published] == "true"
filtered = filtered.where(published: false) if params[:published] == "false"
filtered
diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb
new file mode 100644
index 000000000..7bb120293
--- /dev/null
+++ b/app/services/event_dashboard.rb
@@ -0,0 +1,295 @@
+class EventDashboard
+ def initialize(event)
+ @event = event
+ end
+
+ attr_reader :event
+
+ def registrant_count
+ active_registration_ids.size
+ end
+
+ # Cancelled / no-show registrations.
+ def inactive_registration_count
+ event.event_registrations.where(status: EventRegistration::INACTIVE_STATUSES).count
+ end
+
+ # Active registrants as Person records, ordered by display name.
+ def registrants
+ @registrants ||= people_sorted(registrant_ids)
+ end
+
+ def scholarship_total_cents
+ scholarships.sum(:amount_cents)
+ end
+
+ def scholarship_recipient_count
+ scholarships.distinct.count(:recipient_id)
+ end
+
+ # Scholarships whose tasks are done (their dollars are applied) vs still
+ # outstanding (awarded but not yet applied).
+ def completed_scholarship_cents
+ completed_scholarships.sum(:amount_cents)
+ end
+
+ def outstanding_scholarship_cents
+ outstanding_scholarships.sum(:amount_cents)
+ end
+
+ def completed_scholarship_registrants
+ @completed_scholarship_registrants ||= people_sorted(completed_scholarships.distinct.pluck(:recipient_id))
+ end
+
+ def outstanding_scholarship_registrants
+ @outstanding_scholarship_registrants ||= people_sorted(outstanding_scholarships.distinct.pluck(:recipient_id))
+ end
+
+ # Real money allocated to this event's registrations from payments.
+ def received_cents
+ registration_allocations.where(source_type: "Payment").sum(:amount)
+ end
+
+ # Still owed across all active registrations, after payments and scholarships.
+ def outstanding_cents
+ active_registration_ids.sum do |id|
+ [ event.cost_cents.to_i - allocated_by_registration.fetch(id, 0), 0 ].max
+ end
+ end
+
+ # Full-price value of all active registrations (before scholarships/discounts).
+ def total_cents
+ event.cost_cents.to_i * registrant_count
+ end
+
+ # Registration-fee subtotal: money received plus money still owed. This is the
+ # cash side of registration fees (it excludes scholarship-covered cost), so it
+ # reconciles with the Paid + Due breakdown and the grand-total equation.
+ # Differs from total_cents, the full pre-scholarship price.
+ def registration_subtotal_cents
+ received_cents + outstanding_cents
+ end
+
+ # Everything accounted for: registration fees (received + still owed),
+ # scholarships awarded, and continuing-education fees.
+ def grand_total_cents
+ registration_subtotal_cents + scholarship_total_cents + cont_ed_total_cents
+ end
+
+ def paid_count
+ return registrant_count if free?
+ active_registration_ids.count { |id| allocated_by_registration.fetch(id, 0) >= event.cost_cents.to_i }
+ end
+
+ def unpaid_count
+ return 0 if free?
+ registrant_count - paid_count
+ end
+
+ # Registrants whose cost is fully covered (payments and/or completed
+ # scholarships) vs those still owing.
+ def paid_registrants
+ @paid_registrants ||= people_sorted(registrants_for(paid_registration_ids))
+ end
+
+ def unpaid_registrants
+ @unpaid_registrants ||= people_sorted(registrants_for(active_registration_ids - paid_registration_ids))
+ end
+
+ # Continuing-education fee: a flat per-registrant add-on. Not yet implemented —
+ # the fee amount and per-registration paid/outstanding tracking will arrive
+ # with a future migration. Stubbed to zero so the dashboard renders the
+ # section without depending on columns that don't exist yet.
+ def cont_ed_fee_cents = 0
+ def cont_ed_total_cents = 0
+ def cont_ed_paid_count = 0
+ def cont_ed_unpaid_count = 0
+ def cont_ed_paid_cents = 0
+ def cont_ed_outstanding_cents = 0
+ def cont_ed_paid_registrants = []
+ def cont_ed_unpaid_registrants = []
+
+ def free?
+ event.cost_cents.to_i <= 0
+ end
+
+ # Unique orgs from both the snapshot taken at registration time and the
+ # registrants' currently-active affiliations.
+ def organizations
+ @organizations ||= Organization.where(id: organization_ids).order(:name)
+ end
+
+ def organization_count
+ organization_ids.size
+ end
+
+ # Distinct registrant ids per organization, across the registration-time
+ # snapshot and registrants' currently-active affiliations.
+ def organization_registrant_ids_by_org
+ @organization_registrant_ids_by_org ||= begin
+ snapshot = EventRegistrationOrganization
+ .joins(:event_registration)
+ .where(event_registration_id: active_registration_ids)
+ .pluck(:organization_id, "event_registrations.registrant_id")
+ affiliated = Affiliation.active
+ .where(person_id: registrant_ids)
+ .pluck(:organization_id, :person_id)
+ (snapshot + affiliated).each_with_object(Hash.new { |hash, key| hash[key] = Set.new }) do |(organization_id, person_id), map|
+ map[organization_id] << person_id if organization_id
+ end
+ end
+ end
+
+ # Distinct registrant count per organization.
+ def organization_counts
+ @organization_counts ||= organization_registrant_ids_by_org.transform_values(&:size)
+ end
+
+ # Registrant ids tied to at least one organization — the people behind the
+ # organizations count.
+ def organization_registrant_ids
+ @organization_registrant_ids ||= organization_registrant_ids_by_org.values.flat_map(&:to_a).uniq
+ end
+
+ def sectors
+ @sectors ||= Sector.where(id: registrant_sector_ids).order(:name)
+ end
+
+ # Distinct registrant count per sector.
+ def sector_counts
+ @sector_counts ||= SectorableItem
+ .where(sectorable_type: "Person", sectorable_id: registrant_ids)
+ .distinct
+ .group(:sector_id)
+ .count(:sectorable_id)
+ end
+
+ # Registrant ids that belong to at least one sector — the people behind the
+ # sectors count.
+ def sector_registrant_ids
+ @sector_registrant_ids ||= SectorableItem
+ .where(sectorable_type: "Person", sectorable_id: registrant_ids)
+ .distinct
+ .pluck(:sectorable_id)
+ end
+
+ def states
+ @states ||= Address
+ .active
+ .where(addressable_type: "Person", addressable_id: registrant_ids)
+ .where.not(state: [ nil, "" ])
+ .distinct
+ .pluck(:state)
+ .sort
+ end
+
+ # Distinct registrant count per state.
+ def state_counts
+ @state_counts ||= Address
+ .active
+ .where(addressable_type: "Person", addressable_id: registrant_ids)
+ .where.not(state: [ nil, "" ])
+ .group(:state)
+ .distinct
+ .count(:addressable_id)
+ end
+
+ # Registrant ids that have at least one active address with a state on file —
+ # i.e. the people behind the states count.
+ def state_registrant_ids
+ @state_registrant_ids ||= Address
+ .active
+ .where(addressable_type: "Person", addressable_id: registrant_ids)
+ .where.not(state: [ nil, "" ])
+ .distinct
+ .pluck(:addressable_id)
+ end
+
+ # Distinct [ state, county ] pairs across active registrants' active addresses,
+ # sorted by state then county. Pairing in the state lets the manage filter
+ # disambiguate same-named counties across states (e.g. "CA - Warren" vs
+ # "NY - Warren").
+ def counties
+ @counties ||= Address
+ .active
+ .where(addressable_type: "Person", addressable_id: registrant_ids)
+ .where.not(county: [ nil, "" ])
+ .where.not(state: [ nil, "" ])
+ .distinct
+ .pluck(:state, :county)
+ .sort
+ end
+
+ private
+
+ def active_registrations
+ @active_registrations ||= event.event_registrations.active
+ end
+
+ def active_registration_ids
+ @active_registration_ids ||= active_registrations.pluck(:id)
+ end
+
+ def registrant_ids
+ @registrant_ids ||= active_registrations.pluck(:registrant_id)
+ end
+
+ def registration_allocations
+ Allocation.where(allocatable_type: "EventRegistration", allocatable_id: active_registration_ids)
+ end
+
+ def allocated_by_registration
+ @allocated_by_registration ||= registration_allocations.group(:allocatable_id).sum(:amount)
+ end
+
+ def scholarships
+ @scholarships ||= Scholarship
+ .joins(:allocation)
+ .where(allocations: { allocatable_type: "EventRegistration", allocatable_id: active_registration_ids })
+ end
+
+ def completed_scholarships
+ scholarships.where(tasks_completed: true)
+ end
+
+ def outstanding_scholarships
+ scholarships.where(tasks_completed: false)
+ end
+
+ def paid_registration_ids
+ @paid_registration_ids ||= active_registration_ids.select do |id|
+ allocated_by_registration.fetch(id, 0) >= event.cost_cents.to_i
+ end
+ end
+
+ def registrant_id_by_registration
+ @registrant_id_by_registration ||= active_registrations.pluck(:id, :registrant_id).to_h
+ end
+
+ def registrants_for(registration_ids)
+ registration_ids.filter_map { |id| registrant_id_by_registration[id] }
+ end
+
+ def people_sorted(person_ids)
+ Person.where(id: person_ids).sort_by(&:name)
+ end
+
+ def organization_ids
+ @organization_ids ||= begin
+ snapshot_ids = EventRegistrationOrganization
+ .where(event_registration_id: active_registration_ids)
+ .pluck(:organization_id)
+ affiliated_ids = Affiliation.active
+ .where(person_id: registrant_ids)
+ .pluck(:organization_id)
+ (snapshot_ids + affiliated_ids).compact.uniq
+ end
+ end
+
+ def registrant_sector_ids
+ @registrant_sector_ids ||= SectorableItem
+ .where(sectorable_type: "Person", sectorable_id: registrant_ids)
+ .distinct
+ .pluck(:sector_id)
+ end
+end
diff --git a/app/views/events/_dashboard_money_row.html.erb b/app/views/events/_dashboard_money_row.html.erb
new file mode 100644
index 000000000..55cf57cf1
--- /dev/null
+++ b/app/views/events/_dashboard_money_row.html.erb
@@ -0,0 +1,54 @@
+<%#
+ Expandable money breakdown sub-row (e.g. Paid / Outstanding / Completed).
+ Reads as "[icon] [count] [label] … [amount] [chevron]"; the summary toggles the
+ registrant list, and the jump link (outside the toggle, to the right of the
+ chevron) drills into the filtered manage list.
+
+ Locals:
+ label: row label (e.g. "Paid")
+ icon: Font Awesome icon name (e.g. "fa-money-bill-wave")
+ amount_cents: dollar figure for the row, in cents
+ count: registrant count for the row
+ registrants: Person records to list when expanded
+ filter_path: manage path filtered to this row's registrants
+ icon_color: optional icon color class (defaults to neutral gray)
+ amount_color: optional amount color class (defaults to neutral gray)
+%>
+<%
+ icon_color = local_assigns.fetch(:icon_color, "text-gray-400")
+ amount_color = local_assigns.fetch(:amount_color, "text-gray-700")
+ icon_bg = local_assigns[:icon_bg]
+%>
+
+
+
+
+ <% if icon_bg.present? %>
+
+
+
+ <% else %>
+
+ <% end %>
+ <%= count %>
+ <%= label %>
+
+
+ <%= number_to_currency(amount_cents / 100.0) %>
+
+
+
+ <% if registrants.any? %>
+
+ <% registrants.each do |registrant| %>
+ - <%= link_to registrant.name, person_path(registrant), class: "hover:underline" %>
+ <% end %>
+
+ <% else %>
+ None
+ <% end %>
+
+ <%= link_to filter_path, class: "shrink-0 flex h-7 items-center text-gray-300 hover:text-gray-500", title: "View #{label.downcase} registrants" do %>
+
+ <% end %>
+
diff --git a/app/views/events/_manage_search.html.erb b/app/views/events/_manage_search.html.erb
index 51b5a0e41..73d70c007 100644
--- a/app/views/events/_manage_search.html.erb
+++ b/app/views/events/_manage_search.html.erb
@@ -29,6 +29,57 @@
focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
+ <% if @event.cost_cents.to_i > 0 %>
+
+ <%= label_tag :payment_status, "Payment", class: "block text-sm font-medium text-gray-700 mb-1" %>
+ <%= select_tag :payment_status,
+ options_for_select(
+ [ [ "Paid in full", "paid" ], [ "Not paid in full", "unpaid" ] ],
+ params[:payment_status]
+ ),
+ include_blank: "Any payment status",
+ class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
+ focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
+
+
+
+ <%= label_tag :scholarship, "Scholarship", class: "block text-sm font-medium text-gray-700 mb-1" %>
+ <%= select_tag :scholarship,
+ options_for_select(
+ [ [ "All recipients", "yes" ], [ "Tasks complete", "complete" ], [ "Tasks not complete", "incomplete" ] ],
+ params[:scholarship]
+ ),
+ include_blank: "All registrants",
+ class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
+ focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
+
+ <% end %>
+
+ <% if @dashboard.states.any? %>
+
+ <%= label_tag :state, "State", class: "block text-sm font-medium text-gray-700 mb-1" %>
+ <%= select_tag :state,
+ options_for_select(@dashboard.states, params[:state]),
+ include_blank: "All states",
+ class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
+ focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
+
+ <% end %>
+
+ <% if @dashboard.counties.any? %>
+
+ <%= label_tag :county, "County", class: "block text-sm font-medium text-gray-700 mb-1" %>
+ <%= select_tag :county,
+ options_for_select(
+ @dashboard.counties.map { |state, county| [ "#{state} - #{county}", "#{state}|#{county}" ] },
+ params[:county]
+ ),
+ include_blank: "All counties",
+ class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm
+ focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
+
+ <% end %>
+
<%= link_to "Clear filters", manage_event_path(@event),
class: "btn btn-utility-outline",
diff --git a/app/views/events/dashboard.html.erb b/app/views/events/dashboard.html.erb
new file mode 100644
index 000000000..acb2ba40e
--- /dev/null
+++ b/app/views/events/dashboard.html.erb
@@ -0,0 +1,298 @@
+<% content_for(:page_bg_class, "admin-only bg-blue-100") %>
+
+ <%# Top bar: back link + actions %>
+
+ <%= link_to "← Event", event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %>
+
+ <%= link_to "Manage registrants", manage_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+ <%= link_to "View", event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
+
+
+
+ <%# Centered title block %>
+
+ <%= link_to @event.title, edit_event_path(@event), class: "text-sm font-semibold text-gray-500 uppercase tracking-wide hover:text-gray-700" %>
+
+
+ Dashboard
+
+ <% if @event.start_date.present? %>
+
+ <%= @event.date_range %>
+
+ <% end %>
+
+
+ <%# Money cards (paid events only) — shown first %>
+ <% if @dashboard.free? %>
+
+ Free event — no payments or scholarships
+
+ <% else %>
+ <%# Money reads as an equation: Grand total = Registration fees + Scholarships + Cont ed fees.
+ Each addend owns Paid/Completed + Outstanding sub-rows (nested inside its card).
+ Grand total is a full-width hero; the three addends sit in a row beneath it. %>
+
+ <%# Grand total — full-width headline read as an inline equation:
+ $grand = $registration + $scholarships + $cont_ed. Left-aligned; the headline
+ figure dominates while the addends stay muted, and the row wraps on mobile. %>
+ <%= link_to manage_event_path(@event),
+ class: "block rounded-xl border border-indigo-200 bg-indigo-50 p-4 shadow-sm hover:border-indigo-300 transition-colors" do %>
+
+
+
+ Grand total
+
+
+
+ <%= pluralize(@dashboard.registrant_count, "registrant") %>
+
+
+
+
<%= number_to_currency(@dashboard.grand_total_cents / 100.0) %>
+
=
+
+
+
<%= number_to_currency(@dashboard.registration_subtotal_cents / 100.0) %>
+
Registration fees
+
+
+
+
+
<%= number_to_currency(@dashboard.scholarship_total_cents / 100.0) %>
+
Scholarships
+
+
+
+
+
<%= number_to_currency(@dashboard.cont_ed_total_cents / 100.0) %>
+
Cont ed fees
+
+
+ <% end %>
+
+ <%# The three addends: Registration fees + Scholarships + Cont ed fees. Each owns
+ paid/completed + outstanding sub-rows that expand to the registrants behind the figure. %>
+
+ <%# Payments = Paid + Outstanding %>
+
+
+
+
+ Registration fees
+
+ <%= link_to manage_event_path(@event),
+ class: "inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-white px-2.5 py-1 text-xs font-medium text-gray-600 shadow-sm hover:border-gray-300 hover:text-gray-800 transition-colors",
+ title: "View all registrants" do %>
+
+ <%= pluralize(@dashboard.registrant_count, "registrant") %>
+ <% end %>
+
+
<%= number_to_currency(@dashboard.registration_subtotal_cents / 100.0) %>
+
+
+ <%= render "dashboard_money_row", label: "Paid", icon: "fa-money-bill-wave",
+ amount_cents: @dashboard.received_cents,
+ count: @dashboard.paid_count, registrants: @dashboard.paid_registrants,
+ filter_path: manage_event_path(@event, payment_status: "paid") %>
+ <%= render "dashboard_money_row", label: "Due", icon: "fa-hourglass-half",
+ icon_color: @dashboard.unpaid_count.positive? ? "text-white" : "text-gray-400",
+ icon_bg: @dashboard.unpaid_count.positive? ? "bg-orange-500" : nil,
+ amount_cents: @dashboard.outstanding_cents,
+ count: @dashboard.unpaid_count, registrants: @dashboard.unpaid_registrants,
+ filter_path: manage_event_path(@event, payment_status: "unpaid") %>
+
+
+
+ <%# Scholarships = Completed + Outstanding %>
+
+
+
+
+ Scholarships
+
+ <%= link_to manage_event_path(@event, scholarship: "yes"),
+ class: "inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-white px-2.5 py-1 text-xs font-medium text-gray-600 shadow-sm hover:border-gray-300 hover:text-gray-800 transition-colors",
+ title: "View scholarship recipients" do %>
+
+ <%= pluralize(@dashboard.scholarship_recipient_count, "recipient") %>
+ <% end %>
+
+
<%= number_to_currency(@dashboard.scholarship_total_cents / 100.0) %>
+
+
+ <%= render "dashboard_money_row", label: "Completed", icon: "fa-circle-check",
+ amount_cents: @dashboard.completed_scholarship_cents,
+ count: @dashboard.completed_scholarship_registrants.size, registrants: @dashboard.completed_scholarship_registrants,
+ filter_path: manage_event_path(@event, registrant_ids: @dashboard.completed_scholarship_registrants.map(&:id).join("-")) %>
+ <%= render "dashboard_money_row", label: "Incomplete", icon: "fa-hourglass-half",
+ icon_color: @dashboard.outstanding_scholarship_registrants.any? ? "text-white" : "text-gray-400",
+ icon_bg: @dashboard.outstanding_scholarship_registrants.any? ? "bg-orange-500" : nil,
+ amount_cents: @dashboard.outstanding_scholarship_cents,
+ count: @dashboard.outstanding_scholarship_registrants.size, registrants: @dashboard.outstanding_scholarship_registrants,
+ filter_path: manage_event_path(@event, registrant_ids: @dashboard.outstanding_scholarship_registrants.map(&:id).join("-")) %>
+
+
+
+ <%# Cont ed fees = Paid + Outstanding %>
+
+
+
+
+ Cont ed fees
+
+ <%= link_to manage_event_path(@event),
+ class: "inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-white px-2.5 py-1 text-xs font-medium text-gray-600 shadow-sm hover:border-gray-300 hover:text-gray-800 transition-colors",
+ title: "View all registrants" do %>
+
+ <%= pluralize(@dashboard.registrant_count, "registrant") %>
+ <% end %>
+
+
<%= number_to_currency(@dashboard.cont_ed_total_cents / 100.0) %>
+
+
+ <%= render "dashboard_money_row", label: "Paid", icon: "fa-money-bill-wave",
+ amount_cents: @dashboard.cont_ed_paid_cents,
+ count: @dashboard.cont_ed_paid_count, registrants: @dashboard.cont_ed_paid_registrants,
+ filter_path: manage_event_path(@event, registrant_ids: @dashboard.cont_ed_paid_registrants.map(&:id).join("-")) %>
+ <%= render "dashboard_money_row", label: "Due", icon: "fa-hourglass-half",
+ icon_color: @dashboard.cont_ed_unpaid_count.positive? ? "text-white" : "text-gray-400",
+ icon_bg: @dashboard.cont_ed_unpaid_count.positive? ? "bg-orange-500" : nil,
+ amount_cents: @dashboard.cont_ed_outstanding_cents,
+ count: @dashboard.cont_ed_unpaid_count, registrants: @dashboard.cont_ed_unpaid_registrants,
+ filter_path: manage_event_path(@event, registrant_ids: @dashboard.cont_ed_unpaid_registrants.map(&:id).join("-")) %>
+
+
+
+
+ <% end %>
+
+ <%# Headcount cards — each expands to reveal its full list %>
+
+
+
+
+
+
+
+ <%= link_to @dashboard.registrant_count, manage_event_path(@event, registrant_ids: @dashboard.registrants.map(&:id).join("-")), class: "hover:underline" %>
+
+ Registrants
+
+
+
+ Active registrations<% if @dashboard.inactive_registration_count.positive? %> · <%= @dashboard.inactive_registration_count %> inactive<% end %>
+
+
+ <% if @dashboard.registrants.any? %>
+
+ <% @dashboard.registrants.each do |registrant| %>
+ -
+ <%= link_to registrant.name, person_path(registrant), class: "block truncate rounded px-1 -mx-1 py-0.5 hover:bg-gray-50" %>
+
+ <% end %>
+
+ <% else %>
+
None yet
+ <% end %>
+
+
+
+
+
+
+
+
+
+ <%= link_to @dashboard.organization_count, manage_event_path(@event, registrant_ids: @dashboard.organization_registrant_ids.join("-")), class: "hover:underline" %>
+
+ Organizations
+
+
+
+ Via registrants
+
+
+ <% if @dashboard.organizations.any? %>
+
+ <% @dashboard.organizations.each do |organization| %>
+ -
+ <%= link_to organization_path(organization), class: "text-emerald-600 hover:text-emerald-800 shrink-0", title: "View #{organization.name} profile" do %>
+
+ <% end %>
+ <%= link_to manage_event_path(@event, registrant_ids: @dashboard.organization_registrant_ids_by_org.fetch(organization.id, []).to_a.join("-")), class: "flex items-center justify-between gap-2 flex-1 min-w-0 rounded px-1 py-0.5 hover:bg-gray-50", title: "Registrants from #{organization.name}" do %>
+ <%= organization.name %>
+ <%= @dashboard.organization_counts.fetch(organization.id, 0) %>
+ <% end %>
+
+ <% end %>
+
+ <% else %>
+
None yet
+ <% end %>
+
+
+
+
+
+
+
+
+
+ <%= link_to @dashboard.sectors.size, manage_event_path(@event, registrant_ids: @dashboard.sector_registrant_ids.join("-")), class: "hover:underline" %>
+
+ Sectors
+
+
+
+ Via registrants
+
+
+ <% if @dashboard.sectors.any? %>
+
+ <% @dashboard.sectors.each do |sector| %>
+ -
+ <%= link_to manage_event_path(@event, sector: sector.id), class: "flex items-center justify-between gap-2 rounded px-1 -mx-1 py-0.5 hover:bg-gray-50", title: sector.name do %>
+ <%= sector.name %>
+ <%= @dashboard.sector_counts.fetch(sector.id, 0) %>
+ <% end %>
+
+ <% end %>
+
+ <% else %>
+
None yet
+ <% end %>
+
+
+
+
+
+
+
+
+
+ <%= link_to @dashboard.states.size, manage_event_path(@event, registrant_ids: @dashboard.state_registrant_ids.join("-")), class: "hover:underline" %>
+
+ States
+
+
+
+ Via registrants
+
+
+ <% if @dashboard.states.any? %>
+
+ <% @dashboard.states.each do |state| %>
+ -
+ <%= link_to manage_event_path(@event, state: state), class: "flex items-center justify-between gap-2 rounded px-1 -mx-1 py-0.5 hover:bg-gray-50" do %>
+ <%= state %>
+ <%= @dashboard.state_counts.fetch(state, 0) %>
+ <% end %>
+
+ <% end %>
+
+ <% else %>
+
None yet
+ <% end %>
+
+
+
+
diff --git a/app/views/events/manage.html.erb b/app/views/events/manage.html.erb
index 0f5fd036f..e5318bdf3 100644
--- a/app/views/events/manage.html.erb
+++ b/app/views/events/manage.html.erb
@@ -11,6 +11,7 @@
+ <%= link_to "Dashboard", dashboard_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
<% if allowed_to?(:edit?, @event) %>
<%= link_to "Edit event", edit_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700 px-2 py-1" %>
<%= render "form_actions_menu" %>
diff --git a/config/routes.rb b/config/routes.rb
index b9f0a1206..a30dd46f1 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -118,6 +118,7 @@
end
resources :events do
member do
+ get :dashboard
get :manage
get :preview_reminder
patch :preview
diff --git a/db/seeds/dummy_dev_seeds.rb b/db/seeds/dummy_dev_seeds.rb
index ae41c1e01..4ca1e5724 100644
--- a/db/seeds/dummy_dev_seeds.rb
+++ b/db/seeds/dummy_dev_seeds.rb
@@ -756,6 +756,46 @@
end
end
+puts "Assigning addresses and sectors to people…"
+# Curated state/county pairs so the event overview's States and Counties cards
+# show a recognizable spread rather than scattered random values.
+person_locations = [
+ { state: "CA", county: "Los Angeles", city: "Los Angeles", locality: "LA City" },
+ { state: "CA", county: "Orange", city: "Santa Ana", locality: "Orange County" },
+ { state: "CA", county: "San Francisco", city: "San Francisco", locality: "Northern CA" },
+ { state: "NY", county: "Kings", city: "Brooklyn", locality: "Outside CA" },
+ { state: "TX", county: "Travis", city: "Austin", locality: "Outside CA" },
+ { state: "WA", county: "King", city: "Seattle", locality: "Outside CA" },
+ { state: "IL", county: "Cook", city: "Chicago", locality: "Outside CA" }
+]
+person_sector_pool = Sector.all.to_a
+
+Person.find_each.with_index do |person, i|
+ if person.addresses.empty?
+ loc = person_locations[i % person_locations.size]
+ person.addresses.create!(
+ address_type: "personal",
+ street_address: Faker::Address.street_address,
+ city: loc[:city],
+ state: loc[:state],
+ county: loc[:county],
+ locality: loc[:locality],
+ zip_code: Faker::Address.zip_code,
+ primary: true
+ )
+ end
+
+ if person_sector_pool.any? && person.sectors.empty?
+ person_sector_pool.sample(rand(1..3)).each do |sector|
+ SectorableItem.find_or_create_by!(
+ sector_id: sector.id,
+ sectorable_type: "Person",
+ sectorable_id: person.id
+ )
+ end
+ end
+end
+
puts "Creating CommunityNews…"
[
"Workshop Spotlight: Building Confidence Through Art",
@@ -854,7 +894,9 @@
puts "Linking some WorkshopVariations to WorkshopVariationIdeas…"
WorkshopVariationIdea.all.sample(2).each_with_index do |idea, i|
- variation = WorkshopVariation.where(workshop_variation_idea_id: nil).sample
+ # Only link variations that pass validation — skips any legacy/invalid rows
+ # so update! doesn't fail on records this seed didn't create.
+ variation = WorkshopVariation.where(workshop_variation_idea_id: nil).find(&:valid?)
next unless variation
variation.update!(workshop_variation_idea_id: idea.id, published: i > 0)
@@ -1152,6 +1194,80 @@
).call
end
+puts "Creating Events with shared forms…"
+admin_user = User.find_by(email: "umberto.user@example.com")
+registration_form = Form.standalone.find_by!(role: "registration")
+scholarship_form = Form.standalone.find_by!(role: "scholarship")
+
+# Each entry: [title, form_type, cost_cents, scholarship?, visibility, span_days]
+# form_type: :long, :short, or :none. span_days (optional) makes a multi-day event.
+dev_events = [
+ [ "AWBW Facilitator Training", :long, 15_000, true,
+ { published: true, featured: true, publicly_visible: true } ],
+ [ "Facilitator Training: Trauma-Informed Art Practices", :long, 12_000, true,
+ { published: true, featured: true }, 3 ],
+ [ "A Year of Healing and Rebuilding Together Wellness Day", :short, 0, false,
+ { published: true, publicly_visible: true, publicly_featured: true, featured: true } ],
+ [ "Youth Creativity Day", :short, 0, false,
+ { published: true, publicly_visible: true, publicly_featured: true } ],
+ [ "Mindful Art for Survivors Workshop", :short, 5_000, true,
+ { published: true, publicly_visible: true, publicly_featured: true } ],
+ [ "Community Open Studio Night", :none, 0, false,
+ { published: true, featured: true } ],
+ [ "Annual Celebration of Voices", :none, 0, false,
+ { published: true, publicly_visible: true } ],
+ [ "Art as Healing: Virtual Group Session", :short, 0, false,
+ { published: true, featured: true } ],
+ [ "Leaders in Creativity: Facilitator Roundtable", :short, 0, false,
+ { published: true, publicly_visible: true } ],
+ [ "Family Creative Expression Day", :short, 0, false,
+ { published: true, publicly_visible: true, publicly_featured: true } ],
+ [ "Creative Safety & Support Workshop", :short, 2_500, true,
+ { published: true, featured: true } ],
+ [ "Healing Through Art: Spring Community Gathering", :short, 0, false,
+ { published: true, publicly_visible: true } ]
+]
+
+dev_events.each_with_index do |(title, form_type, cost_cents, scholarship, visibility, span_days), i|
+ # Soonest events sort last; index order is start_date DESC, so reversing the
+ # offset puts the first two entries (the data-rich trainings) at the top of the list.
+ start_date = Time.current + ((dev_events.length - i) * 5).days
+ end_date = start_date + (span_days ? (span_days - 1).days : 0) + rand(2..4).hours
+ registration_close = start_date - rand(2..7).days
+ registerable = form_type != :none
+
+ desc_content = Faker::Lorem.paragraph(sentence_count: 6)
+ event = Event.find_or_create_by!(title: title) do |e|
+ e.description = desc_content
+ e.rhino_description = desc_content
+ e.start_date = start_date
+ e.end_date = end_date
+ e.registration_close_date = registration_close
+ e.cost_cents = cost_cents
+ e.public_registration_enabled = false
+ e.created_by = admin_user
+ visibility.each { |k, v| e.send(:"#{k}=", v) }
+ end
+
+ # Keep the demo schedule current and deterministic on re-seed — find_or_create_by!
+ # only sets dates on create, so without this an existing DB keeps stale dates and
+ # neither the index ordering nor the multi-day span would update.
+ event.update!(start_date: start_date, end_date: end_date, registration_close_date: registration_close)
+
+ if registerable
+ EventForm.find_or_create_by!(event: event, role: "registration") do |ef|
+ ef.form = registration_form
+ end
+ event.update!(public_registration_enabled: true) unless event.public_registration_enabled?
+ end
+
+ if scholarship
+ EventForm.find_or_create_by!(event: event, role: "scholarship") do |ef|
+ ef.form = scholarship_form
+ end
+ end
+end
+
puts "Creating Event Registrations…"
# Key people for named scenarios
@@ -1188,7 +1304,7 @@
# Kim Davis: cancelled (has user)
if facilitator_training
[
- { person: amy_person, status: "registered", scholarship_recipient: true, scholarship_tasks_completed: false },
+ { person: amy_person, status: "registered", scholarship_requested: true },
{ person: maria_j, status: "registered" },
{ person: anna_g, status: "attended" },
{ person: mario_j, status: "registered" },
@@ -1207,7 +1323,7 @@
if trauma_training
[
{ person: sarah_s, status: "registered" },
- { person: jessica_b, status: "registered", scholarship_recipient: true, scholarship_tasks_completed: true },
+ { person: jessica_b, status: "registered", scholarship_requested: true },
{ person: angel_g, status: "registered" },
{ person: linda_w, status: "no_show" }
].each do |data|
@@ -1271,9 +1387,7 @@
event: data[:event],
registrant: data[:person],
status: data[:status] || "registered",
- scholarship_recipient: data[:scholarship_recipient] || false,
- scholarship_tasks_completed: data[:scholarship_tasks_completed] || false,
- scholarship_requested: data[:scholarship_recipient] || false
+ scholarship_requested: data[:scholarship_requested] || false
)
end
@@ -1388,6 +1502,91 @@
end
end
+puts "Creating Scholarships, Payments, and Allocations…"
+# Gives the paid dev events real money + scholarship records so the event
+# overview dashboard (registrants / received / outstanding / scholarships)
+# shows meaningful numbers. Registrations and applications (registration-form
+# submissions) are created above; this fills in the financial side.
+#
+# Each registration is funded at most once — the guard skips any registration
+# that already has allocations, so the section is safe to re-run.
+
+org_payer = Organization.find_by(name: "Angel Step Inn")
+
+# Mirrors ScholarshipsController: build the scholarship with a $0 allocation,
+# then set the amount + tasks_completed so sync_allocation_amount funds the
+# allocation only when the recipient's tasks are complete.
+award_scholarship = ->(registration, amount_cents:, tasks_completed:) do
+ scholarship = Scholarship.new(recipient: registration.registrant)
+ scholarship.build_allocation(allocatable: registration, amount: 0)
+ scholarship.save!
+ scholarship.update!(amount_cents: amount_cents, tasks_completed: tasks_completed)
+ scholarship
+end
+
+# payer is a Person or an Organization; kind is :cash or :check.
+record_payment = ->(registration, payer:, amount_cents:, kind: :cash) do
+ payer_attrs = payer.is_a?(Organization) ? { organization: payer } : { person: payer }
+ created_at = rand(3..30).days.ago
+ payment = case kind
+ when :check
+ CheckPayment.create!(**payer_attrs, amount_cents: amount_cents, check_number: "CHK-#{rand(10_000..99_999)}", created_at: created_at)
+ else
+ CashPayment.create!(**payer_attrs, amount_cents: amount_cents, created_at: created_at)
+ end
+ Allocation.create!(source: payment, allocatable: registration, amount: amount_cents, created_at: created_at)
+end
+
+# Funds a registration once. `scholarship` and `payments` describe what to build.
+fund_registration = ->(event, person, scholarship: nil, payments: []) do
+ return unless event && person
+ registration = EventRegistration.find_by(event: event, registrant: person)
+ return unless registration
+ return if registration.allocations.exists?
+
+ award_scholarship.(registration, **scholarship) if scholarship
+ payments.each { |payment| record_payment.(registration, **payment) }
+end
+
+# --- AWBW Facilitator Training ($150) ---
+# Amy: pending scholarship (tasks incomplete → $0 allocated) + partial cash → still owes
+fund_registration.(facilitator_training, amy_person,
+ scholarship: { amount_cents: 10_000, tasks_completed: false },
+ payments: [ { payer: amy_person, amount_cents: 5_000, kind: :cash } ])
+# Maria: paid in full by cash
+fund_registration.(facilitator_training, maria_j,
+ payments: [ { payer: maria_j, amount_cents: 15_000, kind: :cash } ])
+# Anna: paid in full by check from her organization (org-payer scenario)
+fund_registration.(facilitator_training, anna_g,
+ payments: [ { payer: org_payer || anna_g, amount_cents: 15_000, kind: :check } ])
+# Mario: partial cash → still owes
+fund_registration.(facilitator_training, mario_j,
+ payments: [ { payer: mario_j, amount_cents: 5_000, kind: :cash } ])
+
+# --- Facilitator Training: Trauma-Informed Art Practices ($120) ---
+# Sarah: paid in full by check
+fund_registration.(trauma_training, sarah_s,
+ payments: [ { payer: sarah_s, amount_cents: 12_000, kind: :check } ])
+# Jessica: completed scholarship ($80) + cash for the remainder → paid in full
+fund_registration.(trauma_training, jessica_b,
+ scholarship: { amount_cents: 8_000, tasks_completed: true },
+ payments: [ { payer: jessica_b, amount_cents: 4_000, kind: :cash } ])
+# Angel: partial cash → still owes
+fund_registration.(trauma_training, angel_g,
+ payments: [ { payer: angel_g, amount_cents: 6_000, kind: :cash } ])
+
+# --- Mindful Art for Survivors Workshop ($50) ---
+# Amy: paid in full by cash
+fund_registration.(mindful_art, amy_person,
+ payments: [ { payer: amy_person, amount_cents: 5_000, kind: :cash } ])
+
+[ facilitator_training, trauma_training, mindful_art ].compact.each do |event|
+ dashboard = EventDashboard.new(event)
+ puts " #{event.title}: #{dashboard.registrant_count} registrants, " \
+ "received #{dashboard.received_cents / 100.0}, outstanding #{dashboard.outstanding_cents / 100.0}, " \
+ "scholarships #{dashboard.scholarship_total_cents / 100.0} (#{dashboard.scholarship_recipient_count})"
+end
+
puts "Creating Resources…"
10.times do |i|
kind = Resource::PUBLISHED_KINDS.sample
diff --git a/spec/decorators/event_decorator_spec.rb b/spec/decorators/event_decorator_spec.rb
index b734979ba..7dd0b504b 100644
--- a/spec/decorators/event_decorator_spec.rb
+++ b/spec/decorators/event_decorator_spec.rb
@@ -18,6 +18,33 @@
end
end
+ describe "#date_range" do
+ it "shows a single weekday-prefixed date when there is no end date" do
+ event = build(:event, start_date: Time.zone.local(2026, 6, 11, 12), end_date: nil).decorate
+ expect(event.date_range).to eq("Thu, Jun 11, 2026")
+ end
+
+ it "shows a single date when start and end fall on the same day" do
+ event = build(:event, start_date: Time.zone.local(2026, 6, 11, 9), end_date: Time.zone.local(2026, 6, 11, 17)).decorate
+ expect(event.date_range).to eq("Thu, Jun 11, 2026")
+ end
+
+ it "collapses month and year for a same-month range" do
+ event = build(:event, start_date: Time.zone.local(2026, 6, 11, 9), end_date: Time.zone.local(2026, 6, 13, 17)).decorate
+ expect(event.date_range).to eq("Thu-Sat, Jun 11-13, 2026")
+ end
+
+ it "collapses only the year for a same-year, cross-month range" do
+ event = build(:event, start_date: Time.zone.local(2026, 6, 30, 9), end_date: Time.zone.local(2026, 7, 2, 17)).decorate
+ expect(event.date_range).to eq("Tue, Jun 30 - Thu, Jul 2, 2026")
+ end
+
+ it "shows both years for a cross-year range" do
+ event = build(:event, start_date: Time.zone.local(2025, 12, 31, 9), end_date: Time.zone.local(2026, 1, 2, 17)).decorate
+ expect(event.date_range).to eq("Wed, Dec 31, 2025 - Fri, Jan 2, 2026")
+ end
+ end
+
describe "#labelled_cost" do
it "returns nil when cost_cents is nil" do
event = build(:event, cost_cents: nil).decorate
diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb
index a61368dec..91a478c7b 100644
--- a/spec/models/event_registration_spec.rb
+++ b/spec/models/event_registration_spec.rb
@@ -50,6 +50,124 @@
end
end
+ describe ".registrant_ids" do
+ it "returns registrations for the registrants in a hyphenated id list" do
+ person_a = create(:person)
+ person_b = create(:person)
+ person_c = create(:person)
+ reg_a = create(:event_registration, registrant: person_a)
+ reg_b = create(:event_registration, registrant: person_b)
+ reg_c = create(:event_registration, registrant: person_c)
+
+ results = EventRegistration.registrant_ids("#{person_a.id}-#{person_b.id}")
+ expect(results).to include(reg_a, reg_b)
+ expect(results).not_to include(reg_c)
+ end
+ end
+
+ describe ".registrant_sector" do
+ it "returns registrations whose registrant belongs to the sector" do
+ sector = create(:sector)
+ in_sector = create(:person)
+ create(:sectorable_item, sector: sector, sectorable: in_sector)
+ out_sector = create(:person)
+ reg_in = create(:event_registration, registrant: in_sector)
+ reg_out = create(:event_registration, registrant: out_sector)
+
+ results = EventRegistration.registrant_sector(sector.id)
+ expect(results).to include(reg_in)
+ expect(results).not_to include(reg_out)
+ end
+ end
+
+ describe "payment and scholarship scopes" do
+ let(:event) { create(:event, cost_cents: 1000) }
+ let(:paid_reg) { create(:event_registration, event: event) }
+ let(:unpaid_reg) { create(:event_registration, event: event) }
+ let(:scholarship_reg) { create(:event_registration, event: event) }
+ let(:incomplete_scholarship_reg) { create(:event_registration, event: event) }
+
+ before do
+ create(:allocation, source: create(:payment, amount_cents: 1000, amount_cents_remaining: 1000),
+ allocatable: paid_reg, amount: 1000)
+ create(:allocation, source: create(:payment, amount_cents: 400, amount_cents_remaining: 400),
+ allocatable: unpaid_reg, amount: 400)
+ completed = create(:scholarship, recipient: scholarship_reg.registrant, tasks_completed: true, amount_cents: 1000)
+ create(:allocation, source: completed, allocatable: scholarship_reg, amount: 1000)
+ incomplete = create(:scholarship, recipient: incomplete_scholarship_reg.registrant, tasks_completed: false, amount_cents: 1000)
+ create(:allocation, source: incomplete, allocatable: incomplete_scholarship_reg, amount: 0)
+ end
+
+ describe ".paid_in_full" do
+ it "returns registrations whose allocations cover the cost" do
+ results = EventRegistration.paid_in_full
+ expect(results).to include(paid_reg, scholarship_reg)
+ expect(results).not_to include(unpaid_reg)
+ end
+ end
+
+ describe ".not_paid_in_full" do
+ it "returns registrations still owing money" do
+ results = EventRegistration.not_paid_in_full
+ expect(results).to include(unpaid_reg)
+ expect(results).not_to include(paid_reg, scholarship_reg)
+ end
+ end
+
+ describe ".with_scholarship" do
+ it "returns only registrations funded by a scholarship" do
+ results = EventRegistration.with_scholarship
+ expect(results).to include(scholarship_reg, incomplete_scholarship_reg)
+ expect(results).not_to include(paid_reg, unpaid_reg)
+ end
+ end
+
+ describe ".scholarship_tasks_completed" do
+ it "returns recipients whose scholarship tasks are complete" do
+ results = EventRegistration.scholarship_tasks_completed
+ expect(results).to include(scholarship_reg)
+ expect(results).not_to include(incomplete_scholarship_reg, paid_reg, unpaid_reg)
+ end
+ end
+
+ describe ".scholarship_tasks_incomplete" do
+ it "returns recipients whose scholarship tasks are not complete" do
+ results = EventRegistration.scholarship_tasks_incomplete
+ expect(results).to include(incomplete_scholarship_reg)
+ expect(results).not_to include(scholarship_reg, paid_reg, unpaid_reg)
+ end
+ end
+
+ describe ".scholarship_status" do
+ it "maps 'yes' to all recipients" do
+ expect(EventRegistration.scholarship_status("yes")).to include(scholarship_reg, incomplete_scholarship_reg)
+ expect(EventRegistration.scholarship_status("yes")).not_to include(paid_reg, unpaid_reg)
+ end
+
+ it "maps 'complete' to completed-task recipients" do
+ expect(EventRegistration.scholarship_status("complete")).to include(scholarship_reg)
+ expect(EventRegistration.scholarship_status("complete")).not_to include(incomplete_scholarship_reg)
+ end
+
+ it "maps 'incomplete' to incomplete-task recipients" do
+ expect(EventRegistration.scholarship_status("incomplete")).to include(incomplete_scholarship_reg)
+ expect(EventRegistration.scholarship_status("incomplete")).not_to include(scholarship_reg)
+ end
+ end
+
+ describe ".payment_status" do
+ it "maps 'paid' to paid_in_full" do
+ expect(EventRegistration.payment_status("paid")).to include(paid_reg)
+ expect(EventRegistration.payment_status("paid")).not_to include(unpaid_reg)
+ end
+
+ it "maps 'unpaid' to not_paid_in_full" do
+ expect(EventRegistration.payment_status("unpaid")).to include(unpaid_reg)
+ expect(EventRegistration.payment_status("unpaid")).not_to include(paid_reg)
+ end
+ end
+ end
+
describe "#scholarship?" do
it "returns true when registration has a scholarship" do
reg = create(:event_registration)
diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb
index df978b7ad..ccbda1089 100644
--- a/spec/requests/events_spec.rb
+++ b/spec/requests/events_spec.rb
@@ -365,6 +365,159 @@
end
end
+ describe "GET /events/:id/manage with payment and scholarship filters" do
+ let(:event) { create(:event, cost_cents: 1_000) }
+ let(:paid_person) { create(:person, first_name: "Paid", last_name: "Person") }
+ let(:unpaid_person) { create(:person, first_name: "Unpaid", last_name: "Person") }
+ let(:scholarship_person) { create(:person, first_name: "Scholar", last_name: "Person") }
+
+ let!(:paid_reg) do
+ reg = create(:event_registration, event: event, registrant: paid_person)
+ create(:allocation, source: create(:payment, amount_cents: 1_000, amount_cents_remaining: 1_000),
+ allocatable: reg, amount: 1_000)
+ reg
+ end
+ let!(:unpaid_reg) { create(:event_registration, event: event, registrant: unpaid_person) }
+ let(:pending_scholarship_person) { create(:person, first_name: "Pending", last_name: "Person") }
+ let!(:scholarship_reg) do
+ reg = create(:event_registration, event: event, registrant: scholarship_person)
+ scholarship = create(:scholarship, recipient: scholarship_person, tasks_completed: true, amount_cents: 1_000)
+ create(:allocation, source: scholarship, allocatable: reg, amount: 1_000)
+ reg
+ end
+ let!(:pending_scholarship_reg) do
+ reg = create(:event_registration, event: event, registrant: pending_scholarship_person)
+ scholarship = create(:scholarship, recipient: pending_scholarship_person, tasks_completed: false, amount_cents: 1_000)
+ create(:allocation, source: scholarship, allocatable: reg, amount: 0)
+ reg
+ end
+
+ before { sign_in admin }
+
+ it "filters to paid-in-full registrants" do
+ get manage_event_path(event, payment_status: "paid")
+ expect(response.body).to include("Paid Person")
+ expect(response.body).to include("Scholar Person")
+ expect(response.body).not_to include("Unpaid Person")
+ end
+
+ it "filters to not-paid-in-full registrants" do
+ get manage_event_path(event, payment_status: "unpaid")
+ expect(response.body).to include("Unpaid Person")
+ expect(response.body).not_to include("Paid Person")
+ end
+
+ it "filters to all scholarship recipients" do
+ get manage_event_path(event, scholarship: "yes")
+ expect(response.body).to include("Scholar Person")
+ expect(response.body).to include("Pending Person")
+ expect(response.body).not_to include("Paid Person")
+ expect(response.body).not_to include("Unpaid Person")
+ end
+
+ it "filters to recipients whose tasks are complete" do
+ get manage_event_path(event, scholarship: "complete")
+ expect(response.body).to include("Scholar Person")
+ expect(response.body).not_to include("Pending Person")
+ end
+
+ it "filters to recipients whose tasks are not complete" do
+ get manage_event_path(event, scholarship: "incomplete")
+ expect(response.body).to include("Pending Person")
+ expect(response.body).not_to include("Scholar Person")
+ end
+ end
+
+ describe "GET /events/:id/manage with state and county filters" do
+ let(:ca_person) { create(:person, first_name: "Cali", last_name: "Person") }
+ let(:ny_person) { create(:person, first_name: "York", last_name: "Person") }
+ # Same county name ("Kings") in a different state, to prove disambiguation.
+ let(:ca_kings_person) { create(:person, first_name: "Caliking", last_name: "Person") }
+
+ let!(:ca_reg) { create(:event_registration, event: event, registrant: ca_person) }
+ let!(:ny_reg) { create(:event_registration, event: event, registrant: ny_person) }
+ let!(:ca_kings_reg) { create(:event_registration, event: event, registrant: ca_kings_person) }
+
+ before do
+ create(:address, addressable: ca_person, state: "CA", county: "Los Angeles")
+ create(:address, addressable: ny_person, state: "NY", county: "Kings")
+ create(:address, addressable: ca_kings_person, state: "CA", county: "Kings")
+ sign_in admin
+ end
+
+ it "filters registrants by state" do
+ get manage_event_path(event, state: "CA")
+ expect(response.body).to include("Cali Person")
+ expect(response.body).not_to include("York Person")
+ end
+
+ it "filters registrants by a state-scoped county value" do
+ get manage_event_path(event, county: "NY|Kings")
+ expect(response.body).to include("York Person")
+ expect(response.body).not_to include("Cali Person")
+ expect(response.body).not_to include("Caliking Person")
+ end
+
+ it "filters registrants by a hyphenated registrant id list" do
+ get manage_event_path(event, registrant_ids: ca_person.id.to_s)
+ expect(response.body).to include("Cali Person")
+ expect(response.body).not_to include("York Person")
+ end
+
+ it "filters registrants by sector" do
+ sector = create(:sector)
+ create(:sectorable_item, sector: sector, sectorable: ca_person)
+
+ get manage_event_path(event, sector: sector.id)
+ expect(response.body).to include("Cali Person")
+ expect(response.body).not_to include("York Person")
+ end
+ end
+
+ describe "GET /events/:id/dashboard" do
+ let(:event) { create(:event, cost_cents: 10_000) }
+ let(:person) { create(:person) }
+ let(:organization) { create(:organization, name: "Overview Org") }
+ let!(:registration) do
+ create(:affiliation, person: person, organization: organization)
+ create(:event_registration, event: event, registrant: person, status: "registered")
+ end
+
+ context "as admin" do
+ before { sign_in admin }
+
+ it "renders the dashboard with registrant count and organizations" do
+ get dashboard_event_path(event)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Dashboard")
+ expect(response.body).to include("Overview Org")
+ end
+
+ it "renders the payments section with totals for a paid event" do
+ create(:allocation, source: create(:payment, amount_cents: 6_000, amount_cents_remaining: 6_000),
+ allocatable: registration, amount: 6_000)
+
+ get dashboard_event_path(event)
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Registration fees")
+ expect(response.body).to include("Cont ed fees")
+ expect(response.body).to include("Paid")
+ expect(response.body).to include("$60.00")
+ end
+ end
+
+ context "as non-admin non-owner" do
+ before { sign_in user }
+
+ it "redirects" do
+ get dashboard_event_path(event)
+ expect(response).to redirect_to(root_path)
+ end
+ end
+ end
+
describe "Google Analytics snippets" 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
new file mode 100644
index 000000000..a40250e19
--- /dev/null
+++ b/spec/routing/events_routing_spec.rb
@@ -0,0 +1,9 @@
+require "rails_helper"
+
+RSpec.describe EventsController, type: :routing do
+ describe "routing" do
+ it "routes to #dashboard" do
+ expect(get: "/events/1/dashboard").to route_to("events#dashboard", id: "1")
+ end
+ end
+end
diff --git a/spec/services/event_dashboard_spec.rb b/spec/services/event_dashboard_spec.rb
new file mode 100644
index 000000000..b5a8dc8fb
--- /dev/null
+++ b/spec/services/event_dashboard_spec.rb
@@ -0,0 +1,281 @@
+require "rails_helper"
+
+RSpec.describe EventDashboard do
+ subject(:dashboard) { described_class.new(event) }
+
+ context "with a populated paid event" do
+ let(:event) { create(:event, cost_cents: 10_000) }
+
+ # Two active registrants and one cancelled (which should be ignored everywhere).
+ let(:person1) { create(:person) }
+ let(:person2) { create(:person) }
+ let(:cancelled_person) { create(:person) }
+
+ let(:org_a) { create(:organization, name: "Alpha Org") }
+ let(:org_b) { create(:organization, name: "Beta Org") }
+ let(:org_c) { create(:organization, name: "Gamma Org") }
+ let(:org_excluded) { create(:organization, name: "Excluded Org") }
+
+ let(:sector1) { create(:sector, name: "Domestic Violence") }
+ let(:sector2) { create(:sector, name: "Mental Health") }
+ let(:sector_excluded) { create(:sector, name: "Veterans & Military") }
+
+ let!(:reg1) do
+ # Affiliation exists before registration so it is captured in the snapshot.
+ create(:affiliation, person: person1, organization: org_a)
+ create(:event_registration, event: event, registrant: person1, status: "registered")
+ end
+
+ let!(:reg2) do
+ create(:affiliation, person: person2, organization: org_c)
+ create(:event_registration, event: event, registrant: person2, status: "registered")
+ end
+
+ before do
+ # Affiliation added after registration: present via active affiliations, not the snapshot.
+ create(:affiliation, person: person1, organization: org_b)
+
+ # Cancelled registration — its org/sector/state/money must be ignored.
+ create(:affiliation, person: cancelled_person, organization: org_excluded)
+ cancelled_reg = create(:event_registration, event: event, registrant: cancelled_person, status: "cancelled")
+ create(:allocation, source: create(:payment, amount_cents: 5_000, amount_cents_remaining: 5_000),
+ allocatable: cancelled_reg, amount: 5_000)
+
+ # Money: reg1 fully covered (payment + scholarship), reg2 partly paid.
+ create(:allocation, source: create(:payment, amount_cents: 6_000, amount_cents_remaining: 6_000),
+ allocatable: reg1, amount: 6_000)
+ scholarship = create(:scholarship, recipient: person1, amount_cents: 4_000)
+ create(:allocation, source: scholarship, allocatable: reg1, amount: 4_000)
+
+ create(:allocation, source: create(:payment, amount_cents: 3_000, amount_cents_remaining: 3_000),
+ allocatable: reg2, amount: 3_000)
+
+ # Sectors on registrants (sector1 shared, sector2 unique, excluded one belongs to cancelled person).
+ create(:sectorable_item, sector: sector1, sectorable: person1)
+ create(:sectorable_item, sector: sector1, sectorable: person2)
+ create(:sectorable_item, sector: sector2, sectorable: person2)
+ create(:sectorable_item, sector: sector_excluded, sectorable: cancelled_person)
+
+ # States from active registrant addresses; inactive address excluded.
+ create(:address, addressable: person1, state: "CA", county: "Los Angeles")
+ create(:address, addressable: person2, state: "NY", county: "Kings")
+ create(:address, addressable: person2, state: "TX", county: "Travis", inactive: true)
+ create(:address, addressable: cancelled_person, state: "FL", county: "Miami-Dade")
+ end
+
+ it "counts only active registrants" do
+ expect(dashboard.registrant_count).to eq(2)
+ end
+
+ it "counts inactive (cancelled / no-show) registrations" do
+ expect(dashboard.inactive_registration_count).to eq(1)
+ end
+
+ it "returns only active registrants as Person records" do
+ expect(dashboard.registrants).to contain_exactly(person1, person2)
+ end
+
+ describe "money" do
+ it "sums received payments across active registrations" do
+ expect(dashboard.received_cents).to eq(9_000)
+ end
+
+ it "reports outstanding as the remaining cost after payments and scholarships" do
+ expect(dashboard.outstanding_cents).to eq(7_000)
+ end
+
+ it "reports total as full-price value of active registrations" do
+ expect(dashboard.total_cents).to eq(20_000)
+ end
+
+ it "reports registration subtotal as received plus outstanding" do
+ expect(dashboard.registration_subtotal_cents).to eq(16_000)
+ end
+
+ it "reports grand total as registration subtotal plus scholarships plus cont ed" do
+ expect(dashboard.grand_total_cents).to eq(20_000)
+ expect(dashboard.grand_total_cents).to eq(
+ dashboard.registration_subtotal_cents + dashboard.scholarship_total_cents + dashboard.cont_ed_total_cents
+ )
+ end
+
+ it "is not free when the event has a cost" do
+ expect(dashboard.free?).to be(false)
+ end
+
+ it "counts registrants paid in full" do
+ expect(dashboard.paid_count).to eq(1)
+ end
+
+ it "counts registrants not paid in full" do
+ expect(dashboard.unpaid_count).to eq(1)
+ end
+ end
+
+ describe "scholarships" do
+ it "sums scholarship dollars for active registrations" do
+ expect(dashboard.scholarship_total_cents).to eq(4_000)
+ end
+
+ it "counts unique scholarship recipients" do
+ expect(dashboard.scholarship_recipient_count).to eq(1)
+ end
+ end
+
+ describe "organizations" do
+ it "combines snapshot orgs and active affiliation orgs, deduped" do
+ expect(dashboard.organizations).to contain_exactly(org_a, org_b, org_c)
+ end
+
+ it "counts unique organizations" do
+ expect(dashboard.organization_count).to eq(3)
+ end
+
+ it "counts distinct registrants per organization" do
+ expect(dashboard.organization_counts).to eq(org_a.id => 1, org_b.id => 1, org_c.id => 1)
+ end
+
+ it "returns the registrant ids tied to an organization" do
+ expect(dashboard.organization_registrant_ids).to contain_exactly(person1.id, person2.id)
+ end
+
+ it "maps each organization to its registrant ids" do
+ map = dashboard.organization_registrant_ids_by_org
+ expect(map[org_a.id].to_a).to contain_exactly(person1.id)
+ expect(map[org_b.id].to_a).to contain_exactly(person1.id)
+ expect(map[org_c.id].to_a).to contain_exactly(person2.id)
+ end
+ end
+
+ describe "sectors" do
+ it "returns unique sectors across active registrants" do
+ expect(dashboard.sectors).to contain_exactly(sector1, sector2)
+ end
+
+ it "counts distinct registrants per sector" do
+ expect(dashboard.sector_counts).to eq(sector1.id => 2, sector2.id => 1)
+ end
+
+ it "returns the registrant ids that belong to a sector" do
+ expect(dashboard.sector_registrant_ids).to contain_exactly(person1.id, person2.id)
+ end
+ end
+
+ describe "states" do
+ it "returns unique states from active registrants' active addresses" do
+ expect(dashboard.states).to eq(%w[CA NY])
+ end
+
+ it "counts distinct registrants per state" do
+ expect(dashboard.state_counts).to eq("CA" => 1, "NY" => 1)
+ end
+
+ it "returns the registrant ids that have a state on file" do
+ expect(dashboard.state_registrant_ids).to contain_exactly(person1.id, person2.id)
+ end
+ end
+
+ describe "counties" do
+ it "returns unique [ state, county ] pairs from active registrants' active addresses" do
+ expect(dashboard.counties).to eq([ [ "CA", "Los Angeles" ], [ "NY", "Kings" ] ])
+ end
+ end
+ end
+
+ describe "money breakdown registrant lists" do
+ let(:event) { create(:event, cost_cents: 10_000) }
+ let(:paid_person) { create(:person) }
+ let(:unpaid_person) { create(:person) }
+ let(:completed_person) { create(:person) }
+ let(:pending_person) { create(:person) }
+
+ let!(:paid_reg) { create(:event_registration, event: event, registrant: paid_person, status: "registered") }
+ let!(:unpaid_reg) { create(:event_registration, event: event, registrant: unpaid_person, status: "registered") }
+ let!(:completed_reg) { create(:event_registration, event: event, registrant: completed_person, status: "registered") }
+ let!(:pending_reg) { create(:event_registration, event: event, registrant: pending_person, status: "registered") }
+
+ before do
+ create(:allocation, source: create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000),
+ allocatable: paid_reg, amount: 10_000)
+ create(:allocation, source: create(:payment, amount_cents: 2_000, amount_cents_remaining: 2_000),
+ allocatable: unpaid_reg, amount: 2_000)
+ completed = create(:scholarship, recipient: completed_person, amount_cents: 10_000, tasks_completed: true)
+ create(:allocation, source: completed, allocatable: completed_reg, amount: 10_000)
+ pending = create(:scholarship, recipient: pending_person, amount_cents: 10_000, tasks_completed: false)
+ create(:allocation, source: pending, allocatable: pending_reg, amount: 0)
+ end
+
+ it "lists registrants paid in full (including scholarship-covered)" do
+ expect(dashboard.paid_registrants).to contain_exactly(paid_person, completed_person)
+ end
+
+ it "lists registrants not paid in full" do
+ expect(dashboard.unpaid_registrants).to contain_exactly(unpaid_person, pending_person)
+ end
+
+ it "splits scholarship dollars into completed and outstanding" do
+ expect(dashboard.completed_scholarship_cents).to eq(10_000)
+ expect(dashboard.outstanding_scholarship_cents).to eq(10_000)
+ end
+
+ it "lists completed scholarship recipients" do
+ expect(dashboard.completed_scholarship_registrants).to contain_exactly(completed_person)
+ end
+
+ it "lists outstanding scholarship recipients" do
+ expect(dashboard.outstanding_scholarship_registrants).to contain_exactly(pending_person)
+ end
+ end
+
+ # Continuing-education fees are stubbed to zero until the feature (and its
+ # migration) lands. The dashboard still renders the section, showing $0.
+ describe "continuing-education fees (stubbed)" do
+ let(:event) { create(:event, cost_cents: 10_000) }
+
+ before do
+ create(:event_registration, event: event, registrant: create(:person), status: "registered")
+ end
+
+ it "reports zero across totals, splits, and registrant lists" do
+ expect(dashboard.cont_ed_total_cents).to eq(0)
+ expect(dashboard.cont_ed_paid_cents).to eq(0)
+ expect(dashboard.cont_ed_outstanding_cents).to eq(0)
+ expect(dashboard.cont_ed_paid_count).to eq(0)
+ expect(dashboard.cont_ed_unpaid_count).to eq(0)
+ expect(dashboard.cont_ed_paid_registrants).to be_empty
+ expect(dashboard.cont_ed_unpaid_registrants).to be_empty
+ end
+
+ it "adds nothing to the grand total" do
+ expect(dashboard.grand_total_cents).to eq(
+ dashboard.scholarship_total_cents + dashboard.received_cents + dashboard.outstanding_cents
+ )
+ end
+ end
+
+ context "with a free event" do
+ let(:event) { create(:event, cost_cents: 0) }
+
+ it "is free and has no total or outstanding cost" do
+ expect(dashboard.free?).to be(true)
+ expect(dashboard.total_cents).to eq(0)
+ expect(dashboard.outstanding_cents).to eq(0)
+ end
+ end
+
+ context "with no registrations" do
+ let(:event) { create(:event, cost_cents: 10_000) }
+
+ it "reports zeros and empty collections" do
+ expect(dashboard.registrant_count).to eq(0)
+ expect(dashboard.registrants).to be_empty
+ expect(dashboard.total_cents).to eq(0)
+ expect(dashboard.received_cents).to eq(0)
+ expect(dashboard.outstanding_cents).to eq(0)
+ expect(dashboard.scholarship_total_cents).to eq(0)
+ expect(dashboard.organizations).to be_empty
+ expect(dashboard.sectors).to be_empty
+ expect(dashboard.states).to be_empty
+ end
+ end
+end
diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb
index 38b32dff8..71ebfbdfb 100644
--- a/spec/views/page_bg_class_alignment_spec.rb
+++ b/spec/views/page_bg_class_alignment_spec.rb
@@ -102,6 +102,7 @@
"app/views/bookmarks/index.html.erb" => "admin-only bg-blue-100",
"app/views/categories/index.html.erb" => "admin-only bg-blue-100",
"app/views/category_types/index.html.erb" => "admin-only bg-blue-100",
+ "app/views/events/dashboard.html.erb" => "admin-only bg-blue-100",
"app/views/events/manage.html.erb" => "admin-only bg-blue-100",
"app/views/events/preview_reminder.html.erb" => "admin-only bg-blue-100",
"app/views/event_registrations/index.html.erb" => "admin-only bg-blue-100",