From 18d5d489c104d9811a1d6c0c09c2a3498606bf7f Mon Sep 17 00:00:00 2001 From: S Mae Beale Date: Sun, 9 Aug 2026 10:40:49 -0400 Subject: [PATCH 1/9] Add auto-detected admin checklist to the event dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins had no at-a-glance view of the outstanding work to run an event; they had to infer it from roster filters, the scholarships page, bulk payments, etc. This adds an "Event checklist" panel under the headcount cards that derives the whole lifecycle of to-dos (set up -> before the event -> during & after) purely from system state — nothing is manually checkable. Outstanding work groups by phase at the top; satisfied / not-applicable items collapse below. Registrant tasks link to the bulk-reminder flow pre-filtered; admin tasks link to the roster / Scholarships / bulk-payments / forms / reports pages pre-filtered. Also adds the drill-in plumbing the checklist needs: an org_status=none roster filter (registrations with no organization linked) and an event_id filter on the forms index. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 3 +- app/controllers/events_controller.rb | 1 + app/controllers/forms_controller.rb | 4 + app/models/event_registration.rb | 9 +- app/services/event_checklist.rb | 336 ++++++++++++++++++ app/services/event_dashboard.rb | 255 +++++++++++++ app/views/events/_checklist.html.erb | 64 ++++ app/views/events/_checklist_badges.html.erb | 10 + app/views/events/_checklist_item.html.erb | 72 ++++ .../events/_checklist_resolved_item.html.erb | 24 ++ app/views/events/dashboard.html.erb | 2 + app/views/forms/index.html.erb | 8 + spec/models/event_registration_spec.rb | 1 + spec/requests/events_spec.rb | 11 + spec/requests/forms_spec.rb | 10 + spec/services/event_checklist_spec.rb | 107 ++++++ spec/services/event_dashboard_spec.rb | 34 ++ 17 files changed, 946 insertions(+), 5 deletions(-) create mode 100644 app/services/event_checklist.rb create mode 100644 app/views/events/_checklist.html.erb create mode 100644 app/views/events/_checklist_badges.html.erb create mode 100644 app/views/events/_checklist_item.html.erb create mode 100644 app/views/events/_checklist_resolved_item.html.erb create mode 100644 spec/services/event_checklist_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 79461d4afc..01899e744a 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 63e7c73d5b..53d2d10c36 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 6e5be9055a..d715d6cc2e 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 900705e7e3..ad36d84025 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 0000000000..c48b7bbd63 --- /dev/null +++ b/app/services/event_checklist.rb @@ -0,0 +1,336 @@ +# 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. + Item = Data.define(:key, :phase, :title, :actor, :kind, :status, :count, + :money_cents, :detail, :registrants, :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, + link_organizations, 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: [], 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, 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: [], 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: [], + 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: [], 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 + + # --- 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 + task(key: :allocate_bulk_payments, phase: :before, title: "Allocate bulk payments", actor: :admin, + relevant: relevant, count: count, + 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 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: "Collect registration fees", 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 reminder") + 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 + task(key: :follow_up_agreements, phase: :before, title: "Follow up scholarship agreements", actor: :registrant, + relevant: relevant, count: count, + registrants: count.positive? ? @dashboard.scholarship_agreement_unsigned_registrants : [], + action_path: recipients_event_path(@event), action_label: "Scholarships") + 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: "Complete 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 reminder") + 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: "Collect 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 reminder") + 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: "Collect CE fees", 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 reminder") + 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 admin-entered — best-effort", + action_path: registrants_event_path(@event, ce_status: "registered"), action_label: "Review") + 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: statistics_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 7cfb89864f..939af526f5 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -479,6 +479,179 @@ 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 + + # 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 + + # 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 free? event.cost_cents.to_i <= 0 end @@ -971,6 +1144,88 @@ 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 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 0000000000..ba9fec5ea1 --- /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 0000000000..5a3b1491f1 --- /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 0000000000..8137a90174 --- /dev/null +++ b/app/views/events/_checklist_item.html.erb @@ -0,0 +1,72 @@ +<%# + 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", + link_organizations: "fa-building-circle-exclamation", 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" 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.title %> + <%= render "events/checklist_badges", item: item %> + + + + <% if item.detail.present? %>

<%= item.detail %>

