From 8d4590924ba288ecaf4ac428d85de21da32077c0 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 11:21:37 -0400 Subject: [PATCH 01/46] CE cutover: reroute intake/display onto the new models, drop ce_* columns Completes the continuing-education cutover started by the foundation models (#1916). Reroutes registration intake, the public callout, and every read site (callouts, onboarding, reminders, CSV) off the flat EventRegistration#ce_* columns and onto ContinuingEducationRegistration / ProfessionalLicense, then drops the ce_credit_requested / ce_hours_requested / ce_license_number columns. Reconciled with #1833 (registrants-index CE column + filter), which merged to main while this branch was in flight and built the same scaffolding on the old columns: - Kept #1833's scaffolding (CE column, dropdown filter, column toggles, layout, onboarding columns, bulk-reminder filter, CSV columns, eyebrow nav) and repointed every CE data read to the new models. - EventRegistration.ce_status is now a derived scope over the CE registrations (needs_license / requested / paid / issued / not_issued), replacing the old ce_credit_requested-based buckets; the roster + reminder dropdowns follow it. - EventRegistration#ce_status_label and the aggregators (ce_requested?, ce_hours_total, ce_amount_owed_cents, ce_license_provided?, ce_paid_in_full?) read the new models; the registrants column gates on a CE record existing rather than the dropped boolean (the "requested, no record yet" state is gone now that intake creates the record). - ReminderRecipientFilter's CE matching flows through the shared ce_status scope (its per-record matchers were redundant with main's scope approach). Migration timestamped to run after the foundation migrations now in main. Co-Authored-By: Claude Opus 4.8 --- .../event_registrations_controller.rb | 31 ++++- app/controllers/events/callouts_controller.rb | 35 +++++- app/controllers/events_controller.rb | 28 +++-- .../ce_credit_requested_controller.js | 20 +--- app/helpers/events_helper.rb | 5 + .../continuing_education_registration.rb | 17 +++ app/models/event_registration.rb | 110 ++++++++++++----- app/models/form_field.rb | 12 +- .../public_registration.rb | 39 +++--- app/services/magic_ticket_callouts.rb | 14 +-- app/views/event_registrations/_form.html.erb | 67 ++++++----- .../events/_registrants_results.html.erb | 12 +- app/views/events/_registrants_search.html.erb | 3 +- .../_reminder_recipient_filters.html.erb | 2 +- app/views/events/callouts/ce.html.erb | 38 +++--- app/views/events/onboarding/_row.html.erb | 12 +- config/routes.rb | 1 + ...ove_ce_columns_from_event_registrations.rb | 16 +++ db/schema.rb | 5 +- db/seeds/dev/events_management.rb | 22 +++- spec/helpers/event_helper_spec.rb | 6 +- .../continuing_education_registration_spec.rb | 23 ++++ spec/models/event_registration_spec.rb | 113 +++++++++++------- spec/models/form_field_spec.rb | 4 +- spec/requests/event_registrations_spec.rb | 28 +++-- spec/requests/events/bulk_reminders_spec.rb | 2 +- spec/requests/events/registrations_spec.rb | 25 +++- spec/requests/events_spec.rb | 87 +++++++------- .../public_registration_spec.rb | 70 +++++------ spec/services/magic_ticket_callouts_spec.rb | 44 ++++--- .../reminder_recipient_filter_spec.rb | 42 ++++--- ...ublic_registration_form_submission_spec.rb | 3 +- 32 files changed, 591 insertions(+), 345 deletions(-) create mode 100644 db/migrate/20260629122601_remove_ce_columns_from_event_registrations.rb diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index bf669fbded..856ef1f4ba 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -60,6 +60,7 @@ def create authorize! @event_registration if @event_registration.save + reconcile_ce_registration if params[:ce].present? respond_to do |format| format.html { redirect_to confirm_event_registration_path(@event_registration, return_to: params[:return_to]) @@ -87,6 +88,7 @@ def update @event_registration.notifications.select(&:new_record?).each { |n| n.recipient_email = recipient_email } if @event_registration.save + reconcile_ce_registration if params[:ce].present? respond_to do |format| format.turbo_stream format.html { @@ -323,6 +325,32 @@ def toggle_checklist_step(step, completed) end end + # Reconcile the admin CE section (posted under the `ce` namespace) into the + # registration's CE registration + professional license. Creating/updating CE is + # open to anyone who can edit the registration (admin or owner); the registrant's + # (single) CE registration is found-or-built against the licence for the typed + # number, with the editable hours applied. Removing CE is a delete, so it's + # gated to admins and never cascades away a CE registration that already carries + # payments or discounts. + def reconcile_ce_registration + ce = params[:ce] + unless ActiveModel::Type::Boolean.new.cast(ce[:requested]) + if allowed_to?(:manage?, with: EventRegistrationPolicy) + @event_registration.continuing_education_registrations.where.missing(:allocations).destroy_all + end + return + end + + license = ProfessionalLicense.find_or_create_for( + person: @event_registration.registrant, number: ce[:license_number].to_s.strip.presence + ) + ce_registration = @event_registration.continuing_education_registrations.first_or_initialize + ce_registration.professional_license = license + ce_registration.hours = ce[:hours] if ce[:hours].present? + ce_registration.cost_cents = (ce[:cost].to_d * 100).round if ce[:cost].present? + ce_registration.save! + end + # Strong parameters def event_registration_params params.require(:event_registration).permit( @@ -331,9 +359,6 @@ def event_registration_params :shoutout, :intends_to_pay, :expected_payment_method, - :ce_credit_requested, - :ce_hours_requested, - :ce_license_number, :fee_note, *EventRegistration::DAY_FIELDS, organization_ids: [], diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index c736818163..92a59571ae 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -58,10 +58,30 @@ def scholarship @form_responses_available = @event.registration_form&.form_submissions&.exists?(person: @event_registration.registrant) end - # CE hours status: requested hours, amount owed, and license number. + # CE hours status: hours, amount owed, and license number. def ce end + # Public license-number entry from the CE callout. Sets the number on the + # registrant's (first) CE registration via a found-or-created license, mirrors + # it onto the registration's form answer, then returns to the callout. Plain + # full-page POST — no Turbo. + def update_ce_license + ce_registration = @event_registration.continuing_education_registrations.first + return redirect_to(registration_ce_path(@event_registration.slug)) unless ce_registration + + number = params[:license_number].to_s.strip.presence + ce_registration.professional_license = ProfessionalLicense.find_or_create_for( + person: @event_registration.registrant, number: number + ) + ce_registration.save! + record_ce_license_answer(number) + + redirect_to registration_ce_path(@event_registration.slug), notice: "License number saved." + rescue ActiveRecord::RecordInvalid + redirect_to registration_ce_path(@event_registration.slug), alert: "We couldn't save that license number." + end + # Forms page: callout-card links to the W-9 and letter-to-supervisors # resource pages (when seeded) and the invoice, each returning to forms. def forms @@ -113,6 +133,19 @@ def set_event @event = @event_registration.event end + # Keep the registrant's form submission in step with a license number entered + # on the callout, so the registration record shows the same value. A no-op + # when the form, field, or submission isn't present. + def record_ce_license_answer(number) + form = @event.registration_form + field = form&.form_fields&.find_by(field_identifier: "ce_license_number") + submission = form&.form_submissions&.find_by(person: @event_registration.registrant) + return unless field && submission + + answer = submission.form_answers.find_or_initialize_by(form_field: field) + answer.update!(submitted_answer: number.to_s, question_name_when_answered: field.name) + end + # Builds the callout-card links shown on the forms page. The W-9 opens in # its own resource page (preview + download) when seeded; the invoice is # always available. diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index feacac6827..823b50bbaf 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -65,11 +65,9 @@ def sample_ticket invoice_requested: @show_all_options, scholarship_requested: @show_all_options, shoutout: @show_all_options, - ce_credit_requested: @show_all_options, - ce_hours_requested: @show_all_options ? 6 : nil, - ce_license_number: @show_all_options ? "SAMPLE-12345" : nil, created_at: Time.current ) + build_sample_ce_registration if @show_all_options end def background @@ -132,7 +130,7 @@ def onboarding authorize! @event, to: :registrants? @event = @event.decorate scope = @event.event_registrations - .includes(:checklist_completions, :organizations, :allocations, :scholarships, :comments, registrant: [ :user, { affiliations: :organization } ]) + .includes(:checklist_completions, :organizations, :allocations, :scholarships, :comments, { continuing_education_registrations: :professional_license }, registrant: [ :user, { affiliations: :organization } ]) .joins(:registrant) scope = scope.keyword(params[:keyword]) if params[:keyword].present? @@ -186,7 +184,7 @@ def details def ce_hours authorize! @event, to: :ce_hours? - if @event.ce_hours_details.blank? + unless @event.ce_eligible? redirect_to event_path(@event, reg: params[:reg].presence) return end @@ -511,6 +509,18 @@ def copy_registration_form private + # Build (unsaved) a CE registration on the sample ticket so the "Show all + # options" preview renders a populated CE card. Mirrors a complete, paid-looking + # CE record without touching the database. + def build_sample_ce_registration + license = ProfessionalLicense.new(person: @event_registration.registrant, number: "SAMPLE-12345") + @event_registration.continuing_education_registrations.build( + professional_license: license, + hours: @event.ce_hours_offered || 6, + cost_cents: @event.ce_hours_cost_cents || 15_000 + ) + end + # The registrations the admin checked on the recipient picker, narrowed to those # we can actually email. Shared by the confirm interstitial and the send action # so both operate on exactly the same set. @@ -626,11 +636,11 @@ def onboarding_csv_row(registration, cost_required, day_count, include_ce = fals row << (scholarship ? (scholarship.grant&.name.presence || "Unfunded") : "") row << onboarding_scholarship_tasks_csv(registration) if include_ce - ce_hours = registration.ce_hours_requested.to_i - row << (registration.ce_credit_requested? ? "Yes" : "No") - row << (ce_hours.positive? ? ce_hours : "") + ce_hours = registration.ce_hours_total + row << (registration.ce_requested? ? "Yes" : "No") + row << (ce_hours.positive? ? ContinuingEducationRegistration.format_hours(ce_hours) : "") row << (registration.ce_amount_owed_cents.positive? ? helpers.dollars_from_cents(registration.ce_amount_owed_cents) : "") - row << registration.ce_license_number.to_s + row << registration.ce_license_numbers.join("; ") end EventRegistration::CHECKLIST_STEPS.each_key do |step| row << (registration.checklist_step_completed?(step) ? "Yes" : "No") diff --git a/app/frontend/javascript/controllers/ce_credit_requested_controller.js b/app/frontend/javascript/controllers/ce_credit_requested_controller.js index 58b2528798..0af0a5e395 100644 --- a/app/frontend/javascript/controllers/ce_credit_requested_controller.js +++ b/app/frontend/javascript/controllers/ce_credit_requested_controller.js @@ -4,11 +4,11 @@ import { Controller } from "@hotwired/stimulus" // toggle to signal save state: amber while the choice is pending (changed but // not yet saved), the continuing-education theme color once it matches the // stored "on" value, neutral gray when stored as off. While "Requested" is on it -// reveals the CE details (license number + hours) and keeps the "Provided" badge -// and amount-owed total ($rate × hours) in sync as the admin edits them. +// reveals the CE details (license number, hours, cost) and keeps the "Provided" +// badge in sync as the admin edits the license number. export default class extends Controller { - static targets = ["checkbox", "track", "details", "license", "licenseBadge", "hours", "amount"] - static values = { initial: Boolean, rate: Number } + static targets = ["checkbox", "track", "details", "license", "licenseBadge"] + static values = { initial: Boolean } connect() { this.refresh() @@ -25,7 +25,6 @@ export default class extends Controller { if (this.hasDetailsTarget) this.detailsTarget.classList.toggle("hidden", !checked) this.updateLicenseBadge() - this.updateAmount() } updateLicenseBadge() { @@ -40,15 +39,4 @@ export default class extends Controller { ? ' Provided' : ' Not provided' } - - updateAmount() { - if (!this.hasHoursTarget || !this.hasAmountTarget) return - - const hours = Math.max(0, parseInt(this.hoursTarget.value, 10) || 0) - const owed = hours * this.rateValue - // Mirror the dollars_from_cents helper: drop the cents when the amount is a - // whole number of dollars, keep two decimals otherwise. - const fractionDigits = Number.isInteger(owed) ? 0 : 2 - this.amountTarget.textContent = `$${owed.toLocaleString("en-US", { minimumFractionDigits: fractionDigits, maximumFractionDigits: 2 })}` - } } diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 2871b44ba8..c2855a2f35 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -1,4 +1,9 @@ module EventsHelper + # Display a CE hours figure without trailing zeros (e.g. "6", "1.5"). + def ce_hours_display(hours) + ContinuingEducationRegistration.format_hours(hours) + end + # Stable anchor id for a registrant's row on the Onboarding matrix, so back-links # from detail pages can scroll to (and highlight) the row they came from. def onboarding_row_id(record_or_id) diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 16d697dee8..0ab5531862 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -20,6 +20,14 @@ class ContinuingEducationRegistration < ApplicationRecord # Payment interface (allocations_sum / paid_in_full? / remaining_cost / …) comes from # Registerable, driven by this record's own cost_cents column. + # Display a CE hours figure without trailing zeros: "6", "1.5". + def self.format_hours(hours) + return if hours.blank? + + number = hours.to_f + number == number.to_i ? number.to_i.to_s : number.to_s + end + # CE certificate eligibility — its own rule (not shared): the event grants CE, # the registrant attended, the training has ended, and the CE balance is paid. def certificate_available? @@ -29,6 +37,15 @@ def certificate_available? event.end_date&.past? && event_registration.attended? && paid_in_full? end + # Human-readable payment status, mirroring EventRegistration#payment_status_label. + # CE has no "intends to pay" concept (that's an event-access affordance), so the + # middle state is a genuine partial payment instead. + def payment_status_label + return "Paid" if paid_in_full? + return "Partial" if partially_paid? + "Due" + end + private # Snapshot the hours offered and total cost from the event when they aren't set diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 96cb0e45ee..1029296c12 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -40,16 +40,11 @@ class EventRegistration < ApplicationRecord # only the first event.day_count of them are shown on the Onboarding tab. DAY_FIELDS = (1..5).map { |day| "completed_day_#{day}" }.freeze - # Default price the registrant owes per requested continuing-education hour. - # The CE summary on the registration form multiplies it by ce_hours_requested. - CE_HOURLY_RATE_DOLLARS = 25 - # Validations validates :registrant_id, uniqueness: { scope: :event_id } validates :event_id, presence: true validates :status, inclusion: { in: ATTENDANCE_STATUSES }, allow_nil: false validates :slug, uniqueness: true, allow_nil: true - validates :ce_hours_requested, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true # Scopes scope :registrant_name, ->(registrant_name) { joins(:registrant).where( @@ -147,18 +142,34 @@ 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. + # Filter by CE state. All derived (no stored CE status): payment (requested/paid) + # is computed from allocations vs cost like the registration's own payment state; + # issued/not_issued read the certificate delivery; needs_license is a CE + # registration sitting on a placeholder license. 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 + paid_sql = <<~SQL.squish + COALESCE((SELECT SUM(a.amount) FROM allocations a + WHERE a.allocatable_type = 'ContinuingEducationRegistration' + AND a.allocatable_id = cer.id), 0) >= cer.cost_cents + SQL + condition = + case value + when "needs_license" then "pl.number IS NULL" + when "paid" then paid_sql + when "requested" then "NOT (#{paid_sql})" + when "issued" then "cer.certificate_sent_at IS NOT NULL" + when "not_issued" then "cer.certificate_sent_at IS NULL" + end + next all if condition.blank? + + where(<<~SQL.squish) + EXISTS ( + SELECT 1 FROM continuing_education_registrations cer + JOIN professional_licenses pl ON pl.id = cer.professional_license_id + WHERE cer.event_registration_id = event_registrations.id + AND (#{condition}) + ) + SQL } scope :comment_status, ->(value) { commented = Comment.where(commentable_type: "EventRegistration").select(:commentable_id) @@ -242,6 +253,9 @@ def self.search_by_params(params) .where(event_registration_organizations: { organization_id: params[:organization_id] }) .distinct end + if params[:ce_status].present? + registrations = registrations.ce_status(params[:ce_status]) + end registrations end @@ -314,26 +328,66 @@ def cost_cents event.cost_cents end - # True when the registrant has supplied a CE license number. - def ce_license_provided? - ce_license_number.present? + # CE is now tracked as one or more ContinuingEducationRegistration records, + # each against a professional license. These aggregate across them so callers + # (callouts, onboarding, CSV) read a single registration-level figure. + def ce_requested? + if ce_registrations_in_memory? + return continuing_education_registrations.any? + end + continuing_education_registrations.exists? + end + + def ce_hours_total + if ce_registrations_in_memory? + return continuing_education_registrations.sum { |c| c.hours.to_d } + end + continuing_education_registrations.sum(:hours) 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. + # ce_status filter buckets. Nil when CE wasn't requested, so callers can render + # a placeholder. "Needs license" takes precedence (it's the actionable state), + # then certificate issuance, then 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? + return unless ce_requested? + return "Needs license" unless ce_license_provided? + return "Issued" if continuing_education_registrations.all? { |c| c.certificate_sent_at.present? } + return "Paid" if ce_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 - ce_hours_requested.to_i * CE_HOURLY_RATE_DOLLARS * 100 + if ce_registrations_in_memory? + return continuing_education_registrations.sum { |c| c.cost_cents.to_i } + end + continuing_education_registrations.sum(:cost_cents) + end + + # True only when every CE registration has a known license number on file. + def ce_license_provided? + return false unless ce_requested? + + continuing_education_registrations.all? { |c| c.professional_license&.number_known? } + end + + # True when CE is requested and every CE registration is fully paid. + def ce_paid_in_full? + return false unless ce_requested? + + continuing_education_registrations.all?(&:paid_in_full?) + end + + # License numbers on file across this registration's CE registrations. + def ce_license_numbers + continuing_education_registrations.filter_map { |c| c.professional_license&.number } + end + + # Read CE registrations from the in-memory collection rather than the DB when + # it's already loaded or this registration isn't persisted (e.g. the unsaved + # sample-ticket preview builds CE registrations without saving). + def ce_registrations_in_memory? + continuing_education_registrations.loaded? || new_record? end def joinable? diff --git a/app/models/form_field.rb b/app/models/form_field.rb index c8ee2feba6..7fc003ef67 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -68,14 +68,10 @@ class FormField < ApplicationRecord # Specify options scoped to a single field by its field_identifier, rather than # to an option label everywhere it appears (SPECIFY_OPTION_PLACEHOLDERS). The - # CE-interest question's "Yes" reveals a "How many CE hours?" box that only - # makes sense there — a bare "Yes" anywhere else must stay a plain choice. The - # typed value folds into the answer as "Yes: ", which the registration - # service parses onto EventRegistration#ce_hours_requested. The identifier - # matches EventRegistrationServices::PublicRegistration::CE_CREDIT_INTEREST_IDENTIFIER. - FIELD_SPECIFY_OPTION_PLACEHOLDERS = { - "ce_credit_interest" => { "Yes" => "How many CE hours?" } - }.freeze + # CE-interest question once revealed a "How many CE hours?" box here, but CE + # hours now come from the event, so the question is a plain Yes/No. Kept as the + # general mechanism for any future field-scoped specify box. + FIELD_SPECIFY_OPTION_PLACEHOLDERS = {}.freeze # Fallback character ceilings applied when a free-form field has no explicit # max_characters set. This is a safety net against pathological submissions diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index 6dc60d988a..a0be5a7a17 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -3,10 +3,14 @@ class PublicRegistration Result = Struct.new(:success?, :event_registration, :form_submission, :errors, keyword_init: true) # Well-known field_identifier of the "magic" CE question seeded onto the - # registration form. Answering it "Yes" toggles the registration's - # ce_credit_requested flag. Kept here so the seed, service, and specs agree. + # registration form. Answering it "Yes" creates a ContinuingEducationRegistration + # (hours come from the event). Kept here so the seed, service, and specs agree. CE_CREDIT_INTEREST_IDENTIFIER = "ce_credit_interest".freeze + # Well-known field_identifier of the CE license-number question. Its answer + # seeds the registrant's ProfessionalLicense. + CE_LICENSE_NUMBER_IDENTIFIER = "ce_license_number".freeze + # Well-known field_identifier of the "Additional forms" multi-select question. # Checking "Invoice" / "W-9" toggles the registration's invoice_requested / # w9_requested flags, which the digital ticket reads to surface those downloads. @@ -62,7 +66,7 @@ def call existing = @event.event_registrations.find_by(registrant: person) if existing existing.update!(scholarship_requested: true) if @scholarship_requested - existing.update!(ce_credit_requested: true, ce_hours_requested: ce_hours_requested) if ce_credit_requested? + create_ce_registration(existing, person) existing.update!(w9_requested: true) if w9_requested? existing.update!(invoice_requested: true) if invoice_requested? payment_method = field_value("payment_method")&.strip @@ -78,6 +82,7 @@ def call end event_registration = create_event_registration(person) + create_ce_registration(event_registration, person) connect_organization(event_registration, organization) submission = create_form_submission(person) save_scholarship_submission(person) @@ -384,33 +389,31 @@ def create_event_registration(person) @event.event_registrations.create!( registrant: person, scholarship_requested: @scholarship_requested, - ce_credit_requested: ce_credit_requested?, - ce_hours_requested: ce_hours_requested, w9_requested: w9_requested?, invoice_requested: invoice_requested?, expected_payment_method: field_value("payment_method")&.strip.presence ) end - # The CE-interest answer, which "Yes" folds an hours specify box into as - # "Yes: ". Split off the leading label so a Yes/No check ignores the - # folded hours (see FormField::FIELD_SPECIFY_OPTION_PLACEHOLDERS). - def ce_answer_label - field_value(CE_CREDIT_INTEREST_IDENTIFIER).to_s.split(":", 2).first.to_s.strip + # Create the registrant's CE registration when they opt in, against a license + # found-or-created from the license-number answer (a placeholder license when + # none was given). Hours come from the event via the model. No-op when they + # didn't opt in or a CE registration already exists for this registration. + def create_ce_registration(event_registration, person) + return unless ce_credit_requested? + return if event_registration.continuing_education_registrations.exists? + + license = ProfessionalLicense.find_or_create_for(person: person, number: ce_license_number) + event_registration.continuing_education_registrations.create!(professional_license: license) end # True when the registrant answered "Yes" to the seeded CE-interest question. def ce_credit_requested? - ce_answer_label.casecmp?("yes") + field_value(CE_CREDIT_INTEREST_IDENTIFIER).to_s.strip.casecmp?("yes") end - # The CE hours typed into the "Yes" specify box, as a positive integer, or nil - # when CE was not requested or no valid hours were entered. - def ce_hours_requested - return unless ce_credit_requested? - - hours = field_value(CE_CREDIT_INTEREST_IDENTIFIER).to_s.split(":", 2).last.to_s.strip.to_i - hours.positive? ? hours : nil + def ce_license_number + field_value(CE_LICENSE_NUMBER_IDENTIFIER)&.strip.presence end # The "Additional forms" question is a multi-select, so its submitted value is diff --git a/app/services/magic_ticket_callouts.rb b/app/services/magic_ticket_callouts.rb index a040933a86..aaecbb8b7e 100644 --- a/app/services/magic_ticket_callouts.rb +++ b/app/services/magic_ticket_callouts.rb @@ -119,8 +119,8 @@ def scholarship_badge(awarded, tasks_outstanding) # they have, becoming a reference card once requested with hours and a license # number on file. Shown when the event offers CE or the registrant asked for it. def ce_hours_card - return unless registration.ce_credit_requested? - complete = registration.ce_hours_requested.present? && registration.ce_license_provided? + return unless registration.ce_requested? + complete = registration.ce_license_provided? Card.new(icon_class: "fa-solid fa-graduation-cap", color: "teal", title: event.ce_hours_details_label, subtitle: ce_hours_subtitle, @@ -133,8 +133,8 @@ def ce_hours_card end def ce_hours_subtitle - return "#{registration.ce_hours_requested} hours" if registration.ce_hours_requested.present? - "Continuing education credit" + hours = ContinuingEducationRegistration.format_hours(event.ce_hours_offered) + hours.present? ? "#{hours} hours" : "Continuing education credit" end # Teal "$X due" once hours + license are on file and money is owed; otherwise an @@ -153,11 +153,9 @@ def ce_hours_badge(complete) amount_cents.positive? ? "#{amount} · #{needed}" : needed end + # Hours are set by the event now, so the only thing a requesting registrant can + # still be missing is their license number. def ce_missing_text - missing_hours = registration.ce_hours_requested.blank? - missing_license = !registration.ce_license_provided? - return "Hours & license number needed" if missing_hours && missing_license - return "Hours needed" if missing_hours "License number needed" end diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb index 9cd5247c47..8499043f4a 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -222,16 +222,24 @@

CE credits

+ <% ce_reg = f.object.continuing_education_registrations.first %> + <% ce_requested = f.object.ce_requested? %> + <% ce_license = ce_reg&.professional_license&.number %> + <% ce_hours_value = ce_reg&.hours || f.object.event&.ce_hours_offered %> + <% ce_cost_value = ce_reg&.cost_cents || f.object.event&.ce_hours_cost_cents %> + <%# CE is stored as its own ContinuingEducationRegistration(s), so these + inputs post under a separate `ce` namespace and the controller + reconciles them into a registration + professional license. Hours and + cost are stored values (defaulting from the event), not a derived total. ---- %>
+ data-ce-credit-requested-initial-value="<%= ce_requested %>"> -
"> +
">
- + "> - text-[0.55rem]"> - <%= f.object.ce_license_provided? ? "Provided" : "Not provided" %> + class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.65rem] font-medium <%= ce_license.present? ? "bg-teal-50 text-teal-700" : "bg-gray-100 text-gray-500" %>"> + text-[0.55rem]"> + <%= ce_license.present? ? "Provided" : "Not provided" %>
- <%= f.text_field :ce_license_number, - class: "w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none", - placeholder: "Not provided", - data: { "ce-credit-requested-target": "license", action: "input->ce-credit-requested#refresh" } %> +
-
- - <%= f.number_field :ce_hours_requested, - min: 0, step: 1, - class: "w-28 rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-900 shadow-sm tabular-nums focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none", - placeholder: "0", - data: { "ce-credit-requested-target": "hours", action: "input->ce-credit-requested#refresh" } %> -
- -
- - Amount owed - ($<%= EventRegistration::CE_HOURLY_RATE_DOLLARS %>/hr) - - <%= dollars_from_cents(f.object.ce_amount_owed_cents) %> +
+
+ + +
+
+ + +
diff --git a/app/views/events/_registrants_results.html.erb b/app/views/events/_registrants_results.html.erb index c4f853186d..51a0c1e558 100644 --- a/app/views/events/_registrants_results.html.erb +++ b/app/views/events/_registrants_results.html.erb @@ -264,18 +264,14 @@ <% if ce_col %> - <%# CE progression mirrors scholarship's colors. Until a CE registration - record exists it's just "Requested"; once it does, walk through - license → payment → paid. "Create" when CE wasn't requested. %> + <%# CE progression mirrors scholarship's colors: walk through + license → payment → paid once a CE registration exists. + "Create" when CE wasn't requested (no registration record). %> <% cer = registration.continuing_education_registrations.first %> - <% if !registration.ce_credit_requested? %> + <% if cer.nil? %> <% ce_classes = "bg-gray-50 text-gray-400 border-gray-200" %> <% ce_text = "Create" %> <% ce_title = "No CE credit requested" %> - <% elsif cer.nil? %> - <% ce_classes = "bg-amber-50 text-amber-700 border-amber-200" %> - <% ce_text = "Requested" %> - <% ce_title = "CE requested — no registration record yet" %> <% elsif !cer.professional_license&.number_known? %> <% ce_classes = "bg-amber-50 text-amber-700 border-amber-200" %> <% ce_text = "No license #" %> diff --git a/app/views/events/_registrants_search.html.erb b/app/views/events/_registrants_search.html.erb index c9246bd47f..47bc6b43ac 100644 --- a/app/views/events/_registrants_search.html.erb +++ b/app/views/events/_registrants_search.html.erb @@ -36,7 +36,7 @@ <% if @ce_eligible %> <%= render "events/filter_select", param: :ce_status, label: "CE status", - options: [ [ "Requested", "requested" ], [ "License not provided", "license_not_provided" ], [ "Hours not provided", "hours_not_provided" ], [ "Paid", "paid" ] ], + options: [ [ "Needs license", "needs_license" ], [ "Requested", "requested" ], [ "Paid", "paid" ], [ "Issued", "issued" ], [ "Not issued", "not_issued" ] ], selected: params[:ce_status], blank: "Any CE status", field_class: field_class %> <% end %> @@ -77,5 +77,4 @@ class: "btn btn-utility", data: { action: "collection#clearAndSubmit" } %>
-
<% end %> diff --git a/app/views/events/_reminder_recipient_filters.html.erb b/app/views/events/_reminder_recipient_filters.html.erb index 3491a19764..8ed5a1121d 100644 --- a/app/views/events/_reminder_recipient_filters.html.erb +++ b/app/views/events/_reminder_recipient_filters.html.erb @@ -47,7 +47,7 @@ <% if @ce_eligible %> <%= render "events/filter_select", param: :ce_status, label: "CE status", - options: [ [ "Requested", "requested" ], [ "License not provided", "license_not_provided" ], [ "Hours not provided", "hours_not_provided" ], [ "Paid", "paid" ] ], + options: [ [ "Needs license", "needs_license" ], [ "Requested", "requested" ], [ "Paid", "paid" ], [ "Issued", "issued" ], [ "Not issued", "not_issued" ] ], selected: params[:ce_status], blank: "Any CE status", field_class: field_class %> <% end %> diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 25423fdb6f..e8074eb5a7 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -1,10 +1,12 @@ <% content_for(:page_bg_class, "public") %> <% content_for(:page_title, "#{@event.ce_hours_details_label} — #{@event.title}") %> <%= render layout: "events/callouts/callout_page", locals: { title: @event.ce_hours_details_label } do %> - <% if @event_registration.ce_credit_requested? %> + <% if @event_registration.ce_requested? %> + <% ce_registration = @event_registration.continuing_education_registrations.first %> + <% license_number = ce_registration&.professional_license&.number %>

