diff --git a/AGENTS.md b/AGENTS.md index 9ef534102b..91b948843d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| | `app/models/` | ActiveRecord models | ~80 files | -| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~40 files | +| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~49 files | | `app/jobs/` | SolidQueue background jobs | 4 files | | `app/models/concerns/` | Shared model modules | 16 concerns | @@ -58,7 +58,7 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| | `app/controllers/` | Rails controllers (admin/, events/) | ~78 files | -| `app/views/` | ERB templates | ~632 files | +| `app/views/` | ERB templates | ~745 files | | `app/decorators/` | Draper decorators for view logic | ~40 files | | `app/policies/` | ActionPolicy authorization rules | ~55 files | | `app/presenters/` | Presentation objects | 6 files | @@ -196,11 +196,13 @@ action, or `authorize! :workshop, to: :summary?`). - `EventDashboard` — Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total) - `EventRevenueReport` — Cross-event revenue report grouped by calendar year (money in vs org subsidy vs net, CE fees, chart series) for the CEO revenue page - `EventRevenueFigures` — Batch-loads the per-event money components `EventRevenueReport` rows are built from (registration payments/outstanding, funded/unfunded scholarships, discounts, CE paid/outstanding) in a fixed number of grouped queries; mirrors the `EventDashboard` definitions +- `EventScholarshipFigures` — Batch-loads the per-event scholarship figures `EventScholarshipReport` columns are built from (funded/unfunded dollars + counts, attended count) in a fixed number of grouped queries; optional `funder:` narrows to a donor's grants. Mirrors the `EventDashboard` funded/unfunded split, replacing the one-dashboard-per-event it used to build - `EventParticipationReport` — Cross-event participation report grouped by calendar year (unique people trained vs attended seats vs per-status outcome counts, chart series) for the events participation page; sibling of `EventRevenueReport` -- `TrainingAttendeesRoster` — Cross-event counterpart to `EventDashboard`: builds the per-registrant lookup maps the shared `events/_registrant_roster` partial reads (sector/age/org/status/location/scholarship/CE plus the attended-trainings event column) for a paginated page of people who have attended a facilitator training; backs the `events#training_attendees` index -- `TrainingAttendeesBreakdowns` — Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over the whole filtered training-attendee population, profile-sourced, for the shared `events/_registrant_breakdowns` partial on the `events#training_attendees` index +- `AttendeesRoster` — Cross-event counterpart to `EventDashboard`: builds the per-registrant lookup maps the shared `events/_registrant_roster` partial reads (sector/age/org/status/location/scholarship/CE plus the events-attended column) for a paginated page of people; backs the `events#attendees` index. Takes `events:` + `registrations:` — the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope — so a person's columns show what's in scope rather than their whole history, and never an event the viewer can't see +- `AttendeesBreakdowns` — Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over an arbitrary people set, profile-sourced, for the shared `events/_registrant_breakdowns` partial. Backs the `events#attendees` index charts (cross-event; `events:` / `registrations:` = the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope) and the `events#recipients` charts frame (one event's scholarship recipients, `registrations:` = `EventRegistration.active` so it counts them regardless of attendance). Also exposes `*_registrant_ids_by_*` maps mirroring `EventDashboard`'s, so a breakdown row can drill in by person id — they regroup rows already loaded for the counts, adding no queries +- `AttendeesActiveFilters` — Human-readable, removable-chip descriptors for a page's "drill-in" filters (registrant_ids, organization, org city, age group / life experience / setting categories, country, school district, scholarship, CE, sector, state) — params that narrow a list without a field in a visible filter form. `CHIP_PARAMS` is the attendees index's set (omitting anything with its own control); pass `chip_params:` for a page with no filter form at all — `ROSTER_CHIP_PARAMS` for the per-event roster, `%w[ registrant_ids ]` for scholarship recipients - `ReportPeriods` — Shared module (included by `EventRevenueReport` and `EventParticipationReport`) resolving the reporting-hub period toggle (this year / last year / all time) to a metric scope + label for the summary cards -- `EventScholarshipReport` — Cross-event scholarship report grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventDashboard`) per facilitator training, plus an attended-trainee count split into "Training" (scheduled) vs "On-demand" (`event.on_demand?`). Sibling of `EventRevenueReport`/`EventParticipationReport` (includes `ReportPeriods`); powers the `events#scholarships` report page and the statistics-hub scholarship summary card +- `EventScholarshipReport` — Cross-event scholarship report grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventScholarshipFigures`) per facilitator training, plus an attended-trainee count split into "Live" (scheduled instructor-led) vs "On-demand" (`event.on_demand?`). Sibling of `EventRevenueReport`/`EventParticipationReport` (includes `ReportPeriods`); powers the `events#scholarships` report page and the reports-hub scholarship summary card - `ScholarshipApplication` — Gathers one person's scholarship-application answers for an event by field across all their submissions, so answers surface whether captured on a dedicated scholarship form, an embedded registration section, or the registration submission itself (used by the scholarship edit page and the public submission view) - `WorkshopSearchService` — Complex filtering, sorting, pagination with ActionPolicy - `WorkshopFromIdeaService` — Converts WorkshopIdea to Workshop with asset migration @@ -214,7 +216,7 @@ action, or `authorize! :workshop, to: :summary?`). - `RichTextMigrator` — Rich text migration utility - `DisplayImagePresenter` — Image display logic - `ScholarshipsGrouping` (presenter) — Groups scholarships into the index's funder → grant → recipient hierarchy; grant-free awards collect under a trailing "Unfunded" group -- `RegistrantCityBreakdown` (presenter) — Groups an event's registrants by the city of the org linked on their registration, counting registrants + scholarship recipients per city; drives the shared "Registrants by city" card on the background dashboard and scholarship-recipients page (fed plucked data by `EventDashboard`) +- `RegistrantCityBreakdown` (presenter) — Groups an event's registrants by the city of the org linked on their registration, counting registrants + scholarship recipients per city; drives the shared "Registrants by city" card inside `events/_registrant_breakdowns` on all three people-pages — per-event roster, cross-event attendees index, and scholarship recipients (fed plucked data by `EventDashboard` or `AttendeesBreakdowns`) - `AllocationLedgerLabel` (presenter) — Shared payment-method/label + check-number labelling for an allocation, used by the invoice and receipt ledgers so they can't drift ### Event Registrations @@ -354,7 +356,7 @@ end - `timeframe` — Date range filtering - `toggle_lock` — Lock/unlock toggle UI - `toggle_user_icon` — User icon visibility toggle -- `us_map_chart` — US states choropleth map (event Background states breakdown) +- `us_map_chart` — US states choropleth map (event roster / attendees index states breakdown) ### JS Dependencies diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 3477ed8671..03838b5df0 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -104,11 +104,16 @@ def update when "ticket" then redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other when "preview_reminder" then redirect_to preview_reminder_event_path(@event_registration.event), notice: notice, status: :see_other when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other - when "training_attendees" then redirect_to training_attendees_events_path, notice: notice, status: :see_other + when "attendees" then redirect_to attendees_events_path, notice: notice, status: :see_other + when "roster" then redirect_to roster_event_path(@event_registration.event), notice: notice, status: :see_other + # Two ways back to the recipients page: the shout-outs section (the + # feature-a-shout-out flow) or the recipient's own card (their name). when "recipients" then redirect_to recipients_event_path(@event_registration.event, anchor: "shout-outs"), notice: notice, status: :see_other + when "recipient_card" then redirect_to helpers.recipients_event_card_path(@event_registration.event, @event_registration.slug), notice: notice, status: :see_other else # No explicit origin: keep admins in the management context (the - # roster) rather than dropping them on the public registration show. + # registrants list) rather than dropping them on the public + # registration show. if allowed_to?(:manage?, with: EventRegistrationPolicy) redirect_to helpers.registrants_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other else @@ -302,7 +307,9 @@ def destroy case params[:return_to] when "registrants" then redirect_to registrants_event_path(event) when "onboarding" then redirect_to onboarding_event_path(event) - when "training_attendees" then redirect_to training_attendees_events_path + when "attendees" then redirect_to attendees_events_path + when "roster" then redirect_to roster_event_path(event) + when "recipients", "recipient_card" then redirect_to recipients_event_path(event) else redirect_to event_registrations_path end end diff --git a/app/controllers/events/bulk_payments_controller.rb b/app/controllers/events/bulk_payments_controller.rb index 0e44535457..f94cc93aa4 100644 --- a/app/controllers/events/bulk_payments_controller.rb +++ b/app/controllers/events/bulk_payments_controller.rb @@ -1,9 +1,11 @@ module Events class BulkPaymentsController < ApplicationController + include AhoyTracking before_action :set_event def index authorize! @event + track_view("events.bulk_payments", { event_id: @event.id }) @event_registrations = @event.event_registrations.active.includes(:registrant) @submissions = @event.form_submissions diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 70674ee9fc..b038cd6536 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -2,8 +2,18 @@ class EventsController < ApplicationController include AhoyTracking, TagAssignable skip_before_action :authenticate_user!, only: [ :index, :show, :staff ] skip_before_action :verify_authenticity_token, only: [ :preview ] - before_action :set_event, only: %i[ show edit update destroy preview dashboard sample_ticket background registrants onboarding staff edit_staff update_staff recipients feature_recipient_shoutout preview_reminder confirm_reminder send_reminder copy_registration_form ] - before_action :set_report_filters, only: %i[ revenue participation statistics scholarships ] + before_action :set_event, only: %i[ show edit update destroy preview dashboard sample_ticket registrants roster onboarding staff edit_staff update_staff recipients preview_reminder confirm_reminder send_reminder copy_registration_form feature_recipient_shoutout ] + before_action :set_report_filters, only: %i[ revenue participation reports scholarships ] + # The cross-event report suite is visible to admins and event owners alike; what + # differs is the rows, which EventPolicy's :reportable scope narrows to the + # viewer's own events. + before_action :authorize_report!, only: %i[ revenue participation reports scholarships attendees ] + # Log a visit to each event page / report. after_action so it only fires once + # the action rendered successfully (authorization inside the actions has passed); + # the turbo_frame_request? / redirect guards skip the lazy results/charts + # sub-requests and the confirm-reminder bounce-back. send_reminder is logged + # inline on a successful send (it always redirects). + after_action :track_page_view, only: %i[ dashboard roster registrants recipients staff onboarding edit preview sample_ticket revenue participation reports scholarships attendees confirm_reminder ] def index authorize! @@ -32,7 +42,6 @@ def show # KPI strip with the year of the event navigated from (when arriving from a # dashboard), otherwise the current year. def revenue - authorize! events, selected_year = filtered_report_events(Event.paid) @report = EventRevenueReport.new(events, featured_year: selected_year || @filter_event&.start_date&.year) end @@ -41,15 +50,14 @@ def revenue # grouped by year. Scopes to all events by default, narrowable to facilitator # trainings, and to a single year via the ahoy-style time-period select. def participation - authorize! events, selected_year = filtered_report_events(Event.all) @report = EventParticipationReport.new(events, featured_year: selected_year) end - # Events statistics hub: the revenue, participation and scholarship report - # summaries side by side, each linking to its full report. - def statistics - authorize! + # Reports hub: the revenue, participation and scholarship report summaries side + # by side, each linking to its full report. Pre-filterable to a single event via + # event_id (the per-event "Reports" tab links here scoped to its event). + def reports @period = params[:period].presence_in(%w[ this_year last_year all_time ]) || "this_year" @revenue_report = EventRevenueReport.new(report_events(Event.paid)) @participation_report = EventParticipationReport.new(report_events(Event.all)) @@ -58,38 +66,40 @@ def statistics # Cross-event scholarship report: scholarship dollars and award counts (funded # vs unfunded) per facilitator training, grouped by year, with an attended- - # trainee count split into Training vs On-demand. Sibling of the revenue and - # participation reports; admin-only. + # trainee count split into Live vs On-demand. Sibling of the revenue and + # participation reports. def scholarships - authorize! events, selected_year = filtered_report_events(Event.facilitator_trainings) @report = EventScholarshipReport.new(events, featured_year: selected_year, funder: @filter_funder) end - # Cross-event index of everyone who has attended a facilitator training, deduped - # to one row per person. Lazy-loaded like the people index: the frame request - # builds the filtered/paginated page and its roster; the full request renders the - # shell (header, filters, skeleton). - def training_attendees - authorize! + # Cross-event index of the people behind event registrations, deduped to one row + # per person. Lazy-loaded like the people index: the frame request builds the + # filtered/paginated page and its roster; the full request renders the shell + # (header, filters, skeleton). The population defaults to people who attended a + # facilitator training, but attendance outcome and event type are filters — the + # report KPIs drill in here for no-shows and non-trainings too. + def attendees + @attendance_status = attendee_attendance_status + @attendee_event_type = attendee_event_type unless turbo_frame_request? - set_training_attendee_filter_options - return render :training_attendees + set_attendee_filter_options + return render :attendees end - people = filtered_training_attendees + people = filtered_attendees # The charts frame is loaded lazily (only when the user reveals it), so the # expensive cross-event breakdowns run solely on that request. - if turbo_frame_request_id == "training_attendees_charts" - @breakdowns = TrainingAttendeesBreakdowns.new(people) - return render :training_attendees_charts + if turbo_frame_request_id == "attendees_charts" + @breakdowns = AttendeesBreakdowns.new(people, events: attendee_events, registrations: attendee_registrations) + return render :attendees_charts end per_page = params[:number_of_items_per_page].presence || 25 @count_display = people.count @people = people.paginate(page: params[:page], per_page: per_page) - @roster = TrainingAttendeesRoster.new(@people) - render :training_attendees_results + @roster = AttendeesRoster.new(@people, events: attendee_events, registrations: attendee_registrations) + render :attendees_results end def new @@ -146,10 +156,16 @@ def sample_ticket @event_registration = SampleTicketRegistration.new(@event, all_options: @show_all_options).registration end - def background - authorize! @event, to: :background? + # The event's active-registrant roster (registrant table + demographic charts), + # over the shared roster/breakdown partials fed by EventDashboard. The charts are + # loaded lazily into their own Turbo frame so the heavier breakdown queries only + # run when the admin reveals them (mirrors the attendees index). + def roster + authorize! @event, to: :roster? @event = @event.decorate @dashboard = EventDashboard.new(@event) + @roster_registrants = filtered_roster_registrants + render :event_roster_charts if turbo_frame_request_id == "event_roster_charts" end def registrants @@ -221,7 +237,10 @@ def onboarding authorize! @event, to: :registrants? @event = @event.decorate scope = @event.event_registrations - .includes(:checklist_completions, :organizations, :allocations, :scholarships, :comments, { continuing_education_registrations: [ :professional_license, :allocations ] }, registrant: [ :user, { affiliations: :organization } ]) + # Preload each org's affiliations: the row's program-status badge classifies + # them via Organization#facilitator_status_on, which filters the loaded + # association — unloaded, that's a per-org query pulling every affiliation row. + .includes(:checklist_completions, { organizations: :affiliations }, :allocations, :scholarships, :comments, { continuing_education_registrations: [ :professional_license, :allocations ] }, registrant: [ :user, { affiliations: :organization } ]) .joins(:registrant) scope = scope.keyword(params[:keyword]) if params[:keyword].present? @@ -283,6 +302,21 @@ def recipients authorize! @event, to: :recipients? @event = @event.decorate @dashboard = EventDashboard.new(@event) + + # Charts frame is loaded lazily (only when the admin reveals it), so the + # breakdowns run solely on that request — and it returns before the cards' + # profile-preloading applicant list, which that frame never renders. + # Scoped to this event's scholarship recipients via the shared cross-event + # AttendeesBreakdowns. + if turbo_frame_request_id == "recipients_charts" + recipients = Person.where(id: @dashboard.scholarship_applicant_ids) + @breakdowns = AttendeesBreakdowns.new(recipients, + events: Event.where(id: @event.id), + registrations: EventRegistration.active) + return render :recipients_charts + end + + @recipient_applicants = filtered_recipient_applicants end # From the recipients page "Add shoutout" control: flag the chosen registrant @@ -403,6 +437,7 @@ def send_reminder recipient_labels = registrations.map { |r| "#{r.registrant.full_name} <#{r.registrant.preferred_email}>" } EventMailer.event_registration_reminder_fyi(@event, recipient_labels, custom_message: custom_message.presence).deliver_later + track_view("events.send_reminder", { event_id: @event.id, recipient_count: registrations.size }) redirect_to registrants_event_path(@event), notice: "Reminder emails are being sent to #{registrations.size} registrant#{'s' if registrations.size != 1}." end @@ -518,11 +553,128 @@ def staff_update_return_path end end - # Shared filter state for the revenue/participation/statistics/scholarships - # report pages: the event-type, specific-event and abbreviation-search filters, - # plus the event list for the Event dropdown. + # Authorize the cross-event report suite. An explicit event_id (from the + # per-event Reports tab) is authorized against that event, so asking for one you + # don't own fails loudly rather than silently returning nothing. The unfiltered + # view is open to event owners as well as admins — EventPolicy's :reportable + # scope narrows the rows, so clearing the event filter lands an owner on their + # own events instead of bouncing them off an admin-only page. + def authorize_report! + @filter_event ||= Event.find_by(id: params[:event_id]) if params[:event_id].present? + if @filter_event + authorize! @filter_event, to: :event_reports? + else + authorize! to: :cross_event_reports? + end + end + + # The events whose rows the current user may see in the report suite: every + # event for an admin, their own for an event owner. Every report query starts + # here, so the filter params can only narrow, never widen. + def reportable_events(base = Event.all) + authorized_scope(base, as: :reportable) + end + + # The attendees index's population filters. These are DEFAULTS, not a fixed base: + # you can't filter into no-shows from a set that already excluded them, so the + # base is every reportable registration and these narrow it. Unfiltered, the page + # shows exactly what it always has — people who attended a facilitator training. + # Opt out with EventRegistration::FILTER_ALL (the participation report's + # all-outcomes and all-event-types drill-ins). + DEFAULT_ATTENDANCE_STATUS = "attended".freeze + DEFAULT_ATTENDEE_EVENT_TYPE = "trainings".freeze + + def attendee_attendance_status + params[:attendance_status].presence || DEFAULT_ATTENDANCE_STATUS + end + + def attendee_event_type + params[:event_type].presence || DEFAULT_ATTENDEE_EVENT_TYPE + end + + # The recipient cards, narrowed by a breakdown drill-in — same rule as the + # roster: only the list shrinks, the breakdowns stay whole. + def filtered_recipient_applicants + ids = params[:registrant_ids].presence&.to_s&.split("-")&.map(&:to_i)&.to_set + return @dashboard.scholarship_applicants unless ids + @dashboard.scholarship_applicants.select { |person| ids.include?(person.id) } + end + + # The roster table's rows, narrowed by a breakdown drill-in. Only the table + # shrinks — the stat bar and the breakdowns deliberately stay whole, so the + # charts remain the full picture and keep working as a navigation surface while + # the table below shows what was clicked. + def filtered_roster_registrants + ids = roster_drill_in_ids + return @dashboard.registrants unless ids + @dashboard.registrants.select { |person| ids.include?(person.id) } + end + + # Person ids behind the clicked breakdown row. Most dimensions arrive as an + # explicit id list (EventDashboard already has the maps); sector and state have + # no per-value id map, so they arrive as named filters and resolve here. + def roster_drill_in_ids + return @roster_drill_in_ids if defined?(@roster_drill_in_ids) + + @roster_drill_in_ids = + if params[:registrant_ids].present? + params[:registrant_ids].to_s.split("-").map(&:to_i).to_set + elsif params[:sector].present? + Person.where(id: person_sector_ids(params[:sector])).ids.to_set + elsif params[:state].present? + Person.where(id: person_address_ids(state: params[:state])).ids.to_set + end + end + + # The events the attendees index draws from: the viewer's reportable events + # narrowed by the event-side filters. Shared by the registration scope and by the + # roster/breakdowns, so a person's row only shows events in scope rather than + # their whole history. + def attendee_events + @attendee_events ||= begin + scope = reportable_events + # Same vocabulary as the report suite's Event type filter, so the hub's + # Attendees/Breakdowns links carry a Live or On-demand filter through instead + # of falling through to every event type. + scope = scope.facilitator_trainings if attendee_event_type == "trainings" + scope = scope.facilitator_trainings.live if attendee_event_type == "live" + scope = scope.facilitator_trainings.on_demand if attendee_event_type == "on_demand" + scope = scope.where(facilitator_training: false) if attendee_event_type == "other" + # Off @filter_event rather than the raw param: the policy scope already bounds + # the events, and the resolved record is one event where params[:event_id] + # could be an array asking for several. + scope = scope.where(id: @filter_event.id) if @filter_event + scope = scope.in_year(params[:event_year]) if params[:event_year].present? + scope + end + end + + # Registrations (any registrant) on those events, narrowed by the registration-side + # filters — the population behind the index and the basis for its org/scholarship/ + # CE drill-in filters. + def attendee_registrations + @attendee_registrations ||= begin + scope = EventRegistration.where(event_id: attendee_events.select(:id)) + scope = scope.attendance_status(attendee_attendance_status) unless attendee_attendance_status == EventRegistration::FILTER_ALL + scope = scope.payment_status(params[:payment_status]) if params[:payment_status].present? + scope = scope.funder(params[:funder]) if params[:funder].present? + scope + end + end + + # Ahoy log for a visit to an event page/report, e.g. "view.events.roster". Ties + # the event via event_id where there is one (per-event pages, or a report scoped + # to an event). Skips the lazy Turbo-frame sub-requests so a page counts once. + def track_page_view + return if turbo_frame_request? || response.redirect? + track_view("events.#{action_name}", { event_id: @event&.id || params[:event_id].presence }.compact) + end + + # Shared filter state for the revenue/participation/reports/scholarships report + # pages: the event-type, specific-event and abbreviation-search filters, plus the + # event list for the Event dropdown. def set_report_filters - @event_type = params[:event_type].presence_in(%w[ trainings other ]) + @event_type = params[:event_type].presence_in(%w[ trainings live on_demand other ]) @filter_event = Event.find_by(id: params[:event_id]) if params[:event_id].present? @event_search = params[:search].presence @filter_funder = GlobalID::Locator.locate_signed(params[:funder_sgid]) if params[:funder_sgid].present? @@ -533,7 +685,9 @@ def set_report_filters when "scholarships" then Event.facilitator_trainings else Event.all end - @filter_events = dropdown_scope.order(start_date: :desc) + # Only offer events the viewer may report on, so picking one from the dropdown + # can never land them on a forbidden event. + @filter_events = reportable_events(dropdown_scope).order(start_date: :desc) end # Applies the shared report filters (event type, specific event) plus a @@ -562,7 +716,10 @@ def report_events(base) # Narrows `base` by the event-type, specific-event and search (abbreviation OR # title) filters. def scoped_report_base(base) + base = reportable_events(base) base = base.facilitator_trainings if @event_type == "trainings" + base = base.facilitator_trainings.live if @event_type == "live" + base = base.facilitator_trainings.on_demand if @event_type == "on_demand" base = base.where(facilitator_training: false) if @event_type == "other" base = base.where(id: @filter_event.id) if @filter_event if @event_search @@ -580,19 +737,14 @@ def selected_report_year(time_period) Integer(time_period, exception: false) end - # People (Person records) with an attended facilitator-training registration, - # narrowed by the training-attendees index filters. The training-specific - # filters (event, year) constrain which attended registrations qualify; the rest - # filter the people. Distinct via the id subquery, so joins never duplicate rows. - def filtered_training_attendees - registrations = EventRegistration.attended - .joins(:event) - .where(events: { facilitator_training: true }) - registrations = registrations.where(events: { id: params[:event_id] }) if params[:event_id].present? - registrations = registrations.where("YEAR(events.start_date) = ?", params[:event_year]) if params[:event_year].present? - - scope = Person.where(id: registrations.select(:registrant_id)) - # Dash-joined person ids, e.g. from the statistics-hub participation totals. + # People (Person records) behind the attendees index's registrations, narrowed by + # the index filters. The event- and registration-side filters (see + # #attendee_events / #attendee_registrations) constrain which registrations + # qualify; the rest filter the people. Distinct via the id subquery, so joins + # never duplicate rows. + def filtered_attendees + scope = Person.where(id: attendee_registrations.select(:registrant_id)) + # Dash-joined person ids, e.g. from the reports-hub participation totals. scope = scope.where(id: params[:registrant_ids].to_s.split("-")) if params[:registrant_ids].present? scope = scope.search_by_params({ contact_info: params[:contact_info] }) if params[:contact_info].present? scope = scope.where(id: person_sector_ids(params[:sector])) if params[:sector].present? @@ -607,11 +759,11 @@ def filtered_training_attendees scope = scope.where(id: person_linked_organization_ids(params[:organization_id])) if params[:organization_id].present? scope = scope.where(id: person_linked_org_city_ids(params[:org_city])) if params[:org_city].present? if params[:scholarship].present? - ids = training_scholarship_recipient_ids + ids = scholarship_recipient_person_ids scope = params[:scholarship] == "no" ? scope.where.not(id: ids) : scope.where(id: ids) end if params[:ce].present? - ids = training_ce_person_ids + ids = ce_person_ids scope = params[:ce] == "no" ? scope.where.not(id: ids) : scope.where(id: ids) end scope = scope.where(id: person_address_ids(state: params[:state])) if params[:state].present? @@ -645,17 +797,11 @@ def person_school_district_ids(district) Address.active.where(addressable_type: "Person", district: district).select(:addressable_id) end - # Attended facilitator-training registrations (any registrant) — the basis for - # the org/scholarship/CE drill-in filters. - def attended_training_registrations - EventRegistration.attended.joins(:event).where(events: { facilitator_training: true }) - end - - # Person ids with the given org linked on one of their attended trainings. + # Person ids with the given org linked on one of their in-scope registrations. def person_linked_organization_ids(organization_id) EventRegistrationOrganization .joins(:event_registration) - .where(organization_id: organization_id, event_registration_id: attended_training_registrations.select(:id)) + .where(organization_id: organization_id, event_registration_id: attendee_registrations.select(:id)) .select(Arel.sql("event_registrations.registrant_id")) end @@ -670,7 +816,7 @@ def person_linked_org_city_ids(city_label) def org_ids_by_city_label @org_ids_by_city_label ||= Address.active .where(addressable_type: "Organization", - addressable_id: EventRegistrationOrganization.where(event_registration_id: attended_training_registrations.select(:id)).select(:organization_id)) + addressable_id: EventRegistrationOrganization.where(event_registration_id: attendee_registrations.select(:id)).select(:organization_id)) .order(:id) .pluck(:addressable_id, :city, :state) .each_with_object({}) do |(org_id, city, state), first_label| @@ -686,43 +832,30 @@ def org_ids_by_city_label def person_program_status_ids(status) status_sym = status.to_sym org_ids = Organization - .where(id: EventRegistrationOrganization.where(event_registration_id: attended_training_registrations.select(:id)).select(:organization_id)) + .where(id: EventRegistrationOrganization.where(event_registration_id: attendee_registrations.select(:id)).select(:organization_id)) + .includes(:affiliations) .select { |organization| organization.facilitator_status_on(Date.current) == status_sym } .map(&:id) return Person.none if org_ids.empty? person_linked_organization_ids(org_ids) end - def training_scholarship_recipient_ids + def scholarship_recipient_person_ids EventRegistration - .where(id: Allocation.where(source_type: "Scholarship", allocatable_type: "EventRegistration", allocatable_id: attended_training_registrations.select(:id)).select(:allocatable_id)) + .where(id: Allocation.where(source_type: "Scholarship", allocatable_type: "EventRegistration", allocatable_id: attendee_registrations.select(:id)).select(:allocatable_id)) .select(:registrant_id) end - def training_ce_person_ids + def ce_person_ids EventRegistration - .where(id: ContinuingEducationRegistration.where(event_registration_id: attended_training_registrations.select(:id)).select(:event_registration_id)) + .where(id: ContinuingEducationRegistration.where(event_registration_id: attendee_registrations.select(:id)).select(:event_registration_id)) .select(:registrant_id) end # Person ids with at least one affiliation in the given status (Active / Pending - # / Inactive), matching TrainingAttendeesRoster#affiliation_status. + # / Inactive). def person_affiliation_status_ids(status) - today = Date.current - scope = - case status - when "Active" - Affiliation.where(inactive: false) - .where("affiliations.start_date IS NULL OR affiliations.start_date <= ?", today) - .where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", today) - when "Pending" - Affiliation.where(inactive: false).where("affiliations.start_date > ?", today) - when "Inactive" - Affiliation.where("affiliations.inactive = ? OR affiliations.end_date < ?", true, today) - else - return Person.none - end - scope.select(:person_id) + Affiliation.with_status(status).select(:person_id) end def person_address_ids(state: nil, county: nil) @@ -732,16 +865,17 @@ def person_address_ids(state: nil, county: nil) scope.select(:addressable_id) end - # Option lists for the training-attendees filter selects, built once per full - # page load from the whole attended-training population. - def set_training_attendee_filter_options - @training_events = Event.facilitator_trainings.order(start_date: :desc) - @training_years = @training_events.filter_map { |event| event.start_date&.year }.uniq.sort.reverse - attended_person_ids = Person.where(id: EventRegistration.attended.joins(:event).where(events: { facilitator_training: true }).select(:registrant_id)) - @training_sectors = Sector.where(id: SectorableItem.where(sectorable_type: "Person", sectorable_id: attended_person_ids).select(:sector_id)).order(:name) - addresses = Address.active.where(addressable_type: "Person", addressable_id: attended_person_ids) - @training_states = addresses.where.not(state: [ nil, "" ]).distinct.pluck(:state).sort - @training_counties = addresses.where.not(county: [ nil, "" ]).where.not(state: [ nil, "" ]).distinct.pluck(:state, :county).sort + # Option lists for the attendees index filter selects, built once per full page + # load from the viewer's WHOLE reportable population — deliberately un-narrowed by + # the active filters, so filtering never removes the option that would undo it. + def set_attendee_filter_options + @attendee_event_options = reportable_events.order(start_date: :desc) + @attendee_years = @attendee_event_options.filter_map { |event| event.start_date&.year }.uniq.sort.reverse + person_ids = Person.where(id: EventRegistration.where(event_id: reportable_events.select(:id)).select(:registrant_id)) + @attendee_sectors = Sector.where(id: SectorableItem.where(sectorable_type: "Person", sectorable_id: person_ids).select(:sector_id)).order(:name) + addresses = Address.active.where(addressable_type: "Person", addressable_id: person_ids) + @attendee_states = addresses.where.not(state: [ nil, "" ]).distinct.pluck(:state).sort + @attendee_counties = addresses.where.not(county: [ nil, "" ]).where.not(state: [ nil, "" ]).distinct.pluck(:state, :county).sort end # The registrations the admin checked on the recipient picker, narrowed to those diff --git a/app/controllers/scholarships_controller.rb b/app/controllers/scholarships_controller.rb index 66f7680d5a..2ce6a2fd52 100644 --- a/app/controllers/scholarships_controller.rb +++ b/app/controllers/scholarships_controller.rb @@ -221,7 +221,7 @@ def scholarship_save_path # Return to the recipients roster, scrolling back to the participant card the # Edit link was opened from (its slug rides along in the participant param). def recipients_return_path(event) - recipients_event_path(event, anchor: ("participant-#{params[:participant]}" if params[:participant].present?)) + helpers.recipients_event_card_path(event, params[:participant]) end # After destroying, leave the scholarship entirely: back to the grant when that diff --git a/app/decorators/event_registration_decorator.rb b/app/decorators/event_registration_decorator.rb index 0b1ee28b90..b26e2b8977 100644 --- a/app/decorators/event_registration_decorator.rb +++ b/app/decorators/event_registration_decorator.rb @@ -83,6 +83,36 @@ def self.payment_method_filter_choices [ [ "#{BUDDY_PAYMENT_BADGE.label} (#{BUDDY_PAYMENT_BADGE.code})", EventRegistration::BUDDY_PAYMENT_FILTER ] ] end + # Attendance-status pill styling — the single source of truth shared by the + # editable badge on the registrants table and the read-only pill on the roster. + ATTENDANCE_STATUS_CLASSES = { + "registered" => "bg-blue-50 text-blue-700 border-blue-200", + "attended" => "bg-green-50 text-green-700 border-green-200", + "incomplete_attendance" => "bg-amber-50 text-amber-700 border-amber-200", + "cancelled" => "bg-gray-50 text-gray-500 border-gray-200", + "no_show" => "bg-red-50 text-red-700 border-red-200", + "transferred_in" => "bg-teal-50 text-teal-700 border-teal-200", + "transferred_out" => "bg-purple-50 text-purple-700 border-purple-200" + }.freeze + + ATTENDANCE_STATUS_ICONS = { + "registered" => "fa-clipboard-list", + "attended" => "fa-circle-check", + "incomplete_attendance" => "fa-clock", + "cancelled" => "fa-ban", + "no_show" => "fa-circle-xmark", + "transferred_in" => "fa-right-to-bracket", + "transferred_out" => "fa-right-from-bracket" + }.freeze + + def attendance_status_classes + ATTENDANCE_STATUS_CLASSES.fetch(status, "bg-gray-50 text-gray-500 border-gray-200") + end + + def attendance_status_icon + ATTENDANCE_STATUS_ICONS.fetch(status, "fa-question") + end + def title name end diff --git a/app/decorators/organization_decorator.rb b/app/decorators/organization_decorator.rb index 6d763915e9..b7c513a172 100644 --- a/app/decorators/organization_decorator.rb +++ b/app/decorators/organization_decorator.rb @@ -102,17 +102,12 @@ def facilitation_end_date # In-memory program status (:new / :ongoing / :reinstated) for this org as it # stood on a given date — the same New/Ongoing/Reinstate classification used in - # event context (Organization#facilitator_status_on), computed from the - # already-loaded affiliations so a profile can classify many events without an - # N+1. No facilitator affiliation starting before the date => :new; an earlier - # one still active on the date => :ongoing; all earlier ones ended => :reinstated. + # event context. Delegates to Organization#facilitator_status_on (single source + # of truth), which reads the already-loaded affiliations so a profile can + # classify many events without an N+1. `date` may be a datetime (event.start_date + # is one), so normalize to a Date before the model's date comparisons. def facilitator_status_as_of(date) - reference = date&.to_date || Date.current - earlier = affiliations.select { |affiliation| affiliation.facilitator? && affiliation.start_date.present? && affiliation.start_date.to_date < reference } - return :new if earlier.empty? - - active = earlier.any? { |affiliation| affiliation.end_date.nil? || affiliation.end_date.to_date >= reference } - active ? :ongoing : :reinstated + object.facilitator_status_on(date&.to_date) end def badges diff --git a/app/frontend/javascript/controllers/panel_toggle_controller.js b/app/frontend/javascript/controllers/panel_toggle_controller.js index 6f07a656f5..57668dff4f 100644 --- a/app/frontend/javascript/controllers/panel_toggle_controller.js +++ b/app/frontend/javascript/controllers/panel_toggle_controller.js @@ -1,27 +1,59 @@ import { Controller } from "@hotwired/stimulus" // Connects to data-controller="panel-toggle" -// Independent show/hide toggles: each button controls the panel at the same -// index (DOM order) in panelTargets, swapping its own label and aria-expanded. -// Revealing a hidden panel that holds a lazy Turbo frame lets the frame load on -// first show, so charts stay off the initial request until the admin asks. +// Show/hide named panels. Each button and panel carries data-panel-toggle-name; +// a button toggles every panel sharing its name, and every button controlling +// that panel is kept in sync — its label and its aria-expanded. This lets one +// panel have several controls (a top toggle bar plus the Show/Hide button beside +// the section heading) that never disagree. +// Revealing a hidden panel that holds a lazy Turbo frame loads the frame on first +// show, so charts stay off the initial request until asked for. export default class extends Controller { static targets = ["button", "panel", "label"] + connect() { + // Reconcile every control with its panel's actual visibility on load, so a + // section's toggle and the top bar can't disagree (and it self-heals after a + // Turbo restore or frame reload). + this.panelNames().forEach(name => this.sync(name, this.panelShown(name))) + } + toggle(event) { - const button = event.currentTarget - const index = this.buttonTargets.indexOf(button) - const panel = this.panelTargets[index] - if (!panel) return - - const hidden = panel.classList.toggle("hidden") - button.setAttribute("aria-expanded", String(!hidden)) - - const label = this.labelTargets[index] - if (label) { - label.textContent = hidden - ? button.dataset.panelToggleHiddenLabel - : button.dataset.panelToggleShownLabel - } + const name = event.currentTarget.dataset.panelToggleName + if (this.panelsFor(name).length === 0) return + + const shown = !this.panelShown(name) + this.panelsFor(name).forEach(panel => panel.classList.toggle("hidden", !shown)) + this.sync(name, shown) + } + + // Bring every button that controls the named panel into line with its state. + sync(name, shown) { + this.buttonsFor(name).forEach(button => { + button.setAttribute("aria-expanded", String(shown)) + const label = this.labelTargets.find(target => button.contains(target)) + if (label) { + label.textContent = shown + ? button.dataset.panelToggleShownLabel + : button.dataset.panelToggleHiddenLabel + } + }) + } + + panelsFor(name) { + return this.panelTargets.filter(panel => panel.dataset.panelToggleName === name) + } + + buttonsFor(name) { + return this.buttonTargets.filter(button => button.dataset.panelToggleName === name) + } + + panelShown(name) { + const panel = this.panelsFor(name)[0] + return panel ? !panel.classList.contains("hidden") : false + } + + panelNames() { + return [ ...new Set(this.panelTargets.map(panel => panel.dataset.panelToggleName)) ] } } diff --git a/app/helpers/admin_cards_helper.rb b/app/helpers/admin_cards_helper.rb index c8de982e6f..b400c2c71b 100644 --- a/app/helpers/admin_cards_helper.rb +++ b/app/helpers/admin_cards_helper.rb @@ -29,7 +29,7 @@ def user_content_cards custom_card("Portal activity", admin_activities_counts_path, icon: "📊"), custom_card("Bookmarks tally", tally_bookmarks_path, icon: "🔖"), model_card(:notifications, icon: "🔔", title: t("communications.title")), - custom_card("Events statistics", statistics_events_path, icon: "📊", color: :blue), + custom_card("Event reports", reports_events_path, icon: "📊", color: :blue), model_card(:scholarships, icon: "🎓"), model_card(:topic_subscriptions, icon: "✉️", intensity: 100, title: "Subscriptions"), model_card(:story_ideas, icon: "✍🏾️", intensity: 100), diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index fc7d35e476..34a151358b 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -25,6 +25,35 @@ def registrants_event_row_path(event_or_id, registration_id) registrants_event_path(event_or_id, anchor: registrant_row_id(registration_id), highlight: registration_id) end + # Path back to one recipient's card on the Scholarship recipients page, where the + # cards are anchored by registration slug (`participant-`, with a scroll-mt + # so the sticky header doesn't cover them). Shared by every page reached from + # there — the registration and scholarship edit pages and their post-save + # redirects — so the anchor is built once. A blank slug lands at the top. + def recipients_event_card_path(event, participant_slug) + recipients_event_path(event, anchor: ("participant-#{participant_slug}" if participant_slug.present?)) + end + + # Where a revenue figure drills in. The money lives on registrations, so these + # land on a registrations table with payment columns rather than the attendees + # people-index — matching the per-registrant breakdown rows lower down the same + # report. Scoped to one event that's its Manage list; across events it's the + # registrations index, which takes the same filters. + def revenue_drilldown_path(filters) + event_id = params[:event_id].presence + return registrants_event_path(event_id, **filters.except(:event_year)) if event_id + event_registrations_path(**filters) + end + + # Human label for the attendees index's population filters — e.g. + # "Attended · All trainings". Shown in the page subtitle so the defaults the page + # applies are visible rather than implied. + def attendee_population_label(attendance_status, event_type) + outcome = EventRegistration::ATTENDANCE_FILTER_OPTIONS.rassoc(attendance_status)&.first || "All outcomes" + type = EventRegistration::EVENT_TYPE_FILTER_OPTIONS.rassoc(event_type)&.first || "All events" + "#{outcome} · #{type}" + end + # The scholarships report's filter/toggle state, carried through a drill-in so # its eyebrow can rebuild the exact view (period, event type/id, search, funder, # split/combined layout, and the report's own origin) the user came from. @@ -71,15 +100,6 @@ def scholarships_report_return_path anchor: params[:return_anchor].presence) end - # Stamp a registrants-page link reached from the background dashboard with the - # context its eyebrow needs to send the user back to the exact section they - # drilled in from: return_to marks the origin page, return_anchor the section id - # (matching that section's `id`/`scroll-mt-*` on the background page). - def background_return_path(path, anchor) - separator = path.include?("?") ? "&" : "?" - "#{path}#{separator}#{{ return_to: "background", return_anchor: anchor }.to_query}" - end - # Ordered column descriptors for the event Onboarding matrix. The array index # is the table-sort column index, so the header row and every body row iterate # this same list — keeping header buttons and cell positions aligned no matter @@ -140,37 +160,37 @@ def onboarding_toggle_entries(event) end end - # The statistics hub and the full revenue/participation reports share the + # The reports hub and the full revenue/participation reports share the # event-type and specific-event filters directly, but express the time window # differently: the hub's `period` select offers this_year/last_year/all_time, # while the reports use `time_period` where a specific window is the calendar # year "YYYY". These helpers translate a page's active filters into the query # params for its cross-link so filters carry across — and back — between the - # summaries' "Full report" links and the reports' "Events statistics" eyebrow. + # summaries' "Full report" links and the reports' "← Reports" eyebrow. - # Statistics hub filters → full report query params. - def statistics_to_report_params + # Reports hub filters → full report query params. + def hub_to_report_params { return_to: params[:return_to], event_type: params[:event_type].presence, event_id: params[:event_id].presence, - time_period: statistics_period_to_time_period(@period) + time_period: hub_period_to_time_period(@period) }.compact end - # Full report filters → statistics hub query params. - def report_to_statistics_params + # Full report filters → reports hub query params. + def report_to_hub_params { return_to: params[:return_to], event_type: params[:event_type].presence, event_id: params[:event_id].presence, - period: time_period_to_statistics_period(@time_period) + period: time_period_to_hub_period(@time_period) }.compact end # "last_year" becomes the prior calendar year (reports name specific years # "YYYY"); this_year/all_time pass through unchanged. - def statistics_period_to_time_period(period) + def hub_period_to_time_period(period) return (Date.current.year - 1).to_s if period == "last_year" period end @@ -178,7 +198,7 @@ def statistics_period_to_time_period(period) # Inverse: the prior year maps back to "last_year", the current year to # "this_year"; any other specific year has no hub equivalent, so fall back to # "all_time". - def time_period_to_statistics_period(time_period) + def time_period_to_hub_period(time_period) case time_period when "this_year", "all_time" then time_period when (Date.current.year - 1).to_s then "last_year" diff --git a/app/models/address.rb b/app/models/address.rb index 22fed57341..f0100d65c8 100644 --- a/app/models/address.rb +++ b/app/models/address.rb @@ -2,6 +2,14 @@ class Address < ApplicationRecord LOCALITIES = [ "LA City", "LA County", "Southern CA", "Northern CA", "Central CA", "Orange County", "Outside CA", "Outside USA", "Unknown" ] CONTACT_TYPES = [ nil, "work", "personal", "mailing", "unknown" ].freeze + # USPS abbreviations for the 50 states, DC, and the US territories the atlas + # draws — the whitelist behind every "States" breakdown, so international + # regions (e.g. "ON", "England") are excluded (they belong to the Countries map). + US_STATE_ABBREVIATIONS = %w[ + AL AK AZ AR CA CO CT DE DC FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO + MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY + PR GU VI AS MP + ].freeze belongs_to :addressable, polymorphic: true, touch: true # Affiliations that point to this address as their organization address. Nullify diff --git a/app/models/affiliation.rb b/app/models/affiliation.rb index b35069b5de..b2306ade31 100644 --- a/app/models/affiliation.rb +++ b/app/models/affiliation.rb @@ -4,6 +4,14 @@ class Affiliation < ApplicationRecord # (both treat exactly "Facilitator" as canonical). FACILITATOR_TITLE = "Facilitator".freeze + # Status taxonomy shown as a chip on each person's row, in display order. + STATUSES = %w[ Active Upcoming Inactive ].freeze + # Filter-only value combining the two current-or-future statuses — never a chip, + # since one affiliation is only ever Active or Upcoming, not both. + ACTIVE_OR_UPCOMING = "Active & Upcoming".freeze + # Options offered by the attendees index's Affiliation status filter. + FILTER_STATUSES = [ "Active", "Upcoming", ACTIVE_OR_UPCOMING, "Inactive" ].freeze + belongs_to :organization, inverse_of: :affiliations belongs_to :person, touch: true # Which of the organization's addresses this person is affiliated with (optional). @@ -39,6 +47,29 @@ class Affiliation < ApplicationRecord # TRIM mirrors the in-memory #facilitator? strip so stray whitespace still matches. scope :facilitators, -> { where("BINARY TRIM(title) = ?", "Facilitator") } + # Affiliations whose #status_on(date) equals the given status, expressed in SQL + # so it composes as a subquery (e.g. person-id narrowing). Kept in lock-step with + # #status_on by an executable agreement spec. + scope :with_status, ->(status, on: Date.current) { + case status + when "Active" + where(inactive: false) + .where("affiliations.start_date IS NULL OR affiliations.start_date <= ?", on) + .where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", on) + when "Upcoming" + where(inactive: false).where("affiliations.start_date > ?", on) + when ACTIVE_OR_UPCOMING + # Either of the above: not flagged inactive and not ended. A row that ended + # before `on` is Inactive whatever its start date, so this needs no start + # clause — it matches exactly the rows #status_on calls Active or Upcoming. + where(inactive: false).where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", on) + when "Inactive" + where("affiliations.inactive = ? OR affiliations.end_date < ?", true, on) + else + none + end + } + before_validation :skip_if_duplicate before_save :set_inactive_from_dates after_save :sync_organization_status_with_affiliations @@ -62,6 +93,14 @@ def active? !inactive? && (end_date.nil? || end_date >= Date.current) end + # This affiliation's status as of a date: Inactive (flagged or ended), Upcoming + # (future start), otherwise Active. The in-memory twin of the .with_status scope. + def status_on(date = Date.current) + return "Inactive" if inactive? || (end_date && end_date < date) + return "Upcoming" if start_date && start_date > date + "Active" + end + def name "#{person.name}" if person end diff --git a/app/models/event.rb b/app/models/event.rb index ae277f908b..0b158d5e9d 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -94,6 +94,9 @@ def remote_search_label # Events flagged as facilitator trainings (the "TAC" a scholarship recipient # attends). Drives the scholarship index's training column. scope :facilitator_trainings, -> { where(facilitator_training: true) } + # Delivery format: self-paced ("On-demand") vs scheduled instructor-led ("Live"). + scope :on_demand, -> { where(on_demand: true) } + scope :live, -> { where(on_demand: false) } # start_date is a date column, so compare against a date — a Time would be cast # to midnight and drop events starting today. scope :upcoming, -> { where("start_date >= ?", Date.current) } diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 57c1484ff2..c59c737d5e 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -36,6 +36,25 @@ class EventRegistration < ApplicationRecord # Attendance outcomes surfaced as their own participation buckets; every other # status falls into "other" (registered, transfers, cancellations). NAMED_OUTCOME_STATUSES = %w[ attended incomplete_attendance no_show ].freeze + # Sentinel filter value meaning "don't narrow on this dimension" — for a filter + # that defaults to something specific (the attendees index defaults to attended + # registrations on trainings, so it needs a way to say "all of them"). + FILTER_ALL = "all".freeze + # Attendance-outcome filter options, shared by the registrations index and the + # attendees index so the vocabulary can't drift between them. + ATTENDANCE_FILTER_OPTIONS = ( + ATTENDANCE_STATUSES.map { |status| [ status.humanize, status ] } + + [ [ "Other (registered, transfers, cancellations)", "other" ] ] + ).freeze + # Event-type filter options, matching the .event_type scope's vocabulary and the + # report suite's Event type select (events/_event_type_filter), so the same value + # means the same thing on a report and on the attendees index it drills into. + EVENT_TYPE_FILTER_OPTIONS = [ + [ "All trainings", "trainings" ], + [ "Live trainings", "live" ], + [ "On-demand trainings", "on_demand" ], + [ "Other events", "other" ] + ].freeze # Human labels for each attendance status — the single source of truth for # status display (badges, filters, the dashboard breakdown). @@ -82,11 +101,14 @@ class EventRegistration < ApplicationRecord scope :attendance_status, ->(status) { status == "other" ? where.not(status: NAMED_OUTCOME_STATUSES) : where(status: status) } - # Registrations on facilitator-training events ("trainings") vs everything else - # ("other"); any other value is a no-op so "all events" passes through. + # Registrations on facilitator-training events ("trainings", narrowable to the + # "live"/"on_demand" delivery formats) vs everything else ("other"); any other + # value is a no-op so "all events" passes through. scope :event_type, ->(type) { case type when "trainings" then joins(:event).where(events: { facilitator_training: true }) + when "live" then joins(:event).where(events: { facilitator_training: true, on_demand: false }) + when "on_demand" then joins(:event).where(events: { facilitator_training: true, on_demand: true }) when "other" then joins(:event).where(events: { facilitator_training: false }) else all end @@ -145,32 +167,19 @@ class EventRegistration < ApplicationRecord else all end } - scope :with_funded_scholarship, -> { - where(<<~SQL.squish) - 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.grant_id IS NOT NULL - ) - SQL - } - scope :with_unfunded_scholarship, -> { - where(<<~SQL.squish) - 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.grant_id IS NULL - ) - SQL - } - # Funding source of a registrant's scholarship. "awbw" = org-subsidized (a - # scholarship drawn from no grant); "external" = grant-funded (drawn from an + # Funded vs org-subsidized follow the app-wide split (Scholarship.externally_funded + # / .org_subsidized): a grant AWBW funded itself counts as subsidy, not + # external funding, so it lands in the unfunded set alongside grant-less awards. + scope :with_funded_scholarship, -> { where(id: scholarship_allocatable_ids(Scholarship.externally_funded)) } + scope :with_unfunded_scholarship, -> { where(id: scholarship_allocatable_ids(Scholarship.org_subsidized)) } + # EventRegistration ids that a scholarship in the given relation is allocated to. + def self.scholarship_allocatable_ids(scholarships) + Allocation + .where(allocatable_type: "EventRegistration", source_type: "Scholarship", source_id: scholarships.select(:id)) + .select(:allocatable_id) + end + # Funding source of a registrant's scholarship. "awbw" = org-subsidized (no + # grant, or a grant AWBW funded itself); "external" = grant-funded (drawn from an # external funder's grant). scope :funder, ->(value) { case value @@ -320,6 +329,18 @@ class EventRegistration < ApplicationRecord .distinct } + # { event_id => { status => count } } from a single grouped query, so callers + # never round-trip per event. The single-event dashboard reads its own event's + # slice; the participation report reads every event's at once. + def self.status_counts_by_event(event_ids) + where(event_id: event_ids) + .group(:event_id, :status) + .count + .each_with_object({}) do |((event_id, status), count), map| + (map[event_id] ||= {})[status] = count + end + end + def self.search_by_params(params) registrations = is_a?(ActiveRecord::Relation) ? self : all if params[:registrant_id].present? diff --git a/app/models/grant.rb b/app/models/grant.rb index 6058f40ae0..77f7f65352 100644 --- a/app/models/grant.rb +++ b/app/models/grant.rb @@ -14,6 +14,13 @@ class Grant < ApplicationRecord scope :by_deadline, -> { order(Arel.sql("funds_allocation_deadline IS NULL, funds_allocation_deadline ASC")) } + # Ids of grants the org (AWBW) funded itself — treated as org subsidy, not + # external funding, by the scholarship funding split. Empty when the AWBW org + # isn't on file, collapsing the split back to grant-present vs grant-absent. + def self.self_funded_ids + where(funder: Organization.awbw).ids + end + # Total scholarship draws against a grant, as a correlated subquery. Used by the # funds scopes so they stay flat WHERE clauses — no GROUP BY/HAVING, which would # break will_paginate's total_entries count on the paginated index. diff --git a/app/models/organization.rb b/app/models/organization.rb index 819ea5bb01..aa9a3263cb 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -128,6 +128,10 @@ def affiliated_workshop_logs direct.or(legacy).distinct end + # Facilitator program statuses in display order — the values #facilitator_status + # and #facilitator_status_on return, and the attendees index filters on. + FACILITATOR_PROGRAM_STATUSES = %i[ new ongoing reinstated ].freeze + # Classifies this organization as a facilitator program relative to a reference # ("current") facilitator affiliation — typically a registrant's affiliation # captured through the event registration form: @@ -151,15 +155,18 @@ def facilitator_status(current_affiliation) def facilitator_status_on(reference_date, excluding_affiliation_id: nil) reference_start = reference_date || Date.current - earlier = affiliations.facilitators - .where.not(start_date: nil) - .where("affiliations.start_date < ?", reference_start) - earlier = earlier.where.not(id: excluding_affiliation_id) if excluding_affiliation_id + # Filter the (often preloaded) affiliations in Ruby rather than firing a query + # per org — the event dashboard classifies every represented org this way. + earlier = affiliations.select do |affiliation| + affiliation.facilitator? && + affiliation.start_date && affiliation.start_date < reference_start && + affiliation.id != excluding_affiliation_id + end - return :new unless earlier.exists? + return :new if earlier.empty? - active_overlap = earlier.where("affiliations.end_date IS NULL OR affiliations.end_date >= ?", reference_start) - active_overlap.exists? ? :ongoing : :reinstated + active_overlap = earlier.any? { |affiliation| affiliation.end_date.nil? || affiliation.end_date >= reference_start } + active_overlap ? :ongoing : :reinstated end # Methods diff --git a/app/models/scholarship.rb b/app/models/scholarship.rb index 3fcedb9718..a21ed1b4b9 100644 --- a/app/models/scholarship.rb +++ b/app/models/scholarship.rb @@ -19,6 +19,14 @@ class Scholarship < ApplicationRecord scope :completed, -> { where(tasks_completed: true) } scope :agreement_signed, -> { where.not(agreement_signed_at: nil) } + # Funding split (the app-wide convention, mirrored by EventDashboard and + # EventRevenueFigures): externally funded = backed by a grant whose funder isn't + # the org itself; org-subsidized = no grant, or a grant AWBW funded itself. + # Callers rendering both sides can pass an already-loaded self_funded set to + # avoid re-running Grant.self_funded_ids (an Organization.awbw + pluck) per scope. + scope :externally_funded, ->(self_funded = Grant.self_funded_ids) { where.not(grant_id: [ nil, *self_funded ]) } + scope :org_subsidized, ->(self_funded = Grant.self_funded_ids) { where(grant_id: [ nil, *self_funded ]) } + # Scholarships from grants a given funder (Person/Organization) gave — the # "funder" filter. A blank funder matches nothing. scope :from_funder, ->(funder) { where(grant_id: Grant.where(funder: funder).select(:id)) } diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index decc78ab42..22f2730cd3 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -13,35 +13,32 @@ def search? admin? end - # The cross-event revenue report aggregates money across every event, so it's - # admin-only. - def revenue? - admin? - end - - # The cross-event participation report aggregates attendance across every - # event, so it's admin-only like the revenue report. - def participation? - admin? - end - - # The events statistics hub gathers the cross-event report summaries, so it's - # admin-only like the reports it links to. - def statistics? - admin? - end - - # The scholarship report aggregates scholarship money and award counts across - # every training, so it's admin-only like the revenue report. - def scholarships? - admin? + # The cross-event report suite (revenue, participation, scholarships, the + # reports hub, and the attendees index). Admins see the whole org; an event + # owner sees the same pages narrowed to their own events by the :reportable + # relation scope, so the unfiltered view needs no gate beyond "has events to + # report on" — the rows, not the page, carry the authorization. + def cross_event_reports? + admin? || owns_events? + end + + # A single-event slice of those reports — reached from the per-event Reports and + # Roster tabs, which pass event_id — is visible to that event's owner too. The + # controller resolves event_id to the Event and authorizes against it here, so + # owner? has a real record to check. + def event_reports? + admin? || owner? end - # The cross-event index of everyone who has attended a facilitator training - # aggregates registrants across every event, so it's admin-only like the - # participation report it's reached from. - def training_attendees? - admin? + # Rows the report suite may show: every event for an admin, only their own for + # anyone else. Applied as a scope rather than trusting the event_id filter, so + # no combination of filter params (including an `event_id[]` array, which + # `find_by` collapses to one record while a raw `where` would not) can widen a + # report past what the viewer is allowed to see. + relation_scope(:reportable) do |relation| + next relation if admin? + next relation.none unless authenticated? + relation.where(created_by_id: user.id) end def show? @@ -90,11 +87,13 @@ def registrants? manage? end - def dashboard? - admin? || owner? + # The per-event roster (active registrants + demographics) is owner-visible like + # the rest of the event's management pages. + def roster? + manage? end - def background? + def dashboard? admin? || owner? end @@ -205,6 +204,13 @@ def owner? record.created_by == user end + # Whether there is anything for this user to report on at all — the gate on the + # unfiltered report pages, so a user who owns no events doesn't land on a suite + # of empty reports. + def owns_events? + authenticated? && Event.exists?(created_by_id: user.id) + end + relation_scope do |relation| next relation if admin? diff --git a/app/services/attendees_active_filters.rb b/app/services/attendees_active_filters.rb new file mode 100644 index 0000000000..3c3fe9a8f8 --- /dev/null +++ b/app/services/attendees_active_filters.rb @@ -0,0 +1,59 @@ +# Human-readable descriptors for the attendees index's "drill-in" filters — the +# params that narrow the roster but have no field in the visible filter form +# (they arrive from a chart-row click, the participation summary, or a shared +# link). The index surfaces each as a removable chip so an admin can see and +# clear a filter that would otherwise silently shrink the list. Params that +# already have their own visible control (contact_info, event, sector, program +# status, affiliation status, state, county) are intentionally omitted — the +# control itself already shows them. +class AttendeesActiveFilters + # Drill-in params in chip display order. Each renders via #label_for. + CHIP_PARAMS = %w[ + registrant_ids organization_id org_city age_group life_experience setting + country school_district scholarship ce + ].freeze + # The roster has no filter form at all, so every drill-in it accepts needs a + # chip — including the two the index leaves out because it has controls for them. + ROSTER_CHIP_PARAMS = %w[ registrant_ids sector state ].freeze + + def initialize(params, chip_params: CHIP_PARAMS) + @params = params + @chip_params = chip_params + end + + # [ { param: "age_group", label: "Age group: Teens (13-17)" }, ... ] in + # CHIP_PARAMS order; empty when no drill-in filter is applied. A param whose + # referenced record no longer exists is dropped rather than shown label-less. + def chips + @chip_params.filter_map do |param| + value = @params[param] + next if value.blank? + label = label_for(param, value) + { param: param, label: label } if label.present? + end + end + + private + + def label_for(param, value) + case param + when "registrant_ids" then "#{value.to_s.split("-").size} selected people" + when "sector" then record_label("Sector", Sector, value) + when "state" then "State: #{value}" + when "organization_id" then record_label("Organization", Organization, value) + when "org_city" then "Org city: #{value}" + when "age_group" then record_label("Age group", Category, value) + when "life_experience" then record_label("Life experience", Category, value) + when "setting" then record_label("Setting", Category, value) + when "country" then "Country: #{value}" + when "school_district" then "School district: #{value}" + when "scholarship" then value == "no" ? "No scholarship" : "Scholarship recipients" + when "ce" then value == "no" ? "No continuing education" : "Continuing education" + end + end + + def record_label(prefix, model, id) + name = model.where(id: id).pick(:name) + "#{prefix}: #{name}" if name + end +end diff --git a/app/services/training_attendees_breakdowns.rb b/app/services/attendees_breakdowns.rb similarity index 59% rename from app/services/training_attendees_breakdowns.rb rename to app/services/attendees_breakdowns.rb index 4b36f5f849..7107e18ff7 100644 --- a/app/services/training_attendees_breakdowns.rb +++ b/app/services/attendees_breakdowns.rb @@ -5,17 +5,18 @@ # # Sourced from each person's profile (sectors, age groups, categories, addresses) # and from the orgs/scholarships/CE tied to their attended-training registrations. -class TrainingAttendeesBreakdowns - # US states/territories the atlas draws — international regions are excluded from - # the States breakdown (they belong to Countries), mirroring EventDashboard. - US_STATE_ABBREVIATIONS = %w[ - AL AK AZ AR CA CO CT DE DC FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO - MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY - PR GU VI AS MP - ].freeze - - def initialize(people) +class AttendeesBreakdowns + # events: the events whose registrations may be counted (for the attendees index, + # the viewer's reportable events narrowed by the active filters), so the + # org/scholarship/CE breakdowns never draw on an event the viewer isn't allowed to + # see — or one outside the current filter. + # registrations: the registration scope those breakdowns draw from — attended + # registrations by default, or any active ones when a caller (e.g. the recipients + # charts) wants a single event's people regardless of attendance. + def initialize(people, events: Event.all, registrations: EventRegistration.attended) @people = people + @events = events + @registrations = registrations end def registrant_count @@ -61,27 +62,15 @@ def all_age_group_counts # --- Locations ------------------------------------------------------------- def state_counts - @state_counts ||= active_addresses - .where("UPPER(addresses.state) IN (?)", US_STATE_ABBREVIATIONS) - .group(:state) - .distinct - .count(:addressable_id) + @state_counts ||= state_registrant_ids_by_state.transform_values(&:size) end def country_counts - @country_counts ||= active_addresses - .where.not(country: [ nil, "" ]) - .group(:country) - .distinct - .count(:addressable_id) + @country_counts ||= country_registrant_ids_by_country.transform_values(&:size) end def school_district_counts - @school_district_counts ||= active_addresses - .where.not(district: [ nil, "" ]) - .group(:district) - .distinct - .count(:addressable_id) + @school_district_counts ||= school_district_registrant_ids_by_district.transform_values(&:size) end # --- Profile categories (life experiences / settings) ---------------------- @@ -105,7 +94,9 @@ def settings_counts # --- Organizations + program status ---------------------------------------- def organizations - @organizations ||= Organization.where(id: org_registrant_pairs.map(&:first).uniq).order(:name) + # Preload affiliations: program-status classification (facilitator_status_on) + # reads the loaded association rather than re-querying per org. + @organizations ||= Organization.where(id: org_registrant_pairs.map(&:first).uniq).includes(:affiliations).order(:name) end def organization_counts @@ -137,7 +128,7 @@ def scholarship_recipient_count def ce_registrant_ids @ce_registrant_ids ||= ContinuingEducationRegistration .joins(:event_registration) - .where(event_registration_id: training_registration_ids) + .where(event_registration_id: registration_ids) .distinct .pluck(Arel.sql("event_registrations.registrant_id")) end @@ -155,6 +146,76 @@ def registrant_city_breakdown ) end + # --- Person ids per breakdown row ------------------------------------------ + # Mirrors EventDashboard's `*_registrant_ids_by_*` names so the shared breakdown + # partial can build a drill-in from either source. Every one of these regroups + # rows already loaded for the counts — no extra queries. + + def registrant_ids + person_ids + end + + def primary_sector_registrant_ids_by_sector + @primary_sector_registrant_ids_by_sector ||= person_ids_by(sector_rows.select { |_, _, primary| primary }) { |row| row[1] } + end + + def sector_registrant_ids_by_sector + @sector_registrant_ids_by_sector ||= person_ids_by(sector_rows) { |row| row[1] } + end + + def age_group_registrant_ids_by_category + @age_group_registrant_ids_by_category ||= person_ids_by(age_rows.select { |_, _, primary| primary }) { |row| row[1] } + end + + def all_age_group_registrant_ids_by_category + @all_age_group_registrant_ids_by_category ||= person_ids_by(age_rows) { |row| row[1] } + end + + def life_experience_registrant_ids_by_category + @life_experience_registrant_ids_by_category ||= person_ids_by(category_rows("StoryPopulation")) { |row| row[1] } + end + + def settings_registrant_ids_by_category + @settings_registrant_ids_by_category ||= person_ids_by(category_rows("WorkshopEnvironment")) { |row| row[1] } + end + + def state_registrant_ids_by_state + @state_registrant_ids_by_state ||= person_ids_by(address_rows.select { |_, state, _, _| Address::US_STATE_ABBREVIATIONS.include?(state.to_s.upcase) }) { |row| row[1] } + end + + def country_registrant_ids_by_country + @country_registrant_ids_by_country ||= person_ids_by(address_rows.reject { |_, _, country, _| country.blank? }) { |row| row[2] } + end + + def school_district_registrant_ids_by_district + @school_district_registrant_ids_by_district ||= person_ids_by(address_rows.reject { |_, _, _, district| district.blank? }) { |row| row[3] } + end + + def organization_registrant_ids_by_org + @organization_registrant_ids_by_org ||= org_registrant_pairs + .group_by(&:first) + .transform_values { |pairs| pairs.map(&:last).uniq } + end + + # { :new/:ongoing/:reinstated => person ids } — a person lands in a status if any + # of their linked orgs has it, so the buckets can overlap. + def program_status_registrant_ids + @program_status_registrant_ids ||= organization_registrant_ids_by_org + .each_with_object({ new: [], ongoing: [], reinstated: [] }) do |(org_id, ids), map| + status = program_status_by_organization[org_id] + map[status]&.concat(ids) + end + .transform_values(&:uniq) + end + + def scholarship_registrant_ids + scholarship_recipient_ids + end + + def other_sector_response_registrant_ids + other_sector_responses.map(&:owner_id).uniq + end + # --- Free-text "Other" sector responses ------------------------------------ def other_sector_response_count @@ -177,11 +238,9 @@ def person_ids @person_ids ||= @people.except(:includes, :eager_load, :preload, :order).ids end - def training_registration_ids - @training_registration_ids ||= EventRegistration - .attended - .joins(:event) - .where(events: { facilitator_training: true }, registrant_id: person_ids) + def registration_ids + @registration_ids ||= @registrations + .where(registrant_id: person_ids, event_id: @events.select(:id)) .pluck(:id) end @@ -199,12 +258,28 @@ def age_rows end def category_counts(category_type_name) - CategorizableItem + distinct_person_counts(category_rows(category_type_name)) { |row| row[1] } + end + + # [ [ person_id, category_id ], ... ] for one category type, memoized so the + # counts and the id maps share a single query. + def category_rows(category_type_name) + @category_rows ||= {} + @category_rows[category_type_name] ||= CategorizableItem .joins(category: :category_type) .where(categorizable_type: "Person", categorizable_id: person_ids, category_types: { name: category_type_name }) - .distinct - .group(:category_id) - .count(:categorizable_id) + .pluck(:categorizable_id, :category_id) + end + + # [ [ person_id, state, country, district ], ... ] — one load behind the state, + # country and district counts and their id maps. + def address_rows + @address_rows ||= active_addresses.pluck(:addressable_id, :state, :country, :district) + end + + # { key => distinct person ids }, grouping [ person_id, ... ] rows by the block. + def person_ids_by(rows) + rows.group_by { |row| yield(row) }.transform_values { |grouped| grouped.map(&:first).uniq } end def active_addresses @@ -214,7 +289,7 @@ def active_addresses def org_registrant_pairs @org_registrant_pairs ||= EventRegistrationOrganization .joins(:event_registration) - .where(event_registration_id: training_registration_ids) + .where(event_registration_id: registration_ids) .pluck(:organization_id, Arel.sql("event_registrations.registrant_id")) end @@ -243,7 +318,7 @@ def city_by_organization def scholarship_recipient_ids @scholarship_recipient_ids ||= Scholarship .joins(:allocation) - .where(allocations: { allocatable_type: "EventRegistration", allocatable_id: training_registration_ids }) + .where(allocations: { allocatable_type: "EventRegistration", allocatable_id: registration_ids }) .distinct .pluck(:recipient_id) end diff --git a/app/services/training_attendees_roster.rb b/app/services/attendees_roster.rb similarity index 72% rename from app/services/training_attendees_roster.rb rename to app/services/attendees_roster.rb index 6f45c56421..7360fe5853 100644 --- a/app/services/training_attendees_roster.rb +++ b/app/services/attendees_roster.rb @@ -3,17 +3,23 @@ # counterpart to EventDashboard, which builds the same shape for a single event. # # Constructed with the already-filtered, already-paginated page of Person records -# who have attended a facilitator training, so every map is scoped to just those +# in the current filter scope, so every map is scoped to just those # rows (no whole-history scans). Columns are sourced from each person's own # profile (sectors, age groups, affiliations, address); scholarship/CE/event -# columns come from their attended-training registrations. -class TrainingAttendeesRoster +# columns come from their in-scope registrations. +class AttendeesRoster # Affiliation status taxonomy shown in the index's Affiliation status column and # offered as a filter, in display order. - AFFILIATION_STATUSES = %w[ Active Pending Inactive ].freeze + AFFILIATION_STATUSES = Affiliation::STATUSES - def initialize(people) + # events / registrations: the event + registration scopes whose rows may be + # counted — the index's current filters — so a person's Events/scholarship/CE + # columns show what's in scope rather than their whole history, and never an + # event the viewer isn't allowed to see. + def initialize(people, events: Event.all, registrations: EventRegistration.attended) @people = people.to_a + @events = events + @registrations = registrations end attr_reader :people @@ -23,15 +29,15 @@ def registrant_count people.size end - # Each person's attended facilitator-training registrations, most recent event - # first, keyed by Person id — the source for the roster's Training column. - def training_registrations_by_registrant - @training_registrations_by_registrant ||= attended_training_registrations + # Each person's in-scope registrations, most recent event first, keyed by Person + # id — the source for the roster's Events column. + def event_registrations_by_registrant + @event_registrations_by_registrant ||= scoped_registrations .group_by(&:registrant_id) .transform_values { |registrations| registrations.sort_by { |r| r.event.start_date || Date.new(0) }.reverse } end - # The scholarship shown per person: the one from the most recent training where + # The scholarship shown per person: the one from the most recent in-scope event where # they hold a scholarship. Keyed by Person id (matches EventDashboard's shape). def scholarship_by_recipient @scholarship_by_recipient ||= scholarship_registration_by_person @@ -39,7 +45,7 @@ def scholarship_by_recipient end # [ event, participant slug ] the scholarship icon links to: the recipients page - # of the most recent training where the person holds a scholarship, anchored to + # of the most recent in-scope event where the person holds a scholarship, anchored to # their entry there. def scholarship_link_target(person) registration = scholarship_registration_by_person[person.id] @@ -47,21 +53,27 @@ def scholarship_link_target(person) [ registration.event, registration.slug ] end - # The training the person's shown scholarship comes from, keyed by Person id — - # lets the cross-event index label which training funded it (it may not be the - # top-listed training). + # The registration a person's roster row links to: their most recent in-scope + # event. Mirrors EventDashboard#registration_link_target. + def registration_link_target(person) + registrations_for(person).first + end + + # The event the person's shown scholarship comes from, keyed by Person id — lets + # the cross-event index label which event funded it (it may not be the top-listed + # one). def scholarship_event_by_registrant @scholarship_event_by_registrant ||= scholarship_registration_by_person.transform_values(&:event) end - # The CE registration shown per person: the one from their most recent training + # The CE registration shown per person: the one from their most recent in-scope event # that has continuing education. Keyed by Person id. def ce_registration_by_registrant @ce_registration_by_registrant ||= ce_source_registration_by_registrant .transform_values { |registration| ce_registration_by_event_registration[registration.id] } end - # The training the person's shown CE registration comes from, keyed by Person id. + # The event the person's shown CE registration comes from, keyed by Person id. def ce_event_by_registrant @ce_event_by_registrant ||= ce_source_registration_by_registrant.transform_values(&:event) end @@ -81,14 +93,15 @@ def primary_age_group_names_by_registrant end end - # Organizations linked on the listed people's attended-training registrations, - # name-ordered. Mirrors the roster's Organization column, aggregated across all - # of a person's trainings. + # Organizations linked on the listed people's in-scope registrations, name-ordered. + # Mirrors the roster's Organization column, aggregated across all their events. def organizations - @organizations ||= Organization.where(id: linked_org_ids_by_registrant.values.flatten.uniq).order(:name) + # Preload affiliations: program-status classification (facilitator_status_on) + # reads the loaded association rather than re-querying per org. + @organizations ||= Organization.where(id: linked_org_ids_by_registrant.values.flatten.uniq).includes(:affiliations).order(:name) end - # Linked-organization ids per person, uniqued across their training registrations. + # Linked-organization ids per person, uniqued across their in-scope registrations. def organization_ids_by_registrant linked_org_ids_by_registrant end @@ -129,11 +142,11 @@ def location_label_by_registrant private def registrations_for(person) - training_registrations_by_registrant[person.id] || [] + event_registrations_by_registrant[person.id] || [] end - # The attended-training registration whose CE record the CE icon shows, per - # person: their most recent attended training that has continuing education. + # The registration whose CE record the CE icon shows, per person: their most + # recent in-scope event that has continuing education. def ce_source_registration_by_registrant @ce_source_registration_by_registrant ||= people.each_with_object({}) do |person, map| registration = registrations_for(person).find { |r| ce_registration_by_event_registration[r.id] } @@ -141,22 +154,19 @@ def ce_source_registration_by_registrant end end - def attended_training_registrations - @attended_training_registrations ||= EventRegistration - .attended - .where(registrant_id: people.map(&:id)) - .joins(:event) - .where(events: { facilitator_training: true }) + def scoped_registrations + @scoped_registrations ||= @registrations + .where(registrant_id: people.map(&:id), event_id: @events.select(:id)) .includes(:event) .to_a end def registration_ids - @registration_ids ||= attended_training_registrations.map(&:id) + @registration_ids ||= scoped_registrations.map(&:id) end # The registration whose recipients-page entry the scholarship icon links to, - # per person: the most recent attended training where they hold a scholarship. + # per person: the most recent in-scope event where they hold a scholarship. def scholarship_registration_by_person @scholarship_registration_by_person ||= people.each_with_object({}) do |person, map| registration = registrations_for(person).find { |r| scholarship_by_registration[r.id] } @@ -165,7 +175,7 @@ def scholarship_registration_by_person end # Scholarship per funded registration id (first wins), across the page's - # attended-training registrations. + # in-scope registrations. def scholarship_by_registration @scholarship_by_registration ||= Scholarship .joins(:allocation) @@ -175,7 +185,7 @@ def scholarship_by_registration .transform_values(&:first) end - # First CE registration per attended-training registration id. + # First CE registration per in-scope registration id. def ce_registration_by_event_registration @ce_registration_by_event_registration ||= ContinuingEducationRegistration .where(event_registration_id: registration_ids) @@ -183,7 +193,7 @@ def ce_registration_by_event_registration .transform_values(&:first) end - # Organization ids linked on each person's attended-training registrations + # Organization ids linked on each person's in-scope registrations # (EventRegistrationOrganization), uniqued per person. def linked_org_ids_by_registrant @linked_org_ids_by_registrant ||= EventRegistrationOrganization @@ -195,12 +205,8 @@ def linked_org_ids_by_registrant end end - # One affiliation's status: Inactive (flagged or ended), Pending (future start), - # otherwise Active. def affiliation_status(affiliation) - return "Inactive" if affiliation.inactive? || (affiliation.end_date && affiliation.end_date < Date.current) - return "Pending" if affiliation.start_date && affiliation.start_date > Date.current - "Active" + affiliation.status_on end def program_status_by_organization diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index 571232401f..34d26e7cb0 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -47,7 +47,7 @@ def registrants # Every registration status for this event, counted in one query. def registration_status_counts - @registration_status_counts ||= event.event_registrations.group(:status).count + @registration_status_counts ||= EventRegistration.status_counts_by_event([ event.id ]).fetch(event.id, {}) end # Count of registrations in a single status. @@ -170,6 +170,13 @@ def scholarship_applicants .sort_by(&:name) end + # Person ids of this event's scholarship recipients — the lightweight id list + # behind #scholarship_applicants (no includes/sort), for scoping the recipients + # charts frame, which only needs their ids. + def scholarship_applicant_ids + @scholarship_applicant_ids ||= active_registrations.where(scholarship_requested: true).pluck(:registrant_id) + end + # Scholarship-application answers for this event's applicants, keyed by Person # id and de-duplicated to one answer per question. The scholarship section may # be captured on the registration submission (registered with scholarship @@ -247,11 +254,22 @@ def registration_id_by_registrant # The [ event, participant slug ] a registrant's scholarship icon links to: this # event's recipients page, anchored to their entry. The shared roster partial # reads this so the same column works on the cross-event training-attendees - # index (see TrainingAttendeesRoster#scholarship_link_target). + # index (see AttendeesRoster#scholarship_link_target). def scholarship_link_target(person) [ event, registration_slug_by_registrant[person.id] ] end + # The registration a registrant's roster row links to: their active registration + # for this event. The shared roster partial reads this so the same row link works + # on the cross-event attendees index (see AttendeesRoster). + def registration_link_target(person) + registration_by_registrant[person.id] + end + + def registration_by_registrant + @registration_by_registrant ||= active_registrations.index_by(&:registrant_id) + end + def scholarship_registrants @scholarship_registrants ||= people_sorted(scholarships.distinct.pluck(:recipient_id)) end @@ -442,7 +460,10 @@ def free? # registrants' affiliations that were active at the time of the event # (#reference_date). def organizations - @organizations ||= Organization.where(id: organization_ids).order(:name) + # Preload affiliations: the program-status breakdown classifies every org via + # Organization#facilitator_status_on, which reads the loaded association rather + # than re-querying per org. + @organizations ||= Organization.where(id: organization_ids).includes(:affiliations).order(:name) end def organization_count @@ -456,8 +477,8 @@ def organization_count # as the reference when present, otherwise the org's earliest facilitator # affiliation; an org with no facilitator history at all counts as :new. def program_status_counts - @program_status_counts ||= organizations.each_with_object({ new: 0, ongoing: 0, reinstated: 0 }) do |organization, counts| - counts[program_status_for(organization)] += 1 + @program_status_counts ||= program_status_by_organization.each_with_object({ new: 0, ongoing: 0, reinstated: 0 }) do |(_organization_id, status), counts| + counts[status] += 1 end end @@ -923,22 +944,13 @@ def settings_registrant_ids_by_category private - # USPS abbreviations for the 50 states, DC, and the territories the US atlas - # draws. The States breakdown only shows these, so international registrants' - # regions (e.g. "ON", "England") are excluded — those belong to the Countries map. - US_STATE_ABBREVIATIONS = %w[ - AL AK AZ AR CA CO CT DE DC FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO - MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY - PR GU VI AS MP - ].freeze - # Active registrant addresses whose state is a recognized US state/territory — # the source for every States figure (count card, choropleth, and drill-in). def us_state_addresses Address .active .where(addressable_type: "Person", addressable_id: registrant_ids) - .where("UPPER(addresses.state) IN (?)", US_STATE_ABBREVIATIONS) + .where("UPPER(addresses.state) IN (?)", Address::US_STATE_ABBREVIATIONS) end def district_addresses @@ -1019,10 +1031,6 @@ def reference_date @reference_date ||= (event.start_date || Date.current).to_date end - def scholarship_applicant_ids - @scholarship_applicant_ids ||= active_registrations.where(scholarship_requested: true).pluck(:registrant_id) - end - # Registrant (Person) ids behind active registrations that opted into a # shout-out — the candidates for the recipients page shout-out block. def shoutout_registrant_ids @@ -1179,18 +1187,18 @@ def funder_grant_ids # Externally funded = backed by a grant whose funder isn't the org itself. def funded_scholarships - scholarships.where.not(grant_id: [ nil, *awbw_grant_ids ]) + scholarships.externally_funded(self_funded_grant_ids) end # Org-subsidized = no grant, or a grant the org (AWBW) donated to itself. def unfunded_scholarships - scholarships.where(grant_id: [ nil, *awbw_grant_ids ]) + scholarships.org_subsidized(self_funded_grant_ids) end - # Ids of grants the org donated to itself; empty when the AWBW org isn't on - # file, collapsing the split back to grant-present vs grant-absent. - def awbw_grant_ids - @awbw_grant_ids ||= Grant.where(funder: Organization.awbw).ids + # Ids of grants the org donated to itself; memoized so the funded/unfunded split + # doesn't re-run Grant.self_funded_ids (an Organization.awbw + pluck) per call. + def self_funded_grant_ids + @self_funded_grant_ids ||= Grant.self_funded_ids end # [ [ organization_id, registrant_id ], ... ] from the organizations linked on diff --git a/app/services/event_participation_report.rb b/app/services/event_participation_report.rb index b8f685c450..5739ebef5a 100644 --- a/app/services/event_participation_report.rb +++ b/app/services/event_participation_report.rb @@ -289,13 +289,7 @@ def years_by_value # { event_id => { status => count } } from one grouped query, so no per-event # round trip and no Ruby readiness pass. def status_counts_by_event - @status_counts_by_event ||= EventRegistration - .where(event_id: event_ids) - .group(:event_id, :status) - .count - .each_with_object({}) do |((event_id, status), count), map| - (map[event_id] ||= {})[status] = count - end + @status_counts_by_event ||= EventRegistration.status_counts_by_event(event_ids) end # { year => distinct attended people } in one grouped distinct query. A nil diff --git a/app/services/event_revenue_figures.rb b/app/services/event_revenue_figures.rb index adeb1efdd6..59743e38ed 100644 --- a/app/services/event_revenue_figures.rb +++ b/app/services/event_revenue_figures.rb @@ -77,13 +77,13 @@ def breakdown_for(event) private # A grant counts as external funding only when it exists and the org didn't - # donate it to itself (AWBW) — matching EventDashboard#funded_scholarships. + # fund it itself (AWBW) — the in-memory form of Scholarship.externally_funded. def external_grant?(grant_id) - grant_id.present? && !awbw_grant_ids.include?(grant_id) + grant_id.present? && !self_funded_grant_ids.include?(grant_id) end - def awbw_grant_ids - @awbw_grant_ids ||= Grant.where(funder: Organization.awbw).ids.to_set + def self_funded_grant_ids + @self_funded_grant_ids ||= Grant.self_funded_ids.to_set end def figures_by_event_id diff --git a/app/services/event_scholarship_figures.rb b/app/services/event_scholarship_figures.rb new file mode 100644 index 0000000000..2067259a5b --- /dev/null +++ b/app/services/event_scholarship_figures.rb @@ -0,0 +1,144 @@ +# Per-event scholarship figures for the cross-event scholarship report, loaded for +# every event at once in a handful of grouped queries instead of one EventDashboard +# (and its ~8 queries) per event. +# +# Mirrors EventDashboard's funded/unfunded split (Scholarship.externally_funded / +# .org_subsidized) and its active-registrations-only scope, so a report column and +# that event's dashboard can't disagree — a parity spec holds the two together. +# +# Pass funder: (a Person/Organization) to narrow to scholarships drawn from that +# funder's grants, matching EventDashboard.new(event, scholarship_funder:). +class EventScholarshipFigures + Figures = Struct.new( + :funded_cents, + :unfunded_cents, + :funded_count, + :unfunded_count, + :attended_count, + # { Person id => cents } per split, and the matching name-sorted Person + # records — the report row's expander. A recipient with no award in a split is + # absent from it, so they can appear in one, both, or neither. + :funded_cents_by_recipient, + :unfunded_cents_by_recipient, + :funded_recipients, + :unfunded_recipients, + keyword_init: true + ) do + def scholarship_cents = funded_cents + unfunded_cents + def scholarship_count = funded_count + unfunded_count + end + + EMPTY = Figures.new( + funded_cents: 0, + unfunded_cents: 0, + funded_count: 0, + unfunded_count: 0, + attended_count: 0, + funded_cents_by_recipient: {}.freeze, + unfunded_cents_by_recipient: {}.freeze, + funded_recipients: [].freeze, + unfunded_recipients: [].freeze + ).freeze + + def initialize(events, funder: nil) + @events = events.to_a + @funder = funder + end + + def for(event) + figures_by_event_id.fetch(event.id, EMPTY) + end + + private + + def figures_by_event_id + @figures_by_event_id ||= @events.to_h { |event| [ event.id, build(event) ] } + end + + def build(event) + registration_ids = registration_ids_by_event.fetch(event.id, []) + rows = registration_ids.flat_map { |id| scholarship_rows_by_registration.fetch(id, []) } + funded, unfunded = rows.partition { |grant_id, _amount, _recipient_id| external_grant?(grant_id) } + funded_by_recipient = cents_by_recipient(funded) + unfunded_by_recipient = cents_by_recipient(unfunded) + + Figures.new( + funded_cents: funded.sum { |_grant_id, amount, _recipient_id| amount }, + unfunded_cents: unfunded.sum { |_grant_id, amount, _recipient_id| amount }, + funded_count: funded.size, + unfunded_count: unfunded.size, + attended_count: attended_counts_by_event.fetch(event.id, 0), + funded_cents_by_recipient: funded_by_recipient, + unfunded_cents_by_recipient: unfunded_by_recipient, + funded_recipients: recipients_for(funded_by_recipient.keys), + unfunded_recipients: recipients_for(unfunded_by_recipient.keys) + ) + end + + # { Person id => cents } for one split of a row's scholarship rows. + def cents_by_recipient(rows) + rows.each_with_object(Hash.new(0)) { |(_grant_id, amount, recipient_id), map| map[recipient_id] += amount } + end + + # Name-sorted Person records for the given ids, from one lookup shared by every + # event and both splits — so the expanders add a single Person query in total. + def recipients_for(recipient_ids) + recipient_ids.filter_map { |id| recipients_by_id[id] }.sort_by(&:name) + end + + def recipients_by_id + @recipients_by_id ||= Person + .where(id: scholarship_rows_by_registration.values.flatten(1).map(&:last).uniq) + .index_by(&:id) + end + + # A grant counts as external funding only when it exists and the org didn't + # fund it itself (AWBW) — the in-memory form of Scholarship.externally_funded. + def external_grant?(grant_id) + grant_id.present? && !self_funded_grant_ids.include?(grant_id) + end + + def self_funded_grant_ids + @self_funded_grant_ids ||= Grant.self_funded_ids.to_set + end + + # { event_id => [ registration_id, ... ] } across every event, active only — + # matching EventDashboard, which counts scholarships on active registrations. + def registration_ids_by_event + @registration_ids_by_event ||= EventRegistration + .active + .where(event_id: @events.map(&:id)) + .pluck(:event_id, :id) + .each_with_object({}) { |(event_id, id), map| (map[event_id] ||= []) << id } + end + + def registration_ids + @registration_ids ||= registration_ids_by_event.values.flatten + end + + # { registration_id => [ [ grant_id, amount_cents, recipient_id ], ... ] }, + # optionally narrowed to the funder's grants. + def scholarship_rows_by_registration + @scholarship_rows_by_registration ||= begin + scope = Scholarship + .joins(:allocation) + .where(allocations: { allocatable_type: "EventRegistration", allocatable_id: registration_ids }) + scope = scope.where(grant_id: funder_grant_ids) if @funder + scope + .pluck(Arel.sql("allocations.allocatable_id"), :grant_id, :amount_cents, :recipient_id) + .each_with_object({}) { |(registration_id, grant_id, amount, recipient_id), map| (map[registration_id] ||= []) << [ grant_id, amount, recipient_id ] } + end + end + + # Ids of grants the funder gave — empty (so nothing matches) when they gave none. + def funder_grant_ids + @funder_grant_ids ||= Grant.where(funder: @funder).ids + end + + # { event_id => attended registration count } from one grouped status query. + def attended_counts_by_event + @attended_counts_by_event ||= EventRegistration + .status_counts_by_event(@events.map(&:id)) + .transform_values { |counts| counts.fetch("attended", 0) } + end +end diff --git a/app/services/event_scholarship_report.rb b/app/services/event_scholarship_report.rb index b907dee5e0..cfdfcf562d 100644 --- a/app/services/event_scholarship_report.rb +++ b/app/services/event_scholarship_report.rb @@ -1,37 +1,39 @@ # Scholarship report: scholarship dollars and award counts (funded vs unfunded) # per facilitator training, grouped by calendar year. The sibling of # EventRevenueReport / EventParticipationReport — same year-grouped shape and -# statistics-hub period card, but counting scholarships. +# reports-hub period card, but counting scholarships. # # Funded vs unfunded follows the app-wide convention (see EventDashboard): # funded = backed by an external grant; unfunded = no grant, or a grant the org # (AWBW) donated to itself. Alongside the money it carries a trainee headcount — -# people who ATTENDED — split by delivery format: scheduled sessions total under -# "Training" and self-paced ones (event.on_demand?) under "On-demand". +# people who ATTENDED — split by delivery format: scheduled instructor-led +# sessions total under "Live" and self-paced ones (event.on_demand?) under +# "On-demand". # # Give it a collection of (decorated) facilitator-training events. class EventScholarshipReport - # One training's column. Sources every figure from EventDashboard so the - # funded/unfunded and attendance conventions can't drift from the dashboard. - Column = Struct.new(:event, :dashboard, keyword_init: true) do - def funded_cents = dashboard.funded_scholarship_cents - def unfunded_cents = dashboard.unfunded_scholarship_cents - def scholarship_cents = funded_cents + unfunded_cents - - def funded_count = dashboard.funded_scholarship_count - def unfunded_count = dashboard.unfunded_scholarship_count - def scholarship_count = funded_count + unfunded_count + # One training's column. Sources every figure from EventScholarshipFigures, which + # mirrors EventDashboard's funded/unfunded and attendance conventions (held by a + # parity spec) but loads all events in a fixed number of queries. + Column = Struct.new(:event, :figures, keyword_init: true) do + def funded_cents = figures.funded_cents + def unfunded_cents = figures.unfunded_cents + def scholarship_cents = figures.scholarship_cents + + def funded_count = figures.funded_count + def unfunded_count = figures.unfunded_count + def scholarship_count = figures.scholarship_count # Trainees who fully attended (registration status "attended"). - def attended_count = dashboard.attendance_count_for("attended") + def attended_count = figures.attended_count # Per-recipient scholarship breakdown for the row's expander: the funded / # unfunded recipients (name-sorted Person records) and their dollars keyed by # Person id. A recipient can appear in one split, both, or neither. - def funded_recipients = dashboard.funded_scholarship_recipients - def unfunded_recipients = dashboard.unfunded_scholarship_recipients - def funded_cents_by_recipient = dashboard.funded_scholarship_cents_by_recipient - def unfunded_cents_by_recipient = dashboard.unfunded_scholarship_cents_by_recipient + def funded_recipients = figures.funded_recipients + def unfunded_recipients = figures.unfunded_recipients + def funded_cents_by_recipient = figures.funded_cents_by_recipient + def unfunded_cents_by_recipient = figures.unfunded_cents_by_recipient def on_demand? = event.on_demand? def label = event.compact_label @@ -52,7 +54,7 @@ module Aggregates end # Attendance split by delivery format — these sum to attended_count. - def training_attended_count = columns.reject(&:on_demand?).sum(&:attended_count) + def live_attended_count = columns.reject(&:on_demand?).sum(&:attended_count) def on_demand_attended_count = columns.select(&:on_demand?).sum(&:attended_count) # Distinct scholarship recipients who attended (registration status @@ -60,7 +62,7 @@ def on_demand_attended_count = columns.select(&:on_demand?).sum(&:attended_count # of these trainings counts once. Split by delivery format (a person attending # both formats counts in each split, so the two need not sum to the total). def recipients_attended_count = distinct_attended_recipient_count(columns.map { |column| column.event.id }) - def training_recipients_attended_count = distinct_attended_recipient_count(columns.reject(&:on_demand?).map { |column| column.event.id }) + def live_recipients_attended_count = distinct_attended_recipient_count(columns.reject(&:on_demand?).map { |column| column.event.id }) def on_demand_recipients_attended_count = distinct_attended_recipient_count(columns.select(&:on_demand?).map { |column| column.event.id }) private @@ -89,7 +91,10 @@ def initialize(events, current_year: Date.current.year, featured_year: nil, fund end def columns - @columns ||= @events.map { |event| Column.new(event: event, dashboard: EventDashboard.new(event, scholarship_funder: @funder)) } + @columns ||= begin + figures = EventScholarshipFigures.new(@events, funder: @funder) + @events.map { |event| Column.new(event: event, figures: figures.for(event)) } + end end def any? diff --git a/app/services/report_periods.rb b/app/services/report_periods.rb index 1d26c8d9a8..491e95ce88 100644 --- a/app/services/report_periods.rb +++ b/app/services/report_periods.rb @@ -1,4 +1,4 @@ -# Resolves the statistics-hub period toggle ("this_year" | "last_year" | +# Resolves the reports-hub period toggle ("this_year" | "last_year" | # "all_time") to a metric scope + display label for a report's summary card. # Included by EventRevenueReport and EventParticipationReport, which each supply # an empty_year_group for a year that has no events (so the card shows zeros). diff --git a/app/views/event_registrations/_attendance_status_badge.html.erb b/app/views/event_registrations/_attendance_status_badge.html.erb index e7e6c344af..768ce79e32 100644 --- a/app/views/event_registrations/_attendance_status_badge.html.erb +++ b/app/views/event_registrations/_attendance_status_badge.html.erb @@ -1,29 +1,8 @@ -<% - status_styles = { - "registered" => "bg-blue-50 text-blue-700 border-blue-200", - "attended" => "bg-green-50 text-green-700 border-green-200", - "incomplete_attendance" => "bg-amber-50 text-amber-700 border-amber-200", - "cancelled" => "bg-gray-50 text-gray-500 border-gray-200", - "no_show" => "bg-red-50 text-red-700 border-red-200", - "transferred_in" => "bg-teal-50 text-teal-700 border-teal-200", - "transferred_out" => "bg-purple-50 text-purple-700 border-purple-200" - } - status_icons = { - "registered" => "fa-clipboard-list", - "attended" => "fa-circle-check", - "incomplete_attendance" => "fa-clock", - "cancelled" => "fa-ban", - "no_show" => "fa-circle-xmark", - "transferred_in" => "fa-right-to-bracket", - "transferred_out" => "fa-right-from-bracket" - } - current_style = status_styles[registration.status] || "bg-gray-50 text-gray-500 border-gray-200" - current_icon = status_icons[registration.status] || "fa-question" -%> +<% deco = registration.decorate %>
<%= form_with model: registration, url: event_registration_path(registration), method: :patch, data: { turbo_frame: "_top" } do |f| %> -
- +
+ <%= f.select :status, EventRegistration::ATTENDANCE_STATUSES.map { |s| [EventRegistration.new(status: s).attendance_status_label, s] }, {}, diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb index 269261b1e5..f658ccb844 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -437,7 +437,9 @@ when "bulk_payments" then bulk_payments_return_path(event_registration.event) when "preview_reminder" then preview_reminder_event_path(event_registration.event) when "onboarding" then onboarding_event_row_path(event_registration.event, event_registration.id) - when "training_attendees" then training_attendees_events_path + when "attendees" then attendees_events_path + when "roster" then roster_event_path(event_registration.event) + when "recipient_card" then recipients_event_card_path(event_registration.event, event_registration.slug) when "recipients" then recipients_event_path(event_registration.event, anchor: "shout-outs") else allowed_to?(:manage?, with: EventRegistrationPolicy) ? registrants_event_row_path(event_registration.event, event_registration.id) : registration_ticket_path(event_registration.slug) end diff --git a/app/views/event_registrations/_search_boxes.html.erb b/app/views/event_registrations/_search_boxes.html.erb index 811fb13cd8..80f00cdb8f 100644 --- a/app/views/event_registrations/_search_boxes.html.erb +++ b/app/views/event_registrations/_search_boxes.html.erb @@ -46,7 +46,7 @@
<%= label_tag :attendance_status, "Attendance", class: "block text-sm font-medium text-gray-700 mb-1" %> <%= select_tag :attendance_status, - options_for_select(EventRegistration::ATTENDANCE_STATUSES.map { |status| [ status.humanize, status ] } + [ [ "Other (registered, transfers, cancellations)", "other" ] ], params[:attendance_status]), + options_for_select(EventRegistration::ATTENDANCE_FILTER_OPTIONS, params[:attendance_status]), include_blank: "All statuses", 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", diff --git a/app/views/event_registrations/edit.html.erb b/app/views/event_registrations/edit.html.erb index 6b9d52be16..8fe3dcf828 100644 --- a/app/views/event_registrations/edit.html.erb +++ b/app/views/event_registrations/edit.html.erb @@ -18,9 +18,19 @@ <%= link_to onboarding_event_row_path(@event_registration.event, @event_registration.id), class: "text-sm text-gray-500 hover:text-gray-700" do %> Onboarding <% end %> - <% elsif params[:return_to] == "training_attendees" %> - <%= link_to training_attendees_events_path, class: "text-sm text-gray-500 hover:text-gray-700" do %> - Training attendees + <% elsif params[:return_to] == "attendees" %> + <%= link_to attendees_events_path, class: "text-sm text-gray-500 hover:text-gray-700" do %> + Attendees + <% end %> + <% elsif params[:return_to] == "roster" %> + <%= link_to roster_event_path(@event_registration.event), class: "text-sm text-gray-500 hover:text-gray-700" do %> + Registrant roster + <% end %> + <%# Two ways in from the recipients page: a recipient's name (back to their own + card) and the feature-a-shout-out flow (back to the shout-outs section). %> + <% elsif params[:return_to] == "recipient_card" %> + <%= link_to recipients_event_card_path(@event_registration.event, @event_registration.slug), class: "text-sm text-gray-500 hover:text-gray-700" do %> + Scholarship recipients <% end %> <% elsif params[:return_to] == "recipients" %> <%= link_to recipients_event_path(@event_registration.event, anchor: "shout-outs"), class: "text-sm text-gray-500 hover:text-gray-700" do %> diff --git a/app/views/events/_attendees_count.html.erb b/app/views/events/_attendees_count.html.erb new file mode 100644 index 0000000000..a4919f52e1 --- /dev/null +++ b/app/views/events/_attendees_count.html.erb @@ -0,0 +1,11 @@ +<%# Header + count for the attendees index. Rendered on the shell (no count yet) + and replaced out-of-band by the results frame once the page loads. %> +
+ <%= render "events/report_header", + title: "Event attendees", + icon: "fa-solid fa-user-pen", + theme: :event_registrations, + event: @filter_event, + subtitle: [ (pluralize(@count_display, "person") if @count_display), + attendee_population_label(@attendance_status, @attendee_event_type) ].compact.join(" · ") %> +
diff --git a/app/views/events/_attendees_search.html.erb b/app/views/events/_attendees_search.html.erb new file mode 100644 index 0000000000..52a403dc3b --- /dev/null +++ b/app/views/events/_attendees_search.html.erb @@ -0,0 +1,125 @@ +<%# Filters for the attendees index. Submits into the results frame; the + selects auto-submit on change and the text search on enter/click. `return_to` + rides along so the eyebrow keeps pointing back to wherever the admin arrived + from. %> +<% select_class = "w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500 has-[option:first-child:checked]:text-gray-400" %> +<%= form_tag(attendees_events_path, method: :get, + data: { controller: "collection", turbo_frame: "attendees_results" }, + autocomplete: "off", + class: "rounded-xl border border-gray-200 bg-white p-4 shadow-sm mb-6") do %> + <%= hidden_field_tag :return_to, params[:return_to] %> + <%# Keep drill-in filters applied while the admin tweaks a visible select — the + auto-submit only sends this form's fields, so the drill-ins ride as hidden + inputs and are surfaced (and cleared) via the chip row below. %> + <% AttendeesActiveFilters::CHIP_PARAMS.each do |param| %> + <%= hidden_field_tag param, params[param] if params[param].present? %> + <% end %> + + <% active_filters = AttendeesActiveFilters.new(params).chips %> + <% if active_filters.any? %> +
+ Applied + <% active_filters.each do |filter| %> + + <%= filter[:label] %> + <%= link_to attendees_events_path(request.query_parameters.except("page", filter[:param])), + class: "text-blue-400 hover:text-blue-700", aria: { label: "Remove #{filter[:label]} filter" } do %> + + <% end %> + + <% end %> +
+ <% end %> + +
+
+ <%= label_tag :contact_info, "Name, email, or phone", class: "block text-xs font-semibold uppercase text-gray-500 tracking-wide mb-1" %> +
+ <%= text_field_tag :contact_info, params[:contact_info], + class: "w-full bg-white border border-gray-300 rounded-lg px-3 py-2 pr-10 focus:ring-blue-500 focus:border-blue-500" %> + +
+
+ + <%# Attendance outcome and event type default to "attended" / "trainings" — the + population this page has always shown. Rendered pre-selected rather than as a + blank "All" so the default filter is visible instead of hidden. %> +
+ <%= label_tag :attendance_status, "Attendance", class: "block text-xs font-semibold uppercase text-gray-500 tracking-wide mb-1" %> + <%= select_tag :attendance_status, + options_for_select([ [ "All outcomes", EventRegistration::FILTER_ALL ] ] + EventRegistration::ATTENDANCE_FILTER_OPTIONS, @attendance_status), + class: select_class, onchange: "this.form.submit();" %> +
+ +
+ <%= label_tag :event_type, "Event type", class: "block text-xs font-semibold uppercase text-gray-500 tracking-wide mb-1" %> + <%= select_tag :event_type, + options_for_select([ [ "All events", EventRegistration::FILTER_ALL ] ] + EventRegistration::EVENT_TYPE_FILTER_OPTIONS, @attendee_event_type), + class: select_class, onchange: "this.form.submit();" %> +
+ +
+ <%= label_tag :event_id, "Event", class: "block text-xs font-semibold uppercase text-gray-500 tracking-wide mb-1" %> + <%= select_tag :event_id, + options_from_collection_for_select(@attendee_event_options, :id, :time_title, params[:event_id].to_i), + include_blank: "All events", + class: select_class, onchange: "this.form.submit();" %> +
+ +
+ <%= label_tag :event_year, "Year", class: "block text-xs font-semibold uppercase text-gray-500 tracking-wide mb-1" %> + <%= select_tag :event_year, + options_for_select(@attendee_years.map { |year| [ year.to_s, year ] }, params[:event_year].presence&.to_i), + include_blank: "All years", + class: select_class, onchange: "this.form.submit();" %> +
+ +
+ <%= label_tag :sector, "Sector", class: "block text-xs font-semibold uppercase text-gray-500 tracking-wide mb-1" %> + <%= select_tag :sector, + options_from_collection_for_select(@attendee_sectors, :id, :name, params[:sector].to_i), + include_blank: "All sectors", + class: select_class, onchange: "this.form.submit();" %> +
+ +
+ <%= label_tag :program_status, "Program status", class: "block text-xs font-semibold uppercase text-gray-500 tracking-wide mb-1" %> + <%= select_tag :program_status, + options_for_select(Organization::FACILITATOR_PROGRAM_STATUSES.map { |status| [ status.to_s.titleize, status.to_s ] }, params[:program_status]), + include_blank: "All program statuses", + class: select_class, onchange: "this.form.submit();" %> +
+ +
+ <%= label_tag :affiliation_status, "Affiliation status", class: "block text-xs font-semibold uppercase text-gray-500 tracking-wide mb-1" %> + <%= select_tag :affiliation_status, + options_for_select(Affiliation::FILTER_STATUSES, params[:affiliation_status]), + include_blank: "All statuses", + class: select_class, onchange: "this.form.submit();" %> +
+ +
+ <%= label_tag :state, "State", class: "block text-xs font-semibold uppercase text-gray-500 tracking-wide mb-1" %> + <%= select_tag :state, + options_for_select(@attendee_states, params[:state]), + include_blank: "All states", + class: select_class, onchange: "this.form.submit();" %> +
+ +
+ <%= label_tag :county, "County", class: "block text-xs font-semibold uppercase text-gray-500 tracking-wide mb-1" %> + <%= select_tag :county, + options_for_select(@attendee_counties.map { |state, county| [ "#{county} (#{state})", "#{state}::#{county}" ] }, params[:county]), + include_blank: "All counties", + class: select_class, onchange: "this.form.submit();" %> +
+ +
+ <%= link_to "Clear filters", attendees_events_path, + class: "btn btn-utility-outline", + data: { action: "collection#clearAndSubmit" } %> +
+
+<% end %> diff --git a/app/views/events/_training_attendees_skeleton.html.erb b/app/views/events/_attendees_skeleton.html.erb similarity index 100% rename from app/views/events/_training_attendees_skeleton.html.erb rename to app/views/events/_attendees_skeleton.html.erb diff --git a/app/views/events/_breakdown_card.html.erb b/app/views/events/_breakdown_card.html.erb index 98470d0eec..9c1c538f1b 100644 --- a/app/views/events/_breakdown_card.html.erb +++ b/app/views/events/_breakdown_card.html.erb @@ -52,9 +52,6 @@ <% data.each_with_index do |(label, count), i| %> <% path = row_paths[label] %> - <%# Registrants links carry a back-to-this-section eyebrow; leave other - destinations (e.g. the recipients page) untouched. %> - <% path = background_return_path(path, anchor) if path&.include?("/registrants") %> " <% if path %>onclick="window.location='<%= path %>'"<% end %>> diff --git a/app/views/events/_charts_loading.html.erb b/app/views/events/_charts_loading.html.erb new file mode 100644 index 0000000000..9d5f98b3d9 --- /dev/null +++ b/app/views/events/_charts_loading.html.erb @@ -0,0 +1,21 @@ +<%# Skeleton shown while a charts Turbo frame loads — mirrors the breakdown-card + grid (a pulsing title bar, chart area, and legend rows per card) so revealing + the real charts doesn't jump the layout. Shared by the roster, attendees, and + recipients charts frames. %> +
+ Loading charts… + <% 6.times do %> +
+
+
+
+
+
+
+
+
+
+
+
+ <% end %> +
diff --git a/app/views/events/_charts_toggle_bar.html.erb b/app/views/events/_charts_toggle_bar.html.erb new file mode 100644 index 0000000000..ee44e44ed2 --- /dev/null +++ b/app/views/events/_charts_toggle_bar.html.erb @@ -0,0 +1,22 @@ +<%# Right-aligned show/hide bar for a panel-toggle controller — one button per + panel. Shared by the roster, attendees index, and scholarship recipients so + the table/charts toggle reads identically across them. + Locals: + buttons: array of { name:, icon:, shown_label:, hidden_label:, expanded: } + — name pairs the button to its data-panel-toggle-name="…" panel; + expanded true means the panel starts shown. %> +
+ <% buttons.each do |button| %> + + <% end %> +
diff --git a/app/views/events/_event_filter.html.erb b/app/views/events/_event_filter.html.erb index 7186f841ad..2b93089017 100644 --- a/app/views/events/_event_filter.html.erb +++ b/app/views/events/_event_filter.html.erb @@ -1,7 +1,7 @@ -<%# Specific-event filter shared by the revenue/participation/statistics report +<%# Specific-event filter shared by the revenue/participation/reports report forms. Scopes the report(s) to the chosen event. `all_label` overrides the blank option (revenue lists paid events only). %> -
+
<%= select_tag :event_id, options_from_collection_for_select(@filter_events, :id, :date_title, params[:event_id].to_i), diff --git a/app/views/events/_event_type_filter.html.erb b/app/views/events/_event_type_filter.html.erb index 2ccc6ec3d9..1da1c73441 100644 --- a/app/views/events/_event_type_filter.html.erb +++ b/app/views/events/_event_type_filter.html.erb @@ -1,10 +1,13 @@ -<%# Event-type filter (all events / trainings / other) shared by the - revenue/participation/statistics report forms. `all_label` overrides the - blank option (revenue lists paid events only). %> -
+<%# Event-type filter shared by the revenue/participation/reports report forms: + the blank default (all events) plus all trainings, the Live/On-demand training + splits, and non-trainings. Options come from + EventRegistration::EVENT_TYPE_FILTER_OPTIONS, shared with the attendees index + these reports drill into, so a value means the same thing on both. `all_label` + overrides the blank option (revenue lists paid events only). %> +
<%= select_tag :event_type, - options_for_select([ [ "Trainings", "trainings" ], [ "Other", "other" ] ], @event_type), + options_for_select(EventRegistration::EVENT_TYPE_FILTER_OPTIONS, @event_type), include_blank: local_assigns.fetch(:all_label, "All events"), class: "w-full bg-white border border-gray-300 rounded-lg px-3 py-2 focus:ring-blue-500 focus:border-blue-500", onchange: "this.form.submit()" %> diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 5a34a7079d..11ca9c29c3 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -31,7 +31,7 @@ placeholder: "e.g. TOS205" } %> <%= f.input :facilitator_training, as: :boolean, label: "Facilitator training event", wrapper_html: { class: "mb-0!" } %> - <%= f.input :on_demand, as: :boolean, label: "On-demand (self-paced)", hint: "Trainees count under \"On-demand\" instead of \"Training\" in the scholarships report", wrapper_html: { class: "mb-0!" } %> + <%= f.input :on_demand, as: :boolean, label: "On-demand (self-paced)", hint: "Trainees count under \"On-demand\" instead of \"Live\" in the scholarships report", wrapper_html: { class: "mb-0!" } %>
<%= f.input :pre_title, diff --git a/app/views/events/_organization_row.html.erb b/app/views/events/_organization_row.html.erb index 592619cf27..6fcb5abfc1 100644 --- a/app/views/events/_organization_row.html.erb +++ b/app/views/events/_organization_row.html.erb @@ -4,8 +4,9 @@ with the (empty-slot, indented) non-scholarship ones. The hover jump-link arrow sits after the percentage, matching the other breakdown cards. %> <% is_scholarship = row[:scholarship_count].positive? %> - +<% path = row[:path] %> + transition-colors" + <% if path.present? %>onclick="window.location='<%= path %>'"<% end %>> <% if is_scholarship %> @@ -16,11 +17,17 @@ <% if row[:high_profile] %> <% end %> - <%= link_to row[:name], row[:path], class: "group-hover:underline align-middle" %> + <% if path.present? %> + <%= link_to row[:name], path, class: "group-hover:underline align-middle" %> + <% else %> + <%= row[:name] %> + <% end %> - <%= row[:count] %> · <%= total.zero? ? 0 : (row[:count] * 100.0 / total).round(1) %>% + "><%= row[:count] %> · <%= total.zero? ? 0 : (row[:count] * 100.0 / total).round(1) %>% <%# Jump-link affordance: invisible (white) until the row is hovered, matching the other breakdown cards. %> - + <% if path.present? %> + + <% end %> diff --git a/app/views/events/_panel_cta.html.erb b/app/views/events/_panel_cta.html.erb new file mode 100644 index 0000000000..015be2cff2 --- /dev/null +++ b/app/views/events/_panel_cta.html.erb @@ -0,0 +1,25 @@ +<%# Compact Show/Hide toggle sitting beside a section heading (see + events/_section_heading). It's a second control for the same panel as the top + toggle bar; panel-toggle keeps every control for a panel in sync, so both + labels flip together. + + The visible text is just "Show"/"Hide" — the heading next to it supplies the + subject — so `section` names it for screen readers, where the heading isn't + adjacent. aria-expanded carries the state, so that label stays static. + + Locals: + name: the data-panel-toggle-name of the panel it toggles + section: the section's name, for the accessible label ("Toggle breakdowns") + open: whether the panel is open on this render. Default false. %> +<% open = local_assigns.fetch(:open, false) %> + diff --git a/app/views/events/_participation_summary.html.erb b/app/views/events/_participation_summary.html.erb index 16ae1558d0..904f69a3f9 100644 --- a/app/views/events/_participation_summary.html.erb +++ b/app/views/events/_participation_summary.html.erb @@ -1,11 +1,23 @@ -<%# Compact participation headline for the statistics hub, linking to the full +<%# Compact participation headline for the reports hub, linking to the full report. Expects `report` (an EventParticipationReport) and `period` (its resolved PeriodScope: #label, #year and #metrics). %>
-
+

