diff --git a/AGENTS.md b/AGENTS.md index 4fead975c..da547995a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| | `app/models/` | ActiveRecord models | ~78 files | -| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~29 files | +| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display) | ~30 files | | `app/jobs/` | SolidQueue background jobs | 3 files | | `app/models/concerns/` | Shared model modules | 16 concerns | @@ -201,6 +201,7 @@ end - `EventRegistrationServices::ProcessConfirmation` — Registration confirmation flow - `EventRegistrationServices::PublicRegistration` — Public registration handling +- `EventRegistrationReadiness` — Computes a registration's lifecycle `status` (`:not_ready` → `:ready` → `:certificate_due` → `:completed`) from a pre-event "event ready" checklist, a post-event "completion work" checklist (attendance, scholarship tasks), and certificate delivery, returning the specific outstanding reasons. Reads payment/certificate state via `Registerable` (`paid_in_full?`, `certificate_sent?`) on both the registration and its `continuing_education_registrations`. Drives the registrants roster's single far-right Status badge column (with a short reason under "Not ready" and a cert-type note under "Certificate pending") and its matching filter - `ReminderRecipientFilter` — Decides which event registrations stay checked on the bulk reminder page given the admin's filters (matches in memory, returns matching ids) - `MagicTicketCallouts` — Code-defined ("magic") ticket callout cards (payment, certificate, scholarship, CE hours, art supplies, forms, handouts, portal, videoconference, FAQ), each with its own visibility rule; rendered through the same `_callout_card` partial as admin-configured `RegistrationTicketCallout`s. Their public show pages live under `app/views/events/callouts/` and are served by `Events::CalloutsController` (slug-authorized, no login) @@ -351,7 +352,7 @@ Custom colors defined in `app/frontend/stylesheets/application.tailwind.css`: | `spec/routing/` | ~13 | Route definition tests | | `spec/policies/` | ~9 | Authorization policy tests | | `spec/decorators/` | ~10 | Decorator tests | -| `spec/services/` | ~12 | Service object tests | +| `spec/services/` | ~13 | Service object tests | | `spec/mailers/` | ~5 | Mailer tests | | `spec/helpers/` | ~1 | Helper tests | | `spec/factories/` | ~53 | FactoryBot factory definitions | diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index feacac682..13a099f68 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -82,7 +82,7 @@ def registrants authorize! @event, to: :registrants? @event = @event.decorate scope = @event.event_registrations - .includes(:comments, :organizations, { continuing_education_registrations: [ :professional_license, :allocations ] }, registrant: [ :user, :contact_methods, { avatar_attachment: :blob }, { affiliations: :organization } ]) + .includes(:comments, :organizations, :allocations, :scholarships, { continuing_education_registrations: [ :professional_license, :allocations ] }, registrant: [ :user, :contact_methods, { avatar_attachment: :blob }, { affiliations: :organization } ]) .joins(:registrant) scope = scope.keyword(params[:keyword]) if params[:keyword].present? scope = scope.payment_status(params[:payment_status]) if params[:payment_status].present? @@ -107,10 +107,18 @@ def registrants scope = scope.active if @status_filter == "active" end - @event_registrations = scope.order(Arel.sql("people.first_name, people.last_name")) + @event_registrations = scope.order(Arel.sql("people.first_name, people.last_name")).to_a @dashboard = EventDashboard.new(@event) @ce_eligible = @event.ce_eligible? + @submitted_org_names = submitted_org_names_for(@event_registrations) + @readiness = @event_registrations.to_h do |registration| + [ registration.id, EventRegistrationReadiness.new(registration) ] + end + if params[:readiness].in?(%w[ not_ready ready certificate_due completed ]) + @event_registrations.select! { |r| @readiness[r.id].status.to_s == params[:readiness] } + end + emails = @event_registrations.map { |r| r.registrant.preferred_email&.downcase }.compact @duplicate_emails = emails.tally.select { |_, count| count > 1 }.keys.to_set @@ -541,6 +549,21 @@ def allocated_cents_by_registration(registrations) .sum(:amount) end + # Maps registrant person_id => the organization name they typed on the + # registration form (the `agency_name` answer), in one batch query. Drives both + # the roster's Pending/None org chip and the readiness "Organization not linked" + # check, so both read the same resolved answer. + def submitted_org_names_for(registrations) + registration_form = @event.registration_form + field = registration_form&.form_fields&.find_by(field_identifier: "agency_name") + return {} unless field + + FormAnswer.joins(:form_submission) + .where(form_submissions: { person_id: registrations.map(&:registrant_id), form_id: registration_form.id }, form_field_id: field.id) + .pluck(Arel.sql("form_submissions.person_id"), :submitted_answer) + .to_h + end + def event_registrations_csv_string require "csv" cost_required = @event.cost_cents.to_i > 0 diff --git a/app/services/event_registration_readiness.rb b/app/services/event_registration_readiness.rb new file mode 100644 index 000000000..509dae0ae --- /dev/null +++ b/app/services/event_registration_readiness.rb @@ -0,0 +1,202 @@ +# Decides whether an event registration is "event ready" (the pre-event checklist +# an admin clears before the training) and "completed" (the post-event checklist +# that closes it out), returning the specific failing reasons so the registrants +# roster can render a badge with an explanatory tooltip and the index can filter +# on either state. +# +# Reads only already-loaded associations (organizations, allocations, +# scholarships, continuing_education_registrations) so it adds no per-row queries +# when the roster preloads them. +class EventRegistrationReadiness + def initialize(registration) + @registration = registration + end + + STATUS_LABELS = { + not_ready: "Not ready", + ready: "Ready", + certificate_due: "Certificate pending", + completed: "Completed" + }.freeze + + # Lifecycle order for sorting the roster's Status column. + STATUS_ORDER = %i[ not_ready ready certificate_due completed ].freeze + + def event_ready? + event_ready_issues.empty? + end + + # All post-event work done AND the certificate(s) sent. + def completed? + completion_issues.empty? + end + + # All post-event work done (attended, scholarship tasks met) — i.e. the only + # thing left is sending the certificate(s). This is the admin's "send a + # certificate" queue. + def certifiable? + completion_work_issues.empty? + end + + # The registration's single lifecycle state for the roster's one Status column + # and its matching filter. Completion is the final state and wins; otherwise an + # outstanding pre-event to-do means "not ready"; a registrant who finished the + # post-event work but still needs a certificate is "certificate due"; and a + # clear pre-event checklist with nothing further done is "ready". + def status + return :completed if completed? + return :not_ready unless event_ready? + return :certificate_due if certifiable? + :ready + end + + def status_label + STATUS_LABELS.fetch(status) + end + + # Sort key for the roster's Status column: lifecycle order first, then the + # reason, so same-status rows group by reason (e.g. all "Payment due" together). + def status_sort_key + "#{STATUS_ORDER.index(status)}|#{status_reason}" + end + + # The outstanding items relevant to the current status, for the badge tooltip. + def status_issues + case status + when :not_ready then event_ready_issues + when :certificate_due then certificate_issues + else [] + end + end + + # The short reason shown under the badge for the current status: a two-word + # pre-event reason when not ready, or which certificate(s) are outstanding when + # certificate-pending. Nil for the ready/completed states. + def status_reason + case status + when :not_ready then event_ready_reason + when :certificate_due then certificate_due_reason + end + end + + # Which certificate(s) still need sending, abbreviated for the badge subtext. + def certificate_due_reason + reg_due = !registration_certificate_sent? + ce_due = ce_certificate_pending? + return "Reg + CE" if reg_due && ce_due + return "CE" if ce_due + "Registration" if reg_due + end + + # Each pre-event check, in priority order: [ predicate, two-word reason (shown + # under a "Not ready" badge), full description (tooltip) ]. One table keeps the + # short and long forms in sync. + EVENT_READY_CHECKS = [ + [ :payment_due?, "Payment due", "Payment due" ], + [ :organization_missing?, "Org validation", "No organization linked" ], + [ :scholarship_uncreated?, "No scholarship", "Scholarship not created" ], + [ :scholarship_tasks_incomplete?, "Tasks incomplete", "Scholarship tasks incomplete" ], + [ :ce_unpaid?, "CE unpaid", "CE not paid" ], + [ :ce_license_missing?, "No license #", "CE license number missing" ] + ].freeze + + def event_ready_issues + @event_ready_issues ||= failed_event_ready_checks.map { |_, _, full| full } + end + + # The two-word reason for the highest-priority outstanding pre-event item, + # shown under the "Not ready" badge. Nil when nothing is outstanding. + def event_ready_reason + failed_event_ready_checks.first&.fetch(1) + end + + def completion_issues + completion_work_issues + certificate_issues + end + + # Post-event work that must happen before a certificate can be issued. + def completion_work_issues + @completion_work_issues ||= [].tap do |issues| + issues << attendance_issue if attendance_issue + issues << "Scholarship tasks incomplete" if scholarship_tasks_incomplete? + end + end + + # The certificate(s) that still need sending once the work above is done. + def certificate_issues + @certificate_issues ||= [].tap do |issues| + issues << "Certificate not sent" unless registration_certificate_sent? + issues << "CE certificate not sent" if ce_certificate_pending? + end + end + + private + + attr_reader :registration + + def failed_event_ready_checks + @failed_event_ready_checks ||= EVENT_READY_CHECKS.select { |predicate, _, _| send(predicate) } + end + + def payment_due? + registration.event.cost_cents.to_i > 0 && !registration.paid_in_full? + end + + # The organization needs an admin's attention until one is linked to the + # registration — whether that's a pending submitted name or nothing at all. + # Once any org is linked, they've made the call, so it's resolved. + def organization_missing? + registration.organizations.empty? + end + + def scholarship_uncreated? + registration.scholarship_requested? && !registration.scholarship? + end + + def scholarship_tasks_incomplete? + registration.scholarship? && !registration.scholarship_tasks_met? + end + + def ce_unpaid? + registration.ce_credit_requested? && !ce_paid? + end + + def ce_license_missing? + registration.ce_credit_requested? && !registration.ce_license_provided? + end + + def ce_certificate_pending? + registration.ce_credit_requested? && !ce_certificate_sent? + end + + # Post-event criteria are only met by a full "attended". "incomplete_attendance" + # explicitly does not satisfy them; everything else means they never showed. + def attendance_issue + return nil if registration.attended? + registration.status == "incomplete_attendance" ? "Attendance incomplete" : "Did not attend" + end + + # The admin-created CE billing records for this registration (preloaded on the + # roster). Their payment + certificate state drives the CE readiness checks. + def ce_registrations + registration.continuing_education_registrations + end + + # CE is paid once every CE registration is paid in full. A requested-but-not-yet + # -created CE registration counts as unpaid (nothing to pay against yet). + def ce_paid? + ce_registrations.any? && ce_registrations.all?(&:paid_in_full?) + end + + # The registration's own completion certificate (certificate_sent_at, via + # Registerable#certificate_sent?). + def registration_certificate_sent? + registration.certificate_sent? + end + + # CE certificates are sent once every CE registration's certificate has been + # sent. No CE registration yet means nothing has been issued. + def ce_certificate_sent? + ce_registrations.any? && ce_registrations.all?(&:certificate_sent?) + end +end diff --git a/app/views/event_registrations/_readiness_badge.html.erb b/app/views/event_registrations/_readiness_badge.html.erb new file mode 100644 index 000000000..f37fe3c9e --- /dev/null +++ b/app/views/event_registrations/_readiness_badge.html.erb @@ -0,0 +1,29 @@ +<%# Single status pill for a registration's lifecycle: Not ready (pre-event work + outstanding) → Ready (cleared for the event) → Certificate due (post-event + work done, certificate still to send) → Completed. When there are outstanding + items, the tooltip lists them; `subtext` shows a short reason under the badge. + Locals: + status - Symbol (:not_ready, :ready, :certificate_due, :completed) + label - display text for the status + issues - array of outstanding-condition strings (tooltip, when any) + subtext - optional short reason rendered under the badge %> +<% + subtext = local_assigns[:subtext] + style, icon, subtext_color = case status + when :completed then [ "bg-green-50 text-green-700 border-green-200", "fa-flag-checkered", "text-green-600" ] + when :certificate_due then [ "bg-purple-50 text-purple-700 border-purple-200", "fa-certificate", "text-purple-600" ] + when :ready then [ "bg-blue-50 text-blue-700 border-blue-200", "fa-circle-check", "text-blue-600" ] + else [ "bg-amber-50 text-amber-700 border-amber-200", "fa-circle-exclamation", "text-amber-600" ] + end + tooltip = issues.any? ? issues.join(" · ") : label +%> +