Status

- <% if @event_registration.paid_in_full? %> + <% if @event_registration.ce_paid_in_full? %> Paid @@ -17,31 +19,35 @@
-
Hours requested
- <% if @event_registration.ce_hours_requested.present? %> -
<%= @event_registration.ce_hours_requested %>
- <% else %> -
We don't have your requested hours yet.
- <% end %> +
Hours
+
<%= ce_hours_display(@event_registration.ce_hours_total) || "—" %>
-
Cost<% if @event_registration.ce_hours_requested.present? %> (<%= @event_registration.ce_hours_requested %> × $<%= EventRegistration::CE_HOURLY_RATE_DOLLARS %>)<% end %>
-
<%= @event_registration.ce_hours_requested.present? ? dollars_from_cents(@event_registration.ce_amount_owed_cents) : "—" %>
+
Cost
+
<%= dollars_from_cents(@event_registration.ce_amount_owed_cents) %>
License number
- <% if @event_registration.ce_license_provided? %> -
<%= @event_registration.ce_license_number %>
+ <% if license_number.present? %> +
<%= license_number %>
<% else %> -
We don't have your license number on file yet.
+
Not on file yet.
<% end %>
- <% if @event_registration.ce_hours_requested.blank? || !@event_registration.ce_license_provided? %> -
- We don't have all of your CE details yet. Please <%= link_to "email us", contact_us_path, class: "underline font-medium" %> with your license number and the number of CE hours you'd like. + <%# Registrants supply (or correct) their license number here — a plain + full-page POST, no Turbo, so the page simply reloads with the saved value. %> + <%= form_with url: registration_ce_license_path(@event_registration.slug), method: :post, + data: { turbo: false }, class: "mt-5 border-t border-gray-100 pt-5" do |form| %> + +
+ <%= form.text_field :license_number, value: license_number, id: "license_number", + placeholder: "e.g. LMFT 12345", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + <%= form.submit "Save", class: "shrink-0 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %>
+

Acceptance of CE hours is determined by each state board; we can't guarantee yours will accept them.

<% end %> <% else %>

You haven't requested continuing education credit for this training. CE hours are available for an additional fee — reach out to request credit.

diff --git a/app/views/events/onboarding/_row.html.erb b/app/views/events/onboarding/_row.html.erb index 19ee6ad93f..3581d2a672 100644 --- a/app/views/events/onboarding/_row.html.erb +++ b/app/views/events/onboarding/_row.html.erb @@ -205,7 +205,7 @@ <% when :ce_requested %> - <% ce_requested = registration.ce_credit_requested? %> + <% ce_requested = registration.ce_requested? %> " data-sort-value="<%= ce_requested ? 1 : 0 %>"> <%= link_to edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:opacity-80", title: "Edit CE details", data: { turbo_frame: "_top" } do %> <% if ce_requested %> @@ -217,20 +217,20 @@ <% when :ce_hours %> - <% ce_hours = registration.ce_hours_requested.to_i %> + <% ce_hours = registration.ce_hours_total.to_f %> <%= ce_hours.positive? ? "text-gray-800" : "text-gray-400" %>" data-sort-value="<%= ce_hours %>"> - <%= link_to (ce_hours.positive? ? ce_hours : "—"), edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:underline #{ce_hours.positive? ? "text-gray-800" : "text-gray-400"}", data: { turbo_frame: "_top" } %> + <%= link_to (ce_hours.positive? ? ce_hours_display(ce_hours) : "—"), edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:underline #{ce_hours.positive? ? "text-gray-800" : "text-gray-400"}", data: { turbo_frame: "_top" } %> <% when :ce_amount %> <% ce_cents = registration.ce_amount_owed_cents %> <%= ce_cents.positive? ? "text-gray-800" : "text-gray-400" %>" data-sort-value="<%= ce_cents %>"> - <%= link_to (ce_cents.positive? ? dollars_from_cents(ce_cents) : "—"), edit_event_registration_path(registration, return_to: "onboarding"), title: "CE owed (hours × $#{EventRegistration::CE_HOURLY_RATE_DOLLARS})", class: "hover:underline #{ce_cents.positive? ? "text-gray-800" : "text-gray-400"}", data: { turbo_frame: "_top" } %> + <%= link_to (ce_cents.positive? ? dollars_from_cents(ce_cents) : "—"), edit_event_registration_path(registration, return_to: "onboarding"), title: "CE cost", class: "hover:underline #{ce_cents.positive? ? "text-gray-800" : "text-gray-400"}", data: { turbo_frame: "_top" } %> <% when :ce_license %> - <% ce_license = registration.ce_license_number %> - text-sm" data-sort-value="<%= ce_license.to_s.downcase %>"> + <% ce_license = registration.ce_license_numbers.join(", ") %> + text-sm" data-sort-value="<%= ce_license.downcase %>"> <%= link_to (ce_license.presence || "—"), edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:underline #{ce_license.present? ? "text-gray-700" : "text-gray-400"}", data: { turbo_frame: "_top" } %> diff --git a/config/routes.rb b/config/routes.rb index 94fef6ba86..b0e4dbcea8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -83,6 +83,7 @@ get "registration/:slug/payment", to: "events/callouts#payment", as: :registration_payment get "registration/:slug/certificate", to: "events/callouts#certificate", as: :registration_certificate get "registration/:slug/ce", to: "events/callouts#ce", as: :registration_ce + post "registration/:slug/ce/license", to: "events/callouts#update_ce_license", as: :registration_ce_license get "registration/:slug/forms", to: "events/callouts#forms", as: :registration_forms get "registration/:slug/handouts", to: "events/callouts#handouts", as: :registration_handouts get "registration/:slug/resource/:resource_id", to: "events/callouts#resource", as: :registration_resource diff --git a/db/migrate/20260629122601_remove_ce_columns_from_event_registrations.rb b/db/migrate/20260629122601_remove_ce_columns_from_event_registrations.rb new file mode 100644 index 0000000000..029e38859d --- /dev/null +++ b/db/migrate/20260629122601_remove_ce_columns_from_event_registrations.rb @@ -0,0 +1,16 @@ +class RemoveCeColumnsFromEventRegistrations < ActiveRecord::Migration[8.1] + # CE is now tracked as ContinuingEducationRegistration records, so these flat + # columns are obsolete. No data to preserve (the CE form was never used in + # production). + def up + remove_column :event_registrations, :ce_credit_requested, if_exists: true + remove_column :event_registrations, :ce_hours_requested, if_exists: true + remove_column :event_registrations, :ce_license_number, if_exists: true + end + + def down + add_column :event_registrations, :ce_credit_requested, :boolean, null: false, default: false unless column_exists?(:event_registrations, :ce_credit_requested) + add_column :event_registrations, :ce_hours_requested, :integer unless column_exists?(:event_registrations, :ce_hours_requested) + add_column :event_registrations, :ce_license_number, :string unless column_exists?(:event_registrations, :ce_license_number) + end +end diff --git a/db/schema.rb b/db/schema.rb index c902433e94..4a520d27b0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_29_023519) do +ActiveRecord::Schema[8.1].define(version: 2026_06_29_122601) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -478,9 +478,6 @@ end create_table "event_registrations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| - t.boolean "ce_credit_requested", default: false, null: false - t.integer "ce_hours_requested" - t.string "ce_license_number" t.datetime "certificate_sent_at" t.string "checkout_session_id" t.boolean "completed_day_1", default: false, null: false diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index 90fb18e91a..a9835220df 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -155,8 +155,8 @@ .update_all(subtitle: "Payments are due no more than three weeks after your registration date. " \ "Training details will be sent after payments are received.") -# The CE-interest "magic question": a single Yes/No whose answer drives the -# resulting registration's ce_credit_requested flag (see +# The CE-interest "magic question": a single Yes/No whose "Yes" creates the +# registration's ContinuingEducationRegistration (see # EventRegistrationServices::PublicRegistration). Seeded straight onto the form # with its own section so the form builder's add/remove-section logic leaves it # alone, and carrying the well-known field_identifier the service keys off. A @@ -769,10 +769,11 @@ # so she can reach her training materials (the intends_to_pay scenario). Pairs # with Amy on this same event, who DOES have payments, for side-by-side review. if facilitator_training + facilitator_training.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) [ - { person: amy_person, status: "registered", scholarship_requested: true, w9_requested: true, invoice_requested: true, ce_credit_requested: true }, + { person: amy_person, status: "registered", scholarship_requested: true, w9_requested: true, invoice_requested: true, ce_credit_requested: true, ce_license_number: "LMFT 90210" }, { person: maria_j, status: "registered", invoice_requested: true, ce_credit_requested: true, intends_to_pay: true }, - { person: anna_g, status: "attended", ce_credit_requested: true, intends_to_pay: true }, + { person: anna_g, status: "attended", ce_credit_requested: true, intends_to_pay: true, ce_license_number: "LCSW 11223", ce_status: "issued" }, { person: mario_j, status: "registered" }, { person: kim_d, status: "cancelled" }, { person: aisha_person, status: "registered", intends_to_pay: true } @@ -788,8 +789,9 @@ # Angel Garcia: registered, no form (no user) # Linda Williams: no_show (no user) if trauma_training + trauma_training.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) [ - { person: sarah_s, status: "registered", invoice_requested: true, ce_credit_requested: true }, + { person: sarah_s, status: "registered", invoice_requested: true, ce_credit_requested: true, ce_license_number: "LPCC 44556" }, { person: jessica_b, status: "registered", scholarship_requested: true, ce_credit_requested: true }, { person: angel_g, status: "registered" }, { person: linda_w, status: "no_show" } @@ -858,9 +860,17 @@ # existing DB (find_or_initialize no longer recreates these registrations). registration.w9_requested = data[:w9_requested] || false registration.invoice_requested = data[:invoice_requested] || false - registration.ce_credit_requested = data[:ce_credit_requested] || false registration.intends_to_pay = data[:intends_to_pay] || false registration.save! + + # CE opt-in becomes a ContinuingEducationRegistration against the registrant's + # license (a placeholder when no number is seeded). Hours come from the event. + if data[:ce_credit_requested] && registration.continuing_education_registrations.none? + license = ProfessionalLicense.find_or_create_for(person: data[:person], number: data[:ce_license_number]) + ce_registration = registration.continuing_education_registrations.create!(professional_license: license) + # "issued" in the seed data means the CE certificate was delivered. + ce_registration.mark_certificate_sent! if data[:ce_status] == "issued" + end end # Connect each multi-affiliation registrant's registration to a single one of diff --git a/spec/helpers/event_helper_spec.rb b/spec/helpers/event_helper_spec.rb index 8426f5f677..a70ac60ac9 100644 --- a/spec/helpers/event_helper_spec.rb +++ b/spec/helpers/event_helper_spec.rb @@ -20,11 +20,9 @@ expect(helper.specify_placeholder(nil)).to be_nil end - it "returns the CE hours placeholder for 'Yes' only on the CE field" do + it "does not reveal a box for the CE question's 'Yes' (hours come from the event)" do ce_field = FormField.new(field_identifier: "ce_credit_interest") - other_field = FormField.new(field_identifier: "interested_in_more") - expect(helper.specify_placeholder("Yes", ce_field)).to eq("How many CE hours?") - expect(helper.specify_placeholder("Yes", other_field)).to be_nil + expect(helper.specify_placeholder("Yes", ce_field)).to be_nil end end diff --git a/spec/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb index e90ab505d1..a2e5bea409 100644 --- a/spec/models/continuing_education_registration_spec.rb +++ b/spec/models/continuing_education_registration_spec.rb @@ -186,5 +186,28 @@ def scholarship_for(reg, amount) expect(queries).to be_empty expect(preloaded.paid_in_full?).to be(true) end + + it "reports discounted? and discount_sum like an event registration" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + expect(ce_reg.discounted?).to be(false) + expect(ce_reg.discount_sum).to eq(0) + + create(:allocation, source: create(:discount, amount_cents: 4_000), allocatable: ce_reg, amount: 4_000) + + expect(ce_reg.discounted?).to be(true) + expect(ce_reg.discount_sum).to eq(4_000) + end + + it "labels payment status Due → Partial → Paid" do + ce_reg = create(:continuing_education_registration, cost_cents: 10_000) + expect(ce_reg.payment_status_label).to eq("Due") + + payment = create(:payment, amount_cents: 10_000, amount_cents_remaining: 10_000) + create(:allocation, source: payment, allocatable: ce_reg, amount: 4_000) + expect(ce_reg.payment_status_label).to eq("Partial") + + create(:allocation, source: payment, allocatable: ce_reg, amount: 6_000) + expect(ce_reg.payment_status_label).to eq("Paid") + end end end diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index cdc22ea13f..d0642af6dd 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -176,41 +176,68 @@ end describe ".ce_status" do - let!(:complete_ce) do - create(:event_registration, event: event, ce_credit_requested: true, ce_license_number: "ABC123", ce_hours_requested: 3).tap do |r| - create(:allocation, source: create(:payment, amount_cents: event.cost_cents, amount_cents_remaining: event.cost_cents), - allocatable: r, amount: event.cost_cents) + let(:ce_cost) { 15_000 } + # Known license, fully paid (certificate not yet issued). + let!(:paid_ce) do + create(:event_registration, event: event).tap do |r| + cer = create(:continuing_education_registration, event_registration: r, cost_cents: ce_cost) + create(:allocation, source: create(:payment, amount_cents: ce_cost, amount_cents_remaining: ce_cost), + allocatable: cer, amount: ce_cost) end end - let!(:missing_ce) { create(:event_registration, event: event, ce_credit_requested: true) } - let!(:no_ce) { create(:event_registration, event: event, ce_credit_requested: false) } + # Known license, unpaid. + let!(:requested_ce) do + create(:event_registration, event: event).tap do |r| + create(:continuing_education_registration, event_registration: r, cost_cents: ce_cost) + end + end + # CE registration sitting on a placeholder (numberless) license. + let!(:needs_license_ce) do + create(:event_registration, event: event).tap do |r| + license = create(:professional_license, :placeholder, person: r.registrant) + create(:continuing_education_registration, event_registration: r, professional_license: license, cost_cents: ce_cost) + end + end + # Certificate delivered. + let!(:issued_ce) do + create(:event_registration, event: event).tap do |r| + create(:continuing_education_registration, event_registration: r, cost_cents: ce_cost, certificate_sent_at: Time.current) + end + end + let!(:no_ce) { create(:event_registration, event: event) } - it "maps 'requested' to anyone who asked for CE credit" do - results = EventRegistration.ce_status("requested") - expect(results).to include(complete_ce, missing_ce) - expect(results).not_to include(no_ce) + it "maps 'needs_license' to CE on a placeholder license" do + results = EventRegistration.ce_status("needs_license") + expect(results).to include(needs_license_ce) + expect(results).not_to include(paid_ce, requested_ce, no_ce) + end + + it "maps 'paid' to fully paid CE registrations" do + results = EventRegistration.ce_status("paid") + expect(results).to include(paid_ce) + expect(results).not_to include(requested_ce, no_ce) end - it "maps 'license_not_provided' to CE requests missing a license number" do - results = EventRegistration.ce_status("license_not_provided") - expect(results).to include(missing_ce) - expect(results).not_to include(complete_ce, no_ce) + it "maps 'requested' to CE registrations not yet paid" do + results = EventRegistration.ce_status("requested") + expect(results).to include(requested_ce, needs_license_ce) + expect(results).not_to include(paid_ce, no_ce) end - it "maps 'hours_not_provided' to CE requests missing hours" do - results = EventRegistration.ce_status("hours_not_provided") - expect(results).to include(missing_ce) - expect(results).not_to include(complete_ce, no_ce) + it "maps 'issued' to CE registrations with a delivered certificate" do + results = EventRegistration.ce_status("issued") + expect(results).to include(issued_ce) + expect(results).not_to include(requested_ce, no_ce) end - it "maps 'paid' to CE requests that are paid in full" do - results = EventRegistration.ce_status("paid") - expect(results).to include(complete_ce) - expect(results).not_to include(missing_ce, no_ce) + it "maps 'not_issued' to CE registrations without a delivered certificate" do + results = EventRegistration.ce_status("not_issued") + expect(results).to include(paid_ce, requested_ce) + expect(results).not_to include(issued_ce, no_ce) end it "returns an unfiltered relation for unknown values" do - expect(EventRegistration.ce_status("bogus")).to include(complete_ce, missing_ce, no_ce) + expect(EventRegistration.ce_status("bogus")).to include(paid_ce, requested_ce, no_ce) end end @@ -571,33 +598,39 @@ describe "continuing education" do let(:reg) { create(:event_registration) } + def add_ce(number: "LIC-123", hours: 4, cost_cents: 15_000) + license = create(:professional_license, person: reg.registrant, number: number) + create(:continuing_education_registration, event_registration: reg, professional_license: license, hours: hours, cost_cents: cost_cents) + end + describe "#ce_amount_owed_cents" do - it "multiplies requested hours by the default hourly rate" do - reg.ce_hours_requested = 4 - expect(reg.ce_amount_owed_cents).to eq(4 * EventRegistration::CE_HOURLY_RATE_DOLLARS * 100) + it "sums the cost across the registration's CE registrations" do + add_ce(cost_cents: 10_000) + expect(reg.ce_amount_owed_cents).to eq(10_000) end - it "is zero when no hours are requested" do - reg.ce_hours_requested = nil + it "is zero when no CE is requested" do expect(reg.ce_amount_owed_cents).to eq(0) end end - describe "#ce_license_provided?" do - it "is true only when a license number is present" do - reg.ce_license_number = "LIC-123" - expect(reg).to be_ce_license_provided - reg.ce_license_number = "" - expect(reg).not_to be_ce_license_provided + describe "#ce_requested?" do + it "is true only once a CE registration exists" do + expect(reg).not_to be_ce_requested + add_ce + expect(reg.reload).to be_ce_requested end end - describe "ce_hours_requested validation" do - it "rejects negative or non-integer hours but allows nil" do - reg.ce_hours_requested = nil - expect(reg).to be_valid - reg.ce_hours_requested = -1 - expect(reg).not_to be_valid + describe "#ce_license_provided?" do + it "is true only when every CE registration has a known license number" do + add_ce(number: "LIC-123") + expect(reg.reload).to be_ce_license_provided + end + + it "is false when a CE registration sits on a placeholder license" do + add_ce(number: nil) + expect(reg.reload).not_to be_ce_license_provided end end end diff --git a/spec/models/form_field_spec.rb b/spec/models/form_field_spec.rb index b937d699b0..4acece44a7 100644 --- a/spec/models/form_field_spec.rb +++ b/spec/models/form_field_spec.rb @@ -392,12 +392,12 @@ def selectable_field(type:, option_names:) expect(field.answer_inclusion_error("Foundation/Funder: ACME")).to eq("has an invalid selection") end - it "accepts the CE question's field-scoped 'Yes: ' specify answer" do + it "treats the CE question's 'Yes' as a plain choice (hours come from the event)" do field = selectable_field(type: :single_select_radio, option_names: %w[Yes No]) field.update!(field_identifier: "ce_credit_interest") expect(field.answer_inclusion_error("Yes")).to be_nil - expect(field.answer_inclusion_error("Yes: 6")).to be_nil expect(field.answer_inclusion_error("No")).to be_nil + expect(field.answer_inclusion_error("Yes: 6")).to eq("has an invalid selection") end it "treats a bare 'Yes' as a plain choice on other fields" do diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index f1d7361466..0f8f9fd936 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -34,6 +34,17 @@ expect(response.body).not_to include(existing_registration.registrant.first_name) end + it "filters registrations by ce_status" do + needs_license = create(:event_registration) + placeholder = create(:professional_license, :placeholder, person: needs_license.registrant) + create(:continuing_education_registration, event_registration: needs_license, professional_license: placeholder) + + get event_registrations_path(ce_status: "needs_license") + expect(response).to have_http_status(:success) + expect(response.body).to include(needs_license.registrant.first_name) + expect(response.body).not_to include(existing_registration.registrant.first_name) + end + it "exports CSV with headers and data only (no captions)" do get event_registrations_path, params: { format: :csv } @@ -247,20 +258,21 @@ expect(existing_registration.reload.event_id).to eq(new_event.id) end - it "updates the CE credit requested flag" do + it "creates a CE registration when CE is requested" do patch event_registration_path(existing_registration), - params: { event_registration: { ce_credit_requested: "1" } } + params: { event_registration: { status: existing_registration.status }, ce: { requested: "1" } } - expect(existing_registration.reload.ce_credit_requested).to be(true) + expect(existing_registration.reload.continuing_education_registrations.count).to eq(1) end - it "updates the CE hours and license number" do + it "sets the hours and license number on the CE registration" do patch event_registration_path(existing_registration), - params: { event_registration: { ce_credit_requested: "1", ce_hours_requested: "5", ce_license_number: "LIC-987" } } + params: { event_registration: { status: existing_registration.status }, + ce: { requested: "1", hours: "5", license_number: "LIC-987" } } - existing_registration.reload - expect(existing_registration.ce_hours_requested).to eq(5) - expect(existing_registration.ce_license_number).to eq("LIC-987") + ce_registration = existing_registration.reload.continuing_education_registrations.first + expect(ce_registration.hours).to eq(5) + expect(ce_registration.professional_license.number).to eq("LIC-987") end it "sets the shout-out flag and stores the shout-out text on the registrant" do diff --git a/spec/requests/events/bulk_reminders_spec.rb b/spec/requests/events/bulk_reminders_spec.rb index 091d9a2029..37126a731b 100644 --- a/spec/requests/events/bulk_reminders_spec.rb +++ b/spec/requests/events/bulk_reminders_spec.rb @@ -77,7 +77,7 @@ def checked?(body, registration) it "returns to the picker after save" do patch event_registration_path(jane), - params: { return_to: "preview_reminder", event_registration: { ce_credit_requested: "1" } } + params: { return_to: "preview_reminder", event_registration: { intends_to_pay: "1" } } expect(response).to redirect_to(preview_reminder_event_path(event)) end diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index f9ecb2b57b..dfd5bd12fb 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -222,19 +222,36 @@ let!(:registration) { create(:event_registration, event: event, registrant: user.person) } it "shows status, cost, and the license number on file" do - registration.update!(ce_credit_requested: true, ce_hours_requested: 6, ce_license_number: "LIC123") + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) get registration_ce_path(registration.slug) expect(response).to have_http_status(:success) expect(response.body).to include("Requested") - expect(response.body).to include("Hours requested") + expect(response.body).to include("Hours") expect(response.body).to include("$150") expect(response.body).to include("LIC123") end it "notes when the license number is not yet on file" do - registration.update!(ce_credit_requested: true, ce_hours_requested: 6, ce_license_number: nil) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) get registration_ce_path(registration.slug) - expect(response.body).to include("We don't have your license number on file yet.") + expect(response.body).to include("Not on file yet.") + end + end + + describe "POST /registration/:slug/ce/license" do + let!(:registration) { create(:event_registration, event: event, registrant: user.person) } + + it "saves a license number entered on the callout" do + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) + + post registration_ce_license_path(registration.slug), params: { license_number: "LMFT 7788" } + + expect(response).to redirect_to(registration_ce_path(registration.slug)) + expect(registration.continuing_education_registrations.first.professional_license.number).to eq("LMFT 7788") end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 9f879ce72c..c7575a3df6 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -165,15 +165,15 @@ def offer_ce!(target_event) describe "GET /ce_hours" do let(:event) { create(:event, :published, :publicly_visible) } - it "renders the CE hours page when details are present" do - event.update!(ce_hours_details_label: "Continuing education", ce_hours_details: "

Email your license number

") + it "renders the CE hours page when the event is CE-eligible" do + event.update!(ce_hours_offered: 6, ce_hours_details_label: "Continuing education", ce_hours_details: "

Email your license number