Participation

- <%= link_to "Full report →", participation_events_path(statistics_to_report_params), - class: "text-xs font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline" %> +
+ <%= link_to "Full report →", participation_events_path(hub_to_report_params), + class: "text-xs font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline" %> + <%= link_to "Attendees →", attendees_events_path(hub_to_report_params), + class: "text-xs font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline" %> + <%# Demographics live next to the people they describe, so this points at the + breakdowns rather than duplicating them here: one event's roster when the + hub is scoped, the cross-event attendees index otherwise. ?charts=1 opens + the panel on arrival so the link doesn't land on a collapsed section. %> + <% breakdowns_path = @filter_event ? roster_event_path(@filter_event, charts: 1) : + attendees_events_path(hub_to_report_params.merge(charts: 1)) %> + <%= link_to "Breakdowns →", breakdowns_path, + class: "text-xs font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline" %> +
<% if report.any? %> @@ -23,27 +35,27 @@
Registrations
<%= link_to number_with_delimiter(metrics.active_registration_count), - training_attendees_events_path(registrant_ids: active_ids.join("-").presence || "0", return_to: "statistics"), + attendees_events_path(registrant_ids: active_ids.join("-").presence || "0", event_id: params[:event_id].presence, return_to: "reports"), class: "text-xl font-bold text-gray-900 hover:underline" %> <% if metrics.inactive_registration_count.positive? %>(<%= number_with_delimiter(metrics.inactive_registration_count) %> inactive)<% end %>
<%# Each side links to all registrants of that event type. %>
- <%= link_to "#{number_with_delimiter(registrations[:trainings])} trainings", event_registrations_path(event_type: "trainings", event_year: period.year), class: "hover:underline" %> - · <%= link_to "#{number_with_delimiter(registrations[:non_trainings])} other", event_registrations_path(event_type: "other", event_year: period.year), class: "hover:underline" %> + <%= link_to "#{number_with_delimiter(registrations[:trainings])} trainings", attendees_events_path(event_type: "trainings", attendance_status: EventRegistration::FILTER_ALL, event_year: period.year, event_id: params[:event_id].presence, return_to: "reports"), class: "hover:underline" %> + · <%= link_to "#{number_with_delimiter(registrations[:non_trainings])} other", attendees_events_path(event_type: "other", attendance_status: EventRegistration::FILTER_ALL, event_year: period.year, event_id: params[:event_id].presence, return_to: "reports"), class: "hover:underline" %>
People attended
<%= link_to number_with_delimiter(metrics.unique_people), - training_attendees_events_path(registrant_ids: attended_ids.join("-").presence || "0", return_to: "statistics"), + attendees_events_path(registrant_ids: attended_ids.join("-").presence || "0", event_id: params[:event_id].presence, return_to: "reports"), class: "text-xl font-bold text-blue-700 hover:underline" %>
<%# Each side links to the attended registrants of that event type. %>
- <%= link_to "#{number_with_delimiter(people[:trainings])} trainings", training_attendees_events_path(event_year: period.year, return_to: "statistics"), class: "hover:underline" %> - · <%= link_to "#{number_with_delimiter(people[:non_trainings])} other", event_registrations_path(event_type: "other", attendance_status: "attended", event_year: period.year), class: "hover:underline" %> + <%= link_to "#{number_with_delimiter(people[:trainings])} trainings", attendees_events_path(event_year: period.year, event_id: params[:event_id].presence, return_to: "reports"), class: "hover:underline" %> + · <%= link_to "#{number_with_delimiter(people[:non_trainings])} other", attendees_events_path(event_type: "other", attendance_status: "attended", event_year: period.year, event_id: params[:event_id].presence, return_to: "reports"), class: "hover:underline" %>
@@ -64,6 +76,6 @@ height: "120px", legend: false, library: { borderRadius: 3 } %>
<% else %> -

No attendance recorded yet.

+

No attendance recorded for these filters.

<% end %>
diff --git a/app/views/events/_registrant_breakdowns.html.erb b/app/views/events/_registrant_breakdowns.html.erb index 187723c0b2..b2d102dad2 100644 --- a/app/views/events/_registrant_breakdowns.html.erb +++ b/app/views/events/_registrant_breakdowns.html.erb @@ -1,7 +1,7 @@ -<%# Shared charts & breakdowns, used on a single event's Background page and on the - cross-event training-attendees index. Locals: +<%# Shared charts & breakdowns, used on a single event's Roster page and on the + cross-event attendees index. Locals: data: a breakdown data source responding to the breakdown methods - (EventDashboard for one event, TrainingAttendeesBreakdowns for the + (EventDashboard for one event, AttendeesBreakdowns for the cross-event index). context: :event or :index — chooses how each breakdown row drills in. :event links to the event's filtered registrant list; :index links back to @@ -11,13 +11,22 @@ are context-agnostic. %> <% context = local_assigns.fetch(:context, :event) %> <% index_context = context == :index %> +<% event_context = context == :event %> +<%# :recipients — the scholarship-recipients page. Same idea as :event, but the + ids come from AttendeesBreakdowns (scoped to that event's recipients) and the + drill-in filters that page's cards. %> +<% recipients_context = context == :recipients %> -<%# Drill-in path per dimension. In :event context we link to the event's - registrant list (by named filter or by the registrant_ids behind the row); in - :index context we re-apply this index's filters plus the row's own dimension. %> +<%# Drill-in path per dimension. Every row links to the page it's already on plus + a filter — :event narrows the roster's own table by the registrant_ids behind + the row, :index re-applies this index's filters plus the row's own dimension. + Nothing navigates away, so no back-link/eyebrow plumbing is needed. %> <% filter_params = index_context ? request.query_parameters.except("page", "controller", "action") : {} %> -<% index_path = ->(params) { training_attendees_events_path(filter_params.merge(params)) } %> -<% by_ids = ->(ids) { registrants_event_path(event, registrant_ids: ids.join("-")) } %> +<% index_path = ->(params) { attendees_events_path(filter_params.merge(params)) } %> +<% by_ids = ->(ids) { + ids = ids.to_a.join("-").presence || "0" + recipients_context ? recipients_event_path(event, registrant_ids: ids) : roster_event_path(event, registrant_ids: ids) + } %> <% primary_sector_data = data.primary_sectors.map { |s| [ s.name, data.primary_sector_counts.fetch(s.id, 0) ] }.sort_by { |_, count| -count } %> <% sector_data = data.sectors.map { |s| [ s.name, data.sector_counts.fetch(s.id, 0) ] }.sort_by { |_, count| -count } %> @@ -55,13 +64,13 @@ <% other_response_paths = {} %> <% scholarship_pie_paths = { "Scholarship" => index_path.(scholarship: "yes"), "No scholarship" => index_path.(scholarship: "no") } %> <% ce_pie_paths = { "CE" => index_path.(ce: "yes"), "No CE" => index_path.(ce: "no") } %> -<% else %> +<% elsif event_context %> <% primary_sector_paths = data.primary_sectors.to_h { |s| [ s.name, by_ids.(data.primary_sector_registrant_ids_by_sector.fetch(s.id, [])) ] } %> - <% sector_paths = data.sectors.to_h { |s| [ s.name, registrants_event_path(event, sector: s.id) ] } %> + <% sector_paths = data.sectors.to_h { |s| [ s.name, roster_event_path(event, sector: s.id) ] } %> <% sector_paths["Other"] = by_ids.(data.other_sector_response_registrant_ids) if data.other_sector_response_count.positive? %> <% age_group_paths = data.age_groups.to_h { |c| [ c.name, by_ids.(data.age_group_registrant_ids_by_category.fetch(c.id, [])) ] } %> <% all_age_group_paths = data.all_age_groups.to_h { |c| [ c.name, by_ids.(data.all_age_group_registrant_ids_by_category.fetch(c.id, [])) ] } %> - <% state_paths = data.states.index_with { |state| registrants_event_path(event, state: state) } %> + <% state_paths = data.states.index_with { |state| roster_event_path(event, state: state) } %> <% country_paths = data.country_registrant_ids_by_country.transform_values { |ids| by_ids.(ids) } %> <% school_district_paths = data.school_district_registrant_ids_by_district.transform_values { |ids| by_ids.(ids) } %> <% life_experience_paths = data.life_experiences.to_h { |c| [ c.name, by_ids.(data.life_experience_registrant_ids_by_category.fetch(c.id, [])) ] } %> @@ -71,12 +80,37 @@ <% all_registrant_ids = data.registrants.map(&:id) %> <% non_scholarship_ids = all_registrant_ids - data.scholarship_registrants.map(&:id) %> <% non_ce_ids = all_registrant_ids - data.ce_registrant_ids %> - <% scholarship_pie_paths = { "Scholarship" => recipients_event_path(event), "No scholarship" => by_ids.(non_scholarship_ids) } %> + <% scholarship_pie_paths = { "Scholarship" => by_ids.(data.scholarship_registrants.map(&:id)), "No scholarship" => by_ids.(non_scholarship_ids) } %> <% ce_pie_paths = { "CE" => by_ids.(data.ce_registrant_ids), "No CE" => by_ids.(non_ce_ids) } %> +<% elsif recipients_context %> + <% primary_sector_paths = data.primary_sectors.to_h { |s| [ s.name, by_ids.(data.primary_sector_registrant_ids_by_sector.fetch(s.id, [])) ] } %> + <% sector_paths = data.sectors.to_h { |s| [ s.name, by_ids.(data.sector_registrant_ids_by_sector.fetch(s.id, [])) ] } %> + <% sector_paths["Other"] = by_ids.(data.other_sector_response_registrant_ids) if data.other_sector_response_count.positive? %> + <% age_group_paths = data.age_groups.to_h { |c| [ c.name, by_ids.(data.age_group_registrant_ids_by_category.fetch(c.id, [])) ] } %> + <% all_age_group_paths = data.all_age_groups.to_h { |c| [ c.name, by_ids.(data.all_age_group_registrant_ids_by_category.fetch(c.id, [])) ] } %> + <% state_paths = data.state_registrant_ids_by_state.transform_values { |ids| by_ids.(ids) } %> + <% country_paths = data.country_registrant_ids_by_country.transform_values { |ids| by_ids.(ids) } %> + <% school_district_paths = data.school_district_registrant_ids_by_district.transform_values { |ids| by_ids.(ids) } %> + <% life_experience_paths = data.life_experiences.to_h { |c| [ c.name, by_ids.(data.life_experience_registrant_ids_by_category.fetch(c.id, [])) ] } %> + <% settings_paths = data.settings.to_h { |c| [ c.name, by_ids.(data.settings_registrant_ids_by_category.fetch(c.id, [])) ] } %> + <% program_status_paths = data.program_status_registrant_ids.to_h { |status, ids| [ status.to_s.titleize, by_ids.(ids) ] } %> + <% other_response_paths = data.other_sector_response_rows.to_h { |text, _count, ids| [ text, by_ids.(ids) ] } %> + <% all_registrant_ids = data.registrant_ids %> + <% scholarship_pie_paths = { "Scholarship" => by_ids.(data.scholarship_registrant_ids), + "No scholarship" => by_ids.(all_registrant_ids - data.scholarship_registrant_ids) } %> + <% ce_pie_paths = { "CE" => by_ids.(data.ce_registrant_ids), "No CE" => by_ids.(all_registrant_ids - data.ce_registrant_ids) } %> +<% else %> + <% primary_sector_paths = sector_paths = age_group_paths = all_age_group_paths = {} %> + <% state_paths = country_paths = school_district_paths = life_experience_paths = settings_paths = {} %> + <% program_status_paths = other_response_paths = scholarship_pie_paths = ce_pie_paths = {} %> <% end %> <% org_rows = data.organizations.map { |o| - index_org_path = index_context ? index_path.(organization_id: o.id) : background_return_path(by_ids.(data.organization_registrant_ids_by_org.fetch(o.id, []).to_a), "all-organizations") + index_org_path = if index_context + index_path.(organization_id: o.id) + elsif event_context || recipients_context + by_ids.(data.organization_registrant_ids_by_org.fetch(o.id, []).to_a) + end { name: o.name, high_profile: o.high_profile?, count: data.organization_counts.fetch(o.id, 0), @@ -140,8 +174,15 @@ <% city_row_paths = city_rows.select(&known_city).to_h { |row| [ row.city, index_path.(org_city: row.city) ] } %> <% city_scholarship_paths = city_rows.select { |row| known_city.(row) && row.scholarship_count.positive? }.to_h { |row| [ row.city, index_path.(org_city: row.city, scholarship: "yes") ] } %> <%= render "events/registrant_city_breakdown", breakdown: data.registrant_city_breakdown, metrics: :both, title: "All cities", row_paths: city_row_paths, scholarship_paths: city_scholarship_paths %> - <% else %> + <% elsif event_context %> <%= render "events/registrant_city_breakdown", breakdown: data.registrant_city_breakdown, metrics: :both, title: "All cities", return_anchor: "all-cities" %> + <% elsif recipients_context %> + <% city_rows = data.registrant_city_breakdown.rows %> + <% city_row_paths = city_rows.to_h { |row| [ row.city, by_ids.(row.registrant_ids) ] } %> + <% city_scholarship_paths = city_rows.select { |row| row.scholarship_count.positive? }.to_h { |row| [ row.city, by_ids.(row.scholarship_recipient_ids) ] } %> + <%= render "events/registrant_city_breakdown", breakdown: data.registrant_city_breakdown, metrics: :both, title: "All cities", row_paths: city_row_paths, scholarship_paths: city_scholarship_paths %> + <% else %> + <%= render "events/registrant_city_breakdown", breakdown: data.registrant_city_breakdown, metrics: :both, title: "All cities", row_paths: {} %> <% end %> <% end %>
diff --git a/app/views/events/_registrant_city_breakdown.html.erb b/app/views/events/_registrant_city_breakdown.html.erb index 08d5b09591..a89bece152 100644 --- a/app/views/events/_registrant_city_breakdown.html.erb +++ b/app/views/events/_registrant_city_breakdown.html.erb @@ -1,5 +1,5 @@ <%# Registrants grouped by the city of the organization linked on their - registration — shared by the event background dashboard and the + registration — shared by the event roster page and the scholarship-recipients page. A pure-CSS `peer` toggle swaps between all cities and scholarship-only cities: both lists render server-side and are shown/hidden via `peer-checked:` (no JS). Locals: @@ -7,7 +7,7 @@ metrics: :registrants, :scholarships, or :both (default :both) — which figure(s) each city row shows. Grouping is always by city. title: heading (default "Registrants by city") - return_anchor: optional section id — when set (background dashboard), the card + return_anchor: optional section id — when set (roster page), the card gets that id/scroll-mt and its row links carry a back-to-here eyebrow. Omit it elsewhere (recipients page) for plain links. %> <% metrics = local_assigns.fetch(:metrics, :both) %> @@ -46,14 +46,14 @@ <%# Full list — hidden once the toggle is checked. %> - <%= render partial: "events/registrant_city_row", collection: rows, as: :row, locals: { metrics: metrics, event: @event, return_anchor: return_anchor, row_paths: row_paths, scholarship_paths: scholarship_paths } %> + <%= render partial: "events/registrant_city_row", collection: rows, as: :row, locals: { metrics: metrics, event: @event, row_paths: row_paths, scholarship_paths: scholarship_paths } %>
<%# Scholarship-cities-only list — revealed when the toggle is checked. %> <% if scholarship_rows.any? %> - <%= render partial: "events/registrant_city_row", collection: scholarship_rows, as: :row, locals: { metrics: metrics, event: @event, return_anchor: return_anchor } %> + <%= render partial: "events/registrant_city_row", collection: scholarship_rows, as: :row, locals: { metrics: metrics, event: @event, row_paths: row_paths, scholarship_paths: scholarship_paths } %> <% end %> diff --git a/app/views/events/_registrant_city_row.html.erb b/app/views/events/_registrant_city_row.html.erb index a907660417..2ad52ac6c0 100644 --- a/app/views/events/_registrant_city_row.html.erb +++ b/app/views/events/_registrant_city_row.html.erb @@ -1,25 +1,21 @@ <%# One row of the Registrants-by-city breakdown. Locals: row (a RegistrantCityBreakdown::Row), metrics (:registrants/:scholarships/:both), and - event. The whole row links to that city's filtered registrant list; the - scholarship figure links to just its scholarship recipients. Registrant total + event. The whole row filters the page to that city's registrants; the + scholarship figure to just its scholarship recipients. Registrant total and scholarship figure get their own right-aligned columns so counts line up; tabular-nums + a 3-digit min-width keep padding even. %> -<%# return_anchor (set only on the background dashboard) makes each registrants - link carry a back-to-this-card eyebrow; nil elsewhere leaves links plain. - row_paths/scholarship_paths (set only on the cross-event index) supply per-city - drill-in paths keyed by city label; a missing entry (e.g. Unknown) renders the - row non-clickable. %> -<% return_anchor = local_assigns[:return_anchor] %> +<%# row_paths/scholarship_paths (set on the cross-event index and the read-only + recipients charts) supply per-city paths keyed by city label; a missing entry + (e.g. Unknown) renders the row non-clickable. Absent, the row builds its own + event-scoped roster filter. %> <% row_paths = local_assigns[:row_paths] %> <% scholarship_paths = local_assigns[:scholarship_paths] %> <% if row_paths %> <% row_path = row_paths[row.city] %> <% scholarship_path = scholarship_paths&.dig(row.city) %> <% else %> - <% row_path = registrants_event_path(event, registrant_ids: row.registrant_ids.join("-")) %> - <% row_path = background_return_path(row_path, return_anchor) if return_anchor %> - <% scholarship_path = registrants_event_path(event, registrant_ids: row.scholarship_recipient_ids.join("-")) %> - <% scholarship_path = background_return_path(scholarship_path, return_anchor) if return_anchor %> + <% row_path = roster_event_path(event, registrant_ids: row.registrant_ids.join("-").presence || "0") %> + <% scholarship_path = roster_event_path(event, registrant_ids: row.scholarship_recipient_ids.join("-").presence || "0") %> <% end %> transition-colors" <% if row_path %>onclick="window.location='<%= row_path %>'"<% end %>> diff --git a/app/views/events/_registrant_roster.html.erb b/app/views/events/_registrant_roster.html.erb index 7f755c4267..65c90df0e3 100644 --- a/app/views/events/_registrant_roster.html.erb +++ b/app/views/events/_registrant_roster.html.erb @@ -1,22 +1,32 @@ -<%# Shared registrant roster table, used both on a single event's Background page - and on the cross-event training-attendees index. Locals: +<%# Shared registrant roster table for the cross-event attendees index (and + reusable for any single-event registrant list). Locals: roster: - data object responding to `registrants` (Person records) and the - per-registrant lookup maps (EventDashboard or TrainingAttendeesRoster). + per-registrant lookup maps (EventDashboard or AttendeesRoster). show_event_column: - adds a "Training" column listing each registration's event (label + month/year) linking to its edit page. Off in single-event context. Default false. - clickable_rows: - the whole row links to the person's profile. Default true - (Background); off on the cross-event index, where the event links lead - into the individual registrations instead. %> + clickable_rows: - the whole row links to the person's registration. Default + true; off on the cross-event index, where the event links lead into the + individual registrations instead. + row_return_to: - `return_to` stamped on those registration links so the edit + page's eyebrow comes back here (e.g. "roster"). + show_attendance_status: - adds a read-only "Attendance" column (the + registration's status pill, linking to its edit page). Single-event only. + Default false. + registrants: - override the rows shown, for a page that filters its own table + from a breakdown drill-in. Defaults to every registrant the roster holds. %> <% show_event_column = local_assigns.fetch(:show_event_column, false) %> <% show_affiliation_status = local_assigns.fetch(:show_affiliation_status, false) %> +<% show_attendance_status = local_assigns.fetch(:show_attendance_status, false) %> <% clickable_rows = local_assigns.fetch(:clickable_rows, true) %> +<% row_return_to = local_assigns[:row_return_to] %> +<% registrants = local_assigns.fetch(:registrants, roster.registrants) %> <% affiliation_status_classes = { "Active" => "border-green-300 bg-green-100 text-green-800", - "Pending" => "border-amber-300 bg-amber-100 text-amber-800", + "Upcoming" => "border-amber-300 bg-amber-100 text-amber-800", "Inactive" => "border-gray-300 bg-gray-100 text-gray-600" } %> -<% if roster.registrants.any? %> +<% if registrants.any? %>
<%# Below sm the whole table chain reflows to block so each row stacks into a labeled card (header hidden; per-cell mobile_label supplies the name). @@ -34,7 +44,10 @@ { label: "CE", index: 6, align: "text-center" } ] %> <% columns.insert(4, { label: "Affiliation status", index: 8, align: "text-left" }) if show_affiliation_status %> - <% columns << { label: "Trainings attended", index: 7, align: "text-left" } if show_event_column %> + <% columns << { label: "Events attended", index: 7, align: "text-left" } if show_event_column %> + <%# Single-event roster only, so it never coexists with the cross-event + "Events attended" column — both are the row's last cell (index 7). %> + <% columns << { label: "Attendance", index: 7, align: "text-left" } if show_attendance_status %> <% columns.each do |col| %> @@ -53,20 +66,25 @@ <%# Per-cell label shown only in the stacked mobile card; the column header supplies it at sm+, so it's hidden there. %> <% mobile_label = ->(text) { tag.span(text, class: "sm:hidden shrink-0 w-24 pt-0.5 text-xs font-semibold uppercase tracking-wide text-gray-400") } %> - <% roster.registrants.each do |person| %> + <% registrants.each do |person| %> <% person_orgs = (roster.organization_ids_by_registrant[person.id] || []).filter_map { |org_id| orgs_by_id[org_id] } %> <% location = roster.location_label_by_registrant[person.id] %> <% person_sectors = roster.primary_sector_names_by_registrant[person.id] || [] %> <% person_age_groups = roster.primary_age_group_names_by_registrant[person.id] || [] %> <% scholarship = roster.scholarship_by_recipient[person.id] %> <% ce_registration = roster.ce_registration_by_registrant[person.id] %> + <%# Rows lead into the person's registration for this event — the record + an admin acts on from a roster. Falls back to their profile if they + somehow have no registration to point at. %> + <% registration = roster.registration_link_target(person) %> + <% person_link = registration ? edit_event_registration_path(registration, return_to: row_return_to) : person_path(person) %> " - <% if clickable_rows %>onclick="window.location='<%= person_path(person) %>'"<% end %>> + <% if clickable_rows %>onclick="window.location='<%= person_link %>'"<% end %>> <%# Card title on mobile (larger, allowed to wrap); plain name cell at sm+. %> - <%= link_to person.name, person_path(person), class: "font-bold text-gray-900 hover:underline text-base sm:text-sm" %> + <%= link_to person.name, person_link, class: "font-bold text-gray-900 hover:underline text-base sm:text-sm" %> <%# Primary sector + age group — one column, each independently sortable (data-sort-sector / data-sort-age), mirroring the @@ -159,7 +177,7 @@ <%# CE: the icon shows for registrants with a CE registration and links to that record's edit page. %> - text-sm sm:text-center" data-sort-value="<%= ce_registration ? 1 : 0 %>"> + text-sm sm:text-center" data-sort-value="<%= ce_registration ? 1 : 0 %>"> <%= mobile_label.("CE") %> <% if ce_registration %> <% ce_event = roster.ce_event_by_registrant[person.id] if show_event_column %> @@ -174,23 +192,45 @@ <% end %> + <%# Attendance column (single-event roster only): the registration's + status as a read-only pill that links to its edit page — the + outcome is edited there, not inline. %> + <% if show_attendance_status %> + + <%= mobile_label.("Attendance") %> +
+ <% if registration %> + <% attendance = registration.decorate %> + <%= link_to edit_event_registration_path(registration, return_to: row_return_to), + class: "inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium hover:underline #{attendance.attendance_status_classes}", + title: "Edit registration", + onclick: "event.stopPropagation()" do %> + + <%= registration.attendance_status_label %> + <% end %> + <% else %> + + <% end %> +
+ + <% end %> <%# Training column (cross-event index only): the person's attended trainings, most recent first. The most recent shows inline; when there are more, a chevron expands the rest (and the page-level "Show all events" toggle expands every row at once). Each event links to that registration's edit page. %> <% if show_event_column %> - <% registrations = roster.training_registrations_by_registrant[person.id] || [] %> + <% registrations = roster.event_registrations_by_registrant[person.id] || [] %> <% first_registration = registrations.first %> <% event_link_class = "font-medium #{DomainTheme.text_class_for(:event_registrations, intensity: 700)} hover:underline" %> 1 %>data-controller="expandable-card" data-expandable-card-expanded-value="false" data-action="expandable-cards:expandAll@window->expandable-card#expand expandable-cards:collapseAll@window->expandable-card#collapse"<% end %>> - <%= mobile_label.("Trainings attended") %> + <%= mobile_label.("Events attended") %>
<% if first_registration %>
- <%= link_to edit_event_registration_path(first_registration, return_to: "training_attendees"), class: event_link_class do %> + <%= link_to edit_event_registration_path(first_registration, return_to: "attendees"), class: event_link_class do %> <%= first_registration.event.decorate.compact_label %> <%= first_registration.event.start_date&.strftime("%b %Y") %> <% end %> @@ -203,7 +243,7 @@ <% if registrations.size > 1 %> diff --git a/app/views/events/_scholarship_kpis.html.erb b/app/views/events/_scholarship_kpis.html.erb index 4236ccb413..b1023a9ed0 100644 --- a/app/views/events/_scholarship_kpis.html.erb +++ b/app/views/events/_scholarship_kpis.html.erb @@ -34,7 +34,7 @@
Registrants attended
<%= number_with_delimiter(featured.attended_count) %>
-
<%= featured.training_attended_count %> training · <%= featured.on_demand_attended_count %> on-demand
+
<%= featured.live_attended_count %> live · <%= featured.on_demand_attended_count %> on-demand
diff --git a/app/views/events/_scholarship_summary.html.erb b/app/views/events/_scholarship_summary.html.erb index 658b08a815..012f619572 100644 --- a/app/views/events/_scholarship_summary.html.erb +++ b/app/views/events/_scholarship_summary.html.erb @@ -1,10 +1,10 @@ -<%# Compact scholarship headline for the statistics hub, linking to the full +<%# Compact scholarship headline for the reports hub, linking to the full report. Expects `report` (an EventScholarshipReport) and `period` (its resolved PeriodScope: #label, #year and #metrics). %>