<% end %> +
    + <% item.registrants.each do |registrant| %> +
  • + <%= link_to registrants_event_path(@event, registrant_ids: registrant.id), + class: "group/rowlink flex items-center justify-between gap-2 rounded px-1 -mx-1 py-0.5 hover:bg-gray-50" do %> + <%= registrant.name %> + + <% end %> +
  • + <% 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 0000000000..719ca78d9d --- /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 c1651074fa..386d0beca4 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 ae45104fa1..68f24d43ae 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/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index 17c492fd0b..7d466c7585 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -1346,4 +1346,5 @@ def registration_for(person) expect(preloaded.paid_in_full?).to be(true) end end + end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 93bcd9b4a6..5f13648226 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -326,6 +326,17 @@ def add_ce_registrant(target_event) end end + describe "GET /dashboard" do + let(:event) { create(:event, :publicly_visible, published: true) } + + it "renders the event checklist for an admin" do + sign_in admin + get dashboard_event_path(event) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Event checklist") + 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 c5ecd6a7d2..ec1aef2c84 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 0000000000..ab294fee98 --- /dev/null +++ b/spec/services/event_checklist_spec.rb @@ -0,0 +1,107 @@ +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(4)).to eq(%i[ setup_forms setup_callouts setup_publish setup_event_type ]) + 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 + 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.statistics_events_path) + 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 2b0644561b..92283f87bf 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 From 111901c1a81f4205c744dd8e355f08db8f63643a Mon Sep 17 00:00:00 2001 From: S Mae Beale Date: Sun, 9 Aug 2026 11:59:24 -0400 Subject: [PATCH 2/9] Expand bulk-payment checklist row and label reminders "Remind" The "Allocate bulk payments" row now expands to show who submitted each bulk payment (name + payer organization) and how much of it is still unapplied, so an admin can see the money's source without leaving the dashboard. Registrant-facing checklist items are retitled with a "Remind" prefix and all route to the bulk-reminder flow pre-filtered by criteria (payment/CE/scholarship status); the scholarship-agreement reminder, which has no semantic filter, recreates its recipient selection via the reminder page's name filter. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/event_checklist.rb | 52 ++++++++++++++++------- app/services/event_dashboard.rb | 23 ++++++++++ app/views/events/_checklist_item.html.erb | 31 ++++++++++---- spec/requests/events_spec.rb | 8 +++- spec/services/event_checklist_spec.rb | 48 +++++++++++++++++++++ 5 files changed, 136 insertions(+), 26 deletions(-) diff --git a/app/services/event_checklist.rb b/app/services/event_checklist.rb index c48b7bbd63..e14c3402fb 100644 --- a/app/services/event_checklist.rb +++ b/app/services/event_checklist.rb @@ -25,8 +25,12 @@ class EventChecklist # 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, :action_path, :action_label) do + :money_cents, :detail, :registrants, :detail_rows, :action_path, :action_label) do def todo? status == :todo end @@ -104,7 +108,7 @@ def trackable_items # --- Item builders --------------------------------------------------------- def task(key:, phase:, title:, actor:, action_path:, action_label:, relevant:, - count: 0, registrants: [], money_cents: nil, detail: nil) + count: 0, registrants: [], detail_rows: [], money_cents: nil, detail: nil) status = if !relevant :not_relevant elsif count.positive? @@ -114,7 +118,8 @@ def task(key:, phase:, title:, actor:, action_path:, action_label:, relevant:, 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, action_path: action_path, action_label: action_label) + 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) @@ -127,20 +132,20 @@ def flag(key:, phase:, title:, actor:, action_path:, action_label:, relevant:, d end Item.new(key: key, phase: phase, title: title, actor: actor, kind: :flag, status: status, count: nil, money_cents: nil, detail: detail, - registrants: [], action_path: action_path, action_label: action_label) + 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: 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: [], action_path: nil, action_label: nil) + registrants: [], detail_rows: [], action_path: nil, action_label: nil) end # --- Set up ---------------------------------------------------------------- @@ -184,13 +189,23 @@ def link_organizations 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, + 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 review_flagged_comments relevant = @dashboard.has_registrants? count = relevant ? @dashboard.flagged_comment_count : 0 @@ -203,7 +218,7 @@ def review_flagged_comments def collect_registration_fees relevant = @dashboard.has_registrants? && !@dashboard.free? count = relevant ? @dashboard.unpaid_count : 0 - task(key: :collect_registration_fees, phase: :before, title: "Collect registration fees", actor: :registrant, + task(key: :collect_registration_fees, phase: :before, title: "Remind registration fees due", actor: :registrant, relevant: relevant, count: count, money_cents: relevant ? @dashboard.outstanding_cents : nil, registrants: count.positive? ? @dashboard.unpaid_registrants : [], @@ -232,10 +247,17 @@ def set_scholarship_funders def follow_up_agreements relevant = @dashboard.scholarships_present? && !@dashboard.free? count = relevant ? @dashboard.scholarship_agreement_unsigned_count : 0 - task(key: :follow_up_agreements, phase: :before, title: "Follow up scholarship agreements", actor: :registrant, - relevant: relevant, count: count, - registrants: count.positive? ? @dashboard.scholarship_agreement_unsigned_registrants : [], - action_path: recipients_event_path(@event), action_label: "Scholarships") + 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: "Remind scholarship agreements", actor: :registrant, + relevant: relevant, count: count, registrants: registrants, + action_path: action_path, action_label: "Send reminder") end def fix_zero_scholarships @@ -250,7 +272,7 @@ def fix_zero_scholarships 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: "Complete scholarship tasks", actor: :registrant, + task(key: :complete_scholarship_tasks, phase: :before, title: "Remind 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 reminder") @@ -259,7 +281,7 @@ def complete_scholarship_tasks def collect_ce_licenses relevant = @dashboard.ce_eligible? count = relevant ? @dashboard.ce_license_missing_count : 0 - task(key: :collect_ce_licenses, phase: :before, title: "Collect CE license numbers", actor: :registrant, + task(key: :collect_ce_licenses, phase: :before, title: "Remind 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 reminder") @@ -268,7 +290,7 @@ def collect_ce_licenses def collect_ce_fees relevant = @dashboard.ce_eligible? count = relevant ? @dashboard.cont_ed_unpaid_count : 0 - task(key: :collect_ce_fees, phase: :before, title: "Collect CE fees", actor: :registrant, + task(key: :collect_ce_fees, phase: :before, title: "Remind 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 : [], diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index 939af526f5..e0272fcb78 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 @@ -500,6 +504,25 @@ 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 diff --git a/app/views/events/_checklist_item.html.erb b/app/views/events/_checklist_item.html.erb index 8137a90174..fd5e9282c2 100644 --- a/app/views/events/_checklist_item.html.erb +++ b/app/views/events/_checklist_item.html.erb @@ -31,7 +31,7 @@ %>