") get ce_hours_event_path(event) expect(response).to have_http_status(:ok) expect(response.body).to include("Continuing education") expect(response.body).to include("Email your license number") end - it "redirects to the event when details are blank" do + it "redirects to the event when the event is not CE-eligible" do get ce_hours_event_path(event) expect(response).to redirect_to(event_path(event)) end @@ -851,25 +851,30 @@ def submit_agency_name(name) end describe "GET /events/:id/registrants with the CE status filter" do - let(:event) { create(:event, cost_cents: 1_000) } - let(:complete_person) { create(:person, first_name: "Complete", last_name: "Person") } - let(:missing_person) { create(:person, first_name: "Missing", last_name: "Person") } + let(:event) { offer_ce!(create(:event, cost_cents: 1_000)) } + let(:paid_person) { create(:person, first_name: "Paid", last_name: "Person") } + let(:needs_person) { create(:person, first_name: "Needs", last_name: "License") } let(:none_person) { create(:person, first_name: "Noce", last_name: "Person") } - let!(:complete_reg) do - reg = create(:event_registration, event: event, registrant: complete_person, - ce_credit_requested: true, ce_license_number: "ABC123", ce_hours_requested: 3) - create(:allocation, source: create(:payment, amount_cents: 1_000, amount_cents_remaining: 1_000), - allocatable: reg, amount: 1_000) + # Known license, fully paid. + let!(:paid_reg) do + reg = create(:event_registration, event: event, registrant: paid_person) + cer = create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000) + create(:allocation, source: create(:payment, amount_cents: 15_000, amount_cents_remaining: 15_000), + allocatable: cer, amount: 15_000) reg end - let!(:missing_reg) { create(:event_registration, event: event, registrant: missing_person, ce_credit_requested: true) } - let!(:none_reg) { create(:event_registration, event: event, registrant: none_person, ce_credit_requested: false) } - - before do - offer_ce!(event) - sign_in admin + # CE registration sitting on a placeholder (numberless) license. + let!(:needs_reg) do + reg = create(:event_registration, event: event, registrant: needs_person) + create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, + professional_license: create(:professional_license, :placeholder, person: needs_person)) + reg end + # No CE registration. + let!(:none_reg) { create(:event_registration, event: event, registrant: none_person) } + + before { sign_in admin } it "shows the CE status column and filter when the event offers CE" do get registrants_event_path(event) @@ -886,29 +891,23 @@ def submit_agency_name(name) expect(response.body).to include('data-column-toggle-group-value="ce"') end - it "filters to all CE requests" do + it "filters to CE registrations not yet paid" do get registrants_event_path(event, ce_status: "requested") - expect(response.body).to include("Complete Person") - expect(response.body).to include("Missing Person") + expect(response.body).to include("Needs License") + expect(response.body).not_to include("Paid Person") expect(response.body).not_to include("Noce Person") end - it "filters to CE requests missing a license number" do - get registrants_event_path(event, ce_status: "license_not_provided") - expect(response.body).to include("Missing Person") - expect(response.body).not_to include("Complete Person") - end - - it "filters to CE requests missing hours" do - get registrants_event_path(event, ce_status: "hours_not_provided") - expect(response.body).to include("Missing Person") - expect(response.body).not_to include("Complete Person") + it "filters to CE registrations on a placeholder license" do + get registrants_event_path(event, ce_status: "needs_license") + expect(response.body).to include("Needs License") + expect(response.body).not_to include("Paid Person") end - it "filters to paid CE requests" do + it "filters to paid CE registrations" do get registrants_event_path(event, ce_status: "paid") - expect(response.body).to include("Complete Person") - expect(response.body).not_to include("Missing Person") + expect(response.body).to include("Paid Person") + expect(response.body).not_to include("Needs License") end it "does not crash on an invalid ce_status" do @@ -916,9 +915,9 @@ def submit_agency_name(name) expect(response).to have_http_status(:ok) end - it "hides CE entirely when the event's registration form doesn't offer CE" do + it "hides CE entirely when the event does not offer CE" do plain_event = create(:event) - create(:event_registration, event: plain_event, ce_credit_requested: true) + create(:event_registration, event: plain_event) get registrants_event_path(plain_event) expect(response.body).not_to include("CE status") end @@ -926,7 +925,7 @@ def submit_agency_name(name) it "includes a CE status column in the CSV export" do get registrants_event_path(event, format: :csv) expect(response.body).to include("CE status") - expect(response.body).to include("Incomplete") + expect(response.body).to include("Needs license") end end @@ -942,20 +941,14 @@ def ce_chip_text Nokogiri::HTML(response.body).at_css('td[data-column-toggle-col="ce"]')&.text&.squish end - it "shows Create when CE was not requested" do - create(:event_registration, event: event, registrant: person, ce_credit_requested: false) + it "shows Create when no CE registration exists" do + create(:event_registration, event: event, registrant: person) get registrants_event_path(event) expect(ce_chip_text).to eq("Create") end - it "shows Requested when requested but no CE registration record exists yet" do - create(:event_registration, event: event, registrant: person, ce_credit_requested: true) - get registrants_event_path(event) - expect(ce_chip_text).to eq("Requested") - end - it "shows No license # once a CE record exists without a license number" do - reg = create(:event_registration, event: event, registrant: person, ce_credit_requested: true) + reg = create(:event_registration, event: event, registrant: person) create(:continuing_education_registration, event_registration: reg, professional_license: create(:professional_license, :placeholder, person: person)) get registrants_event_path(event) @@ -963,7 +956,7 @@ def ce_chip_text end it "shows Filed once a license is on file but the CE balance is unpaid" do - reg = create(:event_registration, event: event, registrant: person, ce_credit_requested: true) + reg = create(:event_registration, event: event, registrant: person) create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, professional_license: create(:professional_license, person: person)) get registrants_event_path(event) @@ -971,7 +964,7 @@ def ce_chip_text end it "shows Recipient when the CE balance is paid" do - reg = create(:event_registration, event: event, registrant: person, ce_credit_requested: true) + reg = create(:event_registration, event: event, registrant: person) cer = create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, professional_license: create(:professional_license, person: person)) create(:allocation, source: create(:payment, amount_cents: 15_000, amount_cents_remaining: 15_000), diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index abe26dd270..8f67138038 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -452,67 +452,67 @@ def register_with_org(extra) field end - def register_with_ce(answer) + let!(:ce_license_field) do + form.form_fields.create!( + name: "License number", + answer_type: :free_form_input_one_line, + status: :active, + position: (form.form_fields.maximum(:position) || 0) + 1, + required: false, + field_identifier: described_class::CE_LICENSE_NUMBER_IDENTIFIER, + section: "continuing_education", + visibility: :always_ask + ) + end + + def register_with_ce(answer, license: nil) params = base_form_params(first_name: "Cy", last_name: "Reed", email: "cy@example.com") params = params.merge(ce_field.id.to_s => answer) unless answer.nil? + params = params.merge(ce_license_field.id.to_s => license) if license described_class.call(event: event, form: form, form_params: params) end - it "toggles ce_credit_requested on when answered Yes" do + it "creates a CE registration when answered Yes" do result = register_with_ce("Yes") - expect(result.event_registration.ce_credit_requested).to be true + expect(result.event_registration.continuing_education_registrations.count).to eq(1) end - it "leaves ce_credit_requested off when answered No" do + it "creates no CE registration when answered No" do result = register_with_ce("No") - expect(result.event_registration.ce_credit_requested).to be false + expect(result.event_registration.continuing_education_registrations).to be_empty end - it "leaves ce_credit_requested off when unanswered" do + it "creates no CE registration when unanswered" do result = register_with_ce(nil) - expect(result.event_registration.ce_credit_requested).to be false + expect(result.event_registration.continuing_education_registrations).to be_empty end - it "toggles ce_credit_requested on for an existing registration that answers Yes" do + it "creates a CE registration for an existing registration that answers Yes" do person = create(:person, first_name: "Cy", last_name: "Reed", email: "cy@example.com") - existing = create(:event_registration, event: event, registrant: person, ce_credit_requested: false) + existing = create(:event_registration, event: event, registrant: person) result = register_with_ce("Yes") expect(result.event_registration).to eq(existing) - expect(existing.reload.ce_credit_requested).to be true + expect(existing.reload.continuing_education_registrations.count).to eq(1) end - it "saves the hours folded into a 'Yes: ' specify answer" do - result = register_with_ce("Yes: 6") - expect(result.event_registration.ce_credit_requested).to be true - expect(result.event_registration.ce_hours_requested).to eq(6) + it "records the typed license number on the CE registration's license" do + result = register_with_ce("Yes", license: "LMFT 555") + license = result.event_registration.continuing_education_registrations.first.professional_license + expect(license.number).to eq("LMFT 555") + expect(license.person).to eq(result.event_registration.registrant) end - it "leaves ce_hours_requested nil when Yes carries no hours" do + it "uses a placeholder license when no number is given" do result = register_with_ce("Yes") - expect(result.event_registration.ce_hours_requested).to be_nil + expect(result.event_registration.continuing_education_registrations.first.professional_license.number).to be_nil end - it "leaves ce_hours_requested nil when answered No" do - result = register_with_ce("No") - expect(result.event_registration.ce_hours_requested).to be_nil - end - - it "ignores non-numeric hours in the specify answer" do - result = register_with_ce("Yes: lots") - expect(result.event_registration.ce_credit_requested).to be true - expect(result.event_registration.ce_hours_requested).to be_nil - end - - it "saves the hours onto an existing registration that answers Yes" do - person = create(:person, first_name: "Cy", last_name: "Reed", email: "cy@example.com") - existing = create(:event_registration, event: event, registrant: person, ce_credit_requested: false) - - register_with_ce("Yes: 4") - - expect(existing.reload.ce_credit_requested).to be true - expect(existing.ce_hours_requested).to eq(4) + it "takes the CE hours from the event" do + event.update!(ce_hours_offered: 6) + result = register_with_ce("Yes") + expect(result.event_registration.continuing_education_registrations.first.hours).to eq(6) end end diff --git a/spec/services/magic_ticket_callouts_spec.rb b/spec/services/magic_ticket_callouts_spec.rb index 90c3ac64ad..86e078c948 100644 --- a/spec/services/magic_ticket_callouts_spec.rb +++ b/spec/services/magic_ticket_callouts_spec.rb @@ -60,31 +60,25 @@ def card(reg, title) end it "shows the CE card only when the registrant requested CE credit" do - event.update!(ce_hours_details: "6 hours") expect(card_titles(registration)).not_to include(event.ce_hours_details_label) - registration.update!(ce_credit_requested: true) - expect(card_titles(registration)).to include(event.ce_hours_details_label) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + expect(card_titles(registration.reload)).to include(event.ce_hours_details_label) end - it "shows an amber 'what's needed' CE badge until complete, then a teal amount due" do - registration.update!(ce_credit_requested: true, ce_hours_requested: nil, ce_license_number: nil) - both = card(registration, event.ce_hours_details_label) - expect(both.theme).to eq(DomainTheme.swatch("teal")) - expect(both.subtitle).to eq("Continuing education credit") - expect(both.badge).to eq("Hours & license number needed") - expect(both.badge_classes).to be_nil - - registration.update!(ce_hours_requested: 6, ce_license_number: nil) - license = card(registration, event.ce_hours_details_label) - expect(license.subtitle).to eq("6 hours") - expect(license.badge).to eq("$150 · License number needed") - expect(license.badge_classes).to be_nil - - registration.update!(ce_hours_requested: nil, ce_license_number: "LIC123") - expect(card(registration, event.ce_hours_details_label).badge).to eq("Hours needed") - - registration.update!(ce_hours_requested: 6, ce_license_number: "LIC123") - complete = card(registration, event.ce_hours_details_label) + it "shows a 'license needed' CE badge until provided, then a teal amount due" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + + needs = card(registration.reload, event.ce_hours_details_label) + expect(needs.theme).to eq(DomainTheme.swatch("teal")) + expect(needs.subtitle).to eq("6 hours") + expect(needs.badge).to eq("$150 · License number needed") + expect(needs.badge_classes).to be_nil + + license.update!(number: "LIC123") + complete = card(registration.reload, event.ce_hours_details_label) expect(complete.subtitle).to eq("6 hours") expect(complete.badge).to eq("$150 due") expect(complete.badge_classes).to include("teal") @@ -117,10 +111,12 @@ def card(reg, title) end it "places payment first and FAQ last in the full ordering" do - event.update!(event_details: "Bring supplies", ce_hours_details: "6 hours", + event.update!(event_details: "Bring supplies", ce_hours_details: "6 hours", ce_hours_offered: 6, videoconference_url: "https://example.zoom.us/j/123", start_date: 3.days.ago, end_date: 2.days.ago) - registration.update!(status: "attended", scholarship_requested: true, ce_credit_requested: true) + registration.update!(status: "attended", scholarship_requested: true) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license) expect(card_titles(registration)).to eq([ "Make your payment", "Certificate of completion", diff --git a/spec/services/reminder_recipient_filter_spec.rb b/spec/services/reminder_recipient_filter_spec.rb index 140d5cdf31..b19f039392 100644 --- a/spec/services/reminder_recipient_filter_spec.rb +++ b/spec/services/reminder_recipient_filter_spec.rb @@ -167,32 +167,42 @@ def matched(params, registrations) end context "CE status" do - # Requested CE, supplied both license and hours, and paid in full. + # Requested CE, supplied a license, and paid the CE balance in full. let!(:complete) do registration(first_name: "Complete").tap do |r| - r.update!(ce_credit_requested: true, ce_license_number: "ABC123", ce_hours_requested: 3) - create(:allocation, allocatable: r, amount: 10_000) + license = create(:professional_license, person: r.registrant, number: "ABC123") + ce_reg = create(:continuing_education_registration, event_registration: r, professional_license: license, hours: 4) + payment = create(:payment, amount_cents: ce_reg.cost_cents, amount_cents_remaining: ce_reg.cost_cents) + create(:allocation, source: payment, allocatable: ce_reg, amount: ce_reg.cost_cents) end end - # Requested CE but missing license and hours, unpaid. - let!(:missing) { registration(first_name: "Missing").tap { |r| r.update!(ce_credit_requested: true) } } - # Did not request CE at all. - let!(:no_ce) { registration(first_name: "None").tap { |r| r.update!(ce_license_number: nil, ce_hours_requested: nil) } } - let(:regs) { [ complete, missing, no_ce ] } - - it "filters CE requested" do - expect(matched({ ce_status: "requested" }, regs)).to eq([ complete.id, missing.id ].to_set) + # CE on a placeholder license, unpaid. + let!(:missing) do + registration(first_name: "Missing").tap do |r| + license = create(:professional_license, :placeholder, person: r.registrant) + create(:continuing_education_registration, event_registration: r, professional_license: license, hours: 4) + end + end + # CE with a license on file but the balance unpaid. + let!(:unpaid_known) do + registration(first_name: "Unpaid").tap do |r| + license = create(:professional_license, person: r.registrant, number: "XYZ789") + create(:continuing_education_registration, event_registration: r, professional_license: license, hours: 4) + end end + # No CE registration at all. + let!(:no_ce) { registration(first_name: "None") } + let(:regs) { [ complete, missing, unpaid_known, no_ce ] } - it "filters CE license not provided (only among CE requesters)" do - expect(matched({ ce_status: "license_not_provided" }, regs)).to eq([ missing.id ].to_set) + it "filters CE not yet paid" do + expect(matched({ ce_status: "requested" }, regs)).to eq([ missing.id, unpaid_known.id ].to_set) end - it "filters CE hours not provided (only among CE requesters)" do - expect(matched({ ce_status: "hours_not_provided" }, regs)).to eq([ missing.id ].to_set) + it "filters CE on a placeholder license" do + expect(matched({ ce_status: "needs_license" }, regs)).to eq([ missing.id ].to_set) end - it "filters CE paid (requested CE and paid in full)" do + it "filters CE paid (CE balance paid in full)" do expect(matched({ ce_status: "paid" }, regs)).to eq([ complete.id ].to_set) end end diff --git a/spec/system/public_registration_form_submission_spec.rb b/spec/system/public_registration_form_submission_spec.rb index f44722ef95..74102fcb1a 100644 --- a/spec/system/public_registration_form_submission_spec.rb +++ b/spec/system/public_registration_form_submission_spec.rb @@ -73,8 +73,9 @@ email_2: "robin.alt@example.com") registration = event.event_registrations.find_by!(registrant: person) - expect(registration).to have_attributes(scholarship_requested: false, ce_credit_requested: true, + expect(registration).to have_attributes(scholarship_requested: false, w9_requested: true, invoice_requested: false) + expect(registration.continuing_education_registrations.count).to eq(1) answers = answers_by_identifier(registration_form.form_submissions.find_by!(person: person)) expect(answers).to include( From 6721a02c5d6213e64f5b424221c7f5ec64aa2db1 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 13:53:17 -0400 Subject: [PATCH 02/46] CE: scholarship-style card, ce_requested flag, and a CE edit page Make continuing education mirror scholarships: - Add a stored ce_requested flag on event_registration (joins the other *_requested flags); intake sets it; the registration edit form drives it. - The CE card shows a Requested toggle only when no CE registration exists; saving it on creates the record (against the selected/only license, else a placeholder), with a flash. Once a record exists the card shows a summary + Issued/Not-issued pill + an Edit link, gated on the event being ce_eligible?. - New ContinuingEducationRegistrationsController + policy + edit page: edit license (promoting a placeholder in place), hours, cost; a Certificate issued toggle (certificate_sent_at); and removal guarded against payments. - Rename the derived ce_requested? (record-exists) to ce_registered? to free the name for the column; readers/views key off whichever they mean. Co-Authored-By: Claude Opus 4.8 --- ...uing_education_registrations_controller.rb | 76 ++++++++++++++++ .../event_registrations_controller.rb | 87 ++++++++++++------- app/models/event_registration.rb | 14 +-- ...ontinuing_education_registration_policy.rb | 9 ++ .../public_registration.rb | 1 + app/services/magic_ticket_callouts.rb | 2 +- .../edit.html.erb | 76 ++++++++++++++++ .../_continuing_education.html.erb | 78 +++++++++++++++++ app/views/event_registrations/_form.html.erb | 76 +--------------- app/views/events/callouts/ce.html.erb | 2 +- config/routes.rb | 3 + ...add_ce_requested_to_event_registrations.rb | 14 +++ db/schema.rb | 3 +- spec/models/event_registration_spec.rb | 6 +- ...continuing_education_registrations_spec.rb | 65 ++++++++++++++ spec/requests/event_registrations_spec.rb | 26 ++++-- spec/views/page_bg_class_alignment_spec.rb | 1 + 17 files changed, 417 insertions(+), 122 deletions(-) create mode 100644 app/controllers/continuing_education_registrations_controller.rb create mode 100644 app/policies/continuing_education_registration_policy.rb create mode 100644 app/views/continuing_education_registrations/edit.html.erb create mode 100644 app/views/event_registrations/_continuing_education.html.erb create mode 100644 db/migrate/20260629173817_add_ce_requested_to_event_registrations.rb create mode 100644 spec/requests/continuing_education_registrations_spec.rb diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb new file mode 100644 index 0000000000..7a5edb64e2 --- /dev/null +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -0,0 +1,76 @@ +class ContinuingEducationRegistrationsController < ApplicationController + before_action :set_ce_registration + + def edit + authorize! @ce_registration + end + + def update + authorize! @ce_registration + assign_license(params.dig(:continuing_education_registration, :license_number)) + @ce_registration.hours = params.dig(:continuing_education_registration, :hours) + cost = params.dig(:continuing_education_registration, :cost_dollars) + @ce_registration.cost_cents = (cost.to_d * 100).round if cost.present? + + if @ce_registration.save + redirect_to registration_path, notice: "CE registration updated.", status: :see_other + else + flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence + render :edit, status: :unprocessable_content + end + end + + # Removal mirrors scholarship's destroy but never cascades away a CE registration + # that carries payments — the admin must revert the allocation first. + def destroy + authorize! @ce_registration + if @ce_registration.allocations.exists? + redirect_to edit_continuing_education_registration_path(@ce_registration, return_to: params[:return_to]), + alert: "Can't remove CE — it has payments. Revert the payment first.", status: :see_other + return + end + + registration = @ce_registration.event_registration + @ce_registration.destroy! + registration.update_column(:ce_requested, false) + redirect_to edit_event_registration_path(registration), notice: "CE registration removed.", status: :see_other + end + + # Mark / unmark the CE certificate as issued (sets/clears certificate_sent_at), + # mirroring scholarship's toggle_tasks. + def toggle_certificate + authorize! @ce_registration + issued = @ce_registration.certificate_sent_at.present? + @ce_registration.update!(certificate_sent_at: issued ? nil : Time.current) + redirect_to edit_continuing_education_registration_path(@ce_registration, return_to: params[:return_to]), + notice: issued ? "Certificate marked not issued." : "Certificate marked issued.", status: :see_other + end + + private + + def set_ce_registration + @ce_registration = ContinuingEducationRegistration.find(params[:id]) + end + + # Set/replace the license from a typed number. Promote a placeholder in place + # (no orphaned placeholder) when the number is new to the person; otherwise + # repoint to the person's existing/created license for that number. + def assign_license(number) + number = number.to_s.strip.presence + return if number.nil? + + person = @ce_registration.event_registration.registrant + current = @ce_registration.professional_license + existing = person.professional_licenses.find_by(number: number) + + if current && !current.number_known? && existing.nil? + current.update!(number: number) + else + @ce_registration.professional_license = existing || ProfessionalLicense.find_or_create_for(person: person, number: number) + end + end + + def registration_path + edit_event_registration_path(@ce_registration.event_registration) + end +end diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 856ef1f4ba..58dd99f35c 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -60,7 +60,7 @@ def create authorize! @event_registration if @event_registration.save - reconcile_ce_registration if params[:ce].present? + reconcile_ce_registration if @event_registration.event&.ce_eligible? respond_to do |format| format.html { redirect_to confirm_event_registration_path(@event_registration, return_to: params[:return_to]) @@ -88,23 +88,26 @@ def update @event_registration.notifications.select(&:new_record?).each { |n| n.recipient_email = recipient_email } if @event_registration.save - reconcile_ce_registration if params[:ce].present? + reconcile_ce_registration if @event_registration.event&.ce_eligible? + # Prefer the CE flash ("CE registration created/removed") when reconcile set + # one; a blocked toggle-off sets flash[:alert], which survives independently. + notice = flash[:notice].presence || "Registration was successfully updated." respond_to do |format| format.turbo_stream format.html { case params[:return_to] - when "registrants" then redirect_to registrants_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other - when "index" then redirect_to event_registrations_path, notice: "Registration was successfully updated.", status: :see_other - when "ticket" then redirect_to registration_ticket_path(@event_registration.slug), notice: "Registration was successfully updated.", status: :see_other - when "preview_reminder" then redirect_to preview_reminder_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other - when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: "Registration was successfully updated.", status: :see_other + when "registrants" then redirect_to registrants_event_path(@event_registration.event), notice: notice, status: :see_other + when "index" then redirect_to event_registrations_path, notice: notice, status: :see_other + when "ticket" then redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other + when "preview_reminder" then redirect_to preview_reminder_event_path(@event_registration.event), notice: notice, status: :see_other + when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other else # No explicit origin: keep admins in the management context (the # roster) rather than dropping them on the public registration show. if allowed_to?(:manage?, with: EventRegistrationPolicy) - redirect_to registrants_event_path(@event_registration.event), notice: "Registration was successfully updated.", status: :see_other + redirect_to registrants_event_path(@event_registration.event), notice: notice, status: :see_other else - redirect_to registration_ticket_path(@event_registration.slug), notice: "Registration was successfully updated.", status: :see_other + redirect_to registration_ticket_path(@event_registration.slug), notice: notice, status: :see_other end end } @@ -325,30 +328,55 @@ def toggle_checklist_step(step, completed) end end - # Reconcile the admin CE section (posted under the `ce` namespace) into the - # registration's CE registration + professional license. Creating/updating CE is - # open to anyone who can edit the registration (admin or owner); the registrant's - # (single) CE registration is found-or-built against the licence for the typed - # number, with the editable hours applied. Removing CE is a delete, so it's - # gated to admins and never cascades away a CE registration that already carries - # payments or discounts. + # Keep the registration's CE record in step with the `ce_requested` flag (set on + # the edit form / at intake), mirroring how a scholarship is awarded from its + # "Requested" toggle. Requested + none yet → create a stub against the chosen + # license (or the registrant's only license, else a placeholder); license/hours/ + # cost/certificate are then edited on the CE edit page. Un-requested → remove it, + # admins only, and never one that carries payments (the flag is restored and the + # admin is told to revert the payment first). Sets a flash describing what changed. def reconcile_ce_registration - ce = params[:ce] - unless ActiveModel::Type::Boolean.new.cast(ce[:requested]) - if allowed_to?(:manage?, with: EventRegistrationPolicy) - @event_registration.continuing_education_registrations.where.missing(:allocations).destroy_all - end + if @event_registration.ce_requested? + create_ce_registration_stub + else + remove_ce_registration + end + end + + def create_ce_registration_stub + return if @event_registration.continuing_education_registrations.exists? + + @event_registration.continuing_education_registrations.create!(professional_license: ce_license_for_create) + flash[:notice] = "CE registration created." + end + + def remove_ce_registration + registrations = @event_registration.continuing_education_registrations + return if registrations.none? || !allowed_to?(:manage?, with: EventRegistrationPolicy) + + if registrations.any? { |registration| registration.allocations.exists? } + @event_registration.update_column(:ce_requested, true) + flash[:alert] = "Can't remove CE — it has payments. Revert the payment first." return end - license = ProfessionalLicense.find_or_create_for( - person: @event_registration.registrant, number: ce[:license_number].to_s.strip.presence - ) - ce_registration = @event_registration.continuing_education_registrations.first_or_initialize - ce_registration.professional_license = license - ce_registration.hours = ce[:hours] if ce[:hours].present? - ce_registration.cost_cents = (ce[:cost].to_d * 100).round if ce[:cost].present? - ce_registration.save! + registrations.destroy_all + flash[:notice] = "CE registration removed." + end + + # License a brand-new CE registration attaches to: the one the admin picked, else + # the registrant's only license, else a placeholder (number pending). + def ce_license_for_create + licenses = @event_registration.registrant.professional_licenses + picked = params.dig(:ce, :professional_license_id).presence + return licenses.find_by(id: picked) || placeholder_license if picked + return licenses.first if licenses.count == 1 + + placeholder_license + end + + def placeholder_license + ProfessionalLicense.find_or_create_for(person: @event_registration.registrant) end # Strong parameters @@ -356,6 +384,7 @@ def event_registration_params params.require(:event_registration).permit( :event_id, :registrant_id, :status, :scholarship_requested, + :ce_requested, :shoutout, :intends_to_pay, :expected_payment_method, diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 1029296c12..dddd18fb4d 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -331,7 +331,11 @@ def cost_cents # CE is now tracked as one or more ContinuingEducationRegistration records, # each against a professional license. These aggregate across them so callers # (callouts, onboarding, CSV) read a single registration-level figure. - def ce_requested? + # + # `ce_requested?` is the stored intent flag (column); `ce_registered?` is whether + # a CE registration record actually exists. They align in the normal flow (the + # toggle creates the record), but the readers below key off the record. + def ce_registered? if ce_registrations_in_memory? return continuing_education_registrations.any? end @@ -350,7 +354,7 @@ def ce_hours_total # a placeholder. "Needs license" takes precedence (it's the actionable state), # then certificate issuance, then payment. def ce_status_label - return unless ce_requested? + return unless ce_registered? return "Needs license" unless ce_license_provided? return "Issued" if continuing_education_registrations.all? { |c| c.certificate_sent_at.present? } return "Paid" if ce_paid_in_full? @@ -366,14 +370,14 @@ def ce_amount_owed_cents # True only when every CE registration has a known license number on file. def ce_license_provided? - return false unless ce_requested? + return false unless ce_registered? continuing_education_registrations.all? { |c| c.professional_license&.number_known? } end - # True when CE is requested and every CE registration is fully paid. + # True when a CE registration exists and every one is fully paid. def ce_paid_in_full? - return false unless ce_requested? + return false unless ce_registered? continuing_education_registrations.all?(&:paid_in_full?) end diff --git a/app/policies/continuing_education_registration_policy.rb b/app/policies/continuing_education_registration_policy.rb new file mode 100644 index 0000000000..ed3c582566 --- /dev/null +++ b/app/policies/continuing_education_registration_policy.rb @@ -0,0 +1,9 @@ +class ContinuingEducationRegistrationPolicy < ApplicationPolicy + # The CE registration edit page (license/hours/cost, certificate issuance, + # removal) is an admin management surface, like scholarships. Registrants edit + # their own license number via the public CE callout, not here. + def edit? = admin? + def update? = admin? + def destroy? = admin? + def toggle_certificate? = admin? +end diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index a0be5a7a17..6870d52d9e 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -389,6 +389,7 @@ def create_event_registration(person) @event.event_registrations.create!( registrant: person, scholarship_requested: @scholarship_requested, + ce_requested: ce_credit_requested?, w9_requested: w9_requested?, invoice_requested: invoice_requested?, expected_payment_method: field_value("payment_method")&.strip.presence diff --git a/app/services/magic_ticket_callouts.rb b/app/services/magic_ticket_callouts.rb index aaecbb8b7e..0a7d312b8b 100644 --- a/app/services/magic_ticket_callouts.rb +++ b/app/services/magic_ticket_callouts.rb @@ -119,7 +119,7 @@ def scholarship_badge(awarded, tasks_outstanding) # they have, becoming a reference card once requested with hours and a license # number on file. Shown when the event offers CE or the registrant asked for it. def ce_hours_card - return unless registration.ce_requested? + return unless registration.ce_registered? complete = registration.ce_license_provided? Card.new(icon_class: "fa-solid fa-graduation-cap", color: "teal", title: event.ce_hours_details_label, diff --git a/app/views/continuing_education_registrations/edit.html.erb b/app/views/continuing_education_registrations/edit.html.erb new file mode 100644 index 0000000000..018e24a704 --- /dev/null +++ b/app/views/continuing_education_registrations/edit.html.erb @@ -0,0 +1,76 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% registration = @ce_registration.event_registration %> +<% license = @ce_registration.professional_license %> + +
+
+ <%= link_to edit_event_registration_path(registration), class: "text-sm text-gray-500 hover:text-gray-700" do %> + Registration + <% end %> +
+ +
+ + + +
+

Continuing education

+

<%= registration.registrant&.full_name %> — <%= registration.event.title %>

+
+
+ + <%= form_with model: @ce_registration, url: continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), + method: :patch, data: { turbo: false }, class: "mt-6 space-y-5 rounded-xl border border-gray-200 bg-white p-5 shadow-sm" do |f| %> +
+ + <%= f.text_field :license_number, value: license&.number, id: "continuing_education_registration_license_number", + placeholder: "e.g. LMFT 12345", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +

Leave blank to keep the current license (number pending).

+
+ +
+
+ + <%= f.number_field :hours, value: ContinuingEducationRegistration.format_hours(@ce_registration.hours), min: 0, step: 0.25, + id: "continuing_education_registration_hours", + class: "w-32 rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ + <%= f.number_field :cost_dollars, value: (@ce_registration.cost_cents.to_d / 100), min: 0, step: 0.01, + id: "continuing_education_registration_cost_dollars", + class: "w-32 rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ +
+ <%= f.submit "Save", class: "rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 cursor-pointer" %> +
+ <% end %> + +
+
+

Certificate

+

+ <% if @ce_registration.certificate_sent_at.present? %> + Issued <%= @ce_registration.certificate_sent_at.to_date.to_fs(:long) %> + <% else %> + Not issued yet + <% end %> +

