Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ea24d2f
Add CE status column and filter to registrants index
maebeale Jun 22, 2026
88a3808
Gate CE surfaces on the registration form, toggle the index column
maebeale Jun 22, 2026
bea6023
Gate CE on Event#ce_eligible? instead of a parallel offers_ce?
maebeale Jun 29, 2026
746d281
Polish registrants roster: CE column default-on, layout + density
maebeale Jun 29, 2026
7424023
Tune registrant profile button to a uniform w-40 name-display width
maebeale Jun 29, 2026
f534b79
Widen organization chips (truncate at 52)
maebeale Jun 29, 2026
b19825f
Size org chips to fit a full agency name; widen profile column to w-44
maebeale Jun 29, 2026
327ebfb
Uniform person column sized to ~30 chars (truncate names at 30)
maebeale Jun 29, 2026
817a0cf
Color the present registration-form icon blue instead of green
maebeale Jun 29, 2026
76b7ae1
Roster toggles: Organization + Scholarship columns, CE label, icon po…
maebeale Jun 29, 2026
e6fe4e2
Recolor CE cell states to mirror scholarship
maebeale Jun 29, 2026
1c50ab4
Registrants roster: CE record progression, comment/org filters, eyebrows
maebeale Jun 29, 2026
52c290a
Extract shared events/filter_select partial for roster + reminder fil…
maebeale Jun 29, 2026
a074f8b
Account-status filter, shared filter layout, yellow highlight fix
maebeale Jun 29, 2026
c66eb01
CE chip says 'Filed' once a license is on file (amount due in tooltip)
maebeale Jun 29, 2026
cff7047
Safelist ring colors so the highlight builds; CE Filed=blue, Recipien…
maebeale Jun 29, 2026
ddeaf5d
Shrink the shout-out textarea to a single row
maebeale Jun 29, 2026
89afc01
Registrants filters: keyword flexes, Payment after Attendance, thinne…
maebeale Jun 29, 2026
9564faf
Keyword uses min-w-0 so row-1 filters stay one row (wrap only when cr…
maebeale Jun 29, 2026
fd69e3d
Sync recipients filters with the registrants roster
maebeale Jun 29, 2026
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
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ When changing a model or controller, check whether these related files need upda
- Avoid `update_all` unless explicitly intended
- Prefer service objects under app/services/
- Prefer POROs over concerns when possible
- **Prefer decorators (Draper, app/decorators/) over view helpers for model-specific presentation** β€” when display logic is "about a record" (labels, badges, formatted attributes, status pills), put it on that model's decorator and call `record.decorate.thing`. Reserve `app/helpers/` for generic, cross-model view utilities that aren't tied to one model. Decorators keep presentation testable and out of ERB.
- Use `after_commit` instead of `after_save` for side effects

## RuboCop (rubocop-rails-omakase)
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ When changing a model or controller, check whether these related files need upda
- Avoid `update_all` unless explicitly intended
- Prefer service objects under app/services/
- Prefer POROs over concerns when possible
- **Prefer decorators (Draper, app/decorators/) over view helpers for model-specific presentation** β€” when display logic is "about a record" (labels, badges, formatted attributes, status pills), put it on that model's decorator and call `record.decorate.thing`. Reserve `app/helpers/` for generic, cross-model view utilities that aren't tied to one model. Decorators keep presentation testable and out of ERB.
- Use `after_commit` instead of `after_save` for side effects

## RuboCop (rubocop-rails-omakase)
Expand Down
44 changes: 30 additions & 14 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,15 @@ def registrants
authorize! @event, to: :registrants?
@event = @event.decorate
scope = @event.event_registrations
.includes(:comments, :organizations, registrant: [ :user, :contact_methods, { avatar_attachment: :blob }, { affiliations: :organization } ])
.includes(:comments, :organizations, { continuing_education_registrations: [ :professional_license, :allocations ] }, registrant: [ :user, :contact_methods, { avatar_attachment: :blob }, { affiliations: :organization } ])
.joins(:registrant)
scope = scope.keyword(params[:keyword]) if params[:keyword].present?
scope = scope.payment_status(params[:payment_status]) if params[:payment_status].present?
scope = scope.scholarship_status(params[:scholarship]) if params[:scholarship].present?
scope = scope.ce_status(params[:ce_status]) if params[:ce_status].present?
scope = scope.comment_status(params[:comment_status]) if params[:comment_status].present?
scope = scope.organization_status(params[:org_status], @event) if params[:org_status].present?
scope = scope.account_status(params[:account_status]) if params[:account_status].present?
scope = scope.registrant_ids(params[:registrant_ids]) if params[:registrant_ids].present?
scope = scope.registrant_state(params[:state]) if params[:state].present?
scope = scope.registrant_county(params[:county]) if params[:county].present?
Expand All @@ -105,6 +109,7 @@ def registrants

@event_registrations = scope.order(Arel.sql("people.first_name, people.last_name"))
@dashboard = EventDashboard.new(@event)
@ce_eligible = @event.ce_eligible?

emails = @event_registrations.map { |r| r.registrant.preferred_email&.downcase }.compact
@duplicate_emails = emails.tally.select { |_, count| count > 1 }.keys.to_set
Expand Down Expand Up @@ -314,6 +319,7 @@ def create_bulk_payment
def preview_reminder
authorize! @event
@event = @event.decorate
@ce_eligible = @event.ce_eligible?
@event_registrations = @event.event_registrations
.includes(
:event, :organizations, :comments,
Expand All @@ -325,8 +331,11 @@ def preview_reminder

# Filters keep every registrant in the list and only flag who still matches,
# so the recipient checkboxes pre-check the matched set rather than removing
# rows. See app/views/events/_reminder_recipients.html.erb.
recipient_filter = ReminderRecipientFilter.new(@event_registrations, params)
# rows. See app/views/events/_reminder_recipients.html.erb. The dropdown
# filters reuse the registrants-roster scopes (via the event), so both pages
# stay in sync; @dashboard supplies the state/county options.
@dashboard = EventDashboard.new(@event)
recipient_filter = ReminderRecipientFilter.new(@event_registrations, params, event: @event)
@matched_ids = recipient_filter.matched_ids
@filtering = recipient_filter.filtering?

Expand Down Expand Up @@ -535,15 +544,17 @@ def allocated_cents_by_registration(registrations)
def event_registrations_csv_string
require "csv"
cost_required = @event.cost_cents.to_i > 0
include_ce = @event.ce_eligible?
headers = [ "First name", "Last name", "Email", "Phone", "Organization", "Scholarship recipient", "Scholarship tasks completed", "Payment status", "Intends to pay", "Payment total" ]
headers << "CE status" if include_ce
CSV.generate(headers: headers, write_headers: true) do |csv_out|
@event_registrations.each do |registration|
csv_out << event_registration_csv_row(registration, cost_required)
csv_out << event_registration_csv_row(registration, cost_required, include_ce)
end
end
end

def event_registration_csv_row(registration, cost_required)
def event_registration_csv_row(registration, cost_required, include_ce = false)
person = registration.registrant
orgs = person.affiliations
.select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) }
Expand All @@ -552,7 +563,7 @@ def event_registration_csv_row(registration, cost_required)
total_cents = registration.allocations_sum
payment_total = total_cents.positive? ? format("%.2f", total_cents / 100.0) : ""
payment_status = cost_required ? registration.payment_status_label : ""
[
row = [
person.first_name,
person.last_name,
person.preferred_email.presence || "",
Expand All @@ -564,17 +575,20 @@ def event_registration_csv_row(registration, cost_required)
registration.intends_to_pay? ? "Yes" : "No",
payment_total
]
row << registration.ce_status_label.to_s if include_ce
row
end

def onboarding_csv_string
require "csv"
cost_required = @event.cost_cents.to_i > 0
include_ce = @event.ce_eligible?
day_count = @event.day_count
headers = [ "First name", "Last name", "Email", "Organization", "Program type" ]
headers += [ "Payment status", "Fees due", "Paid amount" ] if cost_required
headers << "Fee note"
headers += [ "Discounted amount", "Scholarship amount", "Scholarship grant", "Scholarship tasks completed" ]
headers += [ "CE requested", "CE hours", "CE amount", "CE license" ]
headers += [ "CE requested", "CE hours", "CE amount", "CE license" ] if include_ce
headers += EventRegistration::CHECKLIST_STEPS.values
headers += [ "Portal user status", "Portal access" ]
headers += (1..day_count).map { |day| "Day #{day}" }
Expand All @@ -583,12 +597,12 @@ def onboarding_csv_string

CSV.generate(headers: headers, write_headers: true) do |csv_out|
@event_registrations.each do |registration|
csv_out << onboarding_csv_row(registration, cost_required, day_count)
csv_out << onboarding_csv_row(registration, cost_required, day_count, include_ce)
end
end
end

def onboarding_csv_row(registration, cost_required, day_count)
def onboarding_csv_row(registration, cost_required, day_count, include_ce = false)
person = registration.registrant
scholarship = registration.scholarships.first
statuses = registration.program_statuses.map { |status| status.to_s.titleize }.join(", ")
Expand All @@ -611,11 +625,13 @@ def onboarding_csv_row(registration, cost_required, day_count)
row << (scholarship ? helpers.dollars_from_cents(scholarship.amount_cents) : "")
row << (scholarship ? (scholarship.grant&.name.presence || "Unfunded") : "")
row << onboarding_scholarship_tasks_csv(registration)
ce_hours = registration.ce_hours_requested.to_i
row << (registration.ce_credit_requested? ? "Yes" : "No")
row << (ce_hours.positive? ? 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
if include_ce
ce_hours = registration.ce_hours_requested.to_i
row << (registration.ce_credit_requested? ? "Yes" : "No")
row << (ce_hours.positive? ? 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
end
EventRegistration::CHECKLIST_STEPS.each_key do |step|
row << (registration.checklist_step_completed?(step) ? "Yes" : "No")
end
Expand Down
21 changes: 12 additions & 9 deletions app/frontend/javascript/controllers/column_toggle_controller.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="column-toggle"
// Toggles visibility of table columns marked with a data attribute.
// Each instance is a single slide switch that shows/hides the table columns
// whose data-column-toggle-col value matches this switch's `group` value, so a
// table can host several independent column toggles (e.g. "User confirmation",
// "CE status"). Columns live outside the switch's element, under a shared
// [data-column-toggle-root] ancestor.

export default class extends Controller {
static targets = ["toggle", "track", "knob"]
static values = { group: String }

toggle() {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ€– From Claude: Generalized to multiple independent switches keyed by group value, so the same controller drives both "User confirmation" and "CE status". Columns live under a shared [data-column-toggle-root] ancestor and are matched by data-column-toggle-col="<group>".

const checked = this.toggleTarget.checked
this.element.querySelectorAll("[data-column-toggle-col]").forEach((el) => {
const root = this.element.closest("[data-column-toggle-root]") || document

root.querySelectorAll(`[data-column-toggle-col="${this.groupValue}"]`).forEach((el) => {
el.classList.toggle("hidden", !checked)
})

if (this.hasTrackTarget) {
this.trackTarget.classList.toggle("bg-gray-300", !checked)
this.trackTarget.classList.toggle("bg-blue-600", checked)
}
if (this.hasKnobTarget) {
this.knobTarget.style.transform = checked ? "translateX(16px)" : ""
}
this.trackTarget.classList.toggle("bg-gray-300", !checked)
this.trackTarget.classList.toggle("bg-blue-600", checked)
this.knobTarget.style.transform = checked ? "translateX(16px)" : ""
}
}
1 change: 1 addition & 0 deletions app/frontend/stylesheets/application.tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

@source inline("{hover:,}bg-{indigo,purple,teal,violet,orange,rose,blue,sky,emerald,slate,lime,stone,amber,green,yellow,gray,cyan,fuchsia,pink}-{50,{100..900..100},950}");
@source inline("{hover:,}border-{indigo,purple,teal,violet,orange,rose,blue,sky,emerald,slate,lime,stone,amber,green,yellow,gray,cyan,fuchsia,pink}-{200,300}");
@source inline("ring-{indigo,purple,teal,violet,orange,rose,blue,sky,emerald,slate,lime,stone,amber,green,yellow,gray,cyan,fuchsia,pink}-{200,300,400,500}");
@source inline("{hover:,}text-{indigo,purple,teal,violet,orange,rose,blue,sky,emerald,slate,lime,stone,amber,green,yellow,gray,cyan,fuchsia,pink}-{50,{100..900..100},950}");

/* Per-field form layout widths (generated from FormField::WIDTH_GRID_SPANS, which
Expand Down
23 changes: 19 additions & 4 deletions app/helpers/events_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,19 @@ def onboarding_event_row_path(event_or_id, registration_id)
onboarding_event_path(event_or_id, anchor: onboarding_row_id(registration_id), highlight: registration_id)
end

# Stable anchor id for a registrant's row on the Registrants roster, so back
# links (e.g. from a viewed submission) can scroll to and highlight it.
def registrant_row_id(record_or_id)
id = record_or_id.respond_to?(:id) ? record_or_id.id : record_or_id
"registrant-row-#{id}"
end

# Path back to a specific registrant's row on the Registrants roster (scrolls
# to and highlights it). Accepts an Event or event id, and a registration id.
def registrants_event_row_path(event_or_id, registration_id)
registrants_event_path(event_or_id, anchor: registrant_row_id(registration_id), highlight: registration_id)
end

# Ordered column descriptors for the event Onboarding matrix. The array index
# is the table-sort column index, so the header row and every body row iterate
# this same list β€” keeping header buttons and cell positions aligned no matter
Expand Down Expand Up @@ -39,10 +52,12 @@ def onboarding_columns(event)
columns << { key: "scholarship_amount", label: "Scholarship amount", kind: :scholarship_amount, sortable: true, align: "center", toggle: "scholarship_amount" }
columns << { key: "funder", label: "Scholarship grant", kind: :funder, sortable: true, align: "left", toggle: "funder" }
columns << { key: "scholarship_tasks_completed", label: "Scholarship tasks done", kind: :scholarship_tasks, sortable: true, align: "center", toggle: "scholarship_tasks_completed" }
columns << { key: "ce_requested", label: "CE requested", kind: :ce_requested, sortable: true, align: "center", toggle: "ce_requested" }
columns << { key: "ce_hours", label: "CE hours", kind: :ce_hours, sortable: true, align: "center", toggle: "ce_hours" }
columns << { key: "ce_amount", label: "CE amount", kind: :ce_amount, sortable: true, align: "center", toggle: "ce_amount" }
columns << { key: "ce_license", label: "License #", kind: :ce_license, sortable: true, align: "center", toggle: "ce_license" }
if event.ce_eligible?
columns << { key: "ce_requested", label: "CE requested", kind: :ce_requested, sortable: true, align: "center", toggle: "ce_requested" }
columns << { key: "ce_hours", label: "CE hours", kind: :ce_hours, sortable: true, align: "center", toggle: "ce_hours" }
columns << { key: "ce_amount", label: "CE amount", kind: :ce_amount, sortable: true, align: "center", toggle: "ce_amount" }
columns << { key: "ce_license", label: "License #", kind: :ce_license, sortable: true, align: "center", toggle: "ce_license" }
end
columns << { key: "fee_note", label: "Fee note", kind: :fee_note, sortable: false, align: "center", toggle: "fee_note" }
columns << { key: "portal_invite", label: "Portal invite", kind: :portal_invite, sortable: true, align: "center", toggle: "portal_invite" }
EventRegistration::CHECKLIST_STEPS.each do |step, label|
Expand Down
19 changes: 13 additions & 6 deletions app/helpers/person_helper.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
module PersonHelper
def person_profile_button(person, truncate_at: nil, subtitle: nil, display_name: nil, data: {}, inactive: false, path_params: {}, width_class: "w-full")
def person_profile_button(person, truncate_at: nil, subtitle: nil, display_name: nil, data: {}, inactive: false, path_params: {}, width_class: "w-full", compact: false)
# Compact mode shrinks the whole control (padding, avatar, type) for dense
# tables like the registrants roster where horizontal space is at a premium.
padding = compact ? "px-2 py-1" : "px-4 py-2"
avatar_size = compact ? "w-6 h-6" : "w-10 h-10"
initial_text_size = compact ? "text-xs" : "text-lg"
name_text_size = compact ? "text-xs" : ""

if inactive
bg = "bg-gray-100"
hover_bg = "hover:bg-gray-200"
Expand All @@ -19,7 +26,7 @@ def person_profile_button(person, truncate_at: nil, subtitle: nil, display_name:
data: { turbo_prefetch: false }.merge(data),
title: hover_title,
class: "group relative flex items-center gap-2
#{width_class} px-4 py-2
#{width_class} #{padding}
border #{border} #{bg} #{hover_bg} rounded-lg
transition-colors duration-200
font-medium shadow-sm leading-none
Expand All @@ -29,11 +36,11 @@ def person_profile_button(person, truncate_at: nil, subtitle: nil, display_name:
# --- Avatar ---
avatar = if person.avatar.present?
image_tag person.avatar.variant(:thumbnail),
class: "w-10 h-10 rounded-full object-cover border border-gray-300 shadow-sm flex-shrink-0"
class: "#{avatar_size} rounded-full object-cover border border-gray-300 shadow-sm flex-shrink-0"
else
content_tag(:span, person.name.to_s.first.to_s.upcase,
class: "w-10 h-10 rounded-full flex items-center justify-center
bg-sky-200 text-sky-700 font-bold text-lg
class: "#{avatar_size} rounded-full flex items-center justify-center
bg-sky-200 text-sky-700 font-bold #{initial_text_size}
border border-sky-300 shadow-sm flex-shrink-0")
end

Expand All @@ -43,7 +50,7 @@ def person_profile_button(person, truncate_at: nil, subtitle: nil, display_name:
name = content_tag(
:span,
display_name,
class: "font-semibold #{text} truncate"
class: "font-semibold #{name_text_size} #{text} truncate"
)

subtitle_tag = if subtitle.present?
Expand Down
Loading