diff --git a/AGENTS.md b/AGENTS.md index 79461d4af..01899e744 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,7 +194,8 @@ action, or `authorize! :workshop, to: :summary?`). ### Business Logic -- `EventDashboard` — Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total). One population per event — `EventRegistration.active` — so the money and the people figures always reconcile; "who completed the training" is an attendance figure over that population (`#attended_count`), never a narrower population +- `EventDashboard` — Aggregates per-event dashboard metrics (registrant/org/sector/state/county counts, scholarship totals, payment received/outstanding/total). One population per event — `EventRegistration.active` — so the money and the people figures always reconcile; "who completed the training" is an attendance figure over that population (`#attended_count`), never a narrower population. Also exposes the checklist "needs attention" counts + registrant lists +- `EventChecklist` — Builds the ordered, auto-detected admin checklist shown on the event dashboard (setup → before → during & after), each item a to-do/done/not-relevant `Item` with count, drill-in path, and affected registrants; reads everything from `EventDashboard`. Backs `events/_checklist` - `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 diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 63e7c73d5..53d2d10c3 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -128,6 +128,7 @@ def dashboard authorize! @event @event = @event.decorate @dashboard = EventDashboard.new(@event) + @checklist = EventChecklist.new(@dashboard) end # Admin preview of the registration ticket. Builds an in-memory sample diff --git a/app/controllers/forms_controller.rb b/app/controllers/forms_controller.rb index 6e5be9055..d715d6cc2 100644 --- a/app/controllers/forms_controller.rb +++ b/app/controllers/forms_controller.rb @@ -5,6 +5,10 @@ class FormsController < ApplicationController def index authorize! @forms = Form.standalone.order(:name) + if params[:event_id].present? + @event = Event.find(params[:event_id]) + @forms = @forms.joins(:event_forms).where(event_forms: { event_id: @event.id }).distinct + end end # Reference page for the field identifiers that wire a question to backend diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 900705e7e..ad36d8402 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -359,10 +359,11 @@ def self.scholarship_allocatable_ids(scholarships) end } # "linked" = at least one organization linked; "unlinked" = no organization - # linked (whether or not an agency name was submitted); "pending" = the - # registrant submitted an agency name on the event's registration form but - # nothing is linked yet (mirrors the Pending chip on the roster). Needs the - # event to resolve its registration form's agency_name field. + # linked (whether or not an agency name was submitted; matches + # EventRegistrationReadiness#organization_missing?); "pending" = the registrant + # submitted an agency name on the event's registration form but nothing is + # linked yet (a subset of "unlinked", mirroring the Pending chip on the roster). + # "pending" needs the event to resolve its registration form's agency_name field. scope :organization_status, ->(value, event) { linked = EventRegistrationOrganization.select(:event_registration_id) case value diff --git a/app/services/event_checklist.rb b/app/services/event_checklist.rb new file mode 100644 index 000000000..e1d9d3ebb --- /dev/null +++ b/app/services/event_checklist.rb @@ -0,0 +1,384 @@ +# The admin checklist for running an event, derived entirely from system state +# (nothing is manually checkable). Each item is a to-do (outstanding work remains), +# done (satisfied), or not relevant (doesn't apply to this event — e.g. free event, +# no CE, or the event hasn't happened yet), in the order an admin works through the +# event's lifecycle: Set up -> Before the event -> During & after. +# +# Registrant-facing work (payment, CE, scholarship tasks) links to the bulk +# reminder flow pre-filtered to the affected people; admin work (linking orgs, +# allocating bulk payments, sending certificates, reviewing reports) links to the +# roster / Scholarships / bulk-payments / forms / reports pages pre-filtered. +# +# Reuses EventDashboard for every count and registrant list. Not-relevant items +# skip their dashboard queries, so pre-event pages don't compute post-event work. +class EventChecklist + include Rails.application.routes.url_helpers + + PHASES = %i[ setup before after ].freeze + PHASE_LABELS = { + setup: "Set up", + before: "Before the event", + after: "During & after the event" + }.freeze + PHASE_SHORT = { setup: "Set up", before: "Before", after: "After" }.freeze + + # kind: :task (count-based), :flag (binary done/not-done), :action (a standing + # action with no done state, e.g. review reports), :placeholder (not built yet). + # status: :todo / :done / :not_relevant. + # A drill row shown when a non-registrant item expands (e.g. bulk-payment + # submitters): a title, optional subtitle (org), amount, and a link. + DetailRow = Data.define(:title, :subtitle, :amount_cents, :path) + + Item = Data.define(:key, :phase, :title, :actor, :kind, :status, :count, + :money_cents, :detail, :registrants, :detail_rows, :action_path, :action_label) do + def todo? + status == :todo + end + + def done? + status == :done + end + + def not_relevant? + status == :not_relevant + end + + def registrant_task? + actor == :registrant + end + + def trackable? + kind == :task || kind == :flag + end + end + + def initialize(dashboard) + @dashboard = dashboard + @event = dashboard.event + end + + def items + @items ||= [ + setup_forms, setup_callouts, setup_publish, setup_event_type, setup_staff, + link_organizations, onboard_trainees, send_portal_invites, allocate_bulk_payments, review_flagged_comments, + collect_registration_fees, issue_scholarships, set_scholarship_funders, + follow_up_agreements, fix_zero_scholarships, complete_scholarship_tasks, + collect_ce_licenses, collect_ce_fees, issue_ce_certificates, + send_pre_event_reminders, + record_attendance, reconcile_ce_hours, send_completion_certificates, + review_reports, post_event_survey + ] + end + + def todo_items + items.select(&:todo?) + end + + def resolved_items + items.reject(&:todo?) + end + + def todo_items_by_phase + todo_items.group_by(&:phase) + end + + def all_clear? + todo_items.empty? + end + + # Progress reflects only trackable (task/flag) items — standing actions and the + # unbuilt survey placeholder don't have a "done" state. + def relevant_count + trackable_items.count { |item| !item.not_relevant? } + end + + def done_count + trackable_items.count(&:done?) + end + + def todo_count + todo_items.size + end + + private + + def trackable_items + items.select(&:trackable?) + end + + # --- Item builders --------------------------------------------------------- + def task(key:, phase:, title:, actor:, action_path:, action_label:, relevant:, + count: 0, registrants: [], detail_rows: [], money_cents: nil, detail: nil) + status = if !relevant + :not_relevant + elsif count.positive? + :todo + else + :done + end + Item.new(key: key, phase: phase, title: title, actor: actor, kind: :task, + status: status, count: count, money_cents: money_cents, detail: detail, + registrants: registrants, detail_rows: detail_rows, + action_path: action_path, action_label: action_label) + end + + def flag(key:, phase:, title:, actor:, action_path:, action_label:, relevant:, done:, detail: nil) + status = if !relevant + :not_relevant + elsif done + :done + else + :todo + end + Item.new(key: key, phase: phase, title: title, actor: actor, kind: :flag, + status: status, count: nil, money_cents: nil, detail: detail, + registrants: [], detail_rows: [], action_path: action_path, action_label: action_label) + end + + def action(key:, phase:, title:, actor:, action_path:, action_label:, relevant:, detail: nil) + Item.new(key: key, phase: phase, title: title, actor: actor, kind: :action, + status: relevant ? :todo : :not_relevant, count: nil, money_cents: nil, + detail: detail, registrants: [], detail_rows: [], + action_path: relevant ? action_path : nil, action_label: action_label) + end + + def placeholder(key:, phase:, title:, detail:) + Item.new(key: key, phase: phase, title: title, actor: :admin, kind: :placeholder, + status: :not_relevant, count: nil, money_cents: nil, detail: detail, + registrants: [], detail_rows: [], action_path: nil, action_label: nil) + end + + # --- Set up ---------------------------------------------------------------- + def setup_forms + flag(key: :setup_forms, phase: :setup, title: "Set up event forms", actor: :admin, + relevant: true, done: @dashboard.registration_form_ready?, + action_path: forms_path(event_id: @event.id), action_label: "Edit forms", + detail: "Registration, CE and scholarship forms") + end + + def setup_callouts + flag(key: :setup_callouts, phase: :setup, title: "Review ticket callouts", actor: :admin, + relevant: true, done: @dashboard.callouts_reviewed?, + action_path: edit_event_path(@event, expand: "callouts", anchor: "registration_ticket_callouts"), + action_label: "Edit", detail: "Detected from edits to the defaults") + end + + def setup_publish + flag(key: :setup_publish, phase: :setup, title: "Publish the event page", actor: :admin, + relevant: true, done: @dashboard.event_page_published?, + action_path: edit_event_path(@event), action_label: "Edit") + end + + def setup_event_type + flag(key: :setup_event_type, phase: :setup, title: "Mark the event type", actor: :admin, + relevant: true, done: @dashboard.event_type_marked?, + action_path: edit_event_path(@event), action_label: "Edit", + detail: "Facilitator training / on-demand — skip if this is a standard event") + end + + def setup_staff + flag(key: :setup_staff, phase: :setup, title: "Indicate event staff", actor: :admin, + relevant: true, done: @dashboard.staff_indicated?, + action_path: staff_event_path(@event), action_label: "Staff") + end + + # --- Before the event ------------------------------------------------------ + def link_organizations + relevant = @dashboard.has_registrants? + count = relevant ? @dashboard.unlinked_registration_count : 0 + task(key: :link_organizations, phase: :before, title: "Link organizations", actor: :admin, + relevant: relevant, count: count, + registrants: count.positive? ? @dashboard.unlinked_registrants : [], + action_path: registrants_event_path(@event, org_status: "unlinked"), action_label: "Review") + end + + def allocate_bulk_payments + relevant = @dashboard.bulk_payment_present? + count = relevant ? @dashboard.unallocated_bulk_payment_count : 0 + rows = count.positive? ? @dashboard.unallocated_bulk_payment_details.map { |detail| bulk_payment_row(detail) } : [] + task(key: :allocate_bulk_payments, phase: :before, title: "Allocate bulk payments", actor: :admin, + relevant: relevant, count: count, detail_rows: rows, + money_cents: relevant ? @dashboard.unallocated_bulk_payment_cents : nil, + detail: "Received, not yet applied", + action_path: bulk_payments_event_path(@event), action_label: "Allocate") + end + + def bulk_payment_row(detail) + DetailRow.new( + title: detail.name.presence || "Unknown payer", + subtitle: detail.organization, + amount_cents: detail.amount_cents, + path: bulk_payments_event_path(@event, expand: detail.submission_id, anchor: "payment-card-#{detail.submission_id}") + ) + end + + def onboard_trainees + relevant = @dashboard.has_registrants? + count = relevant ? @dashboard.onboarding_incomplete_count : 0 + task(key: :onboard_trainees, phase: :before, title: "Set up trainee onboarding", actor: :admin, + relevant: relevant, count: count, + registrants: count.positive? ? @dashboard.onboarding_incomplete_registrants : [], + detail: "Mailchimp & CMS setup", + action_path: onboarding_event_path(@event), action_label: "Onboarding") + end + + def send_portal_invites + relevant = @dashboard.has_registrants? + count = relevant ? @dashboard.uninvited_registration_count : 0 + task(key: :send_portal_invites, phase: :before, title: "Send portal invites", actor: :admin, + relevant: relevant, count: count, + registrants: count.positive? ? @dashboard.uninvited_registrants : [], + detail: "Registrants with no portal account yet", + action_path: preview_reminder_event_path(@event, mode: "invite"), action_label: "Send invites") + end + + def review_flagged_comments + relevant = @dashboard.has_registrants? + count = relevant ? @dashboard.flagged_comment_count : 0 + task(key: :review_flagged_comments, phase: :before, title: "Review flagged comments", actor: :admin, + relevant: relevant, count: count, + registrants: count.positive? ? @dashboard.flagged_comment_registrants : [], + action_path: registrants_event_path(@event, comment_status: "flagged"), action_label: "Review") + end + + def collect_registration_fees + relevant = @dashboard.has_registrants? && !@dashboard.free? + count = relevant ? @dashboard.unpaid_count : 0 + task(key: :collect_registration_fees, phase: :before, title: "Send reminder: registration fees due", actor: :registrant, + relevant: relevant, count: count, + money_cents: relevant ? @dashboard.outstanding_cents : nil, + registrants: count.positive? ? @dashboard.unpaid_registrants : [], + action_path: preview_reminder_event_path(@event, payment_status: "unpaid"), action_label: "Send") + end + + def issue_scholarships + relevant = @dashboard.scholarship_requests_present? && !@dashboard.free? + count = relevant ? @dashboard.scholarship_uncreated_count : 0 + task(key: :issue_scholarships, phase: :before, title: "Issue scholarships to requesters", actor: :admin, + relevant: relevant, count: count, + registrants: count.positive? ? @dashboard.scholarship_uncreated_registrants : [], + action_path: recipients_event_path(@event), action_label: "Scholarships") + end + + def set_scholarship_funders + relevant = @dashboard.scholarships_present? && !@dashboard.free? + count = relevant ? @dashboard.scholarship_missing_funder_count : 0 + task(key: :set_scholarship_funders, phase: :before, title: "Set scholarship funders", actor: :admin, + relevant: relevant, count: count, + registrants: count.positive? ? @dashboard.scholarship_missing_funder_registrants : [], + detail: "No grant assigned — may be an intentional org subsidy", + action_path: recipients_event_path(@event), action_label: "Scholarships") + end + + def follow_up_agreements + relevant = @dashboard.scholarships_present? && !@dashboard.free? + count = relevant ? @dashboard.scholarship_agreement_unsigned_count : 0 + registrants = count.positive? ? @dashboard.scholarship_agreement_unsigned_registrants : [] + # No semantic reminder filter for "agreement unsigned", so recreate the + # selection on the reminder page via its name filter (multi-value, split on --). + action_path = if registrants.any? + preview_reminder_event_path(@event, name: registrants.map(&:name).join("--")) + else + preview_reminder_event_path(@event) + end + task(key: :follow_up_agreements, phase: :before, title: "Send reminder: scholarship agreements", actor: :registrant, + relevant: relevant, count: count, registrants: registrants, + action_path: action_path, action_label: "Send") + end + + def fix_zero_scholarships + relevant = @dashboard.scholarships_present? && !@dashboard.free? + count = relevant ? @dashboard.scholarship_zero_amount_count : 0 + task(key: :fix_zero_scholarships, phase: :before, title: "Fix $0 scholarship amounts", actor: :admin, + relevant: relevant, count: count, + registrants: count.positive? ? @dashboard.scholarship_zero_amount_registrants : [], + action_path: recipients_event_path(@event), action_label: "Scholarships") + end + + def complete_scholarship_tasks + relevant = @dashboard.scholarships_present? && !@dashboard.free? + count = relevant ? @dashboard.scholarship_tasks_incomplete_count : 0 + task(key: :complete_scholarship_tasks, phase: :before, title: "Send reminder: scholarship tasks", actor: :registrant, + relevant: relevant, count: count, + registrants: count.positive? ? @dashboard.scholarship_tasks_incomplete_registrants : [], + action_path: preview_reminder_event_path(@event, scholarship: "incomplete"), action_label: "Send") + end + + def collect_ce_licenses + relevant = @dashboard.ce_eligible? + count = relevant ? @dashboard.ce_license_missing_count : 0 + task(key: :collect_ce_licenses, phase: :before, title: "Send reminder: CE license numbers", actor: :registrant, + relevant: relevant, count: count, + registrants: count.positive? ? @dashboard.ce_license_missing_registrants : [], + action_path: preview_reminder_event_path(@event, ce_status: "needs_license"), action_label: "Send") + end + + def collect_ce_fees + relevant = @dashboard.ce_eligible? + count = relevant ? @dashboard.cont_ed_unpaid_count : 0 + task(key: :collect_ce_fees, phase: :before, title: "Send reminder: CE fees due", actor: :registrant, + relevant: relevant, count: count, + money_cents: relevant ? @dashboard.cont_ed_outstanding_cents : nil, + registrants: count.positive? ? @dashboard.cont_ed_unpaid_registrants : [], + action_path: preview_reminder_event_path(@event, ce_status: "requested"), action_label: "Send") + end + + def issue_ce_certificates + relevant = @dashboard.ce_eligible? + count = relevant ? @dashboard.ce_certificate_pending_count : 0 + task(key: :issue_ce_certificates, phase: :before, title: "Issue CE certificates", actor: :admin, + relevant: relevant, count: count, + registrants: count.positive? ? @dashboard.ce_certificate_pending_registrants : [], + action_path: registrants_event_path(@event, ce_status: "not_issued"), action_label: "Review") + end + + def send_pre_event_reminders + relevant = @dashboard.has_registrants? && !@dashboard.event_over? + flag(key: :send_pre_event_reminders, phase: :before, title: "Send pre-event reminder emails", actor: :admin, + relevant: relevant, done: @dashboard.pre_event_reminder_sent?, + detail: "Goes to all registrants", + action_path: preview_reminder_event_path(@event), action_label: "Send") + end + + # --- During & after the event ---------------------------------------------- + def record_attendance + relevant = @dashboard.has_registrants? && @dashboard.event_started? + count = relevant ? @dashboard.attendance_pending_count : 0 + task(key: :record_attendance, phase: :after, title: "Record attendance", actor: :admin, + relevant: relevant, count: count, + registrants: count.positive? ? @dashboard.attendance_pending_registrants : [], + action_path: registrants_event_path(@event, attendance_status: "registered"), action_label: "Record") + end + + def reconcile_ce_hours + relevant = @dashboard.event_over? && @dashboard.ce_eligible? + count = relevant ? @dashboard.ce_hours_incomplete_count : 0 + task(key: :reconcile_ce_hours, phase: :after, title: "Reconcile CE hours", actor: :admin, + relevant: relevant, count: count, + registrants: count.positive? ? @dashboard.ce_hours_incomplete_registrants : [], + detail: "Hours are registrant-submitted", + action_path: participation_events_path, action_label: "Participation") + end + + def send_completion_certificates + relevant = @dashboard.event_over? + count = relevant ? @dashboard.completion_certificate_pending_count : 0 + task(key: :send_completion_certificates, phase: :after, title: "Send completion certificates", actor: :admin, + relevant: relevant, count: count, + registrants: count.positive? ? @dashboard.completion_certificate_pending_registrants : [], + action_path: registrants_event_path(@event, readiness: "certificate_due"), action_label: "Review") + end + + def review_reports + action(key: :review_reports, phase: :after, title: "Review event reports", actor: :admin, + relevant: @dashboard.event_over?, + action_path: reports_events_path, action_label: "View reports") + end + + def post_event_survey + # TODO(post-event-survey): wire up detection + drill-in once the survey + # feature exists (no survey model/flow is built today). + placeholder(key: :post_event_survey, phase: :after, title: "Follow up post-event survey", + detail: "Coming soon (not built)") + end +end diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index 7cfb89864..9ad1758b8 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -10,6 +10,10 @@ def initialize(event, scholarship_funder: nil) attr_reader :event + # One unallocated bulk-payment submission: who submitted it, their org (the + # payer_organization answer), the amount still to apply, and ids for linking. + BulkPaymentDetail = Data.define(:name, :organization, :amount_cents, :submission_id, :slug) + def registrant_count active_registration_ids.size end @@ -479,6 +483,222 @@ def cont_ed_due_by_registrant @cont_ed_due_by_registrant ||= ce_cents_by_registrant { |ce_registration| ce_due_cents(ce_registration) } end + # --- Checklist / needs attention ------------------------------------------ + # Auto-detected outstanding admin work, consumed by EventChecklist. Each count + # pairs with a *_registrants list (the people behind it) for the dashboard's + # expandable checklist rows; relevance helpers below gate which items apply. + + # Registrant list behind #unlinked_registration_count — the admin's org-linking + # queue (active registrations with no organization linked). The count itself is + # #unlinked_registration_count. + def unlinked_registrants + @unlinked_registrants ||= people_sorted(unlinked_registrant_ids) + end + + # Bulk-payment money received but not yet applied to a registration. + def unallocated_bulk_payment_count + @unallocated_bulk_payment_count ||= bulk_payments.where("amount_cents_remaining > 0").count + end + + def bulk_payment_present? + bulk_payments.exists? + end + + # Per-submission breakdown behind #unallocated_bulk_payment_count: who submitted + # each bulk payment (name + org) and how much of it is still unapplied. + def unallocated_bulk_payment_details + @unallocated_bulk_payment_details ||= event.form_submissions + .where(role: "bulk_payment") + .includes(:person, :payment, form_answers: :form_field) + .filter_map do |submission| + payment = submission.payment + next unless payment && payment.amount_cents_remaining.positive? + BulkPaymentDetail.new( + name: submission.person&.name, + organization: submission.answers_by_identifier["payer_organization"].presence, + amount_cents: payment.amount_cents_remaining, + submission_id: submission.id, + slug: submission.slug + ) + end + end + + # Active registrations carrying a flagged comment an admin should review. + def flagged_comment_count + flagged_comment_registrant_ids.size + end + + def flagged_comment_registrants + @flagged_comment_registrants ||= people_sorted(flagged_comment_registrant_ids) + end + + # Active registrants who haven't been fully set up in every onboarding system + # (Mailchimp + CMS, per EventRegistration::CHECKLIST_STEPS). + def onboarding_incomplete_count + onboarding_incomplete_registrant_ids.size + end + + def onboarding_incomplete_registrants + @onboarding_incomplete_registrants ||= people_sorted(onboarding_incomplete_registrant_ids) + end + + # Active registrants with no portal account yet — the people a login invite is for + # (matches the reminders flow's invite mode, which targets user-less registrants). + def uninvited_registration_count + uninvited_registrant_ids.size + end + + def uninvited_registrants + @uninvited_registrants ||= people_sorted(uninvited_registrant_ids) + end + + # Scholarship requested on the registration but no Scholarship created yet. + def scholarship_uncreated_count + scholarship_uncreated_registrant_ids.size + end + + def scholarship_uncreated_registrants + @scholarship_uncreated_registrants ||= people_sorted(scholarship_uncreated_registrant_ids) + end + + # Scholarships with no grant assigned — may still need a funder (or be an + # intentional org subsidy; best-effort signal). + def scholarship_missing_funder_count + scholarship_missing_funder_recipient_ids.size + end + + def scholarship_missing_funder_registrants + @scholarship_missing_funder_registrants ||= people_sorted(scholarship_missing_funder_recipient_ids) + end + + # Scholarships still sitting at $0 — a placeholder amount to fill in. + def scholarship_zero_amount_count + scholarship_zero_amount_recipient_ids.size + end + + def scholarship_zero_amount_registrants + @scholarship_zero_amount_registrants ||= people_sorted(scholarship_zero_amount_recipient_ids) + end + + # Scholarship recipients whose tasks aren't complete yet. + def scholarship_tasks_incomplete_count + scholarship_tasks_incomplete_recipient_ids.size + end + + def scholarship_tasks_incomplete_registrants + @scholarship_tasks_incomplete_registrants ||= people_sorted(scholarship_tasks_incomplete_recipient_ids) + end + + # Scholarship recipients who haven't signed their agreement yet. + def scholarship_agreement_unsigned_count + scholarship_agreement_unsigned_recipient_ids.size + end + + def scholarship_agreement_unsigned_registrants + @scholarship_agreement_unsigned_registrants ||= people_sorted(scholarship_agreement_unsigned_recipient_ids) + end + + # CE registrants missing a professional-license number. + def ce_license_missing_count + ce_license_missing_registrant_ids.size + end + + def ce_license_missing_registrants + @ce_license_missing_registrants ||= people_sorted(ce_license_missing_registrant_ids) + end + + # CE registrants whose CE certificate hasn't been sent. + def ce_certificate_pending_count + ce_certificate_pending_registrant_ids.size + end + + def ce_certificate_pending_registrants + @ce_certificate_pending_registrants ||= people_sorted(ce_certificate_pending_registrant_ids) + end + + # CE registrants whose recorded hours fall short of the event's offered hours. + def ce_hours_incomplete_count + ce_hours_incomplete_registrant_ids.size + end + + def ce_hours_incomplete_registrants + @ce_hours_incomplete_registrants ||= people_sorted(ce_hours_incomplete_registrant_ids) + end + + # Active registrants with no attendance outcome recorded yet (still "registered"). + def attendance_pending_count + attendance_count_for("registered") + end + + def attendance_pending_registrants + attendance_registrants("registered") + end + + # Attended registrations whose completion certificate hasn't been sent + # (readiness == :certificate_due with the registration cert still outstanding). + def completion_certificate_pending_count + completion_certificate_pending_registrations.size + end + + def completion_certificate_pending_registrants + @completion_certificate_pending_registrants ||= + people_sorted(completion_certificate_pending_registrations.map(&:registrant_id)) + end + + # True once any pre-event reminder has been sent to this event's registrants. + def pre_event_reminder_sent? + Notification.where(noticeable_type: "EventRegistration", noticeable_id: active_registration_ids, + kind: "event_registration_reminder").exists? + end + + # --- Relevance helpers ----------------------------------------------------- + def has_registrants? + registrant_count.positive? + end + + def event_started? + event.start_date.present? && event.start_date <= Time.current + end + + def event_over? + reference = event.end_date || event.start_date + reference.present? && reference.to_date < Date.current + end + + def ce_eligible? + event.ce_eligible? + end + + def scholarships_present? + scholarship_recipient_count.positive? + end + + def scholarship_requests_present? + scholarship_applicant_ids.any? + end + + def registration_form_ready? + event.registration_form&.form_fields&.published&.exists? || false + end + + def callouts_reviewed? + event.registration_ticket_callouts.any? do |callout| + callout.builtin? ? BuiltinCallouts.customized?(callout) : true + end + end + + def event_page_published? + event.publicly_visible? + end + + def event_type_marked? + event.facilitator_training? || event.on_demand? + end + + def staff_indicated? + event.event_staffs.exists? + end + def free? event.cost_cents.to_i <= 0 end @@ -971,6 +1191,103 @@ def settings_registrant_ids_by_category private + # --- Checklist id helpers -------------------------------------------------- + # Reuses #linked_registration_ids (the "N unlinked" dashboard flag) so the + # checklist's list and that count stay in lockstep. + def unlinked_registrant_ids + @unlinked_registrant_ids ||= active_registrations.where.not(id: linked_registration_ids).pluck(:registrant_id) + end + + def flagged_comment_registrant_ids + @flagged_comment_registrant_ids ||= begin + registration_ids = Comment.flagged + .where(commentable_type: "EventRegistration", commentable_id: active_registration_ids) + .distinct + .pluck(:commentable_id) + registration_ids.filter_map { |id| registrant_id_by_registration[id] }.uniq + end + end + + def uninvited_registrant_ids + @uninvited_registrant_ids ||= active_registrations.account_status("none").distinct.pluck(:registrant_id) + end + + def onboarding_incomplete_registrant_ids + @onboarding_incomplete_registrant_ids ||= begin + fully_onboarded_ids = EventRegistrationChecklistCompletion + .where(event_registration_id: active_registration_ids) + .group(:event_registration_id) + .having("COUNT(DISTINCT step) >= ?", EventRegistration::CHECKLIST_STEPS.size) + .pluck(:event_registration_id) + (active_registration_ids - fully_onboarded_ids).filter_map { |id| registrant_id_by_registration[id] } + end + end + + def scholarship_uncreated_registrant_ids + @scholarship_uncreated_registrant_ids ||= begin + requested = active_registrations.where(scholarship_requested: true) + created_ids = requested.with_scholarship.pluck(:id) + requested.where.not(id: created_ids).pluck(:registrant_id) + end + end + + def scholarship_missing_funder_recipient_ids + @scholarship_missing_funder_recipient_ids ||= scholarships.where(grant_id: nil).distinct.pluck(:recipient_id) + end + + def scholarship_zero_amount_recipient_ids + @scholarship_zero_amount_recipient_ids ||= scholarships.where(amount_cents: 0).distinct.pluck(:recipient_id) + end + + def scholarship_tasks_incomplete_recipient_ids + @scholarship_tasks_incomplete_recipient_ids ||= scholarships.where(tasks_completed: false).distinct.pluck(:recipient_id) + end + + def scholarship_agreement_unsigned_recipient_ids + @scholarship_agreement_unsigned_recipient_ids ||= scholarships.where(agreement_signed_at: nil).distinct.pluck(:recipient_id) + end + + def ce_license_missing_registrant_ids + @ce_license_missing_registrant_ids ||= active_registrations.ce_status("needs_license").distinct.pluck(:registrant_id) + end + + def ce_certificate_pending_registrant_ids + @ce_certificate_pending_registrant_ids ||= active_registrations.ce_status("not_issued").distinct.pluck(:registrant_id) + end + + def ce_hours_incomplete_registrant_ids + @ce_hours_incomplete_registrant_ids ||= begin + offered = event.ce_hours_offered.to_f + if offered.positive? + ce_registrations + .select { |ce_registration| ce_registration.hours.to_f < offered } + .filter_map { |ce_registration| registrant_id_by_registration[ce_registration.event_registration_id] } + .uniq + else + [] + end + end + end + + def completion_certificate_pending_registrations + @completion_certificate_pending_registrations ||= certificate_due_registrations.reject(&:certificate_sent?) + end + + def certificate_due_registrations + @certificate_due_registrations ||= readiness_registrations.select do |registration| + EventRegistrationReadiness.new(registration).status == :certificate_due + end + end + + # Active registrations preloaded with everything EventRegistrationReadiness + # reads, so per-registration readiness runs without extra queries. + def readiness_registrations + @readiness_registrations ||= active_registrations + .includes(:event, :organizations, :allocations, :scholarships, + { continuing_education_registrations: [ :professional_license, :allocations ] }) + .to_a + end + # 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 diff --git a/app/views/events/_checklist.html.erb b/app/views/events/_checklist.html.erb new file mode 100644 index 000000000..ba9fec5ea --- /dev/null +++ b/app/views/events/_checklist.html.erb @@ -0,0 +1,64 @@ +<%# + Event checklist panel — the admin's auto-detected to-do list for the whole event + lifecycle, shown under the headcount cards on the dashboard. Outstanding work sits + at the top grouped by phase; satisfied / not-applicable items collapse below. + Locals: checklist (EventChecklist). +%> +<% + done = checklist.done_count + relevant = checklist.relevant_count + progress = relevant.positive? ? (done * 100 / relevant) : 0 + resolved_done = checklist.resolved_items.count(&:done?) + resolved_na = checklist.resolved_items.size - resolved_done +%> +
+
+
+ Event checklist +
+
+ + <% if checklist.all_clear? %> + All caught up + <% else %> + <%= pluralize(checklist.todo_count, "to do") %> + <% end %> +
+
+ + <% unless checklist.all_clear? %> +
+ <% EventChecklist::PHASES.each do |phase| %> + <% phase_items = checklist.todo_items_by_phase[phase] %> + <% next unless phase_items.present? %> +
+