+
+ <%= button_to toggle_certificate_continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), + method: :patch, class: "rounded-lg border px-3 py-1.5 text-sm font-medium #{@ce_registration.certificate_sent_at.present? ? "border-gray-300 text-gray-600 hover:bg-gray-50" : "border-teal-300 bg-teal-50 text-teal-700 hover:bg-teal-100"} cursor-pointer" do %> + <%= @ce_registration.certificate_sent_at.present? ? "Mark not issued" : "Mark certificate issued" %> + <% end %> +
+ +
+ <%= button_to continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), + method: :delete, form: { data: { turbo_confirm: "Remove this CE registration?" } }, + class: "inline-flex items-center gap-1.5 text-sm font-medium text-red-600 hover:text-red-700 hover:underline cursor-pointer" do %> + Remove CE registration + <% end %> +
+
diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb new file mode 100644 index 0000000000..1b2a9706f6 --- /dev/null +++ b/app/views/event_registrations/_continuing_education.html.erb @@ -0,0 +1,78 @@ +<%# ---- Continuing education — mirrors the scholarship card. "Requested" is a plain + flag saved with the form; saving it on (when none exists) creates a CE + registration stub, which is then filled in on its own edit page. Once a record + exists the toggle is gone and we show the record + an Edit link. Only rendered + for CE-eligible events. ---- %> +
+
+ + + +

Continuing education

+
+ +
+ <% unless ce_registration %> + + + <% licenses = event_registration.registrant&.professional_licenses.to_a %> + <% if licenses.any? %> +
+ + <%= select_tag "ce[professional_license_id]", + options_for_select(licenses.map { |license| [ license.name, license.id ] }), + id: "ce_professional_license_id", + class: "w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+ <% end %> +

Saving with this on creates the CE registration<%= " against the selected license" if licenses.any? %>.

+ <% else %> + <% license_number = ce_registration.professional_license&.number %> +
+
+ <%= dollars_from_cents(ce_registration.cost_cents) %> + · + <%= ContinuingEducationRegistration.format_hours(ce_registration.hours) || "0" %> hrs + <%= link_to edit_continuing_education_registration_path(ce_registration, return_to: "registration"), + class: "ml-auto inline-flex items-center gap-1.5 text-xs font-medium text-gray-500 hover:text-gray-700 hover:underline", + target: "_blank", rel: "noopener" do %> + Edit + + <% end %> +
+ +

+ License: + <% if license_number.present? %> + <%= license_number %> + <% else %> + Needs license + <% end %> +

+ +
+ <% if ce_registration.certificate_sent_at.present? %> + + + Certificate issued + + <% else %> + + + Not issued + + <% end %> +
+
+ <% end %> +
+
diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb index 8499043f4a..25d6aaf49b 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -210,79 +210,9 @@ <%= render "scholarship", event_registration: f.object, scholarship: f.object.scholarships.first %> - <%# ---- CE credits — toggled "Requested" flag, plus the registrant's CE - details (license number + requested hours) and what they owe at the - default hourly rate, recomputed live by the ce-credit-requested - controller. The details collapse when CE isn't requested. ---- %> -
-
- - - -

CE credits

-
- - <% ce_reg = f.object.continuing_education_registrations.first %> - <% ce_requested = f.object.ce_requested? %> - <% ce_license = ce_reg&.professional_license&.number %> - <% ce_hours_value = ce_reg&.hours || f.object.event&.ce_hours_offered %> - <% ce_cost_value = ce_reg&.cost_cents || f.object.event&.ce_hours_cost_cents %> - <%# CE is stored as its own ContinuingEducationRegistration(s), so these - inputs post under a separate `ce` namespace and the controller - reconciles them into a registration + professional license. Hours and - cost are stored values (defaulting from the event), not a derived total. ---- %> -
- - -
"> -
-
- - "> - text-[0.55rem]"> - <%= ce_license.present? ? "Provided" : "Not provided" %> - -
- -
- -
-
- - -
-
- - -
-
-
-
-
+ <% if f.object.event&.ce_eligible? %> + <%= render "continuing_education", event_registration: f.object, ce_registration: f.object.continuing_education_registrations.first %> + <% end %>
<% if f.object.payment_unresolved? %> diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index e8074eb5a7..d4b8f2a0de 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -1,7 +1,7 @@ <% content_for(:page_bg_class, "public") %> <% content_for(:page_title, "#{@event.ce_hours_details_label} — #{@event.title}") %> <%= render layout: "events/callouts/callout_page", locals: { title: @event.ce_hours_details_label } do %> - <% if @event_registration.ce_requested? %> + <% if @event_registration.ce_registered? %> <% ce_registration = @event_registration.continuing_education_registrations.first %> <% license_number = ce_registration&.professional_license&.number %>
diff --git a/config/routes.rb b/config/routes.rb index b0e4dbcea8..9bea3d6381 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -117,6 +117,9 @@ resources :scholarships, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do member { patch :toggle_tasks } end + resources :continuing_education_registrations, only: [ :edit, :update, :destroy ] do + member { patch :toggle_certificate } + end resources :discounts, only: [ :create, :show, :destroy ] do collection do post :allocation_form diff --git a/db/migrate/20260629173817_add_ce_requested_to_event_registrations.rb b/db/migrate/20260629173817_add_ce_requested_to_event_registrations.rb new file mode 100644 index 0000000000..8d294a7fc9 --- /dev/null +++ b/db/migrate/20260629173817_add_ce_requested_to_event_registrations.rb @@ -0,0 +1,14 @@ +class AddCeRequestedToEventRegistrations < ActiveRecord::Migration[8.1] + # The CE "Requested" intent flag, mirroring scholarship_requested / w9_requested + # / invoice_requested. It's the toggle the registrant/admin sets; the + # ContinuingEducationRegistration record is the fulfillment, created from it. + def up + unless column_exists?(:event_registrations, :ce_requested) + add_column :event_registrations, :ce_requested, :boolean, null: false, default: false + end + end + + def down + remove_column :event_registrations, :ce_requested, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 4a520d27b0..b4bec776df 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_06_29_122601) do +ActiveRecord::Schema[8.1].define(version: 2026_06_29_173817) do create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.bigint "action_text_rich_text_id", null: false t.datetime "created_at", null: false @@ -478,6 +478,7 @@ end create_table "event_registrations", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.boolean "ce_requested", default: false, null: false t.datetime "certificate_sent_at" t.string "checkout_session_id" t.boolean "completed_day_1", default: false, null: false diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index d0642af6dd..521c854b66 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -614,11 +614,11 @@ def add_ce(number: "LIC-123", hours: 4, cost_cents: 15_000) end end - describe "#ce_requested?" do + describe "#ce_registered?" do it "is true only once a CE registration exists" do - expect(reg).not_to be_ce_requested + expect(reg).not_to be_ce_registered add_ce - expect(reg.reload).to be_ce_requested + expect(reg.reload).to be_ce_registered end end diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb new file mode 100644 index 0000000000..22a4bee792 --- /dev/null +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -0,0 +1,65 @@ +require "rails_helper" + +RSpec.describe "ContinuingEducationRegistrations", type: :request do + let(:admin) { create(:user, :admin) } + let(:event) { create(:event, ce_hours_offered: 6, ce_hours_cost_cents: 12_000) } + let(:registration) { create(:event_registration, event: event, ce_requested: true) } + let(:ce_registration) do + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant)) + end + + describe "as an admin" do + before { sign_in admin } + + it "renders the edit page" do + get edit_continuing_education_registration_path(ce_registration) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Continuing education") + end + + it "updates hours, cost, and promotes the placeholder license in place" do + license = ce_registration.professional_license + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "4.5", cost_dollars: "90", license_number: "LMFT 555" } } + + ce_registration.reload + expect(ce_registration.hours).to eq(4.5) + expect(ce_registration.cost_cents).to eq(9_000) + # Placeholder is promoted in place rather than orphaned. + expect(ce_registration.professional_license).to eq(license) + expect(license.reload.number).to eq("LMFT 555") + end + + it "marks the certificate issued and back to not issued" do + patch toggle_certificate_continuing_education_registration_path(ce_registration) + expect(ce_registration.reload.certificate_sent_at).to be_present + + patch toggle_certificate_continuing_education_registration_path(ce_registration) + expect(ce_registration.reload.certificate_sent_at).to be_nil + end + + it "removes a CE registration with no payments and clears the flag" do + ce_registration + delete continuing_education_registration_path(ce_registration) + expect(ContinuingEducationRegistration.exists?(ce_registration.id)).to be(false) + expect(registration.reload.ce_requested).to be(false) + end + + it "refuses to remove a CE registration that has payments" do + create(:allocation, source: create(:payment, amount_cents: 12_000, amount_cents_remaining: 12_000), + allocatable: ce_registration, amount: 12_000) + + delete continuing_education_registration_path(ce_registration) + + expect(ContinuingEducationRegistration.exists?(ce_registration.id)).to be(true) + expect(flash[:alert]).to match(/has payments/) + end + end + + it "forbids non-admins" do + sign_in create(:user) + get edit_continuing_education_registration_path(ce_registration) + expect(response).not_to have_http_status(:ok) + end +end diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index 0f8f9fd936..686365c223 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -258,21 +258,29 @@ expect(existing_registration.reload.event_id).to eq(new_event.id) end - it "creates a CE registration when CE is requested" do + it "creates a CE registration stub when the ce_requested flag is set" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 12_000) patch event_registration_path(existing_registration), - params: { event_registration: { status: existing_registration.status }, ce: { requested: "1" } } + params: { event_registration: { status: existing_registration.status, ce_requested: "1" } } - expect(existing_registration.reload.continuing_education_registrations.count).to eq(1) + ce_registration = existing_registration.reload.continuing_education_registrations.first + expect(ce_registration).to be_present + # Hours/cost default from the event; the license is a placeholder until set. + expect(ce_registration.hours).to eq(6) + expect(ce_registration.professional_license.number).to be_nil end - it "sets the hours and license number on the CE registration" do + it "creates the CE registration against the selected existing license" do + event.update!(ce_hours_offered: 6) + registrant = existing_registration.registrant + create(:professional_license, person: registrant, number: "LIC-111") + chosen = create(:professional_license, person: registrant, number: "LIC-987") + patch event_registration_path(existing_registration), - params: { event_registration: { status: existing_registration.status }, - ce: { requested: "1", hours: "5", license_number: "LIC-987" } } + params: { event_registration: { status: existing_registration.status, ce_requested: "1" }, + ce: { professional_license_id: chosen.id } } - ce_registration = existing_registration.reload.continuing_education_registrations.first - expect(ce_registration.hours).to eq(5) - expect(ce_registration.professional_license.number).to eq("LIC-987") + expect(existing_registration.reload.continuing_education_registrations.first.professional_license).to eq(chosen) end it "sets the shout-out flag and stores the shout-out text on the registrant" do diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index a4980c7bc4..26fa7d4abb 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -170,6 +170,7 @@ "app/views/category_types/edit.html.erb" => "admin-only bg-blue-100", "app/views/community_news/edit.html.erb" => "admin-only bg-blue-100", "app/views/event_registrations/edit.html.erb" => "admin-only bg-blue-100", + "app/views/continuing_education_registrations/edit.html.erb" => "admin-only bg-blue-100", "app/views/events/edit.html.erb" => "admin-only bg-blue-100", "app/views/forms/edit.html.erb" => "admin-only bg-blue-100", "app/views/forms/edit_sections.html.erb" => "admin-only bg-blue-100", From 8405e4daf4b2863e7716451a1e7bc293f1db94b0 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 16:38:37 -0400 Subject: [PATCH 03/46] Move CE hours-offered/cost fields under the calendar description These configure whether an event grants CE and at what cost, so they belong with the event's details rather than buried in the registration-ticket callouts panel. The CE-hours ticket card (its label + details text) stays in the callouts panel. Co-Authored-By: Claude Opus 4.8 --- app/views/events/_form.html.erb | 34 ++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 1a7f78b89e..3e849b9583 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -224,6 +224,27 @@ class: "w-full rounded border-gray-300 shadow-sm px-3 py-2 focus:ring-blue-500 focus:border-blue-500", } %> + <%# Continuing education — "CE hours offered" above 0 makes the event + CE-eligible (the gate for the CE card on registrations); the total cost + is what a registrant pays for credit (stored in cents). %> +
+ +
+ + +
+

Edit the CE hours ticket card and its details below, under Registration ticket callouts.

+
+
<%# RIGHT: Autoshow display options %> @@ -471,19 +492,6 @@ title_placeholder: "CE hours", content_help: "CE requirements, payment, sign-in rules, and the post-training evaluation — shown on its own page linked from the ticket. Accepts basic HTML — bold, italics, links, lists, headings, and line breaks.", content_placeholder: "e.g.

AWBW is approved by CAMFT…

Before the training

  • Email your license number
" %> -
- - -
<% when :event_details %> <%= render "events/builtin_callout_card", f: f, card: card, label_field: :event_details_label, content_field: :event_details, From 5929603a825f3b6949bd7f44f5be07a95de4d21b Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 16:45:10 -0400 Subject: [PATCH 04/46] Align scholarship/CE card status chips with a consistent grantor row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CE card always has three rows (cost·hrs / License / chip) but the scholarship card showed its funder line only when a grant existed, so its status chip sat a row higher and the two orange chips didn't line up. Always render the scholarship card's middle row — "Grantor: " when funded, an empty same-height spacer (padding) when not — so both chips align. Co-Authored-By: Claude Opus 4.8 --- app/views/event_registrations/_scholarship.html.erb | 7 ++++++- spec/system/event_registration_edit_spec.rb | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/views/event_registrations/_scholarship.html.erb b/app/views/event_registrations/_scholarship.html.erb index 38d8d1bbfe..d023f0c7fe 100644 --- a/app/views/event_registrations/_scholarship.html.erb +++ b/app/views/event_registrations/_scholarship.html.erb @@ -40,9 +40,12 @@ <% end %>
+ <%# Keep this row present whether funded or not so the card matches the + continuing-education card's height and their status chips line up. + Unfunded → an empty spacer of the same height (padding), no text. %> <% if scholarship.grant&.funder_name.present? %>

- Funded by + Grantor: <% if scholarship.grant.donor %> <%= link_to scholarship.grant.funder_name, polymorphic_path(scholarship.grant.donor), class: "font-medium text-gray-700 hover:underline" %> @@ -50,6 +53,8 @@ <%= scholarship.grant.funder_name %> <% end %>

+ <% else %> + <% end %>
diff --git a/spec/system/event_registration_edit_spec.rb b/spec/system/event_registration_edit_spec.rb index 216bca7b60..886114afaa 100644 --- a/spec/system/event_registration_edit_spec.rb +++ b/spec/system/event_registration_edit_spec.rb @@ -117,7 +117,7 @@ visit edit_event_registration_path(registration) within("section", text: "Scholarship") do - expect(page).to have_text("Funded by") + expect(page).to have_text("Grantor:") expect(page).to have_link("Acme Foundation", href: organization_path(organization)) end end @@ -136,7 +136,7 @@ end end - it "omits the funder line when the scholarship has no grant" do + it "shows no grantor text (just a spacer) when the scholarship has no grant" do scholarship = create(:scholarship, recipient: registration.registrant, amount_cents: 1_000) create(:allocation, source: scholarship, allocatable: registration, amount: 1_000) @@ -144,7 +144,7 @@ visit edit_event_registration_path(registration) within("section", text: "Scholarship") do - expect(page).to have_no_text("Funded by") + expect(page).to have_no_text("Grantor:") end end end From db8bb5d7bb222dc386abd6e72d08e95f03e10fcb Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 16:49:36 -0400 Subject: [PATCH 05/46] CE card: only show the license selector when there's a real choice A single-license registrant was still shown a one-option license dropdown. Only render the selector when the registrant has more than one license on file; with one the controller uses it, with none it creates an empty (placeholder) license to fill in later. Co-Authored-By: Claude Opus 4.8 --- .../event_registrations/_continuing_education.html.erb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb index 1b2a9706f6..cceea0a997 100644 --- a/app/views/event_registrations/_continuing_education.html.erb +++ b/app/views/event_registrations/_continuing_education.html.erb @@ -24,8 +24,11 @@ Requested + <%# Only ask which license when there's a real choice (>1 on file). With one, + the controller uses it; with none, it creates an empty (placeholder) + license to fill in later. %> <% licenses = event_registration.registrant&.professional_licenses.to_a %> - <% if licenses.any? %> + <% if licenses.size > 1 %>
<%= select_tag "ce[professional_license_id]", @@ -34,7 +37,7 @@ class: "w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %>
<% end %> -

Saving with this on creates the CE registration<%= " against the selected license" if licenses.any? %>.

+

Saving with this on creates the CE registration<%= " against the selected license" if licenses.size > 1 %>.

<% else %> <% license_number = ce_registration.professional_license&.number %>
From 7bf776be5ea4e1e58acde7a3f544a14adafa3714 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 16:52:51 -0400 Subject: [PATCH 06/46] CE card: drop the license dropdown; pick license on create, show it after The create state is now just the Requested toggle. Saving creates the CE registration against the registrant's existing license (or an empty placeholder), and the selected license is shown in the card summary and editable on the CE registration's own page. Co-Authored-By: Claude Opus 4.8 --- .../event_registrations_controller.rb | 17 +++++------------ .../_continuing_education.html.erb | 18 ++++-------------- spec/requests/event_registrations_spec.rb | 11 ++++------- 3 files changed, 13 insertions(+), 33 deletions(-) diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 58dd99f35c..fcaba5eeb0 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -364,19 +364,12 @@ def remove_ce_registration flash[:notice] = "CE registration removed." end - # License a brand-new CE registration attaches to: the one the admin picked, else - # the registrant's only license, else a placeholder (number pending). + # License a brand-new CE registration attaches to: the registrant's existing + # license, else an empty placeholder (number pending). Which license is used + # (and its number) is then changeable on the CE registration's edit page. def ce_license_for_create - licenses = @event_registration.registrant.professional_licenses - picked = params.dig(:ce, :professional_license_id).presence - return licenses.find_by(id: picked) || placeholder_license if picked - return licenses.first if licenses.count == 1 - - placeholder_license - end - - def placeholder_license - ProfessionalLicense.find_or_create_for(person: @event_registration.registrant) + @event_registration.registrant.professional_licenses.first || + ProfessionalLicense.find_or_create_for(person: @event_registration.registrant) end # Strong parameters diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb index cceea0a997..65ad92156c 100644 --- a/app/views/event_registrations/_continuing_education.html.erb +++ b/app/views/event_registrations/_continuing_education.html.erb @@ -24,20 +24,10 @@ Requested - <%# Only ask which license when there's a real choice (>1 on file). With one, - the controller uses it; with none, it creates an empty (placeholder) - license to fill in later. %> - <% licenses = event_registration.registrant&.professional_licenses.to_a %> - <% if licenses.size > 1 %> -
- - <%= select_tag "ce[professional_license_id]", - options_for_select(licenses.map { |license| [ license.name, license.id ] }), - id: "ce_professional_license_id", - class: "w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> -
- <% end %> -

Saving with this on creates the CE registration<%= " against the selected license" if licenses.size > 1 %>.

+ <%# No license picker here — saving creates the record against the registrant's + license (or an empty placeholder). The license is then shown below and + editable on the CE registration's own page. %> +

Saving with this on creates the CE registration.

<% else %> <% license_number = ce_registration.professional_license&.number %>
diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index 686365c223..7e88c0ab81 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -270,17 +270,14 @@ expect(ce_registration.professional_license.number).to be_nil end - it "creates the CE registration against the selected existing license" do + it "creates the CE registration against the registrant's existing license" do event.update!(ce_hours_offered: 6) - registrant = existing_registration.registrant - create(:professional_license, person: registrant, number: "LIC-111") - chosen = create(:professional_license, person: registrant, number: "LIC-987") + license = create(:professional_license, person: existing_registration.registrant, number: "LIC-987") patch event_registration_path(existing_registration), - params: { event_registration: { status: existing_registration.status, ce_requested: "1" }, - ce: { professional_license_id: chosen.id } } + params: { event_registration: { status: existing_registration.status, ce_requested: "1" } } - expect(existing_registration.reload.continuing_education_registrations.first.professional_license).to eq(chosen) + expect(existing_registration.reload.continuing_education_registrations.first.professional_license).to eq(license) end it "sets the shout-out flag and stores the shout-out text on the registrant" do From 660d0e148f905ec42cd7931278b0577142f81946 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 16:55:44 -0400 Subject: [PATCH 07/46] CE card: drop the 'Saving creates the CE registration' hint Co-Authored-By: Claude Opus 4.8 --- app/views/event_registrations/_continuing_education.html.erb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb index 65ad92156c..264d3344a1 100644 --- a/app/views/event_registrations/_continuing_education.html.erb +++ b/app/views/event_registrations/_continuing_education.html.erb @@ -27,7 +27,6 @@ <%# No license picker here — saving creates the record against the registrant's license (or an empty placeholder). The license is then shown below and editable on the CE registration's own page. %> -

Saving with this on creates the CE registration.

<% else %> <% license_number = ce_registration.professional_license&.number %>
From 573fb9f7e7c12fc6db91e15b8a408795777b8c87 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 17:03:48 -0400 Subject: [PATCH 08/46] CE toggle: amber-while-pending/teal track + teal card highlight when on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse (and simplify) the ce-credit-requested Stimulus controller so the CE Requested toggle behaves like scholarship's: the track turns amber while the choice is pending, teal once stored on, gray when off — and the whole card gets a teal ring while it's on. Drops the controller's dead targets (license/ hours/details fields were removed earlier), so it's no longer orphaned. Co-Authored-By: Claude Opus 4.8 --- .../ce_credit_requested_controller.js | 35 +++++++------------ .../_continuing_education.html.erb | 9 +++-- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/app/frontend/javascript/controllers/ce_credit_requested_controller.js b/app/frontend/javascript/controllers/ce_credit_requested_controller.js index 0af0a5e395..a904e82643 100644 --- a/app/frontend/javascript/controllers/ce_credit_requested_controller.js +++ b/app/frontend/javascript/controllers/ce_credit_requested_controller.js @@ -1,13 +1,12 @@ import { Controller } from "@hotwired/stimulus" -// Drives the CE-credit box on the registration form. Colors the "Requested" -// toggle to signal save state: amber while the choice is pending (changed but -// not yet saved), the continuing-education theme color once it matches the -// stored "on" value, neutral gray when stored as off. While "Requested" is on it -// reveals the CE details (license number, hours, cost) and keeps the "Provided" -// badge in sync as the admin edits the license number. +// Drives the CE "Requested" toggle on the registration form, mirroring the +// scholarship toggle. Colors the track to signal save state — amber while the +// choice is pending (changed but not yet saved), the continuing-education theme +// color (teal) once it matches the stored "on" value, neutral gray when off — +// and highlights the whole card with a teal ring while it's on. export default class extends Controller { - static targets = ["checkbox", "track", "details", "license", "licenseBadge"] + static targets = ["checkbox", "track", "box"] static values = { initial: Boolean } connect() { @@ -15,6 +14,8 @@ export default class extends Controller { } refresh() { + if (!this.hasCheckboxTarget) return + const checked = this.checkboxTarget.checked const pending = checked !== this.initialValue @@ -22,21 +23,9 @@ export default class extends Controller { this.trackTarget.classList.toggle("bg-teal-600", checked && !pending) this.trackTarget.classList.toggle("bg-gray-200", !checked && !pending) - if (this.hasDetailsTarget) this.detailsTarget.classList.toggle("hidden", !checked) - - this.updateLicenseBadge() - } - - updateLicenseBadge() { - if (!this.hasLicenseTarget || !this.hasLicenseBadgeTarget) return - - const provided = this.licenseTarget.value.trim().length > 0 - this.licenseBadgeTarget.classList.toggle("bg-teal-50", provided) - this.licenseBadgeTarget.classList.toggle("text-teal-700", provided) - this.licenseBadgeTarget.classList.toggle("bg-gray-100", !provided) - this.licenseBadgeTarget.classList.toggle("text-gray-500", !provided) - this.licenseBadgeTarget.innerHTML = provided - ? ' Provided' - : ' Not provided' + if (this.hasBoxTarget) { + this.boxTarget.classList.toggle("ring-2", checked) + this.boxTarget.classList.toggle("ring-teal-400", checked) + } } } diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb index 264d3344a1..7c2e1fca16 100644 --- a/app/views/event_registrations/_continuing_education.html.erb +++ b/app/views/event_registrations/_continuing_education.html.erb @@ -3,7 +3,10 @@ registration stub, which is then filled in on its own edit page. Once a record exists the toggle is gone and we show the record + an Edit link. Only rendered for CE-eligible events. ---- %> -
+
@@ -19,8 +22,10 @@ name="event_registration[ce_requested]" value="1" <%= "checked" if event_registration.ce_requested? %> + data-ce-credit-requested-target="checkbox" + data-action="ce-credit-requested#refresh" class="sr-only peer"> - + Requested From 9b71df69e893ffcd0bc48128e5e19bd526b8aa96 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 21:42:28 -0400 Subject: [PATCH 09/46] Restyle the CE registration edit page to match the scholarship form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap it in the continuing-education-themed (teal) outer card, use the shared centered event page header (icon + title + event + registrant), and split the fields, certificate, and removal into teal section cards with icon chips — mirroring the scholarship edit layout. Co-Authored-By: Claude Opus 4.8 --- .../edit.html.erb | 134 ++++++++++-------- ...continuing_education_registrations_spec.rb | 2 +- 2 files changed, 77 insertions(+), 59 deletions(-) diff --git a/app/views/continuing_education_registrations/edit.html.erb b/app/views/continuing_education_registrations/edit.html.erb index 018e24a704..54de6062b0 100644 --- a/app/views/continuing_education_registrations/edit.html.erb +++ b/app/views/continuing_education_registrations/edit.html.erb @@ -2,75 +2,93 @@ <% registration = @ce_registration.event_registration %> <% license = @ce_registration.professional_license %> -
-
+
+ <%# Top bar: back link + secondary links, matching the scholarship edit page %> +
<%= link_to edit_event_registration_path(registration), class: "text-sm text-gray-500 hover:text-gray-700" do %> Registration <% end %> + <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700" %>
-
- - - -
-

Continuing education

-

<%= registration.registrant&.full_name %> — <%= registration.event.title %>

-
-
+ <%= render "shared/event_page_header", + title: "Edit CE registration", + icon: "fa-solid fa-certificate", + color: :continuing_education, + event: registration.event, + person: registration.registrant %> - <%= form_with model: @ce_registration, url: continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), - method: :patch, data: { turbo: false }, class: "mt-6 space-y-5 rounded-xl border border-gray-200 bg-white p-5 shadow-sm" do |f| %> -
- - <%= f.text_field :license_number, value: license&.number, id: "continuing_education_registration_license_number", - placeholder: "e.g. LMFT 12345", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> -

Leave blank to keep the current license (number pending).

-
+
+ <%= form_with model: @ce_registration, url: continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), + method: :patch, data: { turbo: false } do |f| %> +
+
+ + + +

CE details

+
+ +
+
+ + <%= f.text_field :license_number, value: license&.number, id: "continuing_education_registration_license_number", + placeholder: "e.g. LMFT 12345", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +

Leave blank to keep the current license (number pending).

+
+ +
+
+ + <%= f.number_field :hours, value: ContinuingEducationRegistration.format_hours(@ce_registration.hours), min: 0, step: 0.25, + id: "continuing_education_registration_hours", + class: "w-32 rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ + <%= f.number_field :cost_dollars, value: (@ce_registration.cost_cents.to_d / 100), min: 0, step: 0.01, + id: "continuing_education_registration_cost_dollars", + class: "w-32 rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
-
-
- - <%= f.number_field :hours, value: ContinuingEducationRegistration.format_hours(@ce_registration.hours), min: 0, step: 0.25, - id: "continuing_education_registration_hours", - class: "w-32 rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+ <%= f.submit "Save", class: "rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 cursor-pointer" %> +
+
+
+ <% end %> + +
+
+ + + +

Certificate

-
- - <%= f.number_field :cost_dollars, value: (@ce_registration.cost_cents.to_d / 100), min: 0, step: 0.01, - id: "continuing_education_registration_cost_dollars", - class: "w-32 rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + +
+

+ <% if @ce_registration.certificate_sent_at.present? %> + Issued <%= @ce_registration.certificate_sent_at.to_date.to_fs(:long) %> + <% else %> + Not issued yet + <% end %> +

+ <%= button_to toggle_certificate_continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), + method: :patch, class: "rounded-lg border px-3 py-1.5 text-sm font-medium #{@ce_registration.certificate_sent_at.present? ? "border-gray-300 text-gray-600 hover:bg-gray-50" : "border-teal-300 bg-teal-50 text-teal-700 hover:bg-teal-100"} cursor-pointer" do %> + <%= @ce_registration.certificate_sent_at.present? ? "Mark not issued" : "Mark certificate issued" %> + <% end %>
-
+
- <%= f.submit "Save", class: "rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 cursor-pointer" %> -
- <% end %> - -
-
-

Certificate

-

- <% if @ce_registration.certificate_sent_at.present? %> - Issued <%= @ce_registration.certificate_sent_at.to_date.to_fs(:long) %> - <% else %> - Not issued yet - <% end %> -

+ <%= button_to continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), + method: :delete, form: { data: { turbo_confirm: "Remove this CE registration?" } }, + class: "inline-flex items-center gap-1.5 text-sm font-medium text-red-600 hover:text-red-700 hover:underline cursor-pointer" do %> + Remove CE registration + <% end %>
- <%= button_to toggle_certificate_continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), - method: :patch, class: "rounded-lg border px-3 py-1.5 text-sm font-medium #{@ce_registration.certificate_sent_at.present? ? "border-gray-300 text-gray-600 hover:bg-gray-50" : "border-teal-300 bg-teal-50 text-teal-700 hover:bg-teal-100"} cursor-pointer" do %> - <%= @ce_registration.certificate_sent_at.present? ? "Mark not issued" : "Mark certificate issued" %> - <% end %> -
- -
- <%= button_to continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), - method: :delete, form: { data: { turbo_confirm: "Remove this CE registration?" } }, - class: "inline-flex items-center gap-1.5 text-sm font-medium text-red-600 hover:text-red-700 hover:underline cursor-pointer" do %> - Remove CE registration - <% end %>
diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index 22a4bee792..9f49f18a5f 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -15,7 +15,7 @@ it "renders the edit page" do get edit_continuing_education_registration_path(ce_registration) expect(response).to have_http_status(:ok) - expect(response.body).to include("Continuing education") + expect(response.body).to include("Edit CE registration") end it "updates hours, cost, and promotes the placeholder license in place" do From f63f81d61b0116c0ee09ae883f03956feed547ad Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 21:46:53 -0400 Subject: [PATCH 10/46] Align the org/scholarship/CE card bottom actions on one row The three cards in the registration row are equal-height (grid), but their bottom elements floated at different heights. Make each card a flex column and pin its bottom action (Connect organization link, scholarship chip, CE chip) with mt-auto so they line up. Drops the now-unneeded scholarship grantor spacer since mt-auto handles the alignment. Co-Authored-By: Claude Opus 4.8 --- .../_continuing_education.html.erb | 10 ++++++---- app/views/event_registrations/_form.html.erb | 6 +++--- .../event_registrations/_scholarship.html.erb | 17 +++++++---------- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb index 7c2e1fca16..77aac11ccf 100644 --- a/app/views/event_registrations/_continuing_education.html.erb +++ b/app/views/event_registrations/_continuing_education.html.erb @@ -3,7 +3,7 @@ registration stub, which is then filled in on its own edit page. Once a record exists the toggle is gone and we show the record + an Edit link. Only rendered for CE-eligible events. ---- %> -
@@ -14,7 +14,7 @@

Continuing education

-
+
<% unless ce_registration %>
diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb index 77aac11ccf..7767ba5bac 100644 --- a/app/views/event_registrations/_continuing_education.html.erb +++ b/app/views/event_registrations/_continuing_education.html.erb @@ -33,7 +33,7 @@ license (or an empty placeholder). The license is then shown below and editable on the CE registration's own page. %> <% else %> - <% license_number = ce_registration.professional_license&.number %> + <% license = ce_registration.professional_license %>
<%= dollars_from_cents(ce_registration.cost_cents) %> @@ -49,8 +49,8 @@

License: - <% if license_number.present? %> - <%= license_number %> + <% if license&.number_known? %> + <%= license.name %> <% else %> Needs license <% end %> diff --git a/app/views/people/_form.html.erb b/app/views/people/_form.html.erb index 034d424129..b933581bb1 100644 --- a/app/views/people/_form.html.erb +++ b/app/views/people/_form.html.erb @@ -142,21 +142,6 @@

-
-
- Professional licenses -
-

Used for continuing-education credit. A license with paid CE registrations can't be removed.

- <%= f.simple_fields_for :professional_licenses do |license_form| %> - <%= render "professional_licenses/professional_license_fields", f: license_form %> - <% end %> - <%= link_to_add_association "➕ Add license", - f, - :professional_licenses, - partial: "professional_licenses/professional_license_fields", - class: "btn btn-secondary-outline" %> -
- <% if @person_categories_grouped.present? %> <% primary_age_ids = @person.primary_age_category_ids %> @@ -322,11 +307,6 @@ focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50" }, hint: "Maximum 250 characters" %> - - <%= f.input :credentials, - label: "Credentials", - input_html: { class: "w-full" }, - wrapper_html: { class: "w-full" } %>
@@ -346,6 +326,23 @@
+
+
+ Professional licenses +
+
+

Used for continuing-education credit. A license with paid CE registrations can't be removed.

+ <%= f.simple_fields_for :professional_licenses do |license_form| %> + <%= render "professional_licenses/professional_license_fields", f: license_form %> + <% end %> + <%= link_to_add_association "➕ Add license", + f, + :professional_licenses, + partial: "professional_licenses/professional_license_fields", + class: "btn btn-secondary-outline" %> +
+
+
diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index 9f49f18a5f..cb970893ac 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -18,17 +18,42 @@ expect(response.body).to include("Edit CE registration") end - it "updates hours, cost, and promotes the placeholder license in place" do + it "updates hours, cost, and fills the placeholder license type + number in place" do license = ce_registration.professional_license patch continuing_education_registration_path(ce_registration), - params: { continuing_education_registration: { hours: "4.5", cost_dollars: "90", license_number: "LMFT 555" } } + params: { continuing_education_registration: { hours: "4.5", cost_dollars: "90", license_kind: "LMFT", license_number: "555" } } ce_registration.reload expect(ce_registration.hours).to eq(4.5) expect(ce_registration.cost_cents).to eq(9_000) - # Placeholder is promoted in place rather than orphaned. + # Placeholder is filled in place rather than orphaned. expect(ce_registration.professional_license).to eq(license) - expect(license.reload.number).to eq("LMFT 555") + expect(license.reload).to have_attributes(kind: "LMFT", number: "555") + end + + it "edits the same license in place when correcting a typo (no new record)" do + license = create(:professional_license, person: registration.registrant, kind: "LCSW", number: "11223") + ce_registration.update!(professional_license: license) + + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", license_kind: "LCSW", license_number: "11224" } } + }.not_to change(ProfessionalLicense, :count) + + expect(ce_registration.reload.professional_license).to eq(license) + expect(license.reload.number).to eq("11224") + end + + it "links to the registrant's existing license when the typed number already matches one" do + ce_registration + other = create(:professional_license, person: registration.registrant, kind: "LMFT", number: "99887") + + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", license_kind: "LMFT", license_number: "99887" } } + }.not_to change(ProfessionalLicense, :count) + + expect(ce_registration.reload.professional_license).to eq(other) end it "marks the certificate issued and back to not issued" do From 609af48479194ece49ee5106d21f8ea4fd510ee8 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 22:22:20 -0400 Subject: [PATCH 15/46] Show license type(s) as the profile credential suffix Replace the removed free-text credentials field as the source of the profile name suffix: when 'Show credentials' is on, append the person's distinct professional-license types (e.g. ', LMFT, LCSW') after their name. Keeps the profile_show_credentials toggle; the credentials column is now unused for display. Co-Authored-By: Claude Opus 4.8 --- ...uing_education_registrations_controller.rb | 23 +------- app/controllers/events/callouts_controller.rb | 20 +++---- app/helpers/event_helper.rb | 9 ++- .../continuing_education_registration.rb | 21 +++++++ app/models/form_field.rb | 24 +++----- app/models/person.rb | 7 +++ .../edit.html.erb | 13 +++- app/views/events/callouts/ce.html.erb | 59 ++++++++++++------- .../public_registrations/_form_field.html.erb | 8 +-- app/views/people/show.html.erb | 2 +- spec/helpers/event_helper_spec.rb | 5 -- spec/requests/events/registrations_spec.rb | 25 ++++++-- spec/requests/people_profile_flags_spec.rb | 8 +-- 13 files changed, 128 insertions(+), 96 deletions(-) diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb index 56980a4f66..1e263ce505 100644 --- a/app/controllers/continuing_education_registrations_controller.rb +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -7,8 +7,8 @@ def edit def update authorize! @ce_registration - assign_license(params.dig(:continuing_education_registration, :license_number), - params.dig(:continuing_education_registration, :license_kind)) + @ce_registration.assign_license(number: params.dig(:continuing_education_registration, :license_number), + kind: params.dig(:continuing_education_registration, :license_kind)) @ce_registration.hours = params.dig(:continuing_education_registration, :hours) cost = params.dig(:continuing_education_registration, :cost_dollars) @ce_registration.cost_cents = (cost.to_d * 100).round if cost.present? @@ -53,25 +53,6 @@ def set_ce_registration @ce_registration = ContinuingEducationRegistration.find(params[:id]) end - # Edit the attached license in place from the typed type + number — filling a - # blank placeholder and fixing a typo both just correct this one record (and its - # PaperTrail history). The only exception: if the typed number already belongs to - # another license this person holds, link to that one rather than duplicating or - # colliding on the unique (person, number) index. - def assign_license(number, kind) - number = number.to_s.strip.presence - kind = kind.to_s.strip.presence - current = @ce_registration.professional_license - person = @ce_registration.event_registration.registrant - - match = person.professional_licenses.where.not(id: current.id).find_by(number: number) if number - if match - @ce_registration.professional_license = match - else - current.update!(number: number, kind: kind) - end - end - def registration_path edit_event_registration_path(@ce_registration.event_registration) end diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index ffcc60ccc9..006de8baff 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -62,24 +62,22 @@ def scholarship def ce end - # Public license-number entry from the CE callout. Sets the number on the - # registrant's (first) CE registration via a found-or-created license, mirrors - # it onto the registration's form answer, then returns to the callout. Plain - # full-page POST — no Turbo. + # Public license type + number entry from the CE callout. Edits the license on + # the registrant's (first) CE registration in place, mirrors the number onto the + # registration's form answer, then returns to the callout. Plain full-page POST — + # no Turbo. Shares ContinuingEducationRegistration#assign_license with the admin + # edit page. def update_ce_license ce_registration = @event_registration.continuing_education_registrations.first return redirect_to(registration_ce_path(@event_registration.slug)) unless ce_registration - number = params[:license_number].to_s.strip.presence - ce_registration.professional_license = ProfessionalLicense.find_or_create_for( - person: @event_registration.registrant, number: number - ) + ce_registration.assign_license(number: params[:license_number], kind: params[:license_kind]) ce_registration.save! - record_ce_license_answer(number) + record_ce_license_answer(params[:license_number].to_s.strip.presence) - redirect_to registration_ce_path(@event_registration.slug), notice: "License number saved." + redirect_to registration_ce_path(@event_registration.slug), notice: "License saved." rescue ActiveRecord::RecordInvalid - redirect_to registration_ce_path(@event_registration.slug), alert: "We couldn't save that license number." + redirect_to registration_ce_path(@event_registration.slug), alert: "We couldn't save that license." end # Public CE opt-in from the callout: a registrant who didn't ask for credit at diff --git a/app/helpers/event_helper.rb b/app/helpers/event_helper.rb index 25ac9f890a..6e61efefa4 100644 --- a/app/helpers/event_helper.rb +++ b/app/helpers/event_helper.rb @@ -1,10 +1,9 @@ module EventHelper # The "please specify" placeholder for an option label, or nil when the option - # does not reveal a free-text box. Pass the field to honor placeholders scoped - # to one field (e.g. the CE question's "Yes"). Canonical config lives on - # FormField (shared with answer validation). - def specify_placeholder(label, field = nil) - FormField.specify_placeholder_for(label, field&.field_identifier) + # does not reveal a free-text box. Canonical config lives on FormField (shared + # with answer validation). + def specify_placeholder(label) + FormField.specify_placeholder_for(label) end # True when a stored answer selects the given specify option: the bare label, diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 0ab5531862..22d5df0056 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -37,6 +37,27 @@ def certificate_available? event.end_date&.past? && event_registration.attended? && paid_in_full? end + # Point this registration at the registrant's license for the typed type + + # number, editing the current license in place — filling a blank placeholder and + # fixing a typo both just correct this one record (and its PaperTrail history). + # The exception: if the typed number already belongs to another license this + # person holds, link to that one rather than duplicating or colliding on the + # unique (person, number) index. Does not save the registration itself — callers + # persist it alongside their other changes. + def assign_license(number:, kind:) + number = number.to_s.strip.presence + kind = kind.to_s.strip.presence + current = professional_license + person = event_registration.registrant + + match = person.professional_licenses.where.not(id: current.id).find_by(number: number) if number + if match + self.professional_license = match + else + current.update!(number: number, kind: kind) + end + end + # Human-readable payment status, mirroring EventRegistration#payment_status_label. # CE has no "intends to pay" concept (that's an event-access affordance), so the # middle state is a genuine partial payment instead. diff --git a/app/models/form_field.rb b/app/models/form_field.rb index 7fc003ef67..78bb92bdce 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -66,13 +66,6 @@ class FormField < ApplicationRecord "Foundation/Funder" => "Please list the name of the foundation/funder" }.freeze - # Specify options scoped to a single field by its field_identifier, rather than - # to an option label everywhere it appears (SPECIFY_OPTION_PLACEHOLDERS). The - # CE-interest question once revealed a "How many CE hours?" box here, but CE - # hours now come from the event, so the question is a plain Yes/No. Kept as the - # general mechanism for any future field-scoped specify box. - FIELD_SPECIFY_OPTION_PLACEHOLDERS = {}.freeze - # Fallback character ceilings applied when a free-form field has no explicit # max_characters set. This is a safety net against pathological submissions # (megabyte answers that bloat the DB and break admin/email rendering), not a @@ -339,20 +332,17 @@ def dynamic_categories # The "please specify" placeholder for an option label, or nil when the option # does not reveal a free-text box. Matched case- and whitespace-insensitively - # against SPECIFY_OPTION_PLACEHOLDERS, then (when a field_identifier is given) - # against that field's FIELD_SPECIFY_OPTION_PLACEHOLDERS, which wins. - def self.specify_placeholder_for(label, field_identifier = nil) + # against SPECIFY_OPTION_PLACEHOLDERS. + def self.specify_placeholder_for(label) normalized = label.to_s.strip return if normalized.blank? - field_scoped = FIELD_SPECIFY_OPTION_PLACEHOLDERS[field_identifier]&.find { |key, _| key.casecmp?(normalized) }&.last - field_scoped || SPECIFY_OPTION_PLACEHOLDERS.find { |key, _| key.casecmp?(normalized) }&.last + SPECIFY_OPTION_PLACEHOLDERS.find { |key, _| key.casecmp?(normalized) }&.last end - # True when an option label reveals a free-text "please specify" box, optionally - # scoped to a field via its field_identifier. - def self.specify_option?(label, field_identifier = nil) - specify_placeholder_for(label, field_identifier).present? + # True when an option label reveals a free-text "please specify" box. + def self.specify_option?(label) + specify_placeholder_for(label).present? end # True when an option label is the generic free-text "Other" choice. Unlike @@ -371,7 +361,7 @@ def self.other_option?(label) # their options, so they expose no specify option. def specify_option_labels option_names = dynamic_options? ? dynamic_option_names : answer_options.map(&:name) - option_names.select { |name| FormField.specify_option?(name, field_identifier) } + option_names.select { |name| FormField.specify_option?(name) } end # The names of this field's dynamically-sourced options (Sector or Category), diff --git a/app/models/person.rb b/app/models/person.rb index 6e541f2d28..5b1b828cf7 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -184,6 +184,13 @@ def full_name "#{first_name} #{last_name}" end + # Distinct professional-license types (e.g. "LMFT, LCSW"), shown as a credential + # suffix after the person's name on their profile (replaces the old free-text + # credentials field). Nil when no licensed types are on file. + def license_credentials + professional_licenses.filter_map { |license| license.kind.presence&.strip }.uniq.join(", ").presence + end + def full_name_with_email email = preferred_email name = full_name diff --git a/app/views/continuing_education_registrations/edit.html.erb b/app/views/continuing_education_registrations/edit.html.erb index ec2a9f3bf2..20bbf36f4e 100644 --- a/app/views/continuing_education_registrations/edit.html.erb +++ b/app/views/continuing_education_registrations/edit.html.erb @@ -8,7 +8,18 @@ <%= link_to edit_event_registration_path(registration), class: "text-sm text-gray-500 hover:text-gray-700" do %> Registration <% end %> - <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700" %> +
+ <%# Admin jump to the registrant-facing CE callout (what the registrant sees / + where they enter their own license). Opens in a new tab so the edit context + is kept; matches the sky admin-link convention. %> + <%= link_to registration_ce_path(registration.slug), target: "_blank", rel: "noopener", + class: "inline-flex items-center gap-1 text-xs rounded-full border px-2 py-0.5 bg-sky-100 text-sky-700 border-sky-200 hover:bg-sky-200 transition" do %> + + Registrant's CE page + + <% end %> + <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700" %> +
<%= render "shared/event_page_header", diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 42fc728478..56914f37d7 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -3,8 +3,22 @@ <%= render layout: "events/callouts/callout_page", locals: { title: @event.ce_hours_details_label } do %> <% if @event_registration.ce_registered? %> <% ce_registration = @event_registration.continuing_education_registrations.first %> - <% license_number = ce_registration&.professional_license&.number %> -
+ <% license = ce_registration&.professional_license %> + <% license_kind = license&.kind %> + <% license_number = license&.number %> + <%# Admin-only jump to the management surface for this CE registration. Hidden + from registrants; opens in a new tab so the registrant view is kept. %> + <% if ce_registration && allowed_to?(:edit?, ce_registration) %> +
+ <%= link_to edit_continuing_education_registration_path(ce_registration), target: "_blank", rel: "noopener", + class: "inline-flex items-center gap-1 text-xs rounded-full border px-2 py-0.5 bg-sky-100 text-sky-700 border-sky-200 hover:bg-sky-200 transition" do %> + + Edit CE registration + + <% end %> +
+ <% end %> +

Status

<% if @event_registration.ce_paid_in_full? %> @@ -17,34 +31,35 @@ <% end %>
-
-
+
+
Hours
-
<%= ce_hours_display(@event_registration.ce_hours_total) || "—" %>
+
<%= ce_hours_display(@event_registration.ce_hours_total) || "—" %>
-
+
Cost
-
<%= dollars_from_cents(@event_registration.ce_amount_owed_cents) %>
-
-
-
License number
- <% if license_number.present? %> -
<%= license_number %>
- <% else %> -
Not on file yet.
- <% end %> +
<%= dollars_from_cents(@event_registration.ce_amount_owed_cents) %>
- <%# Registrants supply (or correct) their license number here — a plain - full-page POST, no Turbo, so the page simply reloads with the saved value. %> + <%# Registrants supply (or correct) their license type + number here — a plain + full-page POST, no Turbo, so the page simply reloads with the saved value. + This is the only license display: the editable fields double as the readout. %> <%= form_with url: registration_ce_license_path(@event_registration.slug), method: :post, data: { turbo: false }, class: "mt-5 border-t border-gray-100 pt-5" do |form| %> - -
- <%= form.text_field :license_number, value: license_number, id: "license_number", - placeholder: "e.g. LMFT 12345", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ + <%= form.text_field :license_kind, value: license_kind, id: "license_kind", + placeholder: "e.g. LCSW", + class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ + <%= form.text_field :license_number, value: license_number, id: "license_number", + placeholder: "e.g. 12345", + class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
<%= form.submit "Save", class: "shrink-0 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %>

Acceptance of CE hours is determined by each state board; we can't guarantee yours will accept them.

diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb index 7bec3c4bb7..9605cfe1ca 100644 --- a/app/views/events/public_registrations/_form_field.html.erb +++ b/app/views/events/public_registrations/_form_field.html.erb @@ -78,10 +78,10 @@ data and store none of their own, so fall back to those when present. %> <% radio_options = dynamic_form_field_options(field) || field.form_field_answer_options.includes(:answer_option).map { |ffao| [ ffao.answer_option.name, ffao.answer_option.name ] } %> - <% has_specify = radio_options.any? { |option_label, _option_value| specify_placeholder(option_label, field).present? } %> + <% has_specify = radio_options.any? { |option_label, _option_value| specify_placeholder(option_label).present? } %> <%= tag.div class: "flex flex-wrap gap-2.5 mt-1", data: (has_specify ? { controller: "specify-option", action: "change->specify-option#update" } : {}) do %> <% radio_options.each do |option_label, option_value, option_description| %> - <% placeholder = specify_placeholder(option_label, field) %> + <% placeholder = specify_placeholder(option_label) %> <% is_specify = placeholder.present? %>

- <%= @person.name %><% if @person.credentials.present? && @person.profile_show_credentials? %>, <%= @person.credentials %><% end %> + <%= @person.name %><% if @person.profile_show_credentials? && @person.license_credentials.present? %>, <%= @person.license_credentials %><% end %> <% if @person.pronouns.present? && @person.profile_show_pronouns? %> (<%= @person.pronouns %>) <% end %> diff --git a/spec/helpers/event_helper_spec.rb b/spec/helpers/event_helper_spec.rb index a70ac60ac9..d06f3bbbe5 100644 --- a/spec/helpers/event_helper_spec.rb +++ b/spec/helpers/event_helper_spec.rb @@ -19,11 +19,6 @@ expect(helper.specify_placeholder("Other reasons")).to be_nil expect(helper.specify_placeholder(nil)).to be_nil end - - it "does not reveal a box for the CE question's 'Yes' (hours come from the event)" do - ce_field = FormField.new(field_identifier: "ce_credit_interest") - expect(helper.specify_placeholder("Yes", ce_field)).to be_nil - end end describe "#specify_option_selected?" do diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index 370212eb6b..be3c77338f 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -233,25 +233,40 @@ expect(response.body).to include("LIC123") end - it "notes when the license number is not yet on file" do + it "shows blank license fields when nothing is on file yet" do license = create(:professional_license, :placeholder, person: registration.registrant) create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) get registration_ce_path(registration.slug) - expect(response.body).to include("Not on file yet.") + expect(response.body).to include("License type") + expect(response.body).to include("Your license number") + end + + it "shows an admin jump link to the CE registration only to admins" do + ce = create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant), hours: 6) + + get registration_ce_path(registration.slug) + expect(response.body).not_to include(edit_continuing_education_registration_path(ce)) + + sign_in create(:user, :with_person, super_user: true) + get registration_ce_path(registration.slug) + expect(response.body).to include(edit_continuing_education_registration_path(ce)) end end describe "POST /registration/:slug/ce/license" do let!(:registration) { create(:event_registration, event: event, registrant: user.person) } - it "saves a license number entered on the callout" do + it "saves a license type and number entered on the callout" do license = create(:professional_license, :placeholder, person: registration.registrant) create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) - post registration_ce_license_path(registration.slug), params: { license_number: "LMFT 7788" } + post registration_ce_license_path(registration.slug), params: { license_kind: "LMFT", license_number: "7788" } expect(response).to redirect_to(registration_ce_path(registration.slug)) - expect(registration.continuing_education_registrations.first.professional_license.number).to eq("LMFT 7788") + saved = registration.continuing_education_registrations.first.professional_license + expect(saved.kind).to eq("LMFT") + expect(saved.number).to eq("7788") end end diff --git a/spec/requests/people_profile_flags_spec.rb b/spec/requests/people_profile_flags_spec.rb index 20f46c5bfe..dc93bb6ab8 100644 --- a/spec/requests/people_profile_flags_spec.rb +++ b/spec/requests/people_profile_flags_spec.rb @@ -64,12 +64,12 @@ end describe "#profile_show_credentials" do - before { person.update!(credentials: "LCSW") } + before { create(:professional_license, person: person, kind: "LCSW", number: "11223") } context "when false" do before { person.update!(profile_show_credentials: false) } - it "hides credentials on own profile" do + it "hides the license credentials on own profile" do sign_in owner_user get person_path(person) expect(response.body).not_to include("LCSW") @@ -79,13 +79,13 @@ context "when true" do before { person.update!(profile_show_credentials: true) } - it "shows credentials as a suffix on own profile" do + it "shows the license type as a suffix on own profile" do sign_in owner_user get person_path(person) expect(response.body).to include("LCSW") end - it "shows credentials when admin views profile" do + it "shows the license type when admin views profile" do sign_in admin get person_path(person) expect(response.body).to include("LCSW") From e032b503276a300bfb2c2d81c0330ac5527719d3 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 22:32:57 -0400 Subject: [PATCH 16/46] Group the registrant CE callout into labeled sections The callout body was an unanchored stack of bordered blocks. Split it into "Your CE credit", "Your professional license", and "Details" sections so it reads top-to-bottom, lead with Hours/Cost and trail the status pill, and align the Save button to the input height. Co-Authored-By: Claude Opus 4.8 --- .../edit.html.erb | 2 +- app/views/events/callouts/ce.html.erb | 110 ++++++++++-------- spec/requests/events/registrations_spec.rb | 19 +++ 3 files changed, 84 insertions(+), 47 deletions(-) diff --git a/app/views/continuing_education_registrations/edit.html.erb b/app/views/continuing_education_registrations/edit.html.erb index 20bbf36f4e..a6a1273d4a 100644 --- a/app/views/continuing_education_registrations/edit.html.erb +++ b/app/views/continuing_education_registrations/edit.html.erb @@ -12,7 +12,7 @@ <%# Admin jump to the registrant-facing CE callout (what the registrant sees / where they enter their own license). Opens in a new tab so the edit context is kept; matches the sky admin-link convention. %> - <%= link_to registration_ce_path(registration.slug), target: "_blank", rel: "noopener", + <%= link_to registration_ce_path(registration.slug, return_to: "ce_registration"), target: "_blank", rel: "noopener", class: "inline-flex items-center gap-1 text-xs rounded-full border px-2 py-0.5 bg-sky-100 text-sky-700 border-sky-200 hover:bg-sky-200 transition" do %> Registrant's CE page diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 56914f37d7..6050724914 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -1,8 +1,16 @@ <% content_for(:page_bg_class, "public") %> <% content_for(:page_title, "#{@event.ce_hours_details_label} — #{@event.title}") %> -<%= render layout: "events/callouts/callout_page", locals: { title: @event.ce_hours_details_label } do %> +<% ce_registration = @event_registration.continuing_education_registrations.first %> +<%# Reached from the admin CE registration edit page (return_to=ce_registration): + point the eyebrow back there instead of the registrant's ticket. Gated on edit + access so a registrant who lands on this URL still gets the default ticket back. %> +<% callout_eyebrow = if params[:return_to] == "ce_registration" && ce_registration && allowed_to?(:edit?, ce_registration) + { back_path: edit_continuing_education_registration_path(ce_registration), back_label: "Back to CE registration" } + else + {} + end %> +<%= render layout: "events/callouts/callout_page", locals: { title: @event.ce_hours_details_label, **callout_eyebrow } do %> <% if @event_registration.ce_registered? %> - <% ce_registration = @event_registration.continuing_education_registrations.first %> <% license = ce_registration&.professional_license %> <% license_kind = license&.kind %> <% license_number = license&.number %> @@ -18,52 +26,59 @@ <% end %>

<% end %> -
-

Status

- <% if @event_registration.ce_paid_in_full? %> - - Paid - - <% else %> - - Requested - - <% end %> -
- -
-
-
Hours
-
<%= ce_hours_display(@event_registration.ce_hours_total) || "—" %>
-
-
-
Cost
-
<%= dollars_from_cents(@event_registration.ce_amount_owed_cents) %>
-
-
+
+

Your CE credit

+
+
+
Hours
+
<%= ce_hours_display(@event_registration.ce_hours_total) || "—" %>
+
+
+
Cost
+
<%= dollars_from_cents(@event_registration.ce_amount_owed_cents) %>
+
+
+
Status
+
+ <% if @event_registration.ce_paid_in_full? %> + + Paid + + <% else %> + + Requested + + <% end %> +
+
+
+
<%# Registrants supply (or correct) their license type + number here — a plain full-page POST, no Turbo, so the page simply reloads with the saved value. This is the only license display: the editable fields double as the readout. %> - <%= form_with url: registration_ce_license_path(@event_registration.slug), method: :post, - data: { turbo: false }, class: "mt-5 border-t border-gray-100 pt-5" do |form| %> -
-
- - <%= form.text_field :license_kind, value: license_kind, id: "license_kind", - placeholder: "e.g. LCSW", - class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> -
-
- - <%= form.text_field :license_number, value: license_number, id: "license_number", - placeholder: "e.g. 12345", - class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+

Your professional license

+ <%= form_with url: registration_ce_license_path(@event_registration.slug), method: :post, + data: { turbo: false }, class: "mt-3" do |form| %> +
+
+ + <%= form.text_field :license_kind, value: license_kind, id: "license_kind", + placeholder: "e.g. LCSW", + class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ + <%= form.text_field :license_number, value: license_number, id: "license_number", + placeholder: "e.g. 12345", + class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+ <%= form.submit "Save changes", class: "shrink-0 self-end rounded-lg border border-transparent bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %>
- <%= form.submit "Save", class: "shrink-0 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %> -
-

Acceptance of CE hours is determined by each state board; we can't guarantee yours will accept them.

- <% end %> +

Acceptance of CE hours is determined by each state board; we can't guarantee yours will accept them.

+ <% end %> + <% else %>

You haven't requested continuing education credit for this training. CE hours are available for an additional fee.

<%= form_with url: registration_ce_request_path(@event_registration.slug), method: :post, data: { turbo: false }, class: "mt-4" do |form| %> @@ -72,8 +87,11 @@ <% end %> <% if @event.ce_hours_details.present? %> -
- <%= form_label_html(@event.ce_hours_details) %> -
+
+

Details

+
+ <%= form_label_html(@event.ce_hours_details) %> +
+
<% end %> <% end %> diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index be3c77338f..42f2ab01b7 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -252,6 +252,25 @@ get registration_ce_path(registration.slug) expect(response.body).to include(edit_continuing_education_registration_path(ce)) end + + it "points the eyebrow back to the CE registration when reached from there" do + ce = create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant), hours: 6) + + sign_in create(:user, :with_person, super_user: true) + get registration_ce_path(registration.slug, return_to: "ce_registration") + expect(response.body).to include("Back to CE registration") + expect(response.body).to include(edit_continuing_education_registration_path(ce)) + end + + it "keeps the default ticket eyebrow for a registrant even with the ce_registration origin" do + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant), hours: 6) + + get registration_ce_path(registration.slug, return_to: "ce_registration") + expect(response.body).to include("Back to ticket") + expect(response.body).not_to include("Back to CE registration") + end end describe "POST /registration/:slug/ce/license" do From 40ce096e25bd793d3886b78dc4cf58e4fb76c567 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 22:44:41 -0400 Subject: [PATCH 17/46] Flip the registrant license to read-only with an edit link Once a license number is on file, show it read-only with an Edit link rather than always exposing the form. The link reloads with ?editing=license to flip in the form (a plain full-page nav, no JS), and saving redirects back to the read-only view. Surface a clearer "Needs license #" status and a prompt when no number is on file yet. Co-Authored-By: Claude Opus 4.8 --- app/views/events/callouts/ce.html.erb | 71 ++++++++++++++++------ spec/requests/events/registrations_spec.rb | 21 ++++++- 2 files changed, 71 insertions(+), 21 deletions(-) diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 6050724914..f1cb925d99 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -14,6 +14,7 @@ <% license = ce_registration&.professional_license %> <% license_kind = license&.kind %> <% license_number = license&.number %> + <% license_on_file = license&.number_known? %> <%# Admin-only jump to the management surface for this CE registration. Hidden from registrants; opens in a new tab so the registrant view is kept. %> <% if ce_registration && allowed_to?(:edit?, ce_registration) %> @@ -44,6 +45,10 @@ Paid + <% elsif !license_on_file %> + + Needs license # + <% else %> Requested @@ -54,29 +59,57 @@
- <%# Registrants supply (or correct) their license type + number here — a plain - full-page POST, no Turbo, so the page simply reloads with the saved value. - This is the only license display: the editable fields double as the readout. %> -
+ <%# Registrants supply (or correct) their license type + number here. With a + number on file we show it read-only with an Edit link; that link reloads the + page with ?editing=license to flip in the form (a plain full-page nav, no + JS). Saving POSTs and redirects back to the read-only view. With nothing on + file the form shows straight away. %> + <% editing_license = params[:editing] == "license" %> +

Your professional license

- <%= form_with url: registration_ce_license_path(@event_registration.slug), method: :post, - data: { turbo: false }, class: "mt-3" do |form| %> -
-
- - <%= form.text_field :license_kind, value: license_kind, id: "license_kind", - placeholder: "e.g. LCSW", - class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + + <% if license_on_file && !editing_license %> +
+
+
License type
+
<%= license_kind.presence || "—" %>
-
- - <%= form.text_field :license_number, value: license_number, id: "license_number", - placeholder: "e.g. 12345", - class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
License number
+
<%= license_number %>
- <%= form.submit "Save changes", class: "shrink-0 self-end rounded-lg border border-transparent bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %> + <%= link_to registration_ce_path(@event_registration.slug, editing: "license", return_to: params[:return_to].presence, anchor: "license"), + class: "shrink-0 sm:ml-auto inline-flex items-center gap-1.5 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-50" do %> + Edit + <% end %>
-

Acceptance of CE hours is determined by each state board; we can't guarantee yours will accept them.

+ <% else %> + <%= form_with url: registration_ce_license_path(@event_registration.slug), method: :post, + data: { turbo: false }, class: "mt-3" do |form| %> +
+
+ + <%= form.text_field :license_kind, value: license_kind, id: "license_kind", + placeholder: "e.g. LCSW", + class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ + <%= form.text_field :license_number, value: license_number, id: "license_number", + placeholder: "e.g. 12345", + class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+ <%= form.submit "Save changes", class: "shrink-0 self-end rounded-lg border border-transparent bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %> + <% if license_on_file %> + <%= link_to "Cancel", registration_ce_path(@event_registration.slug, return_to: params[:return_to].presence, anchor: "license"), + class: "shrink-0 self-end px-2 py-2 text-sm font-medium text-gray-500 hover:text-gray-700" %> + <% end %> +
+ <% unless license_on_file %> +

We need your license type and number to issue your CE certificate.

+ <% end %> +

Acceptance of CE hours is determined by each state board; we can't guarantee yours will accept them.

+ <% end %> <% end %>
<% else %> diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index 42f2ab01b7..98b5723f0a 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -221,7 +221,7 @@ describe "GET /registration/:slug/ce" do let!(:registration) { create(:event_registration, event: event, registrant: user.person) } - it "shows status, cost, and the license number on file" do + it "shows status, cost, and the license number on file, read-only with an edit link" do event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) license = create(:professional_license, person: registration.registrant, number: "LIC123") create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) @@ -231,14 +231,31 @@ expect(response.body).to include("Hours") expect(response.body).to include("$150") expect(response.body).to include("LIC123") + # Read-only by default: the form is not rendered, just an Edit link that flips to it. + expect(response.body).to include("editing=license") + expect(response.body).not_to include("Save changes") end - it "shows blank license fields when nothing is on file yet" do + it "flips to the editable form when reached with ?editing=license" do + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) + get registration_ce_path(registration.slug, editing: "license") + expect(response.body).to include("Save changes") + expect(response.body).to include("Cancel") + end + + it "shows blank license fields and a needs-license prompt when nothing is on file yet" do license = create(:professional_license, :placeholder, person: registration.registrant) create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) get registration_ce_path(registration.slug) expect(response.body).to include("License type") expect(response.body).to include("Your license number") + expect(response.body).to include("Needs license #") + expect(response.body).to include("We need your license type and number") + expect(response.body).to include("Save changes") + # Nothing on file yet, so there's no read-only value to edit or cancel back to. + expect(response.body).not_to include("editing=license") + expect(response.body).not_to include(">Cancel<") end it "shows an admin jump link to the CE registration only to admins" do From 24a0626852a87c385f2eb271df15561d5ad2f7ea Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 22:57:43 -0400 Subject: [PATCH 18/46] Fix unclosed filter div and read CE hours from the registration The CE-status filter rewrite accidentally dropped the closing tag for the registrants search Row 2 wrapper, so the browser auto-closed it at the form boundary and distorted the filter row. Restore it. The ticket card subtitle read hours from the event default, diverging from every other registrant-facing surface (CE page, onboarding, CSV) once an admin customizes a registrant's hours. Read the registration's own total instead. Co-Authored-By: Claude Fable 5 --- app/services/magic_ticket_callouts.rb | 6 ++++-- app/views/events/_registrants_search.html.erb | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/services/magic_ticket_callouts.rb b/app/services/magic_ticket_callouts.rb index 0a7d312b8b..873e85ed9e 100644 --- a/app/services/magic_ticket_callouts.rb +++ b/app/services/magic_ticket_callouts.rb @@ -133,8 +133,10 @@ def ce_hours_card end def ce_hours_subtitle - hours = ContinuingEducationRegistration.format_hours(event.ce_hours_offered) - hours.present? ? "#{hours} hours" : "Continuing education credit" + total = registration.ce_hours_total + return "Continuing education credit" unless total.positive? + + "#{ContinuingEducationRegistration.format_hours(total)} hours" end # Teal "$X due" once hours + license are on file and money is owed; otherwise an diff --git a/app/views/events/_registrants_search.html.erb b/app/views/events/_registrants_search.html.erb index 47bc6b43ac..7c5e45b24c 100644 --- a/app/views/events/_registrants_search.html.erb +++ b/app/views/events/_registrants_search.html.erb @@ -77,4 +77,5 @@ class: "btn btn-utility", data: { action: "collection#clearAndSubmit" } %>
+
<% end %> From b9a40ccd3e5223b8c919313899ef372fbc57c316 Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 23:17:05 -0400 Subject: [PATCH 19/46] Render the license Expires field through simple_form The Expires date field was hand-rolled with f.label + f.date_field while the sibling fields use simple_form (wrapper: false), so its label weight and the raw native date input didn't match the rest of the row. Render it as an HTML5 date input via simple_form so the label and control line up with the others. Co-Authored-By: Claude Fable 5 --- .../_professional_license_fields.html.erb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/views/professional_licenses/_professional_license_fields.html.erb b/app/views/professional_licenses/_professional_license_fields.html.erb index 310ec9eed7..1c8872e955 100644 --- a/app/views/professional_licenses/_professional_license_fields.html.erb +++ b/app/views/professional_licenses/_professional_license_fields.html.erb @@ -22,8 +22,12 @@
- <%= f.label :expires_on, "Expires", class: "block text-sm font-medium text-gray-700" %> - <%= f.date_field :expires_on, class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" %> + <%= f.input :expires_on, + as: :date, + html5: true, + wrapper: false, + label: "Expires", + input_html: { class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" } %>
From 14c42baa4bbfeabd275ba3adfa7d1978f1d286fe Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 29 Jun 2026 23:24:34 -0400 Subject: [PATCH 20/46] Add issuing state + expiry to the CE license forms The CE callout (registrant-facing) and CE registration edit (admin) pages only captured the license type and number, while the person edit form captures the full license (type, number, issuing state, expiry). Surface the same two extra fields on both CE surfaces so a registrant or admin can complete the license in the place they're already working, instead of bouncing to the person page. assign_license now persists issuing_state and expires_on alongside number/kind. Co-Authored-By: Claude Fable 5 --- ...uing_education_registrations_controller.rb | 4 ++- app/controllers/events/callouts_controller.rb | 13 ++++---- .../continuing_education_registration.rb | 6 ++-- .../edit.html.erb | 11 +++++++ app/views/events/callouts/ce.html.erb | 33 ++++++++++++++++--- ...continuing_education_registrations_spec.rb | 8 +++-- spec/requests/events/registrations_spec.rb | 8 +++-- 7 files changed, 64 insertions(+), 19 deletions(-) diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb index 1e263ce505..83c5d517d1 100644 --- a/app/controllers/continuing_education_registrations_controller.rb +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -8,7 +8,9 @@ def edit def update authorize! @ce_registration @ce_registration.assign_license(number: params.dig(:continuing_education_registration, :license_number), - kind: params.dig(:continuing_education_registration, :license_kind)) + kind: params.dig(:continuing_education_registration, :license_kind), + issuing_state: params.dig(:continuing_education_registration, :license_issuing_state), + expires_on: params.dig(:continuing_education_registration, :license_expires_on)) @ce_registration.hours = params.dig(:continuing_education_registration, :hours) cost = params.dig(:continuing_education_registration, :cost_dollars) @ce_registration.cost_cents = (cost.to_d * 100).round if cost.present? diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 006de8baff..0f342bf9f8 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -62,16 +62,17 @@ def scholarship def ce end - # Public license type + number entry from the CE callout. Edits the license on - # the registrant's (first) CE registration in place, mirrors the number onto the - # registration's form answer, then returns to the callout. Plain full-page POST — - # no Turbo. Shares ContinuingEducationRegistration#assign_license with the admin - # edit page. + # Public license entry from the CE callout (type, number, issuing state, and + # expiry). Edits the license on the registrant's (first) CE registration in + # place, mirrors the number onto the registration's form answer, then returns to + # the callout. Plain full-page POST — no Turbo. Shares + # ContinuingEducationRegistration#assign_license with the admin edit page. def update_ce_license ce_registration = @event_registration.continuing_education_registrations.first return redirect_to(registration_ce_path(@event_registration.slug)) unless ce_registration - ce_registration.assign_license(number: params[:license_number], kind: params[:license_kind]) + ce_registration.assign_license(number: params[:license_number], kind: params[:license_kind], + issuing_state: params[:license_issuing_state], expires_on: params[:license_expires_on]) ce_registration.save! record_ce_license_answer(params[:license_number].to_s.strip.presence) diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 22d5df0056..ffb683b312 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -44,9 +44,11 @@ def certificate_available? # person holds, link to that one rather than duplicating or colliding on the # unique (person, number) index. Does not save the registration itself — callers # persist it alongside their other changes. - def assign_license(number:, kind:) + def assign_license(number:, kind:, issuing_state: nil, expires_on: nil) number = number.to_s.strip.presence kind = kind.to_s.strip.presence + issuing_state = issuing_state.to_s.strip.presence + expires_on = expires_on.presence current = professional_license person = event_registration.registrant @@ -54,7 +56,7 @@ def assign_license(number:, kind:) if match self.professional_license = match else - current.update!(number: number, kind: kind) + current.update!(number: number, kind: kind, issuing_state: issuing_state, expires_on: expires_on) end end diff --git a/app/views/continuing_education_registrations/edit.html.erb b/app/views/continuing_education_registrations/edit.html.erb index a6a1273d4a..0fad8c4fcc 100644 --- a/app/views/continuing_education_registrations/edit.html.erb +++ b/app/views/continuing_education_registrations/edit.html.erb @@ -55,6 +55,17 @@ placeholder: "e.g. 12345", class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %>
+
+ + <%= f.text_field :license_issuing_state, value: license&.issuing_state, id: "continuing_education_registration_license_issuing_state", + placeholder: "e.g. CA", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ + <%= f.date_field :license_expires_on, value: license&.expires_on, id: "continuing_education_registration_license_expires_on", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
<% if license&.number_known? %>

Editing the type or number corrects the license already on file.

diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index f1cb925d99..f2e91ad041 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -14,6 +14,8 @@ <% license = ce_registration&.professional_license %> <% license_kind = license&.kind %> <% license_number = license&.number %> + <% license_issuing_state = license&.issuing_state %> + <% license_expires_on = license&.expires_on %> <% license_on_file = license&.number_known? %> <%# Admin-only jump to the management surface for this CE registration. Hidden from registrants; opens in a new tab so the registrant view is kept. %> @@ -78,6 +80,14 @@
License number
<%= license_number %>
+
+
Issuing state
+
<%= license_issuing_state.presence || "—" %>
+
+
+
Expires
+
<%= license_expires_on&.to_fs(:long) || "—" %>
+
<%= link_to registration_ce_path(@event_registration.slug, editing: "license", return_to: params[:return_to].presence, anchor: "license"), class: "shrink-0 sm:ml-auto inline-flex items-center gap-1.5 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-50" do %> Edit @@ -86,23 +96,36 @@ <% else %> <%= form_with url: registration_ce_license_path(@event_registration.slug), method: :post, data: { turbo: false }, class: "mt-3" do |form| %> -
-
+
+
<%= form.text_field :license_kind, value: license_kind, id: "license_kind", placeholder: "e.g. LCSW", class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %>
-
+
<%= form.text_field :license_number, value: license_number, id: "license_number", placeholder: "e.g. 12345", class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %>
- <%= form.submit "Save changes", class: "shrink-0 self-end rounded-lg border border-transparent bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %> +
+ + <%= form.text_field :license_issuing_state, value: license_issuing_state, id: "license_issuing_state", + placeholder: "e.g. CA", + class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ + <%= form.date_field :license_expires_on, value: license_expires_on, id: "license_expires_on", + class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+
+ <%= form.submit "Save changes", class: "shrink-0 rounded-lg border border-transparent bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %> <% if license_on_file %> <%= link_to "Cancel", registration_ce_path(@event_registration.slug, return_to: params[:return_to].presence, anchor: "license"), - class: "shrink-0 self-end px-2 py-2 text-sm font-medium text-gray-500 hover:text-gray-700" %> + class: "shrink-0 px-2 py-2 text-sm font-medium text-gray-500 hover:text-gray-700" %> <% end %>
<% unless license_on_file %> diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index cb970893ac..fc48ccc867 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -18,17 +18,19 @@ expect(response.body).to include("Edit CE registration") end - it "updates hours, cost, and fills the placeholder license type + number in place" do + it "updates hours, cost, and fills the placeholder license type, number, state + expiry in place" do license = ce_registration.professional_license patch continuing_education_registration_path(ce_registration), - params: { continuing_education_registration: { hours: "4.5", cost_dollars: "90", license_kind: "LMFT", license_number: "555" } } + params: { continuing_education_registration: { hours: "4.5", cost_dollars: "90", license_kind: "LMFT", + license_number: "555", license_issuing_state: "CA", license_expires_on: "2027-01-31" } } ce_registration.reload expect(ce_registration.hours).to eq(4.5) expect(ce_registration.cost_cents).to eq(9_000) # Placeholder is filled in place rather than orphaned. expect(ce_registration.professional_license).to eq(license) - expect(license.reload).to have_attributes(kind: "LMFT", number: "555") + expect(license.reload).to have_attributes(kind: "LMFT", number: "555", + issuing_state: "CA", expires_on: Date.new(2027, 1, 31)) end it "edits the same license in place when correcting a typo (no new record)" do diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index 98b5723f0a..f951751896 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -293,16 +293,20 @@ describe "POST /registration/:slug/ce/license" do let!(:registration) { create(:event_registration, event: event, registrant: user.person) } - it "saves a license type and number entered on the callout" do + it "saves the license type, number, issuing state, and expiry entered on the callout" do license = create(:professional_license, :placeholder, person: registration.registrant) create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) - post registration_ce_license_path(registration.slug), params: { license_kind: "LMFT", license_number: "7788" } + post registration_ce_license_path(registration.slug), + params: { license_kind: "LMFT", license_number: "7788", + license_issuing_state: "CA", license_expires_on: "2027-01-31" } expect(response).to redirect_to(registration_ce_path(registration.slug)) saved = registration.continuing_education_registrations.first.professional_license expect(saved.kind).to eq("LMFT") expect(saved.number).to eq("7788") + expect(saved.issuing_state).to eq("CA") + expect(saved.expires_on).to eq(Date.new(2027, 1, 31)) end end From 571b83d8c876c751235cd2f7e4805b558f7c166d Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 30 Jun 2026 07:25:33 -0400 Subject: [PATCH 21/46] Match the card prompts and retire the CE-credit Stimulus controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add scholarship link was fuchsia/text-sm; recolor to grey/text-xs so it matches the sibling Connect organization prompt. - The CE card's empty (toggle-only) state had no bottom prompt, leaving it barren next to the other two — add a grey hint pinned to the bottom. CE has no separate award page (saving with Requested on creates the record), so it's a hint rather than a link. - The CE box no longer needs JS: drop the ce-credit-requested Stimulus controller (and its index.js registration / AGENTS.md count) along with the data-controller wiring, and recolor the Requested toggle's checked state. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 2 +- .../ce_credit_requested_controller.js | 31 ------------------- app/frontend/javascript/controllers/index.js | 3 -- .../_continuing_education.html.erb | 17 +++++----- .../event_registrations/_scholarship.html.erb | 4 +-- 5 files changed, 11 insertions(+), 46 deletions(-) delete mode 100644 app/frontend/javascript/controllers/ce_credit_requested_controller.js diff --git a/AGENTS.md b/AGENTS.md index 4202837e91..cf0a48209a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ This codebase (Rails 8.1) | Directory | Purpose | |---|---| | `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) | -| `app/frontend/javascript/controllers/` | Stimulus controllers (74) | +| `app/frontend/javascript/controllers/` | Stimulus controllers (73) | | `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) | | `app/frontend/stylesheets/` | Tailwind CSS and component styles | diff --git a/app/frontend/javascript/controllers/ce_credit_requested_controller.js b/app/frontend/javascript/controllers/ce_credit_requested_controller.js deleted file mode 100644 index a904e82643..0000000000 --- a/app/frontend/javascript/controllers/ce_credit_requested_controller.js +++ /dev/null @@ -1,31 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Drives the CE "Requested" toggle on the registration form, mirroring the -// scholarship toggle. Colors the track to signal save state — amber while the -// choice is pending (changed but not yet saved), the continuing-education theme -// color (teal) once it matches the stored "on" value, neutral gray when off — -// and highlights the whole card with a teal ring while it's on. -export default class extends Controller { - static targets = ["checkbox", "track", "box"] - static values = { initial: Boolean } - - connect() { - this.refresh() - } - - refresh() { - if (!this.hasCheckboxTarget) return - - const checked = this.checkboxTarget.checked - const pending = checked !== this.initialValue - - this.trackTarget.classList.toggle("bg-amber-500", pending) - this.trackTarget.classList.toggle("bg-teal-600", checked && !pending) - this.trackTarget.classList.toggle("bg-gray-200", !checked && !pending) - - if (this.hasBoxTarget) { - this.boxTarget.classList.toggle("ring-2", checked) - this.boxTarget.classList.toggle("ring-teal-400", checked) - } - } -} diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 62b3cebdea..dd5388eb35 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -144,9 +144,6 @@ application.register("scholarship-requested", ScholarshipRequestedController) import ScholarshipStatusToggleController from "./scholarship_status_toggle_controller" application.register("scholarship-status-toggle", ScholarshipStatusToggleController) -import CeCreditRequestedController from "./ce_credit_requested_controller" -application.register("ce-credit-requested", CeCreditRequestedController) - import SearchBoxController from "./search_box_controller" application.register("search-box", SearchBoxController) diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb index 7767ba5bac..7e353e857a 100644 --- a/app/views/event_registrations/_continuing_education.html.erb +++ b/app/views/event_registrations/_continuing_education.html.erb @@ -3,10 +3,7 @@ registration stub, which is then filled in on its own edit page. Once a record exists the toggle is gone and we show the record + an Edit link. Only rendered for CE-eligible events. ---- %> -
+
@@ -22,16 +19,18 @@ name="event_registration[ce_requested]" value="1" <%= "checked" if event_registration.ce_requested? %> - data-ce-credit-requested-target="checkbox" - data-action="ce-credit-requested#refresh" class="sr-only peer"> - + Requested <%# No license picker here — saving creates the record against the registrant's - license (or an empty placeholder). The license is then shown below and - editable on the CE registration's own page. %> + license (or an empty placeholder), then it's editable on its own page. The + grey prompt below mirrors the scholarship/organizations cards' bottom links; + CE has no separate award page, so it's a hint rather than a link. %> +

+ Save with this on to start a CE registration, then add the license and hours. +

<% else %> <% license = ce_registration.professional_license %>
diff --git a/app/views/event_registrations/_scholarship.html.erb b/app/views/event_registrations/_scholarship.html.erb index 222c7a002c..0c43bb642e 100644 --- a/app/views/event_registrations/_scholarship.html.erb +++ b/app/views/event_registrations/_scholarship.html.erb @@ -71,9 +71,9 @@ <% else %>
<%= link_to new_scholarship_path(allocatable_sgid: event_registration.to_sgid.to_s, return_to: "registration"), - class: "inline-flex items-center gap-1.5 text-sm font-medium #{DomainTheme.text_class_for(:scholarships, intensity: 600)} hover:underline", + class: "inline-flex items-center gap-1.5 self-start rounded-md px-2 py-1 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700", target: "_blank", rel: "noopener" do %> - + Add scholarship <% end %> From e78cd107922af5459f17fc4527fee03f8865e256 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 30 Jun 2026 07:48:21 -0400 Subject: [PATCH 22/46] Add deliberate "Add CE registration" flow + universal number formatter Add CE registration flow (mirrors scholarship's new/create): - New/create actions + routes + policy; a "+ Add CE registration" link on the registration card opens a prefilled new form in a new tab and returns to the registration. The Requested toggle still auto-creates a stub on save; this is the alternative where the admin fills license/hours/cost up front. - Extract the CE-details form fields into a shared _details_section partial used by both new and edit. Universal number formatter: - Replace ContinuingEducationRegistration.format_hours (CE-specific) and the EventsHelper#ce_hours_display wrapper with a NumberFormatter PORO + a generic plain_number helper, mirroring MoneyFormatter/dollars_from_cents. Any model, service, or view can now format a trailing-zero-free number one way. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 4 +- ...uing_education_registrations_controller.rb | 65 ++++++++++++++-- app/controllers/events_controller.rb | 2 +- app/helpers/application_helper.rb | 17 +++++ app/helpers/events_helper.rb | 5 -- .../continuing_education_registration.rb | 8 -- ...ontinuing_education_registration_policy.rb | 2 + app/services/magic_ticket_callouts.rb | 2 +- app/services/number_formatter.rb | 15 ++++ .../_details_section.html.erb | 75 +++++++++++++++++++ .../edit.html.erb | 74 +----------------- .../new.html.erb | 36 +++++++++ .../_continuing_education.html.erb | 26 ++++--- app/views/events/callouts/ce.html.erb | 2 +- app/views/events/onboarding/_row.html.erb | 2 +- config/routes.rb | 2 +- ...continuing_education_registrations_spec.rb | 25 +++++++ spec/services/number_formatter_spec.rb | 17 +++++ spec/views/page_bg_class_alignment_spec.rb | 1 + 19 files changed, 269 insertions(+), 111 deletions(-) create mode 100644 app/services/number_formatter.rb create mode 100644 app/views/continuing_education_registrations/_details_section.html.erb create mode 100644 app/views/continuing_education_registrations/new.html.erb create mode 100644 spec/services/number_formatter_spec.rb diff --git a/AGENTS.md b/AGENTS.md index cf0a48209a..7cce58a036 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 | @@ -71,7 +71,7 @@ This codebase (Rails 8.1) | Directory | Purpose | |---|---| | `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) | -| `app/frontend/javascript/controllers/` | Stimulus controllers (73) | +| `app/frontend/javascript/controllers/` | Stimulus controllers (72) | | `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) | | `app/frontend/stylesheets/` | Tailwind CSS and component styles | diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb index 83c5d517d1..531589dbb7 100644 --- a/app/controllers/continuing_education_registrations_controller.rb +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -1,5 +1,32 @@ class ContinuingEducationRegistrationsController < ApplicationController - before_action :set_ce_registration + before_action :set_ce_registration, except: [ :new, :create ] + before_action :set_event_registration, only: [ :new, :create ] + + # Deliberate "Add CE registration" path, mirroring scholarship's new/create. + # The "Requested" toggle on the registration form still auto-creates a stub on + # save; this is the alternative where the admin fills in license/hours/cost up + # front. Hours/cost prefill from the event's offering. + def new + @ce_registration = @event_registration.continuing_education_registrations.build( + hours: @event_registration.event.ce_hours_offered, + cost_cents: @event_registration.event.ce_hours_cost_cents + ) + authorize! @ce_registration + end + + def create + @ce_registration = @event_registration.continuing_education_registrations.build(professional_license: license_for_create) + authorize! @ce_registration + apply_ce_params(@ce_registration) + + if @ce_registration.save + @event_registration.update_column(:ce_requested, true) + redirect_to registration_path, notice: "CE registration created.", status: :see_other + else + flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence + render :new, status: :unprocessable_content + end + end def edit authorize! @ce_registration @@ -7,13 +34,7 @@ def edit def update authorize! @ce_registration - @ce_registration.assign_license(number: params.dig(:continuing_education_registration, :license_number), - kind: params.dig(:continuing_education_registration, :license_kind), - issuing_state: params.dig(:continuing_education_registration, :license_issuing_state), - expires_on: params.dig(:continuing_education_registration, :license_expires_on)) - @ce_registration.hours = params.dig(:continuing_education_registration, :hours) - cost = params.dig(:continuing_education_registration, :cost_dollars) - @ce_registration.cost_cents = (cost.to_d * 100).round if cost.present? + apply_ce_params(@ce_registration) if @ce_registration.save redirect_to registration_path, notice: "CE registration updated.", status: :see_other @@ -55,6 +76,34 @@ def set_ce_registration @ce_registration = ContinuingEducationRegistration.find(params[:id]) end + # The registration a new CE record attaches to, located from the signed global + # id the "Add CE registration" link carries (mirrors scholarship's allocatable). + def set_event_registration + sgid = params[:allocatable_sgid] || params.dig(:continuing_education_registration, :allocatable_sgid) + @event_registration = GlobalID::Locator.locate_signed(sgid) if sgid + redirect_to root_path, alert: "Registration not found.", status: :see_other unless @event_registration + end + + # License a brand-new CE registration attaches to: the registrant's existing + # license, else an empty placeholder. assign_license then fills it from the + # submitted type/number/state/expiry. Mirrors EventRegistrationsController. + def license_for_create + @event_registration.registrant.professional_licenses.first || + ProfessionalLicense.find_or_create_for(person: @event_registration.registrant) + end + + # Apply the submitted license fields, hours, and cost to a CE registration. + # Shared by create and update so both read params the same way. + def apply_ce_params(ce_registration) + ce_registration.assign_license(number: params.dig(:continuing_education_registration, :license_number), + kind: params.dig(:continuing_education_registration, :license_kind), + issuing_state: params.dig(:continuing_education_registration, :license_issuing_state), + expires_on: params.dig(:continuing_education_registration, :license_expires_on)) + ce_registration.hours = params.dig(:continuing_education_registration, :hours) + cost = params.dig(:continuing_education_registration, :cost_dollars) + ce_registration.cost_cents = (cost.to_d * 100).round if cost.present? + end + def registration_path edit_event_registration_path(@ce_registration.event_registration) end diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 823b50bbaf..329f74845d 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -638,7 +638,7 @@ def onboarding_csv_row(registration, cost_required, day_count, include_ce = fals if include_ce ce_hours = registration.ce_hours_total row << (registration.ce_requested? ? "Yes" : "No") - row << (ce_hours.positive? ? ContinuingEducationRegistration.format_hours(ce_hours) : "") + row << (ce_hours.positive? ? helpers.plain_number(ce_hours) : "") row << (registration.ce_amount_owed_cents.positive? ? helpers.dollars_from_cents(registration.ce_amount_owed_cents) : "") row << registration.ce_license_numbers.join("; ") end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5d84242875..9ddd593cb2 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -6,6 +6,17 @@ module ApplicationHelper FORM_LABEL_TAGS = %w[br a p span strong b em i u h1 h2 h3 h4 h5 h6 ul ol li font].freeze FORM_LABEL_ATTRIBUTES = %w[href target rel style size color face].freeze + # Tint a section-header icon (the rounded square in a card header) with its + # domain theme colour only when that section actually holds data, falling back + # to a muted grey otherwise. Lets the registration edit cards signal at a glance + # which sections are populated — purely server-rendered, no JS. Pass the domain + # key (see DomainTheme::COLORS) and a boolean for whether the section has data. + def section_icon_class(domain, active) + return "bg-gray-100 text-gray-400" unless active + + "#{DomainTheme.bg_class_for(domain, intensity: 100)} #{DomainTheme.text_class_for(domain, intensity: 600)}" + end + # Render a form field name / header with a safe subset of HTML allowed. # Uses Rails' SafeListSanitizer, which strips dangerous URL schemes # (e.g. javascript:) from href and CSS-scrubs the style attribute (dropping @@ -451,6 +462,12 @@ def dollars_from_cents(cents) MoneyFormatter.dollars_from_cents(cents) end + # A plain number without insignificant trailing zeros (e.g. CE hours): 6.0 → "6", + # 1.5 → "1.5". Nil for a blank input so callers can render their own placeholder. + def plain_number(number) + NumberFormatter.plain(number) + end + def navbar_bg_class if staging_environment? && !params[:nav_bg_primary].present? "bg-red-600" diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index c2855a2f35..2871b44ba8 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -1,9 +1,4 @@ module EventsHelper - # Display a CE hours figure without trailing zeros (e.g. "6", "1.5"). - def ce_hours_display(hours) - ContinuingEducationRegistration.format_hours(hours) - end - # Stable anchor id for a registrant's row on the Onboarding matrix, so back-links # from detail pages can scroll to (and highlight) the row they came from. def onboarding_row_id(record_or_id) diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index ffb683b312..e0b922f9be 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -20,14 +20,6 @@ class ContinuingEducationRegistration < ApplicationRecord # Payment interface (allocations_sum / paid_in_full? / remaining_cost / …) comes from # Registerable, driven by this record's own cost_cents column. - # Display a CE hours figure without trailing zeros: "6", "1.5". - def self.format_hours(hours) - return if hours.blank? - - number = hours.to_f - number == number.to_i ? number.to_i.to_s : number.to_s - end - # CE certificate eligibility — its own rule (not shared): the event grants CE, # the registrant attended, the training has ended, and the CE balance is paid. def certificate_available? diff --git a/app/policies/continuing_education_registration_policy.rb b/app/policies/continuing_education_registration_policy.rb index ed3c582566..4e9cf4dd7d 100644 --- a/app/policies/continuing_education_registration_policy.rb +++ b/app/policies/continuing_education_registration_policy.rb @@ -2,6 +2,8 @@ class ContinuingEducationRegistrationPolicy < ApplicationPolicy # The CE registration edit page (license/hours/cost, certificate issuance, # removal) is an admin management surface, like scholarships. Registrants edit # their own license number via the public CE callout, not here. + def new? = admin? + def create? = admin? def edit? = admin? def update? = admin? def destroy? = admin? diff --git a/app/services/magic_ticket_callouts.rb b/app/services/magic_ticket_callouts.rb index 873e85ed9e..2d5182bee5 100644 --- a/app/services/magic_ticket_callouts.rb +++ b/app/services/magic_ticket_callouts.rb @@ -136,7 +136,7 @@ def ce_hours_subtitle total = registration.ce_hours_total return "Continuing education credit" unless total.positive? - "#{ContinuingEducationRegistration.format_hours(total)} hours" + "#{NumberFormatter.plain(total)} hours" end # Teal "$X due" once hours + license are on file and money is owed; otherwise an diff --git a/app/services/number_formatter.rb b/app/services/number_formatter.rb new file mode 100644 index 0000000000..85e4cecaec --- /dev/null +++ b/app/services/number_formatter.rb @@ -0,0 +1,15 @@ +# Centralizes how a plain number renders without insignificant trailing zeros, so +# models, services, helpers, and decorators all format the same way (e.g. CE hours: +# 6.0 => "6", 1.5 => "1.5", 0.25 => "0.25"). View code should call the +# `plain_number` helper, which delegates here; models and other POROs (no +# view-helper access) call NumberFormatter directly. Mirrors MoneyFormatter. +class NumberFormatter + # The number as a string with trailing zeros dropped. Nil for a blank input so + # callers can render their own placeholder. + def self.plain(number) + return if number.blank? + + value = number.to_f + value == value.to_i ? value.to_i.to_s : value.to_s + end +end diff --git a/app/views/continuing_education_registrations/_details_section.html.erb b/app/views/continuing_education_registrations/_details_section.html.erb new file mode 100644 index 0000000000..b25ccb05c7 --- /dev/null +++ b/app/views/continuing_education_registrations/_details_section.html.erb @@ -0,0 +1,75 @@ +<%# CE details form fields (license type/number/state/expiry + hours + cost), + shared by the new and edit pages. Renders inside a form_with block — `f` is the + builder. Locals: f, license (may be nil on new), ce_registration, event. %> +
+
+ + + +

CE details

+
+ +
+
+
+
+ + <%= f.text_field :license_kind, value: license&.kind, id: "continuing_education_registration_license_kind", + placeholder: "e.g. LCSW", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ + <%= f.text_field :license_number, value: license&.number, id: "continuing_education_registration_license_number", + placeholder: "e.g. 12345", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ + <%= f.text_field :license_issuing_state, value: license&.issuing_state, id: "continuing_education_registration_license_issuing_state", + placeholder: "e.g. CA", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ + <%= f.date_field :license_expires_on, value: license&.expires_on, id: "continuing_education_registration_license_expires_on", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ <% if license&.number_known? %> +

Editing the type or number corrects the license already on file.

+ <% else %> +

No license on file yet — add the type and number here.

+ <% end %> +
+ +
+
+
+ + <%= f.number_field :hours, value: plain_number(ce_registration.hours), min: 0, step: 0.25, + id: "continuing_education_registration_hours", + class: "w-32 rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ + <%= f.number_field :cost_dollars, value: (ce_registration.cost_cents.to_d / 100), min: 0, step: 0.01, + id: "continuing_education_registration_cost_dollars", + class: "w-32 rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +
+
+ + <%# Event baseline both fields default to (snapshotted onto the registration + at creation). Sits tight under the row like a hint, but applies to both + fields so an admin can see the standard offering and tell when this + registrant's hours/cost have been customized. %> + <% if event.ce_hours_offered.present? || event.ce_hours_cost_cents.present? %> +

+ Event default: + <%= plain_number(event.ce_hours_offered) || "—" %> hrs + · <%= event.ce_hours_cost_cents.present? ? dollars_from_cents(event.ce_hours_cost_cents) : "—" %> +

+ <% end %> +
+
+
diff --git a/app/views/continuing_education_registrations/edit.html.erb b/app/views/continuing_education_registrations/edit.html.erb index 0fad8c4fcc..b2c8aca91c 100644 --- a/app/views/continuing_education_registrations/edit.html.erb +++ b/app/views/continuing_education_registrations/edit.html.erb @@ -32,79 +32,7 @@
<%= form_with model: @ce_registration, url: continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), method: :patch, id: "ce_registration_form", data: { turbo: false } do |f| %> -
-
- - - -

CE details

-
- -
-
-
-
- - <%= f.text_field :license_kind, value: license&.kind, id: "continuing_education_registration_license_kind", - placeholder: "e.g. LCSW", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> -
-
- - <%= f.text_field :license_number, value: license&.number, id: "continuing_education_registration_license_number", - placeholder: "e.g. 12345", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> -
-
- - <%= f.text_field :license_issuing_state, value: license&.issuing_state, id: "continuing_education_registration_license_issuing_state", - placeholder: "e.g. CA", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> -
-
- - <%= f.date_field :license_expires_on, value: license&.expires_on, id: "continuing_education_registration_license_expires_on", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> -
-
- <% if license&.number_known? %> -

Editing the type or number corrects the license already on file.

- <% else %> -

No license on file yet — add the type and number here.

- <% end %> -
- -
-
-
- - <%= f.number_field :hours, value: ContinuingEducationRegistration.format_hours(@ce_registration.hours), min: 0, step: 0.25, - id: "continuing_education_registration_hours", - class: "w-32 rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> -
-
- - <%= f.number_field :cost_dollars, value: (@ce_registration.cost_cents.to_d / 100), min: 0, step: 0.01, - id: "continuing_education_registration_cost_dollars", - class: "w-32 rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> -
-
- - <%# Event baseline both fields default to (snapshotted onto the registration - at creation). Sits tight under the row like a hint, but applies to both - fields so an admin can see the standard offering and tell when this - registrant's hours/cost have been customized. %> - <% event = registration.event %> - <% if event.ce_hours_offered.present? || event.ce_hours_cost_cents.present? %> -

- Event default: - <%= ContinuingEducationRegistration.format_hours(event.ce_hours_offered) || "—" %> hrs - · <%= event.ce_hours_cost_cents.present? ? dollars_from_cents(event.ce_hours_cost_cents) : "—" %> -

- <% end %> -
-
-
+ <%= render "details_section", f: f, license: license, ce_registration: @ce_registration, event: registration.event %> <% end %>
diff --git a/app/views/continuing_education_registrations/new.html.erb b/app/views/continuing_education_registrations/new.html.erb new file mode 100644 index 0000000000..a316fe4766 --- /dev/null +++ b/app/views/continuing_education_registrations/new.html.erb @@ -0,0 +1,36 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% registration = @event_registration %> +<% event = registration.event %> + +
+ <%# Top bar: back link + Home, matching the CE edit page. %> +
+ <%= link_to edit_event_registration_path(registration), class: "text-sm text-gray-500 hover:text-gray-700" do %> + Registration + <% end %> + <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700" %> +
+ + <%= render "shared/event_page_header", + title: "Add CE registration", + icon: "fa-solid fa-certificate", + color: :continuing_education, + event: event, + person: registration.registrant %> + +
+ <%= form_with model: @ce_registration, url: continuing_education_registrations_path, + method: :post, id: "ce_registration_form", data: { turbo: false } do |f| %> + <%= hidden_field_tag :allocatable_sgid, registration.to_sgid.to_s %> + <%= render "details_section", f: f, license: @ce_registration.professional_license, ce_registration: @ce_registration, event: event %> + <% end %> + + <%# ---- Footer actions ---- %> +
+
+ <%= link_to "Cancel", edit_event_registration_path(registration), class: "btn btn-secondary-outline" %> + +
+
+
+
diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb index 7e353e857a..f7e4cb62b0 100644 --- a/app/views/event_registrations/_continuing_education.html.erb +++ b/app/views/event_registrations/_continuing_education.html.erb @@ -5,7 +5,7 @@ for CE-eligible events. ---- %>
- +

Continuing education

@@ -20,24 +20,30 @@ value="1" <%= "checked" if event_registration.ce_requested? %> class="sr-only peer"> - + Requested - <%# No license picker here — saving creates the record against the registrant's - license (or an empty placeholder), then it's editable on its own page. The - grey prompt below mirrors the scholarship/organizations cards' bottom links; - CE has no separate award page, so it's a hint rather than a link. %> -

- Save with this on to start a CE registration, then add the license and hours. -

+ <%# Two ways to create the record, mirroring the scholarship card: the + Requested toggle above auto-creates a stub on save, while "Add CE + registration" opens the full new form (license/hours/cost) in a new tab + and returns here. %> +
+ <%= link_to new_continuing_education_registration_path(allocatable_sgid: event_registration.to_sgid.to_s, return_to: "registration"), + class: "inline-flex items-center gap-1.5 self-start rounded-md px-2 py-1 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700", + target: "_blank", rel: "noopener" do %> + + Add CE registration + + <% end %> +
<% else %> <% license = ce_registration.professional_license %>
<%= dollars_from_cents(ce_registration.cost_cents) %> · - <%= ContinuingEducationRegistration.format_hours(ce_registration.hours) || "0" %> hrs + <%= plain_number(ce_registration.hours) || "0" %> hrs <%= link_to edit_continuing_education_registration_path(ce_registration, return_to: "registration"), class: "ml-auto inline-flex items-center gap-1.5 text-xs font-medium text-gray-500 hover:text-gray-700 hover:underline", target: "_blank", rel: "noopener" do %> diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index f2e91ad041..e7d0b6f2f5 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -34,7 +34,7 @@
Hours
-
<%= ce_hours_display(@event_registration.ce_hours_total) || "—" %>
+
<%= plain_number(@event_registration.ce_hours_total) || "—" %>
Cost
diff --git a/app/views/events/onboarding/_row.html.erb b/app/views/events/onboarding/_row.html.erb index 3581d2a672..6177e81b70 100644 --- a/app/views/events/onboarding/_row.html.erb +++ b/app/views/events/onboarding/_row.html.erb @@ -219,7 +219,7 @@ <% when :ce_hours %> <% ce_hours = registration.ce_hours_total.to_f %> <%= ce_hours.positive? ? "text-gray-800" : "text-gray-400" %>" data-sort-value="<%= ce_hours %>"> - <%= link_to (ce_hours.positive? ? ce_hours_display(ce_hours) : "—"), edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:underline #{ce_hours.positive? ? "text-gray-800" : "text-gray-400"}", data: { turbo_frame: "_top" } %> + <%= link_to (ce_hours.positive? ? plain_number(ce_hours) : "—"), edit_event_registration_path(registration, return_to: "onboarding"), class: "hover:underline #{ce_hours.positive? ? "text-gray-800" : "text-gray-400"}", data: { turbo_frame: "_top" } %> <% when :ce_amount %> diff --git a/config/routes.rb b/config/routes.rb index cf4b30b891..e128981e31 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -118,7 +118,7 @@ resources :scholarships, only: [ :index, :new, :create, :show, :edit, :update, :destroy ] do member { patch :toggle_tasks } end - resources :continuing_education_registrations, only: [ :edit, :update, :destroy ] do + resources :continuing_education_registrations, only: [ :new, :create, :edit, :update, :destroy ] do member { patch :toggle_certificate } end resources :discounts, only: [ :create, :show, :destroy ] do diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index fc48ccc867..6c9a876a07 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -66,6 +66,31 @@ expect(ce_registration.reload.certificate_sent_at).to be_nil end + it "renders the new page for a registration" do + get new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Add CE registration") + end + + it "creates a CE registration with license, hours, and cost, and sets the flag" do + registration + + expect { + post continuing_education_registrations_path, + params: { allocatable_sgid: registration.to_sgid.to_s, + continuing_education_registration: { hours: "4.5", cost_dollars: "90", license_kind: "LMFT", + license_number: "555", license_issuing_state: "CA", license_expires_on: "2027-01-31" } } + }.to change { registration.continuing_education_registrations.count }.by(1) + + ce = registration.continuing_education_registrations.last + expect(response).to redirect_to(edit_event_registration_path(registration)) + expect(ce.hours).to eq(4.5) + expect(ce.cost_cents).to eq(9_000) + expect(ce.professional_license).to have_attributes(kind: "LMFT", number: "555", + issuing_state: "CA", expires_on: Date.new(2027, 1, 31)) + expect(registration.reload.ce_requested).to be(true) + end + it "removes a CE registration with no payments and clears the flag" do ce_registration delete continuing_education_registration_path(ce_registration) diff --git a/spec/services/number_formatter_spec.rb b/spec/services/number_formatter_spec.rb new file mode 100644 index 0000000000..d1b4b6b650 --- /dev/null +++ b/spec/services/number_formatter_spec.rb @@ -0,0 +1,17 @@ +require "rails_helper" + +RSpec.describe NumberFormatter do + describe ".plain" do + it "drops insignificant trailing zeros" do + expect(described_class.plain(6.0)).to eq("6") + expect(described_class.plain(1.5)).to eq("1.5") + expect(described_class.plain(0.25)).to eq("0.25") + expect(described_class.plain(BigDecimal("6"))).to eq("6") + end + + it "returns nil for a blank input" do + expect(described_class.plain(nil)).to be_nil + expect(described_class.plain("")).to be_nil + end + end +end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 26fa7d4abb..7aa69257cd 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -171,6 +171,7 @@ "app/views/community_news/edit.html.erb" => "admin-only bg-blue-100", "app/views/event_registrations/edit.html.erb" => "admin-only bg-blue-100", "app/views/continuing_education_registrations/edit.html.erb" => "admin-only bg-blue-100", + "app/views/continuing_education_registrations/new.html.erb" => "admin-only bg-blue-100", "app/views/events/edit.html.erb" => "admin-only bg-blue-100", "app/views/forms/edit.html.erb" => "admin-only bg-blue-100", "app/views/forms/edit_sections.html.erb" => "admin-only bg-blue-100", From fee5b087a8089a961f9eb23d780339a8f84397fa Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 30 Jun 2026 07:53:21 -0400 Subject: [PATCH 23/46] Add a license picker to the CE registration form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the registrant already holds licenses, the new/edit CE form now shows a License dropdown: pick an existing one to use it as-is, or "Create new license" to add one from the typed fields. Plain server-rendered select (no JS), so the fields stay the source of truth for a new/edited license while the dropdown only chooses which record to write — a CE registration still never exists without a license. With no license on file there's no picker and the fields create the first one, as before. Co-Authored-By: Claude Fable 5 --- ...uing_education_registrations_controller.rb | 4 ++- .../continuing_education_registration.rb | 35 +++++++++++++------ .../_details_section.html.erb | 16 +++++++++ ...continuing_education_registrations_spec.rb | 34 ++++++++++++++++++ 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb index 531589dbb7..6cbc5b4bfe 100644 --- a/app/controllers/continuing_education_registrations_controller.rb +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -8,6 +8,7 @@ class ContinuingEducationRegistrationsController < ApplicationController # front. Hours/cost prefill from the event's offering. def new @ce_registration = @event_registration.continuing_education_registrations.build( + professional_license: @event_registration.registrant.professional_licenses.first, hours: @event_registration.event.ce_hours_offered, cost_cents: @event_registration.event.ce_hours_cost_cents ) @@ -98,7 +99,8 @@ def apply_ce_params(ce_registration) ce_registration.assign_license(number: params.dig(:continuing_education_registration, :license_number), kind: params.dig(:continuing_education_registration, :license_kind), issuing_state: params.dig(:continuing_education_registration, :license_issuing_state), - expires_on: params.dig(:continuing_education_registration, :license_expires_on)) + expires_on: params.dig(:continuing_education_registration, :license_expires_on), + license_id: params.dig(:continuing_education_registration, :professional_license_id)) ce_registration.hours = params.dig(:continuing_education_registration, :hours) cost = params.dig(:continuing_education_registration, :cost_dollars) ce_registration.cost_cents = (cost.to_d * 100).round if cost.present? diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index e0b922f9be..5e29e64f45 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -29,14 +29,19 @@ def certificate_available? event.end_date&.past? && event_registration.attended? && paid_in_full? end - # Point this registration at the registrant's license for the typed type + - # number, editing the current license in place — filling a blank placeholder and - # fixing a typo both just correct this one record (and its PaperTrail history). - # The exception: if the typed number already belongs to another license this - # person holds, link to that one rather than duplicating or colliding on the - # unique (person, number) index. Does not save the registration itself — callers - # persist it alongside their other changes. - def assign_license(number:, kind:, issuing_state: nil, expires_on: nil) + # Point this registration at a license for the typed type + number. `license_id` + # comes from the form's license picker (shown when the registrant holds licenses): + # * an existing license other than the current one → just use it as-is (the + # typed fields are ignored — you switch licenses, you don't edit the one you + # switched to here); + # * "new" → create a brand-new license for the person from the typed fields; + # * blank, or the current license → correct the current license in place + # (filling a blank placeholder or fixing a typo). + # In every "write the typed fields" case, an existing license already holding the + # typed number wins, to avoid duplicating or colliding on the unique + # (person, number) index. Does not save the registration itself — callers persist + # it alongside their other changes. + def assign_license(number:, kind:, issuing_state: nil, expires_on: nil, license_id: nil) number = number.to_s.strip.presence kind = kind.to_s.strip.presence issuing_state = issuing_state.to_s.strip.presence @@ -44,11 +49,21 @@ def assign_license(number:, kind:, issuing_state: nil, expires_on: nil) current = professional_license person = event_registration.registrant - match = person.professional_licenses.where.not(id: current.id).find_by(number: number) if number + if license_id.present? && license_id != "new" && license_id.to_s != current&.id.to_s + picked = person.professional_licenses.find_by(id: license_id) + if picked + self.professional_license = picked + return + end + end + + match = person.professional_licenses.where.not(id: current&.id).find_by(number: number) if number if match self.professional_license = match else - current.update!(number: number, kind: kind, issuing_state: issuing_state, expires_on: expires_on) + target = license_id == "new" ? person.professional_licenses.new : current + target.update!(number: number, kind: kind, issuing_state: issuing_state, expires_on: expires_on) + self.professional_license = target end end diff --git a/app/views/continuing_education_registrations/_details_section.html.erb b/app/views/continuing_education_registrations/_details_section.html.erb index b25ccb05c7..09e72ded80 100644 --- a/app/views/continuing_education_registrations/_details_section.html.erb +++ b/app/views/continuing_education_registrations/_details_section.html.erb @@ -1,6 +1,7 @@ <%# CE details form fields (license type/number/state/expiry + hours + cost), shared by the new and edit pages. Renders inside a form_with block — `f` is the builder. Locals: f, license (may be nil on new), ce_registration, event. %> +<% licenses = ce_registration.event_registration.registrant.professional_licenses.order(:kind, :number) %>
@@ -10,6 +11,21 @@
+ <%# License picker — shown once the registrant holds a license. Pick one to use + it as-is, or "Create new license" to add one from the fields below. With no + license on file there's no picker and the fields create the first one. %> + <% if licenses.any? %> +
+ + <%= f.select :professional_license_id, + options_for_select(licenses.map { |l| [ l.name, l.id ] } + [ [ "Create new license", "new" ] ], license&.id), + {}, + id: "continuing_education_registration_professional_license_id", + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> +

Pick an existing license to use it, or "Create new license" to add one from the fields below.

+
+ <% end %> +
diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index 6c9a876a07..8bdde16de7 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -91,6 +91,40 @@ expect(registration.reload.ce_requested).to be(true) end + it "renders the license picker on new once the registrant holds a license" do + create(:professional_license, person: registration.registrant, kind: "LMFT", number: "111") + get new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s) + expect(response.body).to include("professional_license_id") + expect(response.body).to include("Create new license") + end + + it "points the registration at a picked existing license without editing it" do + a = create(:professional_license, person: registration.registrant, kind: "LMFT", number: "111") + b = create(:professional_license, person: registration.registrant, kind: "LCSW", number: "222") + ce_registration.update!(professional_license: a) + + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { professional_license_id: b.id, + license_kind: "IGNORED", license_number: "999", hours: "6", cost_dollars: "120" } } + + expect(ce_registration.reload.professional_license).to eq(b) + expect(b.reload).to have_attributes(kind: "LCSW", number: "222") + end + + it "creates a new license when 'Create new license' is picked" do + a = create(:professional_license, person: registration.registrant, kind: "LMFT", number: "111") + ce_registration.update!(professional_license: a) + + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { professional_license_id: "new", + license_kind: "LPCC", license_number: "333", hours: "6", cost_dollars: "120" } } + }.to change(ProfessionalLicense, :count).by(1) + + expect(ce_registration.reload.professional_license).to have_attributes(kind: "LPCC", number: "333") + expect(a.reload).to have_attributes(kind: "LMFT", number: "111") + end + it "removes a CE registration with no payments and clears the flag" do ce_registration delete continuing_education_registration_path(ce_registration) From 595046bba7de588dead6894895ef791fabcbc985 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 30 Jun 2026 08:04:53 -0400 Subject: [PATCH 24/46] Tidy the CE registration form: one-line license row, optional markers, atomic create - Lay the four license fields in a single 4-col grid row (widen the form to max-w-3xl) so Expires no longer wraps; mark Issuing state + Expires "(optional)". - Make create atomic: a brand-new license is a build that persists with the CE registration in one transaction, so opening (then abandoning) the new form never leaves a stray placeholder license, and a failed save rolls back both. update is likewise transactional. Co-Authored-By: Claude Fable 5 --- ...uing_education_registrations_controller.rb | 35 +++++++++++-------- .../_details_section.html.erb | 14 ++++---- .../edit.html.erb | 2 +- .../new.html.erb | 2 +- ...continuing_education_registrations_spec.rb | 19 ++++++++++ 5 files changed, 48 insertions(+), 24 deletions(-) diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb index 6cbc5b4bfe..43e8b49496 100644 --- a/app/controllers/continuing_education_registrations_controller.rb +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -18,15 +18,18 @@ def new def create @ce_registration = @event_registration.continuing_education_registrations.build(professional_license: license_for_create) authorize! @ce_registration - apply_ce_params(@ce_registration) - if @ce_registration.save + # One transaction so the new license (a build until now) and the CE registration + # persist together — a failed save leaves neither behind. + ActiveRecord::Base.transaction do + apply_ce_params(@ce_registration) + @ce_registration.save! @event_registration.update_column(:ce_requested, true) - redirect_to registration_path, notice: "CE registration created.", status: :see_other - else - flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence - render :new, status: :unprocessable_content end + redirect_to registration_path, notice: "CE registration created.", status: :see_other + rescue ActiveRecord::RecordInvalid + flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence + render :new, status: :unprocessable_content end def edit @@ -35,14 +38,15 @@ def edit def update authorize! @ce_registration - apply_ce_params(@ce_registration) - if @ce_registration.save - redirect_to registration_path, notice: "CE registration updated.", status: :see_other - else - flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence - render :edit, status: :unprocessable_content + ActiveRecord::Base.transaction do + apply_ce_params(@ce_registration) + @ce_registration.save! end + redirect_to registration_path, notice: "CE registration updated.", status: :see_other + rescue ActiveRecord::RecordInvalid + flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence + render :edit, status: :unprocessable_content end # Removal mirrors scholarship's destroy but never cascades away a CE registration @@ -86,11 +90,12 @@ def set_event_registration end # License a brand-new CE registration attaches to: the registrant's existing - # license, else an empty placeholder. assign_license then fills it from the - # submitted type/number/state/expiry. Mirrors EventRegistrationsController. + # license, else an unsaved build. assign_license fills it from the submitted + # type/number/state/expiry, and it persists with the CE registration in create's + # transaction — so abandoning the new form never leaves a stray license behind. def license_for_create @event_registration.registrant.professional_licenses.first || - ProfessionalLicense.find_or_create_for(person: @event_registration.registrant) + @event_registration.registrant.professional_licenses.build end # Apply the submitted license fields, hours, and cost to a CE registration. diff --git a/app/views/continuing_education_registrations/_details_section.html.erb b/app/views/continuing_education_registrations/_details_section.html.erb index 09e72ded80..3fe9ae874d 100644 --- a/app/views/continuing_education_registrations/_details_section.html.erb +++ b/app/views/continuing_education_registrations/_details_section.html.erb @@ -27,27 +27,27 @@ <% end %>
-
-
+
+
<%= f.text_field :license_kind, value: license&.kind, id: "continuing_education_registration_license_kind", placeholder: "e.g. LCSW", class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %>
-
+
<%= f.text_field :license_number, value: license&.number, id: "continuing_education_registration_license_number", placeholder: "e.g. 12345", class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %>
-
- +
+ <%= f.text_field :license_issuing_state, value: license&.issuing_state, id: "continuing_education_registration_license_issuing_state", placeholder: "e.g. CA", class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %>
-
- +
+ <%= f.date_field :license_expires_on, value: license&.expires_on, id: "continuing_education_registration_license_expires_on", class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %>
diff --git a/app/views/continuing_education_registrations/edit.html.erb b/app/views/continuing_education_registrations/edit.html.erb index b2c8aca91c..52220a407b 100644 --- a/app/views/continuing_education_registrations/edit.html.erb +++ b/app/views/continuing_education_registrations/edit.html.erb @@ -2,7 +2,7 @@ <% registration = @ce_registration.event_registration %> <% license = @ce_registration.professional_license %> -
+
<%# Top bar: back link + secondary links, matching the scholarship edit page %>
<%= link_to edit_event_registration_path(registration), class: "text-sm text-gray-500 hover:text-gray-700" do %> diff --git a/app/views/continuing_education_registrations/new.html.erb b/app/views/continuing_education_registrations/new.html.erb index a316fe4766..1ce90560dd 100644 --- a/app/views/continuing_education_registrations/new.html.erb +++ b/app/views/continuing_education_registrations/new.html.erb @@ -2,7 +2,7 @@ <% registration = @event_registration %> <% event = registration.event %> -
+
<%# Top bar: back link + Home, matching the CE edit page. %>
<%= link_to edit_event_registration_path(registration), class: "text-sm text-gray-500 hover:text-gray-700" do %> diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index 8bdde16de7..180426d282 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -91,6 +91,25 @@ expect(registration.reload.ce_requested).to be(true) end + it "creates no license just from opening the new form" do + registration + expect { + get new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s) + }.not_to change(ProfessionalLicense, :count) + end + + it "leaves no orphan license when the create fails validation" do + registration + params = { allocatable_sgid: registration.to_sgid.to_s, + continuing_education_registration: { hours: "-5", license_kind: "LMFT", license_number: "555" } } + + expect { + post continuing_education_registrations_path, params: params + }.to change(ProfessionalLicense, :count).by(0) + expect(ContinuingEducationRegistration.count).to eq(0) + expect(response).to have_http_status(:unprocessable_content) + end + it "renders the license picker on new once the registrant holds a license" do create(:professional_license, person: registration.registrant, kind: "LMFT", number: "111") get new_continuing_education_registration_path(allocatable_sgid: registration.to_sgid.to_s) From 4d8db132c51a877a20320fc65908cc386d90f36b Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 30 Jun 2026 08:53:57 -0400 Subject: [PATCH 25/46] Unify the CE status badge across every surface The registrants index, CE callout, and CE card each computed their own CE status pill with drifting labels and colors. Consolidate onto one decorator method so the lifecycle reads identically everywhere: Requested -> License # needed -> $X due -> Pending -> Issued, with Pending blue, Issued green, and every actionable/in-progress state amber. The "$X due" state shows the real outstanding balance (cost net of payments), so it persists until paid rather than flipping on the first dollar. ?admin=true on the callout previews the post-payment Pending state without recording a payment, gated on edit access. Co-Authored-By: Claude Opus 4.8 --- .../event_registration_decorator.rb | 31 +++++++ app/models/event_registration.rb | 16 ++++ .../_ce_status_badge.html.erb | 23 +++++ .../_continuing_education.html.erb | 16 +--- .../events/_registrants_results.html.erb | 38 +++----- app/views/events/callouts/ce.html.erb | 63 +++++++------- .../event_registration_decorator_spec.rb | 86 +++++++++++++++++++ spec/models/event_registration_spec.rb | 27 ++++++ spec/requests/events/registrations_spec.rb | 26 +++++- spec/requests/events_spec.rb | 23 +++-- 10 files changed, 270 insertions(+), 79 deletions(-) create mode 100644 app/views/event_registrations/_ce_status_badge.html.erb create mode 100644 spec/decorators/event_registration_decorator_spec.rb diff --git a/app/decorators/event_registration_decorator.rb b/app/decorators/event_registration_decorator.rb index e46f6c7e31..bfb0f21d1e 100644 --- a/app/decorators/event_registration_decorator.rb +++ b/app/decorators/event_registration_decorator.rb @@ -1,4 +1,29 @@ class EventRegistrationDecorator < ApplicationDecorator + # The CE lifecycle badge, shared by every surface that shows CE status (the + # registrants index, the CE callout, the CE card on the registration edit page). + # One progression: Requested → License # needed → $X due → Pending → Issued. + # Pending is blue, Issued is green, every actionable/in-progress state is amber. + CeBadge = Struct.new(:label, :icon, :classes, keyword_init: true) + + CE_BADGE_CLASSES = { + green: "bg-green-50 text-green-700 border-green-200", + blue: "bg-blue-50 text-blue-700 border-blue-200", + amber: "bg-amber-50 text-amber-700 border-amber-200" + }.freeze + + # Nil when CE isn't in play (so the index can show a "Create" affordance instead). + # `simulate_paid:` lets the CE callout's ?admin=true preview the post-payment state + # without recording a payment. + def ce_status_badge(simulate_paid: false) + return unless ce_requested? || ce_registered? + return ce_badge("Requested", "fa-solid fa-clock", :amber) unless ce_registered? + return ce_badge("Issued", "fa-solid fa-circle-check", :green) if ce_certificate_issued? + return ce_badge("License # needed", "fa-solid fa-id-card", :amber) unless ce_license_provided? + return ce_badge("Pending", "fa-solid fa-hourglass-half", :blue) if ce_paid_in_full? || simulate_paid + + ce_badge("#{h.dollars_from_cents(ce_amount_due_cents)} due", "fa-solid fa-dollar-sign", :amber) + end + def title name end @@ -14,4 +39,10 @@ def default_display_image return event.primary_asset.file if event.respond_to?(:primary_asset) && event.primary_asset&.file&.attached? "theme_default.png" end + + private + + def ce_badge(label, icon, color) + CeBadge.new(label: label, icon: icon, classes: CE_BADGE_CLASSES.fetch(color)) + end end diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index dddd18fb4d..f032d0f04d 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -368,6 +368,14 @@ def ce_amount_owed_cents continuing_education_registrations.sum(:cost_cents) end + # Outstanding CE balance across this registration's CE registrations — cost net + # of payments/discounts, floored at zero. The "$X due" the registrant still owes; + # drops to zero once paid. remaining_cost is computed (not a column), so this sums + # in Ruby. + def ce_amount_due_cents + continuing_education_registrations.sum { |c| c.remaining_cost } + end + # True only when every CE registration has a known license number on file. def ce_license_provided? return false unless ce_registered? @@ -375,6 +383,14 @@ def ce_license_provided? continuing_education_registrations.all? { |c| c.professional_license&.number_known? } end + # True when CE is registered and every CE registration's certificate has been + # issued (sent) — the terminal state of the CE lifecycle. + def ce_certificate_issued? + return false unless ce_registered? + + continuing_education_registrations.all? { |c| c.certificate_sent_at.present? } + end + # True when a CE registration exists and every one is fully paid. def ce_paid_in_full? return false unless ce_registered? diff --git a/app/views/event_registrations/_ce_status_badge.html.erb b/app/views/event_registrations/_ce_status_badge.html.erb new file mode 100644 index 0000000000..2ae94fe973 --- /dev/null +++ b/app/views/event_registrations/_ce_status_badge.html.erb @@ -0,0 +1,23 @@ +<%# The canonical CE status badge. Locals: + registration – the EventRegistration + href – wrap the pill in a link to this path (optional) + simulate_paid – preview the post-payment state (CE callout's ?admin=true) + Renders nothing when CE isn't in play; callers handle that case (e.g. the + registrants index shows a "Create" affordance instead). %> +<% href = local_assigns[:href] %> +<% badge = registration.decorate.ce_status_badge(simulate_paid: local_assigns.fetch(:simulate_paid, false)) %> +<% if badge %> + <% base = "inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border text-xs font-medium #{badge.classes}" %> + <% if href %> + <%= link_to href, class: "#{base} px-5 py-0.5 transition hover:opacity-80 hover:shadow-sm", data: { turbo_frame: "_top" } do %> + + <%= badge.label %> + + <% end %> + <% else %> + + + <%= badge.label %> + + <% end %> +<% end %> diff --git a/app/views/event_registrations/_continuing_education.html.erb b/app/views/event_registrations/_continuing_education.html.erb index f7e4cb62b0..a6d56e56b7 100644 --- a/app/views/event_registrations/_continuing_education.html.erb +++ b/app/views/event_registrations/_continuing_education.html.erb @@ -3,7 +3,7 @@ registration stub, which is then filled in on its own edit page. Once a record exists the toggle is gone and we show the record + an Edit link. Only rendered for CE-eligible events. ---- %> -
+
@@ -63,18 +63,8 @@ <%# Pinned to the card's bottom so it lines up with the scholarship card's chip and the organizations card's "Connect organization" link. %> -
- <% if ce_registration.certificate_sent_at.present? %> - - - Certificate issued - - <% else %> - - - Not issued - - <% end %> +
+ <%= render "event_registrations/ce_status_badge", registration: event_registration %>
<% end %> diff --git a/app/views/events/_registrants_results.html.erb b/app/views/events/_registrants_results.html.erb index 51a0c1e558..312d91c1e8 100644 --- a/app/views/events/_registrants_results.html.erb +++ b/app/views/events/_registrants_results.html.erb @@ -264,33 +264,19 @@ <% if ce_col %> - <%# CE progression mirrors scholarship's colors: walk through - license → payment → paid once a CE registration exists. - "Create" when CE wasn't requested (no registration record). %> - <% cer = registration.continuing_education_registrations.first %> - <% if cer.nil? %> - <% ce_classes = "bg-gray-50 text-gray-400 border-gray-200" %> - <% ce_text = "Create" %> - <% ce_title = "No CE credit requested" %> - <% elsif !cer.professional_license&.number_known? %> - <% ce_classes = "bg-amber-50 text-amber-700 border-amber-200" %> - <% ce_text = "No license #" %> - <% ce_title = "CE license number not provided" %> - <% elsif !cer.paid_in_full? %> - <% ce_classes = "bg-blue-50 text-blue-700 border-blue-200" %> - <% ce_text = "Filed" %> - <% ce_title = "CE license filed — #{dollars_from_cents(cer.remaining_cost)} due" %> + <%# Canonical CE badge (Requested → License # needed → $X due → + Pending → Issued). "Create" when CE isn't in play yet. %> + <% if registration.decorate.ce_status_badge.nil? %> + <%= link_to edit_event_registration_path(registration, return_to: "registrants"), + title: "No CE credit requested", + class: "inline-flex items-center gap-1.5 whitespace-nowrap rounded-full text-xs font-medium border px-5 py-0.5 bg-gray-50 text-gray-400 border-gray-200 transition hover:opacity-80 hover:shadow-sm", + data: { turbo_frame: "_top" } do %> + Create + + <% end %> <% else %> - <% ce_classes = "bg-green-50 text-green-700 border-green-200" %> - <% ce_text = "Recipient" %> - <% ce_title = "CE paid" %> - <% end %> - <%= link_to edit_event_registration_path(registration, return_to: "registrants"), - title: ce_title, - class: "inline-flex items-center gap-1.5 whitespace-nowrap rounded-full text-xs font-medium border px-5 py-0.5 #{ce_classes} transition hover:opacity-80 hover:shadow-sm", - data: { turbo_frame: "_top" } do %> - <%= ce_text %> - + <%= render "event_registrations/ce_status_badge", registration: registration, + href: edit_event_registration_path(registration, return_to: "registrants") %> <% end %> <% end %> diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index e7d0b6f2f5..840ac65ca1 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -17,6 +17,10 @@ <% license_issuing_state = license&.issuing_state %> <% license_expires_on = license&.expires_on %> <% license_on_file = license&.number_known? %> + <%# ?admin=true lets an admin preview the post-payment ("Pending") state of the + badge without recording a real payment. Gated on edit access so a registrant + can't fake it by editing the URL. %> + <% simulate_ce_paid = params[:admin] == "true" && ce_registration && allowed_to?(:edit?, ce_registration) %> <%# Admin-only jump to the management surface for this CE registration. Hidden from registrants; opens in a new tab so the registrant view is kept. %> <% if ce_registration && allowed_to?(:edit?, ce_registration) %> @@ -43,19 +47,7 @@
Status
- <% if @event_registration.ce_paid_in_full? %> - - Paid - - <% elsif !license_on_file %> - - Needs license # - - <% else %> - - Requested - - <% end %> + <%= render "event_registrations/ce_status_badge", registration: @event_registration, simulate_paid: simulate_ce_paid %>
@@ -68,57 +60,64 @@ file the form shows straight away. %> <% editing_license = params[:editing] == "license" %>
-

Your professional license

+
+

Your professional license

+ <% if license_on_file && !editing_license %> + <%= link_to registration_ce_path(@event_registration.slug, editing: "license", return_to: params[:return_to].presence, anchor: "license"), + class: "shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-50" do %> + Edit + <% end %> + <% end %> +
<% if license_on_file && !editing_license %> -
+ <%# Each license field on its own row, labels in a fixed column so the values line up. %> +
-
License type
+
License type
<%= license_kind.presence || "—" %>
-
License number
+
License number
<%= license_number %>
-
Issuing state
+
Issuing state
<%= license_issuing_state.presence || "—" %>
-
Expires
+
Expires
<%= license_expires_on&.to_fs(:long) || "—" %>
- <%= link_to registration_ce_path(@event_registration.slug, editing: "license", return_to: params[:return_to].presence, anchor: "license"), - class: "shrink-0 sm:ml-auto inline-flex items-center gap-1.5 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-50" do %> - Edit - <% end %> -
+ <% else %> <%= form_with url: registration_ce_license_path(@event_registration.slug), method: :post, data: { turbo: false }, class: "mt-3" do |form| %>
- + <%= form.text_field :license_kind, value: license_kind, id: "license_kind", - placeholder: "e.g. LCSW", - class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + placeholder: "e.g. LCSW", required: true, + class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %>
- + <%= form.text_field :license_number, value: license_number, id: "license_number", - placeholder: "e.g. 12345", - class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + placeholder: "e.g. 12345", required: true, + class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %>
<%= form.text_field :license_issuing_state, value: license_issuing_state, id: "license_issuing_state", placeholder: "e.g. CA", - class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %>
+ <%# Date inputs render their mm/dd/yyyy hint in the input's own text colour + (no ::placeholder), so grey it while empty and darken once a date is set. %> <%= form.date_field :license_expires_on, value: license_expires_on, id: "license_expires_on", - class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + class: "mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm #{license_expires_on.present? ? "text-gray-900" : "text-gray-400"} shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %>
diff --git a/spec/decorators/event_registration_decorator_spec.rb b/spec/decorators/event_registration_decorator_spec.rb new file mode 100644 index 0000000000..45e6d8028c --- /dev/null +++ b/spec/decorators/event_registration_decorator_spec.rb @@ -0,0 +1,86 @@ +require "rails_helper" + +RSpec.describe EventRegistrationDecorator do + describe "#ce_status_badge" do + subject(:badge) { registration.decorate.ce_status_badge(**opts) } + + let(:registration) { create(:event_registration) } + let(:opts) { {} } + + def add_ce(number: "LIC-1", cost_cents: 15_000) + license = create(:professional_license, person: registration.registrant, number: number) + create(:continuing_education_registration, event_registration: registration, + professional_license: license, cost_cents: cost_cents) + end + + def pay(cer, amount) + payment = create(:payment, person: registration.registrant, amount_cents: amount, amount_cents_remaining: nil) + create(:allocation, source: payment, allocatable: cer, amount: amount) + end + + context "when CE isn't in play" do + it { is_expected.to be_nil } + end + + context "when requested but no CE registration exists yet" do + before { registration.update!(ce_requested: true) } + + it "is an amber Requested badge" do + expect(badge.label).to eq("Requested") + expect(badge.classes).to include("amber") + end + end + + context "when a CE registration sits on a placeholder license" do + before do + create(:continuing_education_registration, event_registration: registration, + professional_license: create(:professional_license, :placeholder, person: registration.registrant)) + end + + it "is an amber License # needed badge" do + expect(badge.label).to eq("License # needed") + expect(badge.classes).to include("amber") + end + end + + context "when the license is on file but unpaid" do + before { add_ce(cost_cents: 15_000) } + + it "shows the balance due in amber" do + expect(badge.label).to eq("$150 due") + expect(badge.classes).to include("amber") + end + + context "with simulate_paid" do + let(:opts) { { simulate_paid: true } } + + it "previews the blue Pending state" do + expect(badge.label).to eq("Pending") + expect(badge.classes).to include("blue") + end + end + end + + context "when paid in full but not issued" do + before { pay(add_ce(cost_cents: 15_000), 15_000) } + + it "is a blue Pending badge" do + expect(badge.label).to eq("Pending") + expect(badge.classes).to include("blue") + end + end + + context "when the certificate has been issued" do + before do + cer = add_ce(cost_cents: 15_000) + pay(cer, 15_000) + cer.mark_certificate_sent! + end + + it "is a green Issued badge" do + expect(badge.label).to eq("Issued") + expect(badge.classes).to include("green") + end + end + end +end diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index 521c854b66..030ac2e10b 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -633,6 +633,33 @@ def add_ce(number: "LIC-123", hours: 4, cost_cents: 15_000) expect(reg.reload).not_to be_ce_license_provided end end + + describe "#ce_amount_due_cents" do + it "is the cost not yet covered by payments, floored at zero" do + cer = add_ce(cost_cents: 15_000) + expect(reg.ce_amount_due_cents).to eq(15_000) + + payment = create(:payment, person: reg.registrant, amount_cents: 6_000, amount_cents_remaining: nil) + create(:allocation, source: payment, allocatable: cer, amount: 6_000) + expect(reg.reload.ce_amount_due_cents).to eq(9_000) + end + + it "is zero once fully paid" do + cer = add_ce(cost_cents: 15_000) + payment = create(:payment, person: reg.registrant, amount_cents: 15_000, amount_cents_remaining: nil) + create(:allocation, source: payment, allocatable: cer, amount: 15_000) + expect(reg.reload.ce_amount_due_cents).to eq(0) + end + end + + describe "#ce_certificate_issued?" do + it "is true only once every CE registration's certificate is sent" do + cer = add_ce + expect(reg.reload).not_to be_ce_certificate_issued + cer.mark_certificate_sent! + expect(reg.reload).to be_ce_certificate_issued + end + end end describe '.search_by_params' do diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb index f951751896..3c5830bec7 100644 --- a/spec/requests/events/registrations_spec.rb +++ b/spec/requests/events/registrations_spec.rb @@ -227,7 +227,8 @@ create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) get registration_ce_path(registration.slug) expect(response).to have_http_status(:success) - expect(response.body).to include("Requested") + # License on file but unpaid → the badge shows the balance due. + expect(response.body).to include("$150 due") expect(response.body).to include("Hours") expect(response.body).to include("$150") expect(response.body).to include("LIC123") @@ -250,7 +251,7 @@ get registration_ce_path(registration.slug) expect(response.body).to include("License type") expect(response.body).to include("Your license number") - expect(response.body).to include("Needs license #") + expect(response.body).to include("License # needed") expect(response.body).to include("We need your license type and number") expect(response.body).to include("Save changes") # Nothing on file yet, so there's no read-only value to edit or cancel back to. @@ -270,6 +271,27 @@ expect(response.body).to include(edit_continuing_education_registration_path(ce)) end + it "lets an admin preview the paid (Pending) state with ?admin=true" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) + + sign_in create(:user, :with_person, super_user: true) + get registration_ce_path(registration.slug, admin: "true") + expect(response.body).to include("Pending") + expect(response.body).not_to include("$150 due") + end + + it "ignores ?admin=true for a registrant (no access)" do + event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000) + license = create(:professional_license, person: registration.registrant, number: "LIC123") + create(:continuing_education_registration, event_registration: registration, professional_license: license, hours: 6) + + get registration_ce_path(registration.slug, admin: "true") + expect(response.body).to include("$150 due") + expect(response.body).not_to include("Pending") + end + it "points the eyebrow back to the CE registration when reached from there" do ce = create(:continuing_education_registration, event_registration: registration, professional_license: create(:professional_license, :placeholder, person: registration.registrant), hours: 6) diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index c7575a3df6..b520530560 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -947,30 +947,41 @@ def ce_chip_text expect(ce_chip_text).to eq("Create") end - it "shows No license # once a CE record exists without a license number" do + it "shows License # needed once a CE record exists without a license number" do reg = create(:event_registration, event: event, registrant: person) create(:continuing_education_registration, event_registration: reg, professional_license: create(:professional_license, :placeholder, person: person)) get registrants_event_path(event) - expect(ce_chip_text).to eq("No license #") + expect(ce_chip_text).to eq("License # needed") end - it "shows Filed once a license is on file but the CE balance is unpaid" do + it "shows the balance due once a license is on file but the CE balance is unpaid" do reg = create(:event_registration, event: event, registrant: person) create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, professional_license: create(:professional_license, person: person)) get registrants_event_path(event) - expect(ce_chip_text).to eq("Filed") + expect(ce_chip_text).to eq("$150 due") end - it "shows Recipient when the CE balance is paid" do + it "shows Pending when the CE balance is paid but the certificate isn't issued" do reg = create(:event_registration, event: event, registrant: person) cer = create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, professional_license: create(:professional_license, person: person)) create(:allocation, source: create(:payment, amount_cents: 15_000, amount_cents_remaining: 15_000), allocatable: cer, amount: 15_000) get registrants_event_path(event) - expect(ce_chip_text).to eq("Recipient") + expect(ce_chip_text).to eq("Pending") + end + + it "shows Issued once the CE certificate has been delivered" do + reg = create(:event_registration, event: event, registrant: person) + cer = create(:continuing_education_registration, event_registration: reg, cost_cents: 15_000, + professional_license: create(:professional_license, person: person)) + create(:allocation, source: create(:payment, amount_cents: 15_000, amount_cents_remaining: 15_000), + allocatable: cer, amount: 15_000) + cer.mark_certificate_sent! + get registrants_event_path(event) + expect(ce_chip_text).to eq("Issued") end end From a6d71ddd032e61053d01dfeeaaffe00344b2c969 Mon Sep 17 00:00:00 2001 From: maebeale Date: Tue, 30 Jun 2026 08:54:05 -0400 Subject: [PATCH 26/46] Tint registration-edit card icons by content + retire scholarship-requested controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Color each card's section icon via section_icon_class(domain, active?) so a card with content reads as filled and an empty one stays muted — applied across the organizations, scholarship, shout-out, comments, payments, and communications cards. Drop the scholarship-requested Stimulus controller in favor of a plain peer-checked toggle, and align card chips/min-heights so the cards line up. Co-Authored-By: Claude Opus 4.8 --- app/frontend/javascript/controllers/index.js | 3 -- .../scholarship_requested_controller.js | 23 ------------ app/views/event_registrations/_form.html.erb | 36 ++++++++++--------- .../_notifications.html.erb | 2 +- .../_payment_history.html.erb | 2 +- .../event_registrations/_scholarship.html.erb | 14 +++----- 6 files changed, 26 insertions(+), 54 deletions(-) delete mode 100644 app/frontend/javascript/controllers/scholarship_requested_controller.js diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index dd5388eb35..1e64a95242 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -138,9 +138,6 @@ application.register("rhino-source", RhinoSourceController) import ScholarshipPreviewController from "./scholarship_preview_controller" application.register("scholarship-preview", ScholarshipPreviewController) -import ScholarshipRequestedController from "./scholarship_requested_controller" -application.register("scholarship-requested", ScholarshipRequestedController) - import ScholarshipStatusToggleController from "./scholarship_status_toggle_controller" application.register("scholarship-status-toggle", ScholarshipStatusToggleController) diff --git a/app/frontend/javascript/controllers/scholarship_requested_controller.js b/app/frontend/javascript/controllers/scholarship_requested_controller.js deleted file mode 100644 index 6e2e4473a5..0000000000 --- a/app/frontend/javascript/controllers/scholarship_requested_controller.js +++ /dev/null @@ -1,23 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -// Colors the "Requested" toggle to signal save state: amber while the choice is -// pending (changed but not yet saved with the registration form), the -// scholarship theme color once it matches the stored "on" value, and neutral -// gray when stored as off. -export default class extends Controller { - static targets = ["checkbox", "track"] - static values = { initial: Boolean } - - connect() { - this.refresh() - } - - refresh() { - const checked = this.checkboxTarget.checked - const pending = checked !== this.initialValue - - this.trackTarget.classList.toggle("bg-amber-500", pending) - this.trackTarget.classList.toggle("bg-fuchsia-600", checked && !pending) - this.trackTarget.classList.toggle("bg-gray-200", !checked && !pending) - } -} diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb index cf057633b3..d28414d3ad 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -110,7 +110,7 @@ <% if f.object.slug.present? %> <%= link_to registration_ticket_path(f.object.slug), - class: "group inline-flex items-center gap-1.5 font-medium #{DomainTheme.text_class_for(:event_registrations, intensity: 600)} hover:underline", + class: "group inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-2.5 py-1 font-medium shadow-sm #{DomainTheme.text_class_for(:event_registrations, intensity: 600)} hover:bg-gray-50 transition-colors", target: "_blank", data: { turbo_frame: "_top" } do %> @@ -121,12 +121,12 @@ <% submissions.each_with_index do |(form_name, ts), i| %> <%= link_to event_public_registration_path(f.object.event, **form_show_params), - class: "group inline-flex items-center gap-1.5 font-medium #{DomainTheme.text_class_for(:event_registrations, intensity: 600)} hover:underline", + class: "group inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-2.5 py-1 font-medium shadow-sm #{DomainTheme.text_class_for(:event_registrations, intensity: 600)} hover:bg-gray-50 transition-colors", target: "_blank", title: "#{form_name} — submitted #{ts.strftime('%b %-d, %Y')}", data: { turbo_frame: "_top" } do %> - View submission<%= " ##{i + 1}" if submissions.size > 1 %> + View form submission<%= " ##{i + 1}" if submissions.size > 1 %> <% end %> <% end %> @@ -138,12 +138,12 @@ <% connected_org_ids = f.object.organizations.map(&:id) %> <% addable_orgs = active_orgs.reject { |org| connected_org_ids.include?(org.id) } %>
-
+
- + -

Registration organizations

+

Registration-linked

@@ -170,14 +170,16 @@

No organizations linked to this registration.

<% end %> - +
+ +
<%# Pending-chip template cloned by org_toggle_controller when adding. Kept in the markup (not a JS string) so Tailwind always compiles the amber variants. %> @@ -239,7 +241,7 @@ <%# ---- Comments ---- %>
- +

Registration comments

@@ -288,7 +290,7 @@ their profile via nested attributes. ---- %>
- +

Shout out

@@ -302,7 +304,7 @@ value="1" <%= "checked" if f.object.shoutout? %> class="sr-only peer"> - + Feature on the recipients page diff --git a/app/views/event_registrations/_notifications.html.erb b/app/views/event_registrations/_notifications.html.erb index c673c4a96a..765baa409d 100644 --- a/app/views/event_registrations/_notifications.html.erb +++ b/app/views/event_registrations/_notifications.html.erb @@ -8,7 +8,7 @@ <% notifications = email.present? ? Notification.email(email).order(created_at: :desc) : Notification.none %>
- +

Registration communications

diff --git a/app/views/event_registrations/_payment_history.html.erb b/app/views/event_registrations/_payment_history.html.erb index 0986c050eb..73708d1ef9 100644 --- a/app/views/event_registrations/_payment_history.html.erb +++ b/app/views/event_registrations/_payment_history.html.erb @@ -8,7 +8,7 @@ <% return if cost_cents <= 0 && event_registration.allocations.empty? %>
- +

Registration payments and allocations

diff --git a/app/views/event_registrations/_scholarship.html.erb b/app/views/event_registrations/_scholarship.html.erb index 0c43bb642e..665f0cf253 100644 --- a/app/views/event_registrations/_scholarship.html.erb +++ b/app/views/event_registrations/_scholarship.html.erb @@ -2,9 +2,9 @@ not create or destroy an award on its own. Awarding is a deliberate action via "Add scholarship". On save, the controller cleans up an unrequested scholarship only when it is an empty stub (no amount, tasks incomplete). ---- %> -
+
- +

Scholarship

@@ -12,18 +12,14 @@
<% unless scholarship %> -