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 +%> +
<%= EventChecklist::PHASE_LABELS[phase] %> · <%= pluralize(phase_items.size, "to do") %>
+<%= item.detail %>
<% end %> +<%= item.detail %>
<% end %> +