From 75dc87da5d9e2d6cdd3f81ac7dce5ce003b3f977 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 4 Jul 2026 10:55:49 -0400 Subject: [PATCH 1/2] Add registrant readiness Status column + filter to the roster Squashed for re-integration onto main (which since merged the CE + certificate system). Reworked in the rebase resolution. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 5 +- app/controllers/events_controller.rb | 27 +- app/services/event_registration_readiness.rb | 208 +++++++++++++ .../_readiness_badge.html.erb | 29 ++ .../events/_registrants_results.html.erb | 19 +- app/views/events/_registrants_search.html.erb | 7 +- spec/requests/events_spec.rb | 76 +++++ .../event_registration_readiness_spec.rb | 284 ++++++++++++++++++ 8 files changed, 645 insertions(+), 10 deletions(-) create mode 100644 app/services/event_registration_readiness.rb create mode 100644 app/views/event_registrations/_readiness_badge.html.erb create mode 100644 spec/services/event_registration_readiness_spec.rb 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..6e7fcc04b 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, submitted_org_name: @submitted_org_names[registration.registrant_id]) ] + 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..641c03d4b --- /dev/null +++ b/app/services/event_registration_readiness.rb @@ -0,0 +1,208 @@ +# 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, registrant.affiliations, +# allocations, scholarships) so it adds no per-row queries when the roster +# preloads them. +class EventRegistrationReadiness + # `submitted_org_name` is the organization the registrant typed on the + # registration form (the `agency_name` answer). It's passed in because resolving + # it is a batch query the roster already runs once for every row. + def initialize(registration, submitted_org_name: nil) + @registration = registration + @submitted_org_name = submitted_org_name.to_s.strip + end + + STATUS_LABELS = { + not_ready: "Not ready", + ready: "Ready", + certificate_due: "Certificate pending", + completed: "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 + + # 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_unlinked?, "Org validation", "Organization not linked" ], + [ :missing_facilitator_affiliation?, "Org validation", "Not a facilitator at a linked organization" ], + [ :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, :submitted_org_name + + 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 + + # Flags a registrant who typed an organization on the form but has none linked. + # Once an admin links any organization they've made the call, so a non-matching + # submitted name is not treated as outstanding. + def organization_unlinked? + submitted_org_name.present? && registration.organizations.empty? + end + + # A registrant linked to an organization is expected to hold an active + # Facilitator affiliation with it. Flags when any linked org lacks one. + def missing_facilitator_affiliation? + registration.organizations.any? do |org| + registration.registrant.affiliations.none? do |affiliation| + affiliation.organization_id == org.id && affiliation.facilitator? && affiliation.active? + end + end + 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 +%> +
+ + + <%= label %> + + <% if subtext.present? %> + <%= subtext %> + <% end %> +
diff --git a/app/views/events/_registrants_results.html.erb b/app/views/events/_registrants_results.html.erb index 0790e70d5..8b5e71be5 100644 --- a/app/views/events/_registrants_results.html.erb +++ b/app/views/events/_registrants_results.html.erb @@ -118,21 +118,19 @@ <% form_submissions = event_form_ids.any? ? FormSubmission.joins(:form).where(form_id: event_form_ids, person_id: @event_registrations.map(&:registrant_id)).pluck(:person_id, :created_at, Arel.sql("forms.name")).group_by(&:first).transform_values { |rows| rows.map { |_, ts, name| [ name, ts ] } } : {} %> <% scholarship_form = @event.scholarship_form %> <% scholarship_submitter_ids = scholarship_form ? FormSubmission.joins(:form_answers).where(form_id: scholarship_form.id, role: "scholarship", person_id: @event_registrations.map(&:registrant_id)).distinct.pluck(:person_id).to_set : Set.new %> - <% registration_form = @event.registration_form %> - <% agency_name_field = registration_form&.form_fields&.find_by(field_identifier: "agency_name") %> - <% submitted_org_names = agency_name_field ? FormAnswer.joins(:form_submission).where(form_submissions: { person_id: @event_registrations.map(&:registrant_id), form_id: registration_form.id }, form_field_id: agency_name_field.id).pluck(Arel.sql("form_submissions.person_id"), :submitted_answer).to_h : {} %> <% if @event_registrations.any? %>
<% payment_col = @event.cost_cents.to_i > 0 %> <% ce_col = @ce_eligible %> <% extra_cols = (payment_col ? 1 : 0) + (ce_col ? 1 : 0) %> <%# Column order: Name, Organization, [CE status], Scholarship, [Payment], - Confirmed (hidden), Attendance, Edit, Date registered (far right). %> + Confirmed (hidden), Attendance, Edit, Date registered, Status (far right). %> <% ce_index = 2 %> <% scholarship_index = ce_col ? 3 : 2 %> <% payment_index = scholarship_index + 1 %> <% attendance_index = 4 + extra_cols %> <% date_index = 6 + extra_cols %> + <% status_index = 7 + extra_cols %> @@ -171,6 +169,9 @@ + @@ -236,7 +237,7 @@ + <% readiness = @readiness[registration.id] %> + <% end %> diff --git a/app/views/events/_registrants_search.html.erb b/app/views/events/_registrants_search.html.erb index c9246bd47..b73053d9a 100644 --- a/app/views/events/_registrants_search.html.erb +++ b/app/views/events/_registrants_search.html.erb @@ -47,8 +47,13 @@ <% end %> - <%# Row 2: organization + comments + location filters, and the clear action. %> + <%# Row 2: readiness + organization + comments + location filters, and the clear action. %>
+ <%# Mirrors the far-right Status column: Not ready → Ready → Certificate pending → Completed. %> + <%= render "events/filter_select", param: :readiness, label: "Status", + options: [ [ "Not ready", "not_ready" ], [ "Ready", "ready" ], [ "Certificate pending", "certificate_due" ], [ "Completed", "completed" ] ], + selected: params[:readiness], blank: "All statuses", field_class: field_class %> + <%= render "events/filter_select", param: :org_status, label: "Organization", options: [ [ "Pending", "pending" ], [ "Linked", "linked" ] ], selected: params[:org_status], blank: "All organizations", field_class: field_class %> diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index f4a25dde0..50ff1f037 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -626,6 +626,82 @@ def submit_agency_name(name) end end + context "readiness filtering" do + let(:ready_person) { create(:person, first_name: "Reada", last_name: "Paidinfull") } + let(:not_ready_person) { create(:person, first_name: "Nottaready", last_name: "Owes") } + let!(:ready_registration) { create(:event_registration, event: event, registrant: ready_person, status: "registered") } + let!(:not_ready_registration) { create(:event_registration, event: event, registrant: not_ready_person, status: "registered") } + + before do + # Pay `ready_registration` in full so it clears the only pre-event + # condition; `not_ready_registration` stays unpaid, so it is not ready. + create(:allocation, + source: create(:payment, amount_cents: event.cost_cents, amount_cents_remaining: event.cost_cents), + allocatable: ready_registration, amount: event.cost_cents) + end + + it "renders the combined Status column with the right badge labels" do + get registrants_event_path(event) + + expect(response.body).to include("Ready") + expect(response.body).to include("Not ready") + end + + it "shows a two-word reason under the Not ready badge" do + get registrants_event_path(event) + + # Nottaready is unpaid on a paid event + expect(response.body).to include("Payment due") + end + + it "shows the Certificate pending badge with a cert-type subtext once an event-ready registrant has attended" do + ready_registration.update!(status: "attended") + + get registrants_event_path(event) + + expect(response.body).to include("Certificate pending") + expect(response.body).to include(">Registration<") + end + + it "shows only not-ready registrants when filtered to not_ready" do + get registrants_event_path(event, params: { readiness: "not_ready" }) + + expect(response.body).to include("Nottaready") + expect(response.body).not_to include("Reada") + end + + it "shows only ready registrants when filtered to ready" do + get registrants_event_path(event, params: { readiness: "ready" }) + + expect(response.body).to include("Reada") + expect(response.body).not_to include("Nottaready") + end + + it "excludes an attended registrant from 'completed' until the certificate is sent" do + ready_registration.update!(status: "attended") + + get registrants_event_path(event, params: { readiness: "completed" }) + + expect(response.body).not_to include("Reada") + expect(response.body).not_to include("Nottaready") + end + + it "shows a registrant under 'completed' once attended and the certificate is sent" do + ready_registration.update!(status: "attended", certificate_sent_at: Time.current) + + get registrants_event_path(event, params: { readiness: "completed" }) + + expect(response.body).to include("Reada") + expect(response.body).not_to include("Nottaready") + end + + it "does not crash on an invalid readiness filter" do + get registrants_event_path(event, params: { readiness: "bogus" }) + + expect(response).to have_http_status(:ok) + end + end + context "event heading" do it "shows the event title and date range after the heading" do event.update!(start_date: Time.zone.local(2026, 6, 2, 9), end_date: Time.zone.local(2026, 6, 2, 17)) diff --git a/spec/services/event_registration_readiness_spec.rb b/spec/services/event_registration_readiness_spec.rb new file mode 100644 index 000000000..7aeb0936b --- /dev/null +++ b/spec/services/event_registration_readiness_spec.rb @@ -0,0 +1,284 @@ +require "rails_helper" + +RSpec.describe EventRegistrationReadiness do + let(:event) { create(:event, cost_cents: 1000) } + let(:registration) { create(:event_registration, event: event, status: "registered") } + let(:submitted_org_name) { nil } + subject(:readiness) { described_class.new(registration, submitted_org_name: submitted_org_name) } + + def pay(reg, cents) + create(:allocation, + source: create(:payment, amount_cents: cents, amount_cents_remaining: cents), + allocatable: reg, amount: cents) + end + + def award_scholarship(reg, tasks_completed:, amount: 1000) + scholarship = create(:scholarship, recipient: reg.registrant, tasks_completed: tasks_completed, amount_cents: amount) + create(:allocation, source: scholarship, allocatable: reg, amount: amount) + scholarship + end + + describe "#event_ready?" do + it "is ready when paid in full with no organization, scholarship, or CE concerns" do + pay(registration, 1000) + + expect(readiness.event_ready?).to be(true) + expect(readiness.event_ready_issues).to be_empty + end + + it "flags payment due when the event has a cost and is not paid in full" do + expect(readiness.event_ready_issues).to include("Payment due") + expect(readiness.event_ready?).to be(false) + end + + it "does not flag payment for a free event" do + event.update!(cost_cents: 0) + + expect(readiness.event_ready_issues).not_to include("Payment due") + end + + it "ignores the intends-to-pay flag (access only, not readiness)" do + registration.update!(intends_to_pay: true) + + expect(readiness.event_ready_issues).to include("Payment due") + end + + context "organization" do + let(:organization) { create(:organization, name: "Helping Hands") } + + it "flags an org answer that is not linked to a registration" do + pay(registration, 1000) + readiness = described_class.new(registration, submitted_org_name: "Some Unlisted Org") + + expect(readiness.event_ready_issues).to include("Organization not linked") + end + + it "does not flag when the submitted org matches a linked org (and a facilitator affiliation exists)" do + pay(registration, 1000) + create(:event_registration_organization, event_registration: registration, organization: organization) + create(:affiliation, person: registration.registrant, organization: organization, title: "Facilitator") + readiness = described_class.new(registration, submitted_org_name: "Helping Hands") + + expect(readiness.event_ready_issues).not_to include("Organization not linked") + expect(readiness.event_ready_issues).not_to include("Not a facilitator at a linked organization") + end + + it "does not flag an unlinked org answer once any organization is linked" do + pay(registration, 1000) + create(:event_registration_organization, event_registration: registration, organization: organization) + create(:affiliation, person: registration.registrant, organization: organization, title: "Facilitator") + readiness = described_class.new(registration, submitted_org_name: "A Different Unlisted Agency") + + expect(readiness.event_ready_issues).not_to include("Organization not linked") + end + + it "flags a linked org the registrant has no facilitator affiliation for" do + pay(registration, 1000) + create(:event_registration_organization, event_registration: registration, organization: organization) + + expect(readiness.event_ready_issues).to include("Not a facilitator at a linked organization") + end + + it "does not count a non-facilitator affiliation as satisfying the requirement" do + pay(registration, 1000) + create(:event_registration_organization, event_registration: registration, organization: organization) + create(:affiliation, person: registration.registrant, organization: organization, title: "Volunteer") + + expect(readiness.event_ready_issues).to include("Not a facilitator at a linked organization") + end + end + + context "scholarship" do + it "flags a requested scholarship that has not been created" do + pay(registration, 1000) + registration.update!(scholarship_requested: true) + + expect(readiness.event_ready_issues).to include("Scholarship not created") + end + + it "flags an awarded scholarship whose tasks are incomplete" do + award_scholarship(registration, tasks_completed: false, amount: 1000) + + expect(readiness.event_ready_issues).to include("Scholarship tasks incomplete") + end + + it "does not flag an awarded scholarship whose tasks are complete" do + award_scholarship(registration, tasks_completed: true, amount: 1000) + + expect(readiness.event_ready_issues).not_to include("Scholarship tasks incomplete") + expect(readiness.event_ready_issues).not_to include("Scholarship not created") + end + end + + context "continuing education" do + it "flags CE as unpaid when CE credit is requested (no CE payment is tracked yet)" do + pay(registration, 1000) + registration.update!(ce_credit_requested: true, ce_license_number: "LIC123") + + expect(readiness.event_ready_issues).to include("CE not paid") + end + + it "flags a missing CE license number when CE credit is requested" do + pay(registration, 1000) + registration.update!(ce_credit_requested: true, ce_license_number: nil) + + expect(readiness.event_ready_issues).to include("CE license number missing") + end + + it "does not flag CE concerns when CE credit was not requested" do + pay(registration, 1000) + + expect(readiness.event_ready_issues).not_to include("CE not paid") + expect(readiness.event_ready_issues).not_to include("CE license number missing") + end + end + end + + describe "#completed? / #completion_issues" do + it "flags non-attendance when the registrant has not attended" do + registration.update!(status: "registered") + + expect(readiness.completion_issues).to include("Did not attend") + expect(readiness.completed?).to be(false) + end + + it "treats incomplete attendance as not satisfying the post-event criteria" do + registration.update!(status: "incomplete_attendance") + + expect(readiness.completion_issues).to include("Attendance incomplete") + end + + it "does not flag attendance once the registrant has attended" do + registration.update!(status: "attended") + + expect(readiness.completion_issues).not_to include("Did not attend") + expect(readiness.completion_issues).not_to include("Attendance incomplete") + end + + it "flags an awarded scholarship whose tasks are incomplete" do + registration.update!(status: "attended") + award_scholarship(registration, tasks_completed: false, amount: 1000) + + expect(readiness.completion_issues).to include("Scholarship tasks incomplete") + end + + it "flags the registration certificate as unsent until certificate_sent_at is set" do + registration.update!(status: "attended") + + expect(readiness.completion_issues).to include("Certificate not sent") + end + + it "clears the registration certificate issue once it has been sent" do + registration.update!(status: "attended", certificate_sent_at: Time.current) + + expect(readiness.completion_issues).not_to include("Certificate not sent") + end + + it "flags the CE certificate as unsent when CE credit was requested" do + registration.update!(status: "attended", ce_credit_requested: true) + + expect(readiness.completion_issues).to include("CE certificate not sent") + end + + it "does not flag a CE certificate when CE credit was not requested" do + registration.update!(status: "attended") + + expect(readiness.completion_issues).not_to include("CE certificate not sent") + end + end + + describe "#status" do + it "is :not_ready when a pre-event condition is outstanding" do + # default registrant is unpaid on a paid event + expect(readiness.status).to eq(:not_ready) + expect(readiness.status_label).to eq("Not ready") + end + + it "is :ready once the pre-event checklist is clear but no post-event work is done" do + pay(registration, 1000) + registration.update!(status: "registered") + + expect(readiness.status).to eq(:ready) + expect(readiness.status_label).to eq("Ready") + end + + it "is :certificate_due once the post-event work is done but the certificate is unsent" do + pay(registration, 1000) + registration.update!(status: "attended") + + expect(readiness.status).to eq(:certificate_due) + expect(readiness.status_label).to eq("Certificate pending") + end + + it "is :completed once attended and the certificate has been sent" do + pay(registration, 1000) + registration.update!(status: "attended", certificate_sent_at: Time.current) + + expect(readiness.status).to eq(:completed) + expect(readiness.status_label).to eq("Completed") + end + + it "prefers :completed over the pre-event checklist when completion is reached" do + allow(readiness).to receive(:completed?).and_return(true) + + expect(readiness.status).to eq(:completed) + expect(readiness.status_label).to eq("Completed") + end + end + + describe "#event_ready_reason" do + it "is nil when the pre-event checklist is clear" do + pay(registration, 1000) + + expect(readiness.event_ready_reason).to be_nil + end + + it "gives a two-word reason for the highest-priority outstanding item" do + # unpaid (highest priority) trumps a later org issue + organization = create(:organization, name: "Helping Hands") + create(:event_registration_organization, event_registration: registration, organization: organization) + + expect(readiness.event_ready_reason).to eq("Payment due") + end + + it "summarizes an organization problem as 'Org validation'" do + pay(registration, 1000) + organization = create(:organization, name: "Helping Hands") + create(:event_registration_organization, event_registration: registration, organization: organization) + + expect(readiness.event_ready_reason).to eq("Org validation") + end + end + + describe "#status_reason" do + it "names the outstanding pre-event reason when not ready" do + expect(readiness.status_reason).to eq("Payment due") + end + + it "names the outstanding certificate when certificate-pending" do + pay(registration, 1000) + registration.update!(status: "attended") + + expect(readiness.status_reason).to eq("Registration") + end + + it "is nil when ready" do + pay(registration, 1000) + registration.update!(status: "registered") + + expect(readiness.status_reason).to be_nil + end + end + + describe "#certificate_due_reason" do + it "names the registration certificate when no CE was requested" do + expect(readiness.certificate_due_reason).to eq("Registration") + end + + it "names both certificates when CE credit was requested" do + registration.update!(ce_credit_requested: true) + + expect(readiness.certificate_due_reason).to eq("Reg + CE") + end + end +end From 2d8cae8f95057f52d8667c841b9c576ed649842d Mon Sep 17 00:00:00 2001 From: maebeale Date: Sat, 4 Jul 2026 14:38:05 -0400 Subject: [PATCH 2/2] Refine Status: org-only "Org validation", status+reason sort, Edit last MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "Org validation" now fires purely on whether an organization is linked (pending or none). A linked org clears it — a registrant with an org no longer reads "Not ready" just for lacking a Facilitator affiliation. - The Status column sorts by lifecycle rank then reason (a "rank|reason" key sorted client-side), so same-status rows group by reason. - Moved the Edit action to the far-right column, after Status. Co-Authored-By: Claude Opus 4.8 --- app/controllers/events_controller.rb | 2 +- app/services/event_registration_readiness.rb | 46 +++++------- .../events/_registrants_results.html.erb | 35 +++++++-- spec/requests/events_spec.rb | 5 +- .../event_registration_readiness_spec.rb | 75 +++++++++---------- 5 files changed, 86 insertions(+), 77 deletions(-) diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 6e7fcc04b..13a099f68 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -113,7 +113,7 @@ def registrants @submitted_org_names = submitted_org_names_for(@event_registrations) @readiness = @event_registrations.to_h do |registration| - [ registration.id, EventRegistrationReadiness.new(registration, submitted_org_name: @submitted_org_names[registration.registrant_id]) ] + [ 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] } diff --git a/app/services/event_registration_readiness.rb b/app/services/event_registration_readiness.rb index 641c03d4b..509dae0ae 100644 --- a/app/services/event_registration_readiness.rb +++ b/app/services/event_registration_readiness.rb @@ -4,16 +4,12 @@ # roster can render a badge with an explanatory tooltip and the index can filter # on either state. # -# Reads only already-loaded associations (organizations, registrant.affiliations, -# allocations, scholarships) so it adds no per-row queries when the roster -# preloads them. +# 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 - # `submitted_org_name` is the organization the registrant typed on the - # registration form (the `agency_name` answer). It's passed in because resolving - # it is a batch query the roster already runs once for every row. - def initialize(registration, submitted_org_name: nil) + def initialize(registration) @registration = registration - @submitted_org_name = submitted_org_name.to_s.strip end STATUS_LABELS = { @@ -23,6 +19,9 @@ def initialize(registration, submitted_org_name: nil) 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 @@ -55,6 +54,12 @@ 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 @@ -88,8 +93,7 @@ def certificate_due_reason # short and long forms in sync. EVENT_READY_CHECKS = [ [ :payment_due?, "Payment due", "Payment due" ], - [ :organization_unlinked?, "Org validation", "Organization not linked" ], - [ :missing_facilitator_affiliation?, "Org validation", "Not a facilitator at a linked organization" ], + [ :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" ], @@ -128,7 +132,7 @@ def certificate_issues private - attr_reader :registration, :submitted_org_name + attr_reader :registration def failed_event_ready_checks @failed_event_ready_checks ||= EVENT_READY_CHECKS.select { |predicate, _, _| send(predicate) } @@ -138,21 +142,11 @@ def payment_due? registration.event.cost_cents.to_i > 0 && !registration.paid_in_full? end - # Flags a registrant who typed an organization on the form but has none linked. - # Once an admin links any organization they've made the call, so a non-matching - # submitted name is not treated as outstanding. - def organization_unlinked? - submitted_org_name.present? && registration.organizations.empty? - end - - # A registrant linked to an organization is expected to hold an active - # Facilitator affiliation with it. Flags when any linked org lacks one. - def missing_facilitator_affiliation? - registration.organizations.any? do |org| - registration.registrant.affiliations.none? do |affiliation| - affiliation.organization_id == org.id && affiliation.facilitator? && affiliation.active? - end - 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? diff --git a/app/views/events/_registrants_results.html.erb b/app/views/events/_registrants_results.html.erb index 8b5e71be5..b557ee794 100644 --- a/app/views/events/_registrants_results.html.erb +++ b/app/views/events/_registrants_results.html.erb @@ -77,6 +77,25 @@ + <%# On by default — Date registered shows unless the admin hides it. %> + + <%# Off by default — Attendance is hidden until the admin reveals it. %>
<%= render "shared/sortable_header", label: "Date registered", index: date_index %> + <%= render "shared/sortable_header", label: "Status", index: status_index %> +
<% linked_orgs = registration.organizations %> - <% submitted_org_name = submitted_org_names[registration.registrant_id].to_s.strip %> + <% submitted_org_name = @submitted_org_names[registration.registrant_id].to_s.strip %> <%# A submitted org name needs admin attention only while nothing is linked yet; once an admin links any org they've made the call, so a non-matching submitted name is no longer treated as pending. %> @@ -405,6 +406,14 @@ <%= link_to "Edit", edit_event_registration_path(registration, return_to: "registrants"), class: "text-gray-500 hover:text-gray-700 underline", data: { turbo_frame: "_top" } %> "><%= registration.created_at.strftime("%b %-d, %Y") %> + <%= render "event_registrations/readiness_badge", + status: readiness.status, + label: readiness.status_label, + issues: readiness.status_issues, + subtext: readiness.status_reason %> +
@@ -165,13 +184,13 @@ - - + @@ -404,16 +423,16 @@ - - + <% readiness = @readiness[registration.id] %> - + <% end %> diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 50ff1f037..2e587dd42 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -633,11 +633,12 @@ def submit_agency_name(name) let!(:not_ready_registration) { create(:event_registration, event: event, registrant: not_ready_person, status: "registered") } before do - # Pay `ready_registration` in full so it clears the only pre-event - # condition; `not_ready_registration` stays unpaid, so it is not ready. + # Pay `ready_registration` in full and link an org so it clears the + # pre-event checklist; `not_ready_registration` stays unpaid → not ready. create(:allocation, source: create(:payment, amount_cents: event.cost_cents, amount_cents_remaining: event.cost_cents), allocatable: ready_registration, amount: event.cost_cents) + create(:event_registration_organization, event_registration: ready_registration, organization: create(:organization)) end it "renders the combined Status column with the right badge labels" do diff --git a/spec/services/event_registration_readiness_spec.rb b/spec/services/event_registration_readiness_spec.rb index 7aeb0936b..9ce53d211 100644 --- a/spec/services/event_registration_readiness_spec.rb +++ b/spec/services/event_registration_readiness_spec.rb @@ -3,8 +3,7 @@ RSpec.describe EventRegistrationReadiness do let(:event) { create(:event, cost_cents: 1000) } let(:registration) { create(:event_registration, event: event, status: "registered") } - let(:submitted_org_name) { nil } - subject(:readiness) { described_class.new(registration, submitted_org_name: submitted_org_name) } + subject(:readiness) { described_class.new(registration) } def pay(reg, cents) create(:allocation, @@ -12,6 +11,11 @@ def pay(reg, cents) allocatable: reg, amount: cents) end + # Links an organization so the "No organization linked" pre-event check clears. + def link_org(reg) + create(:event_registration_organization, event_registration: reg, organization: create(:organization)) + end + def award_scholarship(reg, tasks_completed:, amount: 1000) scholarship = create(:scholarship, recipient: reg.registrant, tasks_completed: tasks_completed, amount_cents: amount) create(:allocation, source: scholarship, allocatable: reg, amount: amount) @@ -19,8 +23,9 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) end describe "#event_ready?" do - it "is ready when paid in full with no organization, scholarship, or CE concerns" do + it "is ready when paid in full with an org linked and no scholarship or CE concerns" do pay(registration, 1000) + link_org(registration) expect(readiness.event_ready?).to be(true) expect(readiness.event_ready_issues).to be_empty @@ -46,45 +51,17 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) context "organization" do let(:organization) { create(:organization, name: "Helping Hands") } - it "flags an org answer that is not linked to a registration" do + it "flags a registration with no organization linked" do pay(registration, 1000) - readiness = described_class.new(registration, submitted_org_name: "Some Unlisted Org") - - expect(readiness.event_ready_issues).to include("Organization not linked") - end - - it "does not flag when the submitted org matches a linked org (and a facilitator affiliation exists)" do - pay(registration, 1000) - create(:event_registration_organization, event_registration: registration, organization: organization) - create(:affiliation, person: registration.registrant, organization: organization, title: "Facilitator") - readiness = described_class.new(registration, submitted_org_name: "Helping Hands") - expect(readiness.event_ready_issues).not_to include("Organization not linked") - expect(readiness.event_ready_issues).not_to include("Not a facilitator at a linked organization") + expect(readiness.event_ready_issues).to include("No organization linked") end - it "does not flag an unlinked org answer once any organization is linked" do + it "does not flag once any organization is linked (no facilitator affiliation required)" do pay(registration, 1000) create(:event_registration_organization, event_registration: registration, organization: organization) - create(:affiliation, person: registration.registrant, organization: organization, title: "Facilitator") - readiness = described_class.new(registration, submitted_org_name: "A Different Unlisted Agency") - expect(readiness.event_ready_issues).not_to include("Organization not linked") - end - - it "flags a linked org the registrant has no facilitator affiliation for" do - pay(registration, 1000) - create(:event_registration_organization, event_registration: registration, organization: organization) - - expect(readiness.event_ready_issues).to include("Not a facilitator at a linked organization") - end - - it "does not count a non-facilitator affiliation as satisfying the requirement" do - pay(registration, 1000) - create(:event_registration_organization, event_registration: registration, organization: organization) - create(:affiliation, person: registration.registrant, organization: organization, title: "Volunteer") - - expect(readiness.event_ready_issues).to include("Not a facilitator at a linked organization") + expect(readiness.event_ready_issues).not_to include("No organization linked") end end @@ -196,6 +173,7 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) it "is :ready once the pre-event checklist is clear but no post-event work is done" do pay(registration, 1000) + link_org(registration) registration.update!(status: "registered") expect(readiness.status).to eq(:ready) @@ -204,6 +182,7 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) it "is :certificate_due once the post-event work is done but the certificate is unsent" do pay(registration, 1000) + link_org(registration) registration.update!(status: "attended") expect(readiness.status).to eq(:certificate_due) @@ -229,22 +208,21 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) describe "#event_ready_reason" do it "is nil when the pre-event checklist is clear" do pay(registration, 1000) + link_org(registration) expect(readiness.event_ready_reason).to be_nil end it "gives a two-word reason for the highest-priority outstanding item" do # unpaid (highest priority) trumps a later org issue - organization = create(:organization, name: "Helping Hands") - create(:event_registration_organization, event_registration: registration, organization: organization) + link_org(registration) + registration.update!(scholarship_requested: true) expect(readiness.event_ready_reason).to eq("Payment due") end - it "summarizes an organization problem as 'Org validation'" do + it "summarizes a missing organization as 'Org validation'" do pay(registration, 1000) - organization = create(:organization, name: "Helping Hands") - create(:event_registration_organization, event_registration: registration, organization: organization) expect(readiness.event_ready_reason).to eq("Org validation") end @@ -257,6 +235,7 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) it "names the outstanding certificate when certificate-pending" do pay(registration, 1000) + link_org(registration) registration.update!(status: "attended") expect(readiness.status_reason).to eq("Registration") @@ -264,12 +243,28 @@ def award_scholarship(reg, tasks_completed:, amount: 1000) it "is nil when ready" do pay(registration, 1000) + link_org(registration) registration.update!(status: "registered") expect(readiness.status_reason).to be_nil end end + describe "#status_sort_key" do + it "prefixes the lifecycle rank and appends the reason (not-ready)" do + # default: unpaid + no org → not-ready, highest-priority reason "Payment due" + expect(readiness.status_sort_key).to eq("0|Payment due") + end + + it "sorts a ready registration (rank 1) after a not-ready one (rank 0)" do + pay(registration, 1000) + link_org(registration) + registration.update!(status: "registered") + + expect(readiness.status_sort_key).to eq("1|") + end + end + describe "#certificate_due_reason" do it "names the registration certificate when no CE was requested" do expect(readiness.certificate_due_reason).to eq("Registration")
+ <%= render "shared/sortable_header", label: "Date registered", index: date_index %> <%= render "shared/sortable_header", label: "Status", index: status_index %>
<%= link_to "Edit", edit_event_registration_path(registration, return_to: "registrants"), class: "text-gray-500 hover:text-gray-700 underline", data: { turbo_frame: "_top" } %>"><%= registration.created_at.strftime("%b %-d, %Y") %>"><%= registration.created_at.strftime("%b %-d, %Y") %> + <%= render "event_registrations/readiness_badge", status: readiness.status, label: readiness.status_label, issues: readiness.status_issues, subtext: readiness.status_reason %> <%= link_to "Edit", edit_event_registration_path(registration, return_to: "registrants"), class: "text-gray-500 hover:text-gray-700 underline", data: { turbo_frame: "_top" } %>