Scholarships

- <%= link_to "Full report →", scholarships_events_path(statistics_to_report_params), + <%= link_to "Full report →", scholarships_events_path(hub_to_report_params), class: "text-xs font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline" %>
@@ -15,7 +15,7 @@
Recipients attended
<%= number_with_delimiter(metrics.recipients_attended_count) %>
-
<%= metrics.training_recipients_attended_count %> non-demand · <%= metrics.on_demand_recipients_attended_count %> on-demand
+
<%= metrics.live_recipients_attended_count %> live · <%= metrics.on_demand_recipients_attended_count %> on-demand
Scholarships awarded
@@ -43,6 +43,6 @@ library: { borderRadius: 3 } %>
<% else %> -

No facilitator trainings yet.

+

No facilitator trainings match these filters.

<% end %>
diff --git a/app/views/events/_scholarships_report_table.html.erb b/app/views/events/_scholarships_report_table.html.erb index d0310c4db3..da4093a310 100644 --- a/app/views/events/_scholarships_report_table.html.erb +++ b/app/views/events/_scholarships_report_table.html.erb @@ -85,7 +85,7 @@
Attended breakdown: - <%= report.training_attended_count %> training + <%= report.live_attended_count %> live · <%= report.on_demand_attended_count %> on-demand
diff --git a/app/views/events/_section_heading.html.erb b/app/views/events/_section_heading.html.erb new file mode 100644 index 0000000000..bdc914c870 --- /dev/null +++ b/app/views/events/_section_heading.html.erb @@ -0,0 +1,27 @@ +<%# A collapsible section's heading with its Show/Hide toggle inline to the right, + shared by the roster, attendees index and scholarship-recipients pages so the + three read identically. The heading stays put while its panel is collapsed — + it's what tells you what the toggle would reveal. + Locals: + title: heading text + icon: Font Awesome icon class (e.g. "fa-chart-pie") + theme: DomainTheme key colouring the heading + intensity: optional DomainTheme intensity + panel: the data-panel-toggle-name this section's toggle controls + open: whether that panel is open on this render. Default false. + section: accessible name for the toggle. Defaults to the downcased title. + spacing: extra wrapper classes (e.g. "mt-10"). + margin: bottom margin, "mb-4" by default; pass "" when the caller already + spaces the row (e.g. a heading sharing a row with an action). %> +<% intensity = local_assigns[:intensity] %> +<% text_class = intensity ? DomainTheme.text_class_for(theme, intensity: intensity) : DomainTheme.text_class_for(theme) %> +
<%= local_assigns[:spacing] %>"> +

+ + <%= title %> +

+ <%= render "events/panel_cta", + name: panel, + section: local_assigns.fetch(:section, title.downcase), + open: local_assigns.fetch(:open, false) %> +
diff --git a/app/views/events/_subnav.html.erb b/app/views/events/_subnav.html.erb index d638cdb860..a8eda812fc 100644 --- a/app/views/events/_subnav.html.erb +++ b/app/views/events/_subnav.html.erb @@ -1,17 +1,23 @@ <%# Shared sub-navigation for an event's admin sub-pages. - Pass `event:` and `current:` (one of :dashboard, :registrants, :background, :scholarships, :staff, :edit). + Pass `event:` and `current:` (one of :dashboard, :registrants, :roster, :scholarships, :edit). + Reports links out to the shared cross-event reports hub pre-filtered to this + event, so that page doesn't render this subnav and has no `current:` value. + Staff has no tab — it's reached from the event's public page ("Meet the + staff"), so a `current:` of :staff simply matches nothing here. The current page renders as a non-link underlined tab; the rest are muted links. %>