Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ def registrants
@ce_eligible = @event.ce_eligible?

@submitted_org_names = submitted_org_names_for(@event_registrations)
preload_ce_certificate_reads(@event_registrations)
@readiness = @event_registrations.to_h do |registration|
[ registration.id, EventRegistrationReadiness.new(registration) ]
end
Expand Down Expand Up @@ -1084,6 +1085,17 @@ def selected_reminder_registrations
.select { |r| r.registrant.preferred_email.present? }
end

# CE certificate availability (readiness + the certificate cell) reads each CE
# registrant's logged attendance time and CE form completion. Preload both for
# the CE rows only, so the roster doesn't query them per row.
def preload_ce_certificate_reads(registrations)
ce_rows = registrations.select { |r| r.continuing_education_registrations.any? }
return if ce_rows.empty?

ActiveRecord::Associations::Preloader.new(records: ce_rows, associations: :event_attendance_time_entries).call
ActiveRecord::Associations::Preloader.new(records: ce_rows.map(&:registrant), associations: :form_completions).call
end

# Maps registrant person_id => the organization name they typed on the
# registration form (the organization_name answer), in one batch query. Drives
# both the roster's Pending/None org chip and the readiness "Organization not
Expand Down
7 changes: 7 additions & 0 deletions app/models/continuing_education_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -146,7 +150,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?
Expand Down
8 changes: 8 additions & 0 deletions app/models/event.rb
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,14 @@ def scholarship_callout
registration_ticket_callouts.detect { |callout| callout.builtin_key == "scholarship" }
end

# The CE hours callout (it carries the CE form). Memoized so the roster's per-row
# CE certificate checks hit the callouts once per event, not per row.
def ce_callout
return @ce_callout if defined?(@ce_callout)
@ce_callout =
registration_ticket_callouts.detect { |callout| callout.builtin_key == "ce_hours" }
end

# The recipient's form row on the (published) scholarship callout β€” whatever form
# an admin links there gates readiness, no special role needed. nil when the
# callout carries no form or is hidden.
Expand Down
20 changes: 15 additions & 5 deletions app/models/event_registration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down Expand Up @@ -816,12 +819,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
Expand Down Expand Up @@ -1008,6 +1012,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)
Expand Down
60 changes: 28 additions & 32 deletions app/services/event_registration_readiness.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
# on either state.
#
# Reads only already-loaded associations (organizations, allocations,
# scholarships, continuing_education_registrations) so it adds no per-row queries
# when the roster preloads them.
# scholarships, continuing_education_registrations, and β€” for CE rows β€” attendance
# entries and form completions) so it adds no per-row queries when the roster
# preloads them.
class EventRegistrationReadiness
def initialize(registration)
@registration = registration
Expand All @@ -27,22 +28,22 @@ def event_ready?
event_ready_issues.empty?
end

# All post-event work done AND the certificate(s) sent.
# All post-event work done AND the certificate(s) available.
def completed?
completion_issues.empty?
end

# All post-event work done (attended, scholarship tasks met, post-event survey in)
# β€” i.e. the only thing left is sending the certificate(s). This is the admin's
# "send a certificate" queue.
# β€” i.e. the only thing left is the certificate(s) unlocking (the training ending,
# or for CE: payment, the CE form, signed-in time).
def certifiable?
(completion_work_issues + survey_issues).empty?
end

# The registration's single lifecycle state for the roster's one Status column
# and its matching filter. Completion is the final state and wins; otherwise an
# outstanding pre-event to-do means "not ready"; a registrant who finished the
# post-event work but still needs a certificate is "certificate due"; and a
# post-event work but whose certificate isn't available yet is "certificate due"; and a
# clear pre-event checklist with nothing further done is "ready".
def status
return :completed if completed?
Expand All @@ -62,6 +63,12 @@ def status_label
STATUS_LABELS.fetch(status)
end

# Heads-ups that don't affect the status β€” a denied CE is skipped by every CE
# check, so the badge just mentions it.
def notes
registration.continuing_education_registrations.any?(&:denied?) ? [ "CE denied" ] : []
end

# Sort key for the roster's Status column: lifecycle order first, then the
# reason, so same-status rows group by reason (e.g. all "Payment due" together).
def status_sort_key
Expand All @@ -88,9 +95,9 @@ def status_reason
end
end

# Which certificate(s) still need sending, abbreviated for the badge subtext.
# Which certificate(s) aren't available yet, abbreviated for the badge subtext.
def certificate_due_reason
reg_due = !registration_certificate_sent?
reg_due = !registration.certificate_available?
ce_due = ce_certificate_pending?
return "Reg + CE" if reg_due && ce_due
return "CE" if ce_due
Expand Down Expand Up @@ -141,11 +148,11 @@ def completion_work_issues
end
end

# The certificate(s) that still need sending once the work above is done.
# The certificate(s) not yet available once the work above is done.
def certificate_issues
@certificate_issues ||= [].tap do |issues|
issues << "Certificate not sent" unless registration_certificate_sent?
issues << "CE certificate not sent" if ce_certificate_pending?
issues << "Certificate not available" unless registration.certificate_available?
issues << "CE certificate not available" if ce_certificate_pending?
end
end

Expand Down Expand Up @@ -199,15 +206,15 @@ def survey_outstanding?
end

def ce_unpaid?
registration.ce_registered? && !ce_paid?
ce_registrations.any? && !ce_paid?
end

def ce_license_missing?
registration.ce_registered? && !registration.ce_license_provided?
ce_registrations.any? && !ce_registrations.all? { |c| c.professional_license&.number_known? }
end

def ce_certificate_pending?
certifiable_ce.any? && !ce_certificate_sent?
certifiable_ce.any? && !certifiable_ce.all?(&:certificate_available?)
end

# Post-event criteria are only met by a full "attended". "incomplete_attendance"
Expand All @@ -219,33 +226,22 @@ def attendance_issue

# The admin-created CE billing records homed on this registration (preloaded on
# the roster). Their PAYMENT + license state drives the CE money/license checks;
# these stay on the home reg after a transfer.
# these stay on the home reg after a transfer. A denied CE is out of the picture β€”
# it never gets a certificate, so it doesn't hold readiness back (see #notes).
def ce_registrations
registration.continuing_education_registrations
registration.continuing_education_registrations.reject(&:denied?)
end

# The CE this registration certifies β€” its own records. After a transfer the
# hours ride on the destination reg's own record, so each reg certifies exactly
# what it holds. (issue #1944)
# The CE this registration certifies β€” its own (undenied) records. After a
# transfer the hours ride on the destination reg's own record, so each reg
# certifies exactly what it holds. (issue #1944)
def certifiable_ce
registration.continuing_education_registrations
ce_registrations
end

# CE is paid once every CE registration is paid in full. A requested-but-not-yet
# -created CE registration counts as unpaid (nothing to pay against yet).
def ce_paid?
ce_registrations.any? && ce_registrations.all?(&:paid_in_full?)
end

# The registration's own completion certificate (certificate_sent_at, via
# Certifiable#certificate_sent?).
def registration_certificate_sent?
registration.certificate_sent?
end

# CE certificates are sent once every CE registration this reg certifies has
# been sent. No certifiable CE means nothing has been issued.
def ce_certificate_sent?
certifiable_ce.any? && certifiable_ce.all?(&:certificate_sent?)
end
end
18 changes: 14 additions & 4 deletions app/services/registrant_ce_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def initialize(event_registration)
def link
return @link if defined?(@link)

@link = ce_callout&.registration_ticket_callout_forms&.first
@link = ce_callout&.registration_ticket_callout_forms&.to_a&.first
end

# The form attached to the CE callout, or nil when none is set.
Expand Down Expand Up @@ -59,8 +59,7 @@ def complete?
def completed_anonymously?
return @completed_anonymously if defined?(@completed_anonymously)

@completed_anonymously = form.present? && registrant_saved? &&
FormCompletion.exists?(person: @event_registration.registrant, form:, event: @event_registration.event)
@completed_anonymously = form.present? && registrant_saved? && form_completion_on_file?
end

private
Expand All @@ -71,6 +70,17 @@ def registrant_saved?
@event_registration.registrant&.persisted?
end

# Reads the registrant's preloaded completions when the roster loaded them.
def form_completion_on_file?
registrant = @event_registration.registrant
event = @event_registration.event
if registrant.form_completions.loaded?
registrant.form_completions.any? { |completion| completion.form_id == form.id && completion.event_id == event.id }
else
FormCompletion.exists?(person: registrant, form:, event:)
end
end

def answerable_fields
return [] unless form

Expand All @@ -92,6 +102,6 @@ def answers_by_field_id
def ce_callout
return @ce_callout if defined?(@ce_callout)