<%= EventChecklist::PHASE_LABELS[phase] %> · <%= pluralize(phase_items.size, "to do") %>

+
+ <% phase_items.each do |item| %> + <%= render "events/checklist_item", item: item %> + <% end %> +
+
+ <% end %> +
+ <% end %> + + <% if checklist.resolved_items.any? %> +
+ + + Done & not applicable · <%= resolved_done %> done · <%= resolved_na %> n/a + +
+ <% checklist.resolved_items.each do |item| %> + <%= render "events/checklist_resolved_item", item: item %> + <% end %> +
+
+ <% end %> +
diff --git a/app/views/events/_checklist_badges.html.erb b/app/views/events/_checklist_badges.html.erb new file mode 100644 index 000000000..5a3b1491f --- /dev/null +++ b/app/views/events/_checklist_badges.html.erb @@ -0,0 +1,10 @@ +<%# + The count + money badges shown after a checklist item's title. + Locals: item (EventChecklist::Item). +%> +<% if item.count.present? && item.count.positive? %> + <%= item.count %> +<% end %> +<% if item.money_cents.present? && item.money_cents.positive? %> + <%= dollars_from_cents(item.money_cents) %> +<% end %> diff --git a/app/views/events/_checklist_item.html.erb b/app/views/events/_checklist_item.html.erb new file mode 100644 index 000000000..1a2b5c73c --- /dev/null +++ b/app/views/events/_checklist_item.html.erb @@ -0,0 +1,86 @@ +<%# + A single to-do row in the event checklist. Anatomy: + [actor chip · fixed width] [icon] [title] [count / money] [chevron] ...... [CTA] + The fixed-width chip keeps titles aligned for easy vertical scanning. People-based + items expand (collapsed by default) to reveal exactly who the item is for; item- + level and money-only items render without a toggle. + + Locals: item (EventChecklist::Item). +%> +<% + icons = { + setup_forms: "fa-file-lines", setup_callouts: "fa-comment-dots", + setup_publish: "fa-globe", setup_event_type: "fa-tags", setup_staff: "fa-user-tie", + link_organizations: "fa-building-circle-exclamation", onboard_trainees: "fa-user-gear", + send_portal_invites: "fa-right-to-bracket", allocate_bulk_payments: "fa-money-check-dollar", + review_flagged_comments: "fa-flag", collect_registration_fees: "fa-hand-holding-dollar", + issue_scholarships: "fa-graduation-cap", set_scholarship_funders: "fa-hand-holding-heart", + follow_up_agreements: "fa-file-signature", fix_zero_scholarships: "fa-dollar-sign", + complete_scholarship_tasks: "fa-list-check", collect_ce_licenses: "fa-id-card", + collect_ce_fees: "fa-award", issue_ce_certificates: "fa-certificate", + send_pre_event_reminders: "fa-bell", record_attendance: "fa-user-check", + reconcile_ce_hours: "fa-clock-rotate-left", send_completion_certificates: "fa-certificate", + review_reports: "fa-chart-line" + } + icon = icons.fetch(item.key, "fa-circle-dot") + chip = item.registrant_task? ? { label: "remind", classes: "bg-emerald-50 text-emerald-600" } : { label: "admin", classes: "bg-blue-50 text-blue-600" } + cta_classes = case item.action_label + when "Scholarships" then "border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100" + when "Send reminder", "Send", "Send invites" then "border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100" + else "border-blue-200 bg-blue-50 text-blue-700 hover:bg-blue-100" + end +%> +
+ <%= chip[:label] %> + <% if item.registrants.any? || item.detail_rows.any? %> +
+ + + + <%= item.title %> + <%= render "events/checklist_badges", item: item %> + + + + <% if item.detail.present? %>

