From ecafd13290c7353efe7e845ee73644684f95149d Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 6 Jun 2026 14:00:33 -0400 Subject: [PATCH] Add per-event dashboard with drill-in registrant filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 17 +- app/controllers/events_controller.rb | 15 +- app/decorators/event_decorator.rb | 16 + app/models/event_registration.rb | 82 +++++ app/models/sector.rb | 2 + app/services/event_dashboard.rb | 295 +++++++++++++++++ .../events/_dashboard_money_row.html.erb | 54 ++++ app/views/events/_manage_search.html.erb | 51 +++ app/views/events/dashboard.html.erb | 298 ++++++++++++++++++ app/views/events/manage.html.erb | 1 + config/routes.rb | 1 + db/seeds/dummy_dev_seeds.rb | 211 ++++++++++++- spec/decorators/event_decorator_spec.rb | 27 ++ spec/models/event_registration_spec.rb | 118 +++++++ spec/requests/events_spec.rb | 153 +++++++++ spec/routing/events_routing_spec.rb | 9 + spec/services/event_dashboard_spec.rb | 281 +++++++++++++++++ spec/views/page_bg_class_alignment_spec.rb | 1 + 18 files changed, 1617 insertions(+), 15 deletions(-) create mode 100644 app/services/event_dashboard.rb create mode 100644 app/views/events/_dashboard_money_row.html.erb create mode 100644 app/views/events/dashboard.html.erb create mode 100644 spec/routing/events_routing_spec.rb create mode 100644 spec/services/event_dashboard_spec.rb 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",