<%= chip[:label] %> - <% if item.registrants.any? %> + <% if item.registrants.any? || item.detail_rows.any? %>
@@ -43,14 +43,27 @@ <% if item.detail.present? %>

<%= item.detail %>

<% end %>
    - <% item.registrants.each do |registrant| %> -
  • - <%= link_to registrants_event_path(@event, registrant_ids: registrant.id), - class: "group/rowlink flex items-center justify-between gap-2 rounded px-1 -mx-1 py-0.5 hover:bg-gray-50" do %> - <%= registrant.name %> - - <% end %> -
  • + <% if item.detail_rows.any? %> + <% item.detail_rows.each do |row| %> +
  • + <%= link_to row.path, class: "group/rowlink flex items-center justify-between gap-2 rounded px-1 -mx-1 py-0.5 hover:bg-gray-50" do %> + + <%= row.title %><% if row.subtitle.present? %> · <%= row.subtitle %><% end %> + + <%= dollars_from_cents(row.amount_cents) %> + <% end %> +
  • + <% end %> + <% else %> + <% item.registrants.each do |registrant| %> +
  • + <%= link_to registrants_event_path(@event, registrant_ids: registrant.id), + class: "group/rowlink flex items-center justify-between gap-2 rounded px-1 -mx-1 py-0.5 hover:bg-gray-50" do %> + <%= registrant.name %> + + <% end %> +
  • + <% end %> <% end %>
diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 5f13648226..10ba9914b9 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -327,13 +327,17 @@ def add_ce_registrant(target_event) end describe "GET /dashboard" do - let(:event) { create(:event, :publicly_visible, published: true) } + let(:event) { create(:event, :publicly_visible, published: true, cost_cents: 10_000) } - it "renders the event checklist for an admin" do + 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("Remind registration fees due") + expect(response.body).to include("Link organizations") end end diff --git a/spec/services/event_checklist_spec.rb b/spec/services/event_checklist_spec.rb index ab294fee98..83bfbedca1 100644 --- a/spec/services/event_checklist_spec.rb +++ b/spec/services/event_checklist_spec.rb @@ -31,6 +31,12 @@ def item(key) 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 'Remind'" do + reminders = checklist.items.select(&:registrant_task?) + expect(reminders).to be_present + expect(reminders.map(&:title)).to all(start_with("Remind")) + end end describe "before the event, with a registrant" do @@ -94,6 +100,48 @@ def item(key) 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) } From e7adc0609a1a469ae91c8653e0b4a50e911ea480 Mon Sep 17 00:00:00 2001 From: S Mae Beale Date: Sun, 9 Aug 2026 12:01:42 -0400 Subject: [PATCH 3/9] Label reminder checklist items "Send reminder" Retitle the registrant-facing checklist items to "Send reminder: " and shorten their button to "Send" so the action reads clearly without doubling. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/event_checklist.rb | 20 ++++++++++---------- spec/requests/events_spec.rb | 2 +- spec/services/event_checklist_spec.rb | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/services/event_checklist.rb b/app/services/event_checklist.rb index e14c3402fb..d374cc8a37 100644 --- a/app/services/event_checklist.rb +++ b/app/services/event_checklist.rb @@ -218,11 +218,11 @@ def review_flagged_comments def collect_registration_fees relevant = @dashboard.has_registrants? && !@dashboard.free? count = relevant ? @dashboard.unpaid_count : 0 - task(key: :collect_registration_fees, phase: :before, title: "Remind registration fees due", actor: :registrant, + 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 reminder") + action_path: preview_reminder_event_path(@event, payment_status: "unpaid"), action_label: "Send") end def issue_scholarships @@ -255,9 +255,9 @@ def follow_up_agreements else preview_reminder_event_path(@event) end - task(key: :follow_up_agreements, phase: :before, title: "Remind scholarship agreements", actor: :registrant, + 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 reminder") + action_path: action_path, action_label: "Send") end def fix_zero_scholarships @@ -272,29 +272,29 @@ def fix_zero_scholarships 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: "Remind scholarship tasks", actor: :registrant, + 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 reminder") + 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: "Remind CE license numbers", actor: :registrant, + 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 reminder") + 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: "Remind CE fees due", actor: :registrant, + 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 reminder") + action_path: preview_reminder_event_path(@event, ce_status: "requested"), action_label: "Send") end def issue_ce_certificates diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 10ba9914b9..42f67be422 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -336,7 +336,7 @@ def add_ce_registrant(target_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("Remind registration fees due") + expect(response.body).to include("Send reminder: registration fees due") expect(response.body).to include("Link organizations") end end diff --git a/spec/services/event_checklist_spec.rb b/spec/services/event_checklist_spec.rb index 83bfbedca1..6b54e505bc 100644 --- a/spec/services/event_checklist_spec.rb +++ b/spec/services/event_checklist_spec.rb @@ -32,10 +32,10 @@ def item(key) expect(item(:review_reports).kind).to eq(:action) end - it "prefixes every registrant-facing reminder item with 'Remind'" do + 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("Remind")) + expect(reminders.map(&:title)).to all(start_with("Send reminder")) end end From d088f7c24d356bf182f5273acd45d125766bc226 Mon Sep 17 00:00:00 2001 From: S Mae Beale Date: Sun, 9 Aug 2026 12:26:03 -0400 Subject: [PATCH 4/9] Add "Indicate event staff" setup item to the event checklist Detected by the presence of any event_staffs; links to the event staff page so an admin can add the roster. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/event_checklist.rb | 8 +++++++- app/services/event_dashboard.rb | 4 ++++ app/views/events/_checklist_item.html.erb | 2 +- spec/services/event_checklist_spec.rb | 13 ++++++++++++- 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/app/services/event_checklist.rb b/app/services/event_checklist.rb index d374cc8a37..91f5f9152f 100644 --- a/app/services/event_checklist.rb +++ b/app/services/event_checklist.rb @@ -59,7 +59,7 @@ def initialize(dashboard) def items @items ||= [ - setup_forms, setup_callouts, setup_publish, setup_event_type, + setup_forms, setup_callouts, setup_publish, setup_event_type, setup_staff, link_organizations, allocate_bulk_payments, review_flagged_comments, collect_registration_fees, issue_scholarships, set_scholarship_funders, follow_up_agreements, fix_zero_scholarships, complete_scholarship_tasks, @@ -176,6 +176,12 @@ def setup_event_type 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? diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index e0272fcb78..8dbbae1e58 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -675,6 +675,10 @@ 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 diff --git a/app/views/events/_checklist_item.html.erb b/app/views/events/_checklist_item.html.erb index fd5e9282c2..e3151c1ca3 100644 --- a/app/views/events/_checklist_item.html.erb +++ b/app/views/events/_checklist_item.html.erb @@ -10,7 +10,7 @@ <% icons = { setup_forms: "fa-file-lines", setup_callouts: "fa-comment-dots", - setup_publish: "fa-globe", setup_event_type: "fa-tags", + setup_publish: "fa-globe", setup_event_type: "fa-tags", setup_staff: "fa-user-tie", link_organizations: "fa-building-circle-exclamation", 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", diff --git a/spec/services/event_checklist_spec.rb b/spec/services/event_checklist_spec.rb index 6b54e505bc..f817700277 100644 --- a/spec/services/event_checklist_spec.rb +++ b/spec/services/event_checklist_spec.rb @@ -12,7 +12,7 @@ def item(key) it "lists items in lifecycle order across the phases" do keys = checklist.items.map(&:key) - expect(keys.first(4)).to eq(%i[ setup_forms setup_callouts setup_publish setup_event_type ]) + 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 @@ -100,6 +100,17 @@ def item(key) 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 "bulk payments" do let(:event) { create(:event, cost_cents: 10_000) } let(:form) { create(:form) } From 3bf5979a4fbbe02bbd3444334f9ab5ac4f3d047c Mon Sep 17 00:00:00 2001 From: S Mae Beale Date: Sun, 9 Aug 2026 16:29:20 -0400 Subject: [PATCH 5/9] Point CE-hours reconcile at the participation report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correct the copy — CE hours are registrant-submitted — and link the row to the participation report rather than the roster. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/event_checklist.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/event_checklist.rb b/app/services/event_checklist.rb index 91f5f9152f..0aaa66631a 100644 --- a/app/services/event_checklist.rb +++ b/app/services/event_checklist.rb @@ -336,8 +336,8 @@ def reconcile_ce_hours 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 admin-entered — best-effort", - action_path: registrants_event_path(@event, ce_status: "registered"), action_label: "Review") + detail: "Hours are registrant-submitted", + action_path: participation_events_path, action_label: "Participation") end def send_completion_certificates From dece8295385e512f1fa4c78b3d5896bd560666cd Mon Sep 17 00:00:00 2001 From: S Mae Beale Date: Sun, 9 Aug 2026 16:30:52 -0400 Subject: [PATCH 6/9] Fix rubocop: drop stray blank line in event_registration_spec Left over from resolving the organization_status rebase conflict. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/models/event_registration_spec.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index 7d466c7585..17c492fd0b 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -1346,5 +1346,4 @@ def registration_for(person) expect(preloaded.paid_in_full?).to be(true) end end - end From 800da1d0daf0848a185dbbbc041f084cfa243d56 Mon Sep 17 00:00:00 2001 From: S Mae Beale Date: Tue, 11 Aug 2026 03:12:22 -0400 Subject: [PATCH 7/9] Point event-reports checklist link at the new reports hub The Events IA refactor renamed statistics_events_path to reports_events_path. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/event_checklist.rb | 2 +- spec/services/event_checklist_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/event_checklist.rb b/app/services/event_checklist.rb index 0aaa66631a..6bea3c4e7d 100644 --- a/app/services/event_checklist.rb +++ b/app/services/event_checklist.rb @@ -352,7 +352,7 @@ def send_completion_certificates def review_reports action(key: :review_reports, phase: :after, title: "Review event reports", actor: :admin, relevant: @dashboard.event_over?, - action_path: statistics_events_path, action_label: "View reports") + action_path: reports_events_path, action_label: "View reports") end def post_event_survey diff --git a/spec/services/event_checklist_spec.rb b/spec/services/event_checklist_spec.rb index f817700277..760b8aa1ad 100644 --- a/spec/services/event_checklist_spec.rb +++ b/spec/services/event_checklist_spec.rb @@ -96,7 +96,7 @@ def item(key) 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.statistics_events_path) + expect(reports.action_path).to eq(Rails.application.routes.url_helpers.reports_events_path) end end From 9c98102c305fa5ccb5f46025de1a834264b0eef9 Mon Sep 17 00:00:00 2001 From: S Mae Beale Date: Tue, 11 Aug 2026 11:00:57 -0400 Subject: [PATCH 8/9] Add trainee-onboarding (Mailchimp/CMS) item to the event checklist Counts active registrants not yet set up in every onboarding system (EventRegistration::CHECKLIST_STEPS) and links to the event onboarding page. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/event_checklist.rb | 12 +++++++++++- app/services/event_dashboard.rb | 21 +++++++++++++++++++++ app/views/events/_checklist_item.html.erb | 3 ++- spec/services/event_checklist_spec.rb | 18 ++++++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/app/services/event_checklist.rb b/app/services/event_checklist.rb index 6bea3c4e7d..45ee054c04 100644 --- a/app/services/event_checklist.rb +++ b/app/services/event_checklist.rb @@ -60,7 +60,7 @@ def initialize(dashboard) def items @items ||= [ setup_forms, setup_callouts, setup_publish, setup_event_type, setup_staff, - link_organizations, allocate_bulk_payments, review_flagged_comments, + link_organizations, onboard_trainees, 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, @@ -212,6 +212,16 @@ def bulk_payment_row(detail) ) 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 review_flagged_comments relevant = @dashboard.has_registrants? count = relevant ? @dashboard.flagged_comment_count : 0 diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index 8dbbae1e58..912efb4d50 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -532,6 +532,16 @@ 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 + # Scholarship requested on the registration but no Scholarship created yet. def scholarship_uncreated_count scholarship_uncreated_registrant_ids.size @@ -1188,6 +1198,17 @@ def flagged_comment_registrant_ids end 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) diff --git a/app/views/events/_checklist_item.html.erb b/app/views/events/_checklist_item.html.erb index e3151c1ca3..7684928283 100644 --- a/app/views/events/_checklist_item.html.erb +++ b/app/views/events/_checklist_item.html.erb @@ -11,7 +11,8 @@ 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", allocate_bulk_payments: "fa-money-check-dollar", + link_organizations: "fa-building-circle-exclamation", onboard_trainees: "fa-user-gear", + 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", diff --git a/spec/services/event_checklist_spec.rb b/spec/services/event_checklist_spec.rb index 760b8aa1ad..16d6c6eac6 100644 --- a/spec/services/event_checklist_spec.rb +++ b/spec/services/event_checklist_spec.rb @@ -111,6 +111,24 @@ def item(key) 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 "bulk payments" do let(:event) { create(:event, cost_cents: 10_000) } let(:form) { create(:form) } From 5bfa0a3f62babe6debb705a0bf35d674b06b51d0 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Wed, 12 Aug 2026 11:07:27 -0400 Subject: [PATCH 9/9] Add 'Send portal invites' checklist item wired to the new invite mode Counts active registrants with no portal account yet and links to the reminders flow's bulk invite mode (mode=invite), which #2168 added. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/event_checklist.rb | 12 +++++++++++- app/services/event_dashboard.rb | 14 ++++++++++++++ app/views/events/_checklist_item.html.erb | 4 ++-- spec/services/event_checklist_spec.rb | 18 ++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/app/services/event_checklist.rb b/app/services/event_checklist.rb index 45ee054c04..e1d9d3ebb8 100644 --- a/app/services/event_checklist.rb +++ b/app/services/event_checklist.rb @@ -60,7 +60,7 @@ def initialize(dashboard) def items @items ||= [ setup_forms, setup_callouts, setup_publish, setup_event_type, setup_staff, - link_organizations, onboard_trainees, allocate_bulk_payments, review_flagged_comments, + 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, @@ -222,6 +222,16 @@ def onboard_trainees 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 diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index 912efb4d50..9ad1758b8e 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -542,6 +542,16 @@ 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 @@ -1198,6 +1208,10 @@ def flagged_comment_registrant_ids 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 diff --git a/app/views/events/_checklist_item.html.erb b/app/views/events/_checklist_item.html.erb index 7684928283..1a2b5c73ca 100644 --- a/app/views/events/_checklist_item.html.erb +++ b/app/views/events/_checklist_item.html.erb @@ -12,7 +12,7 @@ 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", - allocate_bulk_payments: "fa-money-check-dollar", + 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", @@ -26,7 +26,7 @@ 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" then "border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-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 %> diff --git a/spec/services/event_checklist_spec.rb b/spec/services/event_checklist_spec.rb index 16d6c6eac6..de2748b72d 100644 --- a/spec/services/event_checklist_spec.rb +++ b/spec/services/event_checklist_spec.rb @@ -129,6 +129,24 @@ def item(key) 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) }