<%= item.detail %>

<% end %> + +
+ <% else %> +
+
+ + <%= item.title %> + <%= render "events/checklist_badges", item: item %> +
+ <% if item.detail.present? %>

<%= item.detail %>

<% end %> +
+ <% end %> + <% if item.action_path.present? %> + <%= link_to item.action_path, class: "shrink-0 inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-semibold whitespace-nowrap #{cta_classes}" do %> + <%= item.action_label %> + <% end %> + <% end %> +
diff --git a/app/views/events/_checklist_resolved_item.html.erb b/app/views/events/_checklist_resolved_item.html.erb new file mode 100644 index 000000000..719ca78d9 --- /dev/null +++ b/app/views/events/_checklist_resolved_item.html.erb @@ -0,0 +1,24 @@ +<%# + A one-line row in the checklist's collapsed "Done & not applicable" section: + a done item (green check) or a not-relevant / not-yet / unbuilt item (muted). + Locals: item (EventChecklist::Item). +%> +<% + lead_icon = if item.done? + "fa-circle-check text-emerald-500" + elsif item.kind == :placeholder + "fa-flask text-gray-300" + else + "fa-clock text-gray-300" + end + text_class = item.done? ? "text-gray-500" : "text-gray-400" +%> +
+ + <%= EventChecklist::PHASE_SHORT[item.phase] %> + <%= item.title %> + <% if item.count.present? && item.count.positive? %> + <%= item.count %> + <% end %> + <% if item.detail.present? %><%= item.detail %><% end %> +
diff --git a/app/views/events/dashboard.html.erb b/app/views/events/dashboard.html.erb index c1651074f..386d0beca 100644 --- a/app/views/events/dashboard.html.erb +++ b/app/views/events/dashboard.html.erb @@ -446,5 +446,7 @@ + + <%= render "checklist", checklist: @checklist %> diff --git a/app/views/forms/index.html.erb b/app/views/forms/index.html.erb index ae45104fa..68f24d43a 100644 --- a/app/views/forms/index.html.erb +++ b/app/views/forms/index.html.erb @@ -16,6 +16,14 @@ + <% if @event.present? %> +
+ + Showing forms connected to <%= @event.title %> + <%= link_to "Clear filter", forms_path, class: "text-blue-600 hover:underline" %> +
+ <% end %> +
diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 93bcd9b4a..42f67be42 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -326,6 +326,21 @@ def add_ce_registrant(target_event) end end + describe "GET /dashboard" do + let(:event) { create(:event, :publicly_visible, published: true, cost_cents: 10_000) } + + it "renders the event checklist, including an expandable to-do row" do + create(:event_registration, event: event, registrant: create(:person), status: "registered") + sign_in admin + get dashboard_event_path(event) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Event checklist") + # An unpaid registrant with no org linked surfaces reminder + admin to-dos. + expect(response.body).to include("Send reminder: registration fees due") + expect(response.body).to include("Link organizations") + end + end + describe "GET /participation" do let!(:training_2026) { create(:event, title: "TAC 261", facilitator_training: true, start_date: Date.new(2026, 5, 1)) } let!(:webinar_2025) { create(:event, title: "Open webinar", facilitator_training: false, start_date: Date.new(2025, 5, 1)) } diff --git a/spec/requests/forms_spec.rb b/spec/requests/forms_spec.rb index c5ecd6a7d..ec1aef2c8 100644 --- a/spec/requests/forms_spec.rb +++ b/spec/requests/forms_spec.rb @@ -14,6 +14,16 @@ expect(response).to have_http_status(:success) expect(response.body).to include("My Form") end + + it "filters to an event's connected forms when event_id is given" do + event = create(:event) + connected = create(:form, :standalone, name: "Connected Form") + create(:form, :standalone, name: "Other Form") + create(:event_form, event: event, form: connected, role: "registration") + get forms_path(event_id: event.id) + expect(response.body).to include("Connected Form") + expect(response.body).not_to include("Other Form") + end end context "as regular user" do diff --git a/spec/services/event_checklist_spec.rb b/spec/services/event_checklist_spec.rb new file mode 100644 index 000000000..de2748b72 --- /dev/null +++ b/spec/services/event_checklist_spec.rb @@ -0,0 +1,202 @@ +require "rails_helper" + +RSpec.describe EventChecklist do + subject(:checklist) { described_class.new(EventDashboard.new(event)) } + + def item(key) + checklist.items.find { |i| i.key == key } + end + + describe "structure" do + let(:event) { create(:event) } + + it "lists items in lifecycle order across the phases" do + keys = checklist.items.map(&:key) + expect(keys.first(5)).to eq(%i[ setup_forms setup_callouts setup_publish setup_event_type setup_staff ]) + expect(keys.last(2)).to eq(%i[ review_reports post_event_survey ]) + end + + it "keeps the user's scholarship sub-order (issue -> funders -> agreements -> $0 -> tasks)" do + scholarship_keys = %i[ issue_scholarships set_scholarship_funders follow_up_agreements + fix_zero_scholarships complete_scholarship_tasks ] + ordered = checklist.items.map(&:key).select { |k| scholarship_keys.include?(k) } + expect(ordered).to eq(scholarship_keys) + end + + it "always keeps the post-event survey as an unbuilt placeholder" do + expect(item(:post_event_survey).kind).to eq(:placeholder) + expect(item(:post_event_survey)).to be_not_relevant + end + + it "models review reports as a standing action, not a done-able task" do + expect(item(:review_reports).kind).to eq(:action) + end + + it "prefixes every registrant-facing reminder item with 'Send reminder'" do + reminders = checklist.items.select(&:registrant_task?) + expect(reminders).to be_present + expect(reminders.map(&:title)).to all(start_with("Send reminder")) + end + end + + describe "before the event, with a registrant" do + let(:event) { create(:event, cost_cents: 10_000) } + let(:registrant) { create(:person) } + + before { create(:event_registration, event: event, registrant: registrant, status: "registered") } + + it "flags unpaid registrants as a reminder to-do" do + fees = item(:collect_registration_fees) + expect(fees).to be_todo + expect(fees.count).to eq(1) + expect(fees.registrants).to include(registrant) + expect(fees.money_cents).to eq(10_000) + expect(fees.action_path).to include("preview_reminder", "payment_status=unpaid") + end + + it "surfaces registrations with no organization via the roster unlinked filter" do + link = item(:link_organizations) + expect(link).to be_todo + expect(link.count).to eq(1) + expect(link.action_path).to include("org_status=unlinked") + end + + it "does not surface during/after items before the event happens" do + %i[ record_attendance reconcile_ce_hours send_completion_certificates review_reports ].each do |key| + expect(item(key)).to be_not_relevant + end + end + end + + describe "a free event" do + let(:event) { create(:event, cost_cents: 0) } + + before { create(:event_registration, event: event, registrant: create(:person), status: "registered") } + + it "hides the fee and scholarship items" do + %i[ collect_registration_fees issue_scholarships set_scholarship_funders + follow_up_agreements fix_zero_scholarships complete_scholarship_tasks ].each do |key| + expect(item(key)).to be_not_relevant + end + end + end + + describe "after the event" do + let(:event) { create(:event, :ended, cost_cents: 10_000) } + + before { create(:event_registration, event: event, registrant: create(:person), status: "registered") } + + it "surfaces recording attendance for registrants with no outcome" do + attendance = item(:record_attendance) + expect(attendance).to be_todo + expect(attendance.count).to eq(1) + expect(attendance.action_path).to include("attendance_status=registered") + end + + it "turns reviewing reports into a standing to-do pointing at the stats hub" do + reports = item(:review_reports) + expect(reports).to be_todo + expect(reports.action_path).to eq(Rails.application.routes.url_helpers.reports_events_path) + end + end + + describe "staff setup" do + let(:event) { create(:event) } + + it "is a to-do until event staff are indicated, then done" do + expect(item(:setup_staff)).to be_todo + expect(item(:setup_staff).action_path).to eq(Rails.application.routes.url_helpers.staff_event_path(event)) + create(:event_staff, event: event) + expect(described_class.new(EventDashboard.new(event)).items.find { |i| i.key == :setup_staff }).to be_done + end + end + + describe "trainee onboarding" do + let(:event) { create(:event) } + let(:person) { create(:person) } + let!(:reg) { create(:event_registration, event: event, registrant: person, status: "registered") } + + it "is a to-do until every onboarding step is complete, linking to the onboarding page" do + onboarding = item(:onboard_trainees) + expect(onboarding).to be_todo + expect(onboarding.count).to eq(1) + expect(onboarding.registrants).to eq([ person ]) + expect(onboarding.action_path).to eq(Rails.application.routes.url_helpers.onboarding_event_path(event)) + + EventRegistration::CHECKLIST_STEPS.keys.each { |step| reg.checklist_completions.create!(step: step) } + refreshed = described_class.new(EventDashboard.new(event)).items.find { |i| i.key == :onboard_trainees } + expect(refreshed).to be_done + end + end + + describe "portal invites" do + let(:event) { create(:event) } + let(:person) { create(:person, user: nil) } + let!(:reg) { create(:event_registration, event: event, registrant: person, status: "registered") } + + it "is a to-do for registrants with no portal account, linking to invite mode" do + invites = item(:send_portal_invites) + expect(invites).to be_todo + expect(invites.count).to eq(1) + expect(invites.registrants).to eq([ person ]) + expect(invites.action_path).to include("preview_reminder", "mode=invite") + + create(:user, person: person) + refreshed = described_class.new(EventDashboard.new(event)).items.find { |i| i.key == :send_portal_invites } + expect(refreshed).to be_done + end + end + + describe "bulk payments" do + let(:event) { create(:event, cost_cents: 10_000) } + let(:form) { create(:form) } + let(:payer) { create(:person, first_name: "Helena", last_name: "Lopez") } + let!(:submission) { create(:form_submission, form: form, event: event, person: payer, role: "bulk_payment") } + + before do + field = create(:form_field, form: form, field_identifier: "payer_organization") + create(:form_answer, form_submission: submission, form_field: field, submitted_answer: "A Greater Hope") + create(:payment, form_submission: submission, amount_cents: 7_500, amount_cents_remaining: 7_500) + end + + it "expands to each submitter with their org and remaining amount" do + bulk = item(:allocate_bulk_payments) + expect(bulk).to be_todo + expect(bulk.count).to eq(1) + expect(bulk.money_cents).to eq(7_500) + row = bulk.detail_rows.first + expect(row.title).to eq(payer.name) + expect(row.subtitle).to eq("A Greater Hope") + expect(row.amount_cents).to eq(7_500) + end + end + + describe "scholarship agreement reminders" do + let(:event) { create(:event, cost_cents: 10_000) } + let(:recipient) { create(:person) } + + before do + registration = create(:event_registration, event: event, registrant: recipient, status: "registered") + scholarship = create(:scholarship, recipient: recipient, amount_cents: 5_000, + tasks_completed: true, agreement_signed_at: nil) + create(:allocation, source: scholarship, allocatable: registration, amount: 5_000) + end + + it "routes agreement follow-ups to the reminder page via its name filter" do + agreements = item(:follow_up_agreements) + expect(agreements).to be_todo + expect(agreements.action_path).to include("preview_reminder", "name=") + end + end + + describe "progress buckets" do + let(:event) { create(:event, :publicly_visible, published: true) } + + it "counts done vs relevant over trackable items only and resolves the placeholder" do + expect(checklist.relevant_count).to be_positive + expect(checklist.done_count).to be <= checklist.relevant_count + expect(checklist.resolved_items).to include(item(:post_event_survey)) + expect(item(:setup_publish)).to be_done + end + end +end diff --git a/spec/services/event_dashboard_spec.rb b/spec/services/event_dashboard_spec.rb index 2b0644561..92283f87b 100644 --- a/spec/services/event_dashboard_spec.rb +++ b/spec/services/event_dashboard_spec.rb @@ -1190,4 +1190,38 @@ def opt_in(person, text:) expect(affiliation_queries).to be <= 3 end end + + describe "checklist counts" do + let(:event) { create(:event, cost_cents: 10_000) } + + it "lists the registrants behind the unlinked-organization count" do + person = create(:person) + create(:event_registration, event: event, registrant: person, status: "registered") + dashboard = described_class.new(event) + expect(dashboard.unlinked_registration_count).to eq(1) + expect(dashboard.unlinked_registrants).to eq([ person ]) + end + + it "counts scholarships still at $0 and those missing a funder" do + recipient = create(:person) + registration = create(:event_registration, event: event, registrant: recipient, status: "registered") + scholarship = create(:scholarship, recipient: recipient, amount_cents: 0, grant: nil, + tasks_completed: false, agreement_signed_at: nil) + create(:allocation, source: scholarship, allocatable: registration, amount: 0) + dashboard = described_class.new(event) + expect(dashboard.scholarship_zero_amount_count).to eq(1) + expect(dashboard.scholarship_missing_funder_count).to eq(1) + expect(dashboard.scholarship_agreement_unsigned_count).to eq(1) + expect(dashboard.scholarship_zero_amount_registrants).to eq([ recipient ]) + end + + it "reports event_over? / event_started? from the event dates" do + past = described_class.new(create(:event, :ended)) + future = described_class.new(create(:event)) + expect(past.event_over?).to be(true) + expect(past.event_started?).to be(true) + expect(future.event_over?).to be(false) + expect(future.event_started?).to be(false) + end + end end