From ea24d2f6b8a47d41fa5f5d4d1c4b6e9bff7405bb Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 22 Jun 2026 01:40:30 -0400 Subject: [PATCH 01/20] Add CE status column and filter to registrants index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admins need to see and triage registrants' continuing-education credit standing at a glance — who requested CE, who is missing a license number or hours, and who has paid — without opening each registration. Surfaces this as a column and dropdown filter that only appear once an event has CE requests, so non-CE events stay uncluttered, and mirrors the column in the CSV export for parity. Co-Authored-By: Claude Opus 4.8 --- app/controllers/events_controller.rb | 12 +++- app/models/event.rb | 7 ++ app/models/event_registration.rb | 24 +++++++ .../events/_registrants_results.html.erb | 38 ++++++++++- app/views/events/_registrants_search.html.erb | 14 ++++ spec/models/event_registration_spec.rb | 39 +++++++++++ spec/requests/events_spec.rb | 67 +++++++++++++++++++ 7 files changed, 196 insertions(+), 5 deletions(-) diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 74648f3805..13df92bbf0 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -87,6 +87,7 @@ def registrants scope = scope.keyword(params[:keyword]) if params[:keyword].present? scope = scope.payment_status(params[:payment_status]) if params[:payment_status].present? scope = scope.scholarship_status(params[:scholarship]) if params[:scholarship].present? + scope = scope.ce_status(params[:ce_status]) if params[:ce_status].present? scope = scope.registrant_ids(params[:registrant_ids]) if params[:registrant_ids].present? scope = scope.registrant_state(params[:state]) if params[:state].present? scope = scope.registrant_county(params[:county]) if params[:county].present? @@ -105,6 +106,7 @@ def registrants @event_registrations = scope.order(Arel.sql("people.first_name, people.last_name")) @dashboard = EventDashboard.new(@event) + @offers_ce = @event.any_ce_credit_requests? emails = @event_registrations.map { |r| r.registrant.preferred_email&.downcase }.compact @duplicate_emails = emails.tally.select { |_, count| count > 1 }.keys.to_set @@ -535,15 +537,17 @@ def allocated_cents_by_registration(registrations) def event_registrations_csv_string require "csv" cost_required = @event.cost_cents.to_i > 0 + include_ce = @event.any_ce_credit_requests? headers = [ "First name", "Last name", "Email", "Phone", "Organization", "Scholarship recipient", "Scholarship tasks completed", "Payment status", "Intends to pay", "Payment total" ] + headers << "CE status" if include_ce CSV.generate(headers: headers, write_headers: true) do |csv_out| @event_registrations.each do |registration| - csv_out << event_registration_csv_row(registration, cost_required) + csv_out << event_registration_csv_row(registration, cost_required, include_ce) end end end - def event_registration_csv_row(registration, cost_required) + def event_registration_csv_row(registration, cost_required, include_ce = false) person = registration.registrant orgs = person.affiliations .select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) } @@ -552,7 +556,7 @@ def event_registration_csv_row(registration, cost_required) total_cents = registration.allocations_sum payment_total = total_cents.positive? ? format("%.2f", total_cents / 100.0) : "" payment_status = cost_required ? registration.payment_status_label : "" - [ + row = [ person.first_name, person.last_name, person.preferred_email.presence || "", @@ -564,6 +568,8 @@ def event_registration_csv_row(registration, cost_required) registration.intends_to_pay? ? "Yes" : "No", payment_total ] + row << registration.ce_status_label.to_s if include_ce + row end def onboarding_csv_string diff --git a/app/models/event.rb b/app/models/event.rb index 87bfbd9e34..eb27fd5232 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -107,6 +107,13 @@ def registration_form forms.find_by(event_forms: { role: "registration" }) end + # True when at least one registrant for this event requested CE credit. Drives + # the CE status column and filter on the registrants index — there's nothing + # to show or filter when no one has asked for CE. + def any_ce_credit_requests? + event_registrations.exists?(ce_credit_requested: true) + end + # Whether a signed-in user should register in one click rather than being # routed to the registration form. True when no registration form is linked, # or when an admin has explicitly opted members out of the form. A linked form diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index b776d45898..c56dce869c 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -147,6 +147,19 @@ class EventRegistration < ApplicationRecord else all end } + # Mirrors ReminderRecipientFilter#matches_ce_status?. The "license"/"hours" + # sub-statuses only make sense for someone who requested CE credit, so they're + # gated on it. "paid" has no CE-specific payment record yet, so it falls back + # to the registrant being paid in full. + scope :ce_status, ->(value) { + case value + when "requested" then where(ce_credit_requested: true) + when "license_not_provided" then where(ce_credit_requested: true).where(ce_license_number: [ nil, "" ]) + when "hours_not_provided" then where(ce_credit_requested: true).where("COALESCE(ce_hours_requested, 0) <= 0") + when "paid" then where(ce_credit_requested: true).merge(paid_in_full) + else all + end + } scope :keyword, ->(term) { return none if term.blank? @@ -262,6 +275,17 @@ def ce_license_provided? ce_license_number.present? end + # A short label summarizing the registrant's CE credit standing, matching the + # ce_status filter buckets. Nil when CE credit was not requested, so callers + # can render a placeholder. "Incomplete" takes precedence over "Paid" because + # a missing license/hours is the actionable state regardless of payment. + def ce_status_label + return unless ce_credit_requested? + return "Incomplete" if !ce_license_provided? || ce_hours_requested.to_i <= 0 + return "Paid" if paid_in_full? + "Requested" + end + # What the registrant owes for their requested CE hours, in cents, at the # default hourly rate. Zero when no hours were requested. def ce_amount_owed_cents diff --git a/app/views/events/_registrants_results.html.erb b/app/views/events/_registrants_results.html.erb index 5e458c9695..d67e9a1701 100644 --- a/app/views/events/_registrants_results.html.erb +++ b/app/views/events/_registrants_results.html.erb @@ -40,8 +40,11 @@ <% if @event_registrations.any? %>
<% payment_col = @event.cost_cents.to_i > 0 %> - <% date_index = payment_col ? 5 : 4 %> - <% attendance_index = payment_col ? 6 : 5 %> + <% ce_col = @offers_ce %> + <% extra_cols = (payment_col ? 1 : 0) + (ce_col ? 1 : 0) %> + <% ce_index = payment_col ? 4 : 3 %> + <% date_index = 4 + extra_cols %> + <% attendance_index = 5 + extra_cols %> @@ -65,6 +68,12 @@ <% end %> + <% if ce_col %> + + <% end %> + + <% end %> + <% end %> - + @@ -259,7 +281,7 @@ <% end %> <% if ce_col %> - <% end %> -
+ <%= render "shared/sortable_header", label: "CE status", index: ce_index %> + <%= render "shared/sortable_header", label: "Date registered", index: date_index %> @@ -249,6 +258,31 @@ <% end %> + <% if ce_col %> + + <% if (ce_label = registration.ce_status_label) %> + <% ce_badge_classes, ce_badge_icon = case ce_label + when "Incomplete" then [ "bg-amber-50 text-amber-700 border-amber-200", "fa-circle-exclamation" ] + when "Paid" then [ "bg-green-50 text-green-700 border-green-200", "fa-circle-check" ] + else [ "bg-blue-50 text-blue-700 border-blue-200", "fa-certificate" ] + end %> + <% missing_parts = [] %> + <% missing_parts << "license" unless registration.ce_license_provided? %> + <% missing_parts << "hours" if registration.ce_hours_requested.to_i <= 0 %> + <%= link_to edit_event_registration_path(registration, return_to: "registrants"), + title: (missing_parts.any? ? "Missing #{missing_parts.to_sentence}" : "CE credit requested"), + class: "inline-flex items-center gap-1.5 whitespace-nowrap rounded-full text-xs font-medium border px-3 py-0.5 #{ce_badge_classes} hover:opacity-80", + data: { turbo_frame: "_top" } do %> + + <%= ce_label %> + + <% end %> + <% else %> + + <% end %> + + <%= render "shared/sortable_header", label: "Date registered", index: date_index %> +