@ce_callout = @event_registration.event.registration_ticket_callouts.find_by(builtin_key: "ce_hours")
@ce_callout = @event_registration.event.ce_callout
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -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 #{ContinuingEducationRegistration::CERTIFICATE_REQUIREMENTS}. Switch on to make it available now." %>
</div>
<% end %>
</div>
Expand Down
36 changes: 24 additions & 12 deletions app/views/event_registrations/_certificate_issued_toggle.html.erb
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
<%# 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?) %>
<% requirements = ce ? ContinuingEducationRegistration::CERTIFICATE_REQUIREMENTS : EventRegistration::CERTIFICATE_REQUIREMENTS %>
<span id="<%= dom_id(registration, ce ? :ce_certificate_issued : :certificate_issued) %>" class="inline-block">
<%= form_with url: url, method: :patch, class: "inline" do %>
<%= hidden_field_tag :value, "0", id: nil %>
<label class="relative inline-flex cursor-pointer items-center select-none" title="<%= "Transferred out β€” locked" if registration.editing_locked? %>">
<%= check_box_tag :value, "1", issued,
id: nil,
onchange: "this.form.requestSubmit()",
disabled: registration.editing_locked?,
"aria-label": ce ? "CE certificate issued" : "Certificate issued",
class: "peer sr-only" %>
<span class="relative inline-block h-5 w-9 rounded-full bg-gray-300 transition-colors peer-checked:bg-teal-600 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 after:absolute after:top-0.5 after:left-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow after:transition-transform after:content-[''] peer-checked:after:translate-x-4"></span>
</label>
<% if available %>
<span class="inline-flex items-center gap-1.5 whitespace-nowrap text-green-700" title="Unlocked: <%= requirements %>">
<i class="fa-solid fa-circle-check" aria-hidden="true"></i>
<span>Available</span>
</span>
<% else %>
<%= form_with url: url, method: :patch, class: "inline" do %>
<%= hidden_field_tag :value, "0", id: nil %>
<label class="relative inline-flex cursor-pointer items-center select-none" title="<%= registration.editing_locked? ? "Transferred out β€” locked" : "Unlocks automatically once #{requirements}. Switch on to make it available now." %>">
<%= check_box_tag :value, "1", issued,
id: nil,
onchange: "this.form.requestSubmit()",
disabled: registration.editing_locked?,
"aria-label": ce ? "CE certificate availability" : "Certificate availability",
class: "peer sr-only" %>
<span class="relative inline-block h-5 w-9 rounded-full bg-gray-300 transition-colors peer-checked:bg-teal-600 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 after:absolute after:top-0.5 after:left-0.5 after:h-4 after:w-4 after:rounded-full after:bg-white after:shadow after:transition-transform after:content-[''] peer-checked:after:translate-x-4"></span>
</label>
<% end %>
<% end %>
</span>
9 changes: 7 additions & 2 deletions app/views/event_registrations/_readiness_badge.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,26 @@
status - Symbol (:not_ready, :ready, :certificate_due, :completed)
label - display text for the status
issues - array of outstanding-condition strings (tooltip, when any)
subtext - optional short reason rendered under the badge %>
subtext - optional short reason rendered under the badge
notes - optional heads-ups that don't affect the status (tooltip; under the
badge when there's no reason) %>
<%
subtext = local_assigns[:subtext]
notes = local_assigns.fetch(:notes, [])
style, icon, subtext_color = case status
when :completed then [ "bg-green-50 text-green-700 border-green-200", "fa-flag-checkered", "text-green-600" ]
when :certificate_due then [ "bg-purple-50 text-purple-700 border-purple-200", "fa-certificate", "text-purple-600" ]
when :survey_pending then [ "bg-indigo-50 text-indigo-700 border-indigo-200", "fa-clipboard-list", "text-indigo-600" ]
when :ready then [ "bg-blue-50 text-blue-700 border-blue-200", "fa-circle-check", "text-blue-600" ]
else [ "bg-amber-50 text-amber-700 border-amber-200", "fa-circle-exclamation", "text-amber-600" ]
end
tooltip = issues.any? ? issues.join(" Β· ") : label
tooltip = ((issues.any? ? issues : [ label ]) + notes.map { |note| "Note: #{note}" }).join(" Β· ")
%>
<div class="inline-flex flex-col items-center gap-0.5">
<%= render "shared/badge", label: label, classes: style, icon: "fas #{icon}", title: tooltip %>
<% if subtext.present? %>
<span class="text-2xs font-medium <%= subtext_color %>"><%= subtext %></span>
<% elsif notes.any? %>
<span class="text-2xs font-medium text-gray-500"><%= notes.to_sentence %></span>
<% end %>
</div>
Loading
Loading