Skip to content
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/controllers/forms_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
384 changes: 384 additions & 0 deletions app/services/event_checklist.rb

Large diffs are not rendered by default.

317 changes: 317 additions & 0 deletions app/services/event_dashboard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading