From ab15b469cc05ab478cfe8c74943edd60dfbdd005 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 3 Nov 2026 08:34:47 -0500 Subject: [PATCH 1/4] Certificates unlock on their own; roster shows Available, slider makes it available now - Marking the event's own certificate issued now unlocks it (it only fed readiness before) - Marking a CE certificate issued now unlocks the CE certificate too - Roster certificate cells show "Available" once a certificate unlocks on its own; otherwise a slider that makes it available now - Toggling CE also refreshes the event certificate cell (issued CE unlocks it) - CE edit page switch relabeled "CE certificate availability" Co-Authored-By: Claude Opus 5.5 (1M context) --- .../continuing_education_registration.rb | 3 ++ app/models/event_registration.rb | 17 ++++++--- .../_details_section.html.erb | 5 +-- .../_certificate_issued_toggle.html.erb | 35 ++++++++++++------- ...gle_ce_certificate_issued.turbo_stream.erb | 6 +++- .../continuing_education_registration_spec.rb | 8 +++++ spec/models/event_registration_spec.rb | 29 +++++++++++++++ ...continuing_education_registrations_spec.rb | 2 +- spec/requests/event_registrations_spec.rb | 25 +++++++++++++ 9 files changed, 109 insertions(+), 21 deletions(-) diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 9f129418f8..47ea6759e7 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -146,7 +146,10 @@ def origin_ce_registration # attendance was tracked) the logged time approximately covers the hours. # Everything is judged at this record's own event/registration — after a transfer # the hours ride on the destination reg's own record, so there's nothing to walk. + # An admin marking the certificate issued is the override: it unlocks regardless. def certificate_available? + return true if certificate_sent? + event = event_registration&.event return false unless event&.ce_eligible? && approved? return false unless event.end_date&.past? && event_registration.attended? && paid_in_full? diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index f8bd72217a..14f757d1a7 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -816,12 +816,13 @@ def clear_post_survey_completed! end # The certificate of completion unlocks once the training has happened, the - # registrant attended, and any scholarship tasks are complete. Issuing a CE - # certificate (an admin marking the credit sent) is itself an affirmation that - # the registrant completed the training, so it unlocks the certificate too — - # even when attendance was never tracked. + # registrant attended, and any scholarship tasks are complete. Issuing a + # certificate (an admin marking it sent on the roster — the registration's own + # or the CE one) is itself an affirmation that the registrant completed the + # training, so it unlocks the certificate too — even when attendance was never + # tracked. def certificate_available? - return true if ce_certificate_issued? + return true if certificate_sent? || ce_certificate_issued? event.end_date.present? && event.end_date.past? && attended? && scholarship_tasks_met? end @@ -1008,6 +1009,12 @@ def ce_certificate_issued? continuing_education_registrations.all? { |c| c.certificate_sent_at.present? } end + # Every CE registration's certificate is available (on its own or because it was + # marked issued). Reads the preloaded CE records. + def ce_certificate_available? + continuing_education_registrations.any? && continuing_education_registrations.all?(&:certificate_available?) + end + # Issuing keeps an existing issued date — it's printed on the certificate. def mark_certificate_issued!(issued) update!(certificate_sent_at: issued ? certificate_sent_at || Time.current : nil) diff --git a/app/views/continuing_education_registrations/_details_section.html.erb b/app/views/continuing_education_registrations/_details_section.html.erb index 3dd8fd1ba9..8d001617e0 100644 --- a/app/views/continuing_education_registrations/_details_section.html.erb +++ b/app/views/continuing_education_registrations/_details_section.html.erb @@ -112,9 +112,10 @@ on: ce_registration.approved?, detail: ("Denied #{ce_registration.denied_at.to_date.to_fs(:long)}" if ce_registration.denied?), title: "On by default. Switch off to deny: it closes the registrant's sign-in and certificate." %> - <%= render "continuing_education_registrations/status_switch", name: "certificate_issued", label: "CE certificate of completion", + <%= render "continuing_education_registrations/status_switch", name: "certificate_issued", label: "CE certificate availability", on: ce_registration.certificate_sent_at.present?, - detail: ce_registration.certificate_sent_at ? "Issued #{ce_registration.certificate_sent_at.to_date.to_fs(:long)}" : "Not issued" %> + detail: ce_registration.certificate_sent_at ? "Issued #{ce_registration.certificate_sent_at.to_date.to_fs(:long)}" : "Not issued", + title: "Unlocks automatically once requirements are met. Switch on to make it available now." %> <% end %> diff --git a/app/views/event_registrations/_certificate_issued_toggle.html.erb b/app/views/event_registrations/_certificate_issued_toggle.html.erb index 2f93df4795..deadc88d81 100644 --- a/app/views/event_registrations/_certificate_issued_toggle.html.erb +++ b/app/views/event_registrations/_certificate_issued_toggle.html.erb @@ -1,21 +1,32 @@ -<%# Inline "certificate issued" slider toggle, targeted by id for turbo replacement. +<%# Inline certificate availability cell, targeted by id for turbo replacement. Locals: registration, ce (default false) — the CE certificate instead of the event's. + A certificate unlocks on its own once its requirements are met, so then the cell + just says so. Otherwise it's a slider that makes it available now (marking it + issued); once switched on it stays a slider so it can be switched back off. The track is a direct sibling of the checkbox so peer-checked applies (it only reaches later siblings, not their descendants); the knob is the track's ::after. %> <% ce = local_assigns.fetch(:ce, false) %> <% url = ce ? toggle_ce_certificate_issued_event_registration_path(registration) : toggle_certificate_issued_event_registration_path(registration) %> <% issued = ce ? registration.ce_certificate_issued? : registration.certificate_sent? %> +<% available = !issued && (ce ? registration.ce_certificate_available? : registration.certificate_available?) %> - <%= form_with url: url, method: :patch, class: "inline" do %> - <%= hidden_field_tag :value, "0", id: nil %> - + <% if available %> + + + Available + + <% else %> + <%= form_with url: url, method: :patch, class: "inline" do %> + <%= hidden_field_tag :value, "0", id: nil %> + + <% end %> <% end %> diff --git a/app/views/event_registrations/toggle_ce_certificate_issued.turbo_stream.erb b/app/views/event_registrations/toggle_ce_certificate_issued.turbo_stream.erb index 4968e45ea5..b964bf9f71 100644 --- a/app/views/event_registrations/toggle_ce_certificate_issued.turbo_stream.erb +++ b/app/views/event_registrations/toggle_ce_certificate_issued.turbo_stream.erb @@ -1,7 +1,11 @@ -<%# Replace the toggled cell and the row's CE badge — avoids re-rendering the whole roster. %> +<%# Replace the toggled cell, the event certificate cell (an issued CE certificate makes + it available too), and the row's CE badge — avoids re-rendering the whole roster. %> <%= turbo_stream.replace dom_id(@event_registration, :ce_certificate_issued) do %> <%= render "event_registrations/certificate_issued_toggle", registration: @event_registration, ce: true %> <% end %> +<%= turbo_stream.replace dom_id(@event_registration, :certificate_issued) do %> + <%= render "event_registrations/certificate_issued_toggle", registration: @event_registration %> +<% end %> <% if (ce_registration = @event_registration.continuing_education_registrations.first) %> <%= turbo_stream.replace dom_id(@event_registration, :ce_status) do %> <%= render "event_registrations/roster_ce_status", registration: @event_registration, ce_registration: ce_registration %> diff --git a/spec/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb index 10e38e6652..8efae969c9 100644 --- a/spec/models/continuing_education_registration_spec.rb +++ b/spec/models/continuing_education_registration_spec.rb @@ -367,6 +367,14 @@ def ce_reg_for(event:, status:, cost_cents: 0) expect(ce_reg_for(event: event, status: "attended").certificate_available?).to be(true) end + it "is available once an admin marks it issued, even before the training ends and while unpaid" do + event = create(:event, ce_hours_offered: 6, start_date: 2.days.from_now, end_date: 3.days.from_now) + ce_reg = ce_reg_for(event: event, status: "registered", cost_cents: 5_000) + expect(ce_reg.certificate_available?).to be(false) + ce_reg.mark_certificate_sent! + expect(ce_reg.certificate_available?).to be(true) + end + it "is unavailable when the event does not grant CE" do event = create(:event, ce_hours_offered: 0, start_date: 3.days.ago, end_date: 1.day.ago) expect(ce_reg_for(event: event, status: "attended").certificate_available?).to be(false) diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index 9e23655113..0ecbfbc719 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -1299,6 +1299,26 @@ def pay(cents) end end + describe "#certificate_available?" do + let(:ended_event) { create(:event, start_date: 3.days.ago, end_date: 2.days.ago) } + + it "is false before attendance is recorded" do + reg = create(:event_registration, event: ended_event, status: "registered") + expect(reg.certificate_available?).to be false + end + + it "is true once an ended training is attended with no scholarship" do + reg = create(:event_registration, event: ended_event, status: "attended") + expect(reg.certificate_available?).to be true + end + + it "is true once an admin marks the certificate issued, even without attendance" do + reg = create(:event_registration, event: create(:event, start_date: 2.days.from_now, end_date: 3.days.from_now), status: "registered") + reg.mark_certificate_issued!(true) + expect(reg.reload.certificate_available?).to be true + end + end + describe "#remaining_cost_without" do let(:event) { create(:event, cost_cents: 10_000) } let(:reg) { create(:event_registration, event: event) } @@ -1725,6 +1745,15 @@ def add_ce(number: "LIC-123", hours: 4, cost_cents: 15_000) end end + describe "#ce_certificate_available?" do + it "is true once every CE registration's certificate is available" do + cer = add_ce + expect(reg.reload).not_to be_ce_certificate_available + cer.mark_certificate_sent! + expect(reg.reload).to be_ce_certificate_available + end + end + describe "#ce_certificate_issued?" do it "is true only once every CE registration's certificate is sent" do cer = add_ce diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index b45811a605..47bbef485e 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -318,7 +318,7 @@ page = Capybara.string(response.body) expect(page).to have_field("continuing_education_registration[approved]", type: "checkbox", visible: :all) expect(page).to have_field("continuing_education_registration[certificate_issued]", type: "checkbox", visible: :all) - expect(page).to have_text("CE certificate of completion") + expect(page).to have_text("CE certificate availability") end it "renders the new page for a registration" do diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index 777b229a4a..f56278e109 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -354,6 +354,23 @@ def toggle_certificate(value) expect(registration.reload).not_to be_certificate_sent end + it "shows Available instead of the slider once the certificate unlocks on its own" do + event.update!(start_date: 3.days.ago, end_date: 2.days.ago) + registration.update!(status: "attended") + + toggle_certificate("0") + + expect(response.body).to include("Available") + expect(response.body).not_to include('type="checkbox"') + end + + it "keeps the slider (switched on) once marked available, so it can be switched off" do + toggle_certificate("1") + + expect(response.body).to match(/type="checkbox"[^>]*checked="checked"/) + expect(response.body).not_to include("Available") + end + it "leaves a CE registrant's CE certificate alone" do ce = create(:continuing_education_registration, event_registration: registration) @@ -391,6 +408,14 @@ def toggle_ce_certificate(value) expect(stream).to have_css("turbo-stream[action='replace'][target='#{ActionView::RecordIdentifier.dom_id(registration, :ce_status)}']") expect(response.body).to include("Issued") end + + it "refreshes the event certificate cell, which an issued CE certificate makes available" do + toggle_ce_certificate("1") + + stream = Capybara.string(response.body) + expect(stream).to have_css("turbo-stream[action='replace'][target='#{ActionView::RecordIdentifier.dom_id(registration, :certificate_issued)}']") + expect(response.body).to include("Available") + end end describe "POST /event_registrations" do From 39f10d995beeab01ebc821672ce7943bcbd42b50 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Tue, 3 Nov 2026 08:36:49 -0500 Subject: [PATCH 2/4] Spell out certificate unlock requirements in the availability tooltips Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/continuing_education_registration.rb | 4 ++++ app/models/event_registration.rb | 3 +++ .../_details_section.html.erb | 2 +- .../_certificate_issued_toggle.html.erb | 5 +++-- spec/requests/event_registrations_spec.rb | 7 +++++++ 5 files changed, 18 insertions(+), 3 deletions(-) diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 47ea6759e7..32a4a8db37 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -118,6 +118,10 @@ def self.parse_iso_date(value) # sign-ins/early sign-outs. You can't certify hours the sign-in sheet doesn't support. ATTENDANCE_COVERAGE_THRESHOLD = 0.9 + # What #certificate_available? checks, in words — the admin tooltips spell it out. + CERTIFICATE_REQUIREMENTS = "the training has ended, the registrant is marked attended, the CE is approved and paid in full, " \ + "the CE form is complete (if there is one), and signed-in time covers the CE hours (if time was tracked)".freeze + # This record was created by a transfer — carried onto a transferred-in reg from # a matching CE the split left on the source. Identified by that source stub # existing (matched by license), NOT merely by the reg being a transfer-in: a CE diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index 14f757d1a7..6e9b324f0b 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -58,6 +58,9 @@ class EventRegistration < ApplicationRecord # Attendance outcomes surfaced as their own participation buckets; every other # status falls into "other" (registered, transfers, cancellations). NAMED_OUTCOME_STATUSES = %w[ attended incomplete_attendance no_show ].freeze + + # What #certificate_available? checks, in words — the admin tooltips spell it out. + CERTIFICATE_REQUIREMENTS = "the training has ended, the registrant is marked attended, and any scholarship tasks are complete".freeze # Sentinel filter value meaning "don't narrow on this dimension" — for a filter # that defaults to something specific (the attendees index defaults to attended # registrations on trainings, so it needs a way to say "all of them"). diff --git a/app/views/continuing_education_registrations/_details_section.html.erb b/app/views/continuing_education_registrations/_details_section.html.erb index 8d001617e0..f5ddd83aeb 100644 --- a/app/views/continuing_education_registrations/_details_section.html.erb +++ b/app/views/continuing_education_registrations/_details_section.html.erb @@ -115,7 +115,7 @@ <%= render "continuing_education_registrations/status_switch", name: "certificate_issued", label: "CE certificate availability", on: ce_registration.certificate_sent_at.present?, detail: ce_registration.certificate_sent_at ? "Issued #{ce_registration.certificate_sent_at.to_date.to_fs(:long)}" : "Not issued", - title: "Unlocks automatically once requirements are met. Switch on to make it available now." %> + title: "Unlocks automatically once #{ContinuingEducationRegistration::CERTIFICATE_REQUIREMENTS}. Switch on to make it available now." %> <% end %> diff --git a/app/views/event_registrations/_certificate_issued_toggle.html.erb b/app/views/event_registrations/_certificate_issued_toggle.html.erb index deadc88d81..3763baebd2 100644 --- a/app/views/event_registrations/_certificate_issued_toggle.html.erb +++ b/app/views/event_registrations/_certificate_issued_toggle.html.erb @@ -9,16 +9,17 @@ <% url = ce ? toggle_ce_certificate_issued_event_registration_path(registration) : toggle_certificate_issued_event_registration_path(registration) %> <% issued = ce ? registration.ce_certificate_issued? : registration.certificate_sent? %> <% available = !issued && (ce ? registration.ce_certificate_available? : registration.certificate_available?) %> +<% requirements = ce ? ContinuingEducationRegistration::CERTIFICATE_REQUIREMENTS : EventRegistration::CERTIFICATE_REQUIREMENTS %> <% if available %> - + Available <% else %> <%= form_with url: url, method: :patch, class: "inline" do %> <%= hidden_field_tag :value, "0", id: nil %> -