diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e7b7afbda9..217463f976 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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) diff --git a/CLAUDE.md b/CLAUDE.md index dbd052f2ff..40356d2cb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 74648f3805..feacac6827 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -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? @@ -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 @@ -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, @@ -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? @@ -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) } @@ -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 || "", @@ -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}" } @@ -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(", ") @@ -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 diff --git a/app/frontend/javascript/controllers/column_toggle_controller.js b/app/frontend/javascript/controllers/column_toggle_controller.js index 7992e4da08..0329c4ad95 100644 --- a/app/frontend/javascript/controllers/column_toggle_controller.js +++ b/app/frontend/javascript/controllers/column_toggle_controller.js @@ -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() { 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)" : "" } } diff --git a/app/frontend/stylesheets/application.tailwind.css b/app/frontend/stylesheets/application.tailwind.css index bd63158acb..a40cce757d 100644 --- a/app/frontend/stylesheets/application.tailwind.css +++ b/app/frontend/stylesheets/application.tailwind.css @@ -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 diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 25a706efda..2871b44ba8 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -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 @@ -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| diff --git a/app/helpers/person_helper.rb b/app/helpers/person_helper.rb index 6044bb2ef5..e2d97eee10 100644 --- a/app/helpers/person_helper.rb +++ b/app/helpers/person_helper.rb @@ -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" @@ -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 @@ -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 @@ -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? diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index b776d45898..96cb0e45ee 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -147,6 +147,63 @@ class EventRegistration < ApplicationRecord else all end } + # Mirrors ReminderRecipientFilter#matches_ce_status?. The "license"/"hours" + # sub-statuses only make sense for someone who requested CE credit, so they're + # gated on it. "paid" has no CE-specific payment record yet, so it falls back + # to the registrant being paid in full. + scope :ce_status, ->(value) { + case value + when "requested" then where(ce_credit_requested: true) + when "license_not_provided" then where(ce_credit_requested: true).where(ce_license_number: [ nil, "" ]) + when "hours_not_provided" then where(ce_credit_requested: true).where("COALESCE(ce_hours_requested, 0) <= 0") + when "paid" then where(ce_credit_requested: true).merge(paid_in_full) + else all + end + } + scope :comment_status, ->(value) { + commented = Comment.where(commentable_type: "EventRegistration").select(:commentable_id) + case value + when "none" then where.not(id: commented) + when "present" then where(id: commented) + when "flagged" then where(id: Comment.where(commentable_type: "EventRegistration", flagged: true).select(:commentable_id)) + else all + end + } + # Mirrors EventRegistration#account_status (none / has_access / invited / + # no_access) as a DB filter, joining the registrant's login account. + scope :account_status, ->(value) { + # Guard every subquery against NULL person_id (system/audit users) — a NULL in + # a NOT IN list makes the whole comparison return no rows. + with_user = User.where.not(person_id: nil).select(:person_id) + has_access = User.has_access.where.not(person_id: nil).select(:person_id) + invited = User.where.not(person_id: nil).where.not(welcome_instructions_sent_at: nil).select(:person_id) + case value + when "none" then where.not(registrant_id: with_user) + when "has_access" then where(registrant_id: has_access) + when "invited" then where(registrant_id: invited).where.not(registrant_id: has_access) + when "no_access" then where(registrant_id: with_user).where.not(registrant_id: has_access).where.not(registrant_id: invited) + else all + end + } + # "linked" = at least one organization linked; "pending" = the registrant + # submitted an agency name on the event's registration form but nothing is + # linked yet (mirrors the Pending chip on the roster). Needs the event to + # resolve its registration form's agency_name field. + scope :organization_status, ->(value, event) { + linked = EventRegistrationOrganization.select(:event_registration_id) + case value + when "linked" then where(id: linked) + when "pending" + field = event.registration_form&.form_fields&.find_by(field_identifier: "agency_name") + next none unless field + submitted = FormAnswer.joins(:form_submission) + .where(form_field_id: field.id, form_submissions: { form_id: event.registration_form.id }) + .where.not(submitted_answer: [ nil, "" ]) + .select(Arel.sql("form_submissions.person_id")) + where(registrant_id: submitted).where.not(id: linked) + else all + end + } scope :keyword, ->(term) { return none if term.blank? @@ -262,6 +319,17 @@ def ce_license_provided? ce_license_number.present? end + # A short label summarizing the registrant's CE credit standing, matching the + # ce_status filter buckets. Nil when CE credit was not requested, so callers + # can render a placeholder. "Incomplete" takes precedence over "Paid" because + # a missing license/hours is the actionable state regardless of payment. + def ce_status_label + return unless ce_credit_requested? + return "Incomplete" if !ce_license_provided? || ce_hours_requested.to_i <= 0 + return "Paid" if paid_in_full? + "Requested" + end + # What the registrant owes for their requested CE hours, in cents, at the # default hourly rate. Zero when no hours were requested. def ce_amount_owed_cents diff --git a/app/services/reminder_recipient_filter.rb b/app/services/reminder_recipient_filter.rb index adcc677b8a..b0abe89ef5 100644 --- a/app/services/reminder_recipient_filter.rb +++ b/app/services/reminder_recipient_filter.rb @@ -6,18 +6,26 @@ # like scholarships, grants, organizations, comments and the registrant's user # account without extra per-filter SQL. class ReminderRecipientFilter - FILTER_KEYS = %i[ - name reg_org grantor comment email payment_status scholarship_status - account_status ce_status + # Free-text inputs matched in memory against the loaded registrations. + TEXT_KEYS = %i[ name reg_org grantor comment email ].freeze + # Dropdown filters shared with the registrants roster. They reuse the + # EventRegistration scopes (run once as a query) so both pages stay in sync — + # same param names, options, and semantics. + DROPDOWN_KEYS = %i[ + attendance_status payment_status ce_status scholarship comment_status + org_status account_status state county ].freeze + FILTER_KEYS = (TEXT_KEYS + DROPDOWN_KEYS).freeze - def initialize(event_registrations, params) + def initialize(event_registrations, params, event: nil) @event_registrations = event_registrations @params = params + @event = event || event_registrations.first&.event end def matched_ids - @event_registrations.select { |reg| matches?(reg) }.map(&:id).to_set + text_matched = @event_registrations.select { |reg| matches_text?(reg) }.map(&:id).to_set + text_matched & dropdown_matched_ids end # True when at least one filter is narrowing the list. The view uses this to @@ -29,16 +37,31 @@ def filtering? private - def matches?(reg) + def matches_text?(reg) matches_name?(reg) && matches_reg_org?(reg) && matches_grantor?(reg) && matches_comment?(reg) && - matches_email?(reg) && - matches_payment_status?(reg) && - matches_scholarship_status?(reg) && - matches_account_status?(reg) && - matches_ce_status?(reg) + matches_email?(reg) + end + + # Apply the registrants-roster scopes for whichever dropdowns are set, then + # return the matching ids. With no dropdown filter this is every registration, + # so the text-match set passes through unchanged. + def dropdown_matched_ids + return @event_registrations.map(&:id).to_set if @event.nil? + + scope = @event.event_registrations + scope = scope.attendance_status(@params[:attendance_status]) if @params[:attendance_status].present? + scope = scope.payment_status(@params[:payment_status]) if @params[:payment_status].present? + scope = scope.ce_status(@params[:ce_status]) if @params[:ce_status].present? + scope = scope.scholarship_status(@params[:scholarship]) if @params[:scholarship].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_state(@params[:state]) if @params[:state].present? + scope = scope.registrant_county(@params[:county]) if @params[:county].present? + scope.pluck(:id).to_set end def matches_name?(reg) @@ -83,46 +106,6 @@ def registrant_emails(reg) .compact_blank.map(&:downcase) end - def matches_payment_status?(reg) - case @params[:payment_status].presence - when "paid" then reg.paid_in_full? - when "unpaid" then reg.event.cost_cents.to_i > 0 && !reg.paid_in_full? - when "intends_to_pay" then reg.intends_to_pay? - when "paid_or_intends" then reg.payment_access_granted? - else true - end - end - - def matches_scholarship_status?(reg) - case @params[:scholarship_status].presence - when "requested" then reg.scholarship_requested? - when "allocated" then reg.scholarships.any? - when "tasks_completed" - reg.scholarships.any? && reg.scholarships.all?(&:tasks_completed?) - else true - end - end - - def matches_account_status?(reg) - status = @params[:account_status].presence - return true if status.blank? - reg.account_status == status - end - - # CE sub-statuses (missing license / missing hours) only make sense for someone - # who actually requested CE credit, so they're gated on that. "paid" has no - # CE-specific payment record yet, so it falls back to the registrant being paid - # in full. - def matches_ce_status?(reg) - case @params[:ce_status].presence - when "requested" then reg.ce_credit_requested? - when "license_not_provided" then reg.ce_credit_requested? && !reg.ce_license_provided? - when "hours_not_provided" then reg.ce_credit_requested? && reg.ce_hours_requested.to_i <= 0 - when "paid" then reg.ce_credit_requested? && reg.paid_in_full? - else true - end - end - # Text filters accept several values separated by "--" and match a registrant # when ANY of them hits (e.g. "amy--aisha" keeps both Amy and Aisha). Single # hyphens inside a value are preserved. Returns true when no term was typed. diff --git a/app/views/allocations/index.html.erb b/app/views/allocations/index.html.erb index df4fc5dcb9..a24f123c5a 100644 --- a/app/views/allocations/index.html.erb +++ b/app/views/allocations/index.html.erb @@ -11,7 +11,7 @@ Bulk payments <% end %> <% elsif @allocatable.is_a?(EventRegistration) && params[:return_to] == "registrants" %> - <%= link_to registrants_event_path(@allocatable.event), class: "text-sm text-gray-500 hover:text-gray-700" do %> + <%= link_to registrants_event_row_path(@allocatable.event, @allocatable.id), class: "text-sm text-gray-500 hover:text-gray-700" do %> Registrants <% end %> <% elsif @allocatable.is_a?(EventRegistration) && params[:return_to] == "onboarding" %> @@ -23,7 +23,7 @@ Registration <% end %> <% elsif @allocatable.respond_to?(:event) %> - <%= link_to registrants_event_path(@allocatable.event), class: "text-sm text-gray-500 hover:text-gray-700" do %> + <%= link_to registrants_event_row_path(@allocatable.event, @allocatable.id), class: "text-sm text-gray-500 hover:text-gray-700" do %> Registrants <% end %> <% else %> diff --git a/app/views/event_registrations/_form.html.erb b/app/views/event_registrations/_form.html.erb index 13a1091ad2..9cd5247c47 100644 --- a/app/views/event_registrations/_form.html.erb +++ b/app/views/event_registrations/_form.html.erb @@ -137,8 +137,8 @@ <% active_orgs = f.object.registrant.affiliations.select { |a| !a.inactive? && (a.end_date.nil? || a.end_date >= Date.current) }.map(&:organization).compact.uniq.sort_by(&:name) %> <% connected_org_ids = f.object.organizations.map(&:id) %> <% addable_orgs = active_orgs.reject { |org| connected_org_ids.include?(org.id) } %> -
-
+
+
@@ -298,10 +298,10 @@ <% end %> <%# ---- Comments ---- %> -
+
- - + +

Registration comments

<%= link_to event_registration_comments_path(f.object), @@ -372,11 +372,12 @@ <%= rf.input :shoutout_text, label: "Shout-out text", label_html: { class: "sr-only" }, - as: :string, + as: :text, placeholder: "Shown beneath their name on the recipients page", wrapper_html: { class: "mb-0" }, input_html: { maxlength: 1650, + rows: 1, class: "w-full rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-900 shadow-sm focus:border-emerald-500 focus:ring focus:ring-emerald-200 focus:outline-none" } %> <% end %> @@ -442,7 +443,7 @@ <% end %> <% cancel_path = case params[:return_to] - when "registrants" then registrants_event_path(event_registration.event) + when "registrants" then registrants_event_row_path(event_registration.event, event_registration.id) when "index" then event_registrations_path when "preview_reminder" then preview_reminder_event_path(event_registration.event) else allowed_to?(:index?, EventRegistration) ? event_registrations_path : root_path diff --git a/app/views/event_registrations/confirm.html.erb b/app/views/event_registrations/confirm.html.erb index a21f665953..5f4a98324f 100644 --- a/app/views/event_registrations/confirm.html.erb +++ b/app/views/event_registrations/confirm.html.erb @@ -84,7 +84,7 @@
- <% skip_path = @return_to == "registrants" ? registrants_event_path(@event_registration.event) : registration_ticket_path(@event_registration.slug) %> + <% skip_path = @return_to == "registrants" ? registrants_event_row_path(@event_registration.event, @event_registration.id) : registration_ticket_path(@event_registration.slug) %> <%= link_to "Skip", skip_path, class: "btn btn-secondary-outline" %>
diff --git a/app/views/event_registrations/edit.html.erb b/app/views/event_registrations/edit.html.erb index b3baa81e64..93a31ae28e 100644 --- a/app/views/event_registrations/edit.html.erb +++ b/app/views/event_registrations/edit.html.erb @@ -19,7 +19,7 @@ Onboarding <% end %> <% else %> - <%= link_to registrants_event_path(@event_registration.event), class: "text-sm text-gray-500 hover:text-gray-700" do %> + <%= link_to registrants_event_row_path(@event_registration.event, @event_registration.id), class: "text-sm text-gray-500 hover:text-gray-700" do %> Registrants <% end %> <% end %> diff --git a/app/views/event_registrations/link_organization.html.erb b/app/views/event_registrations/link_organization.html.erb index eabdecd84a..198353e7e5 100644 --- a/app/views/event_registrations/link_organization.html.erb +++ b/app/views/event_registrations/link_organization.html.erb @@ -8,7 +8,7 @@ <% if params[:return_to] == "onboarding" %> <%= link_to "← Back to onboarding", onboarding_event_row_path(@event_registration.event, @event_registration.id), class: "text-sm text-gray-500 hover:text-gray-700" %> <% else %> - <%= link_to "← Back to registrants", registrants_event_path(@event_registration.event), class: "text-sm text-gray-500 hover:text-gray-700" %> + <%= link_to "← Back to registrants", registrants_event_row_path(@event_registration.event, @event_registration.id), class: "text-sm text-gray-500 hover:text-gray-700" %> <% end %>
diff --git a/app/views/events/_filter_select.html.erb b/app/views/events/_filter_select.html.erb new file mode 100644 index 0000000000..68e39662ae --- /dev/null +++ b/app/views/events/_filter_select.html.erb @@ -0,0 +1,20 @@ +<%# Shared labeled + wrapper_class – optional wrapper width (defaults to the roster's w-48) %> +
"> + <%= label_tag param, label, class: "block text-sm font-medium text-gray-700 mb-1" %> + <%# Grey the text only while the include_blank placeholder (the first option) is + selected, so a chosen value still reads in dark text. %> + <%= select_tag param, + options_for_select(options, selected), + include_blank: blank, + class: "#{field_class} has-[option:first-child:checked]:text-gray-400" %> +
diff --git a/app/views/events/_registrants_results.html.erb b/app/views/events/_registrants_results.html.erb index 5e458c9695..c4f853186d 100644 --- a/app/views/events/_registrants_results.html.erb +++ b/app/views/events/_registrants_results.html.erb @@ -1,5 +1,7 @@ <%= turbo_frame_tag :registrants_results do %> -
+ <%# Scholarship column shows by default only when the event charges a fee. %> + <% scholarship_on = @event.cost_cents.to_i > 0 %> +
<% current_filter = params[:attendance_status].present? ? nil : (params[:status_filter].presence || "active") %> - +
+ <%# On by default — Organization shows unless the admin hides it. %> + + + <% if @ce_eligible %> + + <% end %> + + <%# On by default when the event charges a fee; always togglable. %> + + + <%# Off by default — Attendance is hidden until the admin reveals it. %> + + + +
<% event_form_ids = @event.form_ids %> @@ -40,8 +122,15 @@ <% if @event_registrations.any? %>
<% payment_col = @event.cost_cents.to_i > 0 %> - <% date_index = payment_col ? 5 : 4 %> - <% attendance_index = payment_col ? 6 : 5 %> + <% ce_col = @ce_eligible %> + <% extra_cols = (payment_col ? 1 : 0) + (ce_col ? 1 : 0) %> + <%# Column order: Name, Organization, [CE status], Scholarship, [Payment], + Confirmed (hidden), Attendance, Edit, Date registered (far right). %> + <% ce_index = 2 %> + <% scholarship_index = ce_col ? 3 : 2 %> + <% payment_index = scholarship_index + 1 %> + <% attendance_index = 4 + extra_cols %> + <% date_index = 6 + extra_cols %> @@ -52,27 +141,34 @@ <%= render "shared/sortable_header", label: "Last", index: 0, key: "last" %> - - + <% end %> + + <% if payment_col %> <% end %> - - - + + @@ -80,33 +176,45 @@ <% @event_registrations.each do |registration| %> <% person = registration.registrant %> - + <% highlighted = params[:highlight].to_s == registration.id.to_s %> + "> - - + <% end %> + + <% end %> - - - + + <% end %> diff --git a/app/views/events/_registrants_search.html.erb b/app/views/events/_registrants_search.html.erb index 1db8ea304f..c9246bd47f 100644 --- a/app/views/events/_registrants_search.html.erb +++ b/app/views/events/_registrants_search.html.erb @@ -1,90 +1,81 @@ +<% field_class = "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> +<% label_class = "block text-sm font-medium text-gray-700 mb-1" %> <%= form_with url: registrants_event_path(@event), method: :get, data: { controller: "collection", turbo_frame: "registrants_results" }, autocomplete: "off", - class: "flex flex-col md:flex-row md:flex-wrap md:items-end gap-4 mb-6" do %> + class: "mb-6 bg-white border border-gray-200 rounded-xl shadow-sm p-4" do %> <%= hidden_field_tag :status_filter, params[:status_filter].presence || "active" %> -
- <%= label_tag :keyword, "Keyword", class: "block text-sm font-medium text-gray-700 mb-1" %> -
- <%= text_field_tag :keyword, params[:keyword], - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm - focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none", - placeholder: "Name, email, phone, city, or organization" %> - + <%# Row 1: keyword input + record-status filters. Keyword flexes to fill the row; + the dropdowns render via the shared events/filter_select partial (also used + by the reminder filters). %> +
+
+ <%= label_tag :keyword, "Keyword", class: label_class %> +
+ <%= text_field_tag :keyword, params[:keyword], class: field_class, + placeholder: "Name, email, phone, city, or organization" %> + +
-
-
- <%= label_tag :attendance_status, "Attendance status", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= select_tag :attendance_status, - options_for_select( - EventRegistration::ATTENDANCE_STATUSES.map { |s| [EventRegistration.new(status: s).attendance_status_label, s] }, - params[:attendance_status] - ), - include_blank: "All statuses", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm - focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> + <%= render "events/filter_select", param: :attendance_status, label: "Attendance status", + options: EventRegistration::ATTENDANCE_STATUSES.map { |s| [ EventRegistration.new(status: s).attendance_status_label, s ] }, + selected: params[:attendance_status], blank: "All statuses", field_class: field_class %> + + <% if @event.cost_cents.to_i > 0 %> + <%= render "events/filter_select", param: :payment_status, label: "Payment", + options: [ [ "Due", "unpaid" ], [ "Paid", "paid" ], [ "Intends to pay", "intends_to_pay" ] ], + selected: params[:payment_status], blank: "Any payment status", field_class: field_class %> + <% end %> + + <% 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" ] ], + selected: params[:ce_status], blank: "Any CE status", field_class: field_class %> + <% end %> + + <% if @event.cost_cents.to_i > 0 %> + <%= render "events/filter_select", param: :scholarship, label: "Scholarship", + options: [ [ "All recipients", "yes" ], [ "Tasks complete", "complete" ], [ "Tasks not complete", "incomplete" ] ], + selected: params[:scholarship], blank: "All registrants", field_class: field_class %> + <% end %>
- <% if @event.cost_cents.to_i > 0 %> -
- <%= label_tag :payment_status, "Payment", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= select_tag :payment_status, - options_for_select( - [ [ "Due", "unpaid" ], [ "Paid", "paid" ], [ "Intends to pay", "intends_to_pay" ] ], - params[:payment_status] - ), - include_blank: "Any payment status", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm - focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> -
+ <%# Row 2: organization + comments + location filters, and the clear action. %> +
+ <%= render "events/filter_select", param: :org_status, label: "Organization", + options: [ [ "Pending", "pending" ], [ "Linked", "linked" ] ], + selected: params[:org_status], blank: "All organizations", field_class: field_class %> -
- <%= label_tag :scholarship, "Scholarship", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= select_tag :scholarship, - options_for_select( - [ [ "All recipients", "yes" ], [ "Tasks complete", "complete" ], [ "Tasks not complete", "incomplete" ] ], - params[:scholarship] - ), - include_blank: "All registrants", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm - focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> -
- <% end %> + <%= render "events/filter_select", param: :comment_status, label: "Comments", + options: [ [ "None", "none" ], [ "Present", "present" ], [ "Flagged", "flagged" ] ], + selected: params[:comment_status], blank: "Any comments", field_class: field_class %> - <% if @dashboard.states.any? %> -
- <%= label_tag :state, "State", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= select_tag :state, - options_for_select(@dashboard.states, params[:state]), - include_blank: "All states", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm - focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> -
- <% end %> + <%= render "events/filter_select", param: :account_status, label: "User account", + options: [ [ "No account", "none" ], [ "Invited", "invited" ], [ "Has access", "has_access" ], [ "No access", "no_access" ] ], + selected: params[:account_status], blank: "Any account status", field_class: field_class %> - <% if @dashboard.counties.any? %> -
- <%= label_tag :county, "County", class: "block text-sm font-medium text-gray-700 mb-1" %> - <%= select_tag :county, - options_for_select( - @dashboard.counties.map { |state, county| [ "#{state} - #{county}", "#{state}|#{county}" ] }, - params[:county] - ), - include_blank: "All counties", - class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm - focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> -
- <% end %> + <% if @dashboard.states.any? %> + <%= render "events/filter_select", param: :state, label: "State", + options: @dashboard.states, selected: params[:state], blank: "All states", field_class: field_class, wrapper_class: "w-full md:w-32" %> + <% end %> + + <% if @dashboard.counties.any? %> + <%= render "events/filter_select", param: :county, label: "County", + options: @dashboard.counties.map { |state, county| [ "#{state} - #{county}", "#{state}|#{county}" ] }, + selected: params[:county], blank: "All counties", field_class: field_class %> + <% end %> -
- <%= link_to "Clear filters", registrants_event_path(@event), - class: "btn btn-utility-outline", - data: { action: "collection#clearAndSubmit" } %> +
+ <%= link_to "Clear filters", registrants_event_path(@event), + 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 2e9a5c618b..3491a19764 100644 --- a/app/views/events/_reminder_recipient_filters.html.erb +++ b/app/views/events/_reminder_recipient_filters.html.erb @@ -3,79 +3,88 @@ auto-submits into the reminder_recipients turbo frame via the collection controller, mirroring the workshops and registrants filters. %> <% field_class = "w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-800 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %> -<%# Grey the dropdown text only while the include_blank placeholder (the first - option) is the one selected, so a chosen value still reads in dark text. %> -<% select_class = "#{field_class} has-[option:first-child:checked]:text-gray-400" %> <% label_class = "block text-sm font-medium text-gray-700 mb-1" %> <%= form_with url: preview_reminder_event_path(@event), method: :get, data: { controller: "collection", turbo_frame: "reminder_recipients" }, autocomplete: "off", class: "bg-white border border-gray-200 rounded-xl shadow-sm p-4 mb-6" do %> -
-
+ <%# Inputs wrap in a flex row sized to match the shared filter dropdowns (w-48). %> +
+
<%= label_tag :name, "Name", class: label_class %> <%= text_field_tag :name, params[:name], class: field_class, placeholder: "Registrant name" %>
-
+
<%= label_tag :reg_org, "Organization", class: label_class %> <%= text_field_tag :reg_org, params[:reg_org], class: field_class, placeholder: "Organization name" %>
-
+
<%= label_tag :grantor, "Grantor", class: label_class %> <%= text_field_tag :grantor, params[:grantor], class: field_class, placeholder: "Grantor name" %>
-
+
<%= label_tag :comment, "Comment", class: label_class %> <%= text_field_tag :comment, params[:comment], class: field_class, placeholder: "Comment text" %>
-
+
<%= label_tag :email, "Email", class: label_class %> <%= text_field_tag :email, params[:email], class: field_class, placeholder: "Email address" %>
-
+ <%# Record-status filters — the same set (params/options) as the registrants + roster so the two stay in sync and filters can carry between the pages. %> +
+ <%= render "events/filter_select", param: :attendance_status, label: "Attendance status", + options: EventRegistration::ATTENDANCE_STATUSES.map { |s| [ EventRegistration.new(status: s).attendance_status_label, s ] }, + selected: params[:attendance_status], blank: "All statuses", field_class: field_class %> + <% if @event.cost_cents.to_i > 0 %> -
- <%= label_tag :payment_status, "Payment", class: label_class %> - <%= select_tag :payment_status, - options_for_select( - [ [ "Paid", "paid" ], [ "Due", "unpaid" ], [ "Intends to pay", "intends_to_pay" ], [ "Paid + intends", "paid_or_intends" ] ], - params[:payment_status] - ), - include_blank: "Any payment status", class: select_class %> -
+ <%= render "events/filter_select", param: :payment_status, label: "Payment", + options: [ [ "Due", "unpaid" ], [ "Paid", "paid" ], [ "Intends to pay", "intends_to_pay" ] ], + selected: params[:payment_status], blank: "Any payment status", field_class: field_class %> <% end %> -
- <%= label_tag :scholarship_status, "Scholarship", class: label_class %> - <%= select_tag :scholarship_status, - options_for_select( - [ [ "Requested", "requested" ], [ "Allocated", "allocated" ], [ "Tasks completed", "tasks_completed" ] ], - params[:scholarship_status] - ), - include_blank: "Any scholarship status", class: select_class %> -
-
- <%= label_tag :ce_status, "CE", class: label_class %> - <%= select_tag :ce_status, - options_for_select( - [ [ "Requested", "requested" ], [ "License not provided", "license_not_provided" ], [ "Hours not provided", "hours_not_provided" ], [ "Paid", "paid" ] ], - params[:ce_status] - ), - include_blank: "Any CE status", class: select_class %> -
-
- <%= label_tag :account_status, "User account", class: label_class %> - <%= select_tag :account_status, - options_for_select( - [ [ "No account", "none" ], [ "Invited", "invited" ], [ "Has access", "has_access" ], [ "No access", "no_access" ] ], - params[:account_status] - ), - include_blank: "Any account status", class: select_class %> -
-
+ + <% 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" ] ], + selected: params[:ce_status], blank: "Any CE status", field_class: field_class %> + <% end %> + + <% if @event.cost_cents.to_i > 0 %> + <%= render "events/filter_select", param: :scholarship, label: "Scholarship", + options: [ [ "All recipients", "yes" ], [ "Tasks complete", "complete" ], [ "Tasks not complete", "incomplete" ] ], + selected: params[:scholarship], blank: "All registrants", field_class: field_class %> + <% end %> +
+ +
+ <%= render "events/filter_select", param: :org_status, label: "Organization", + options: [ [ "Pending", "pending" ], [ "Linked", "linked" ] ], + selected: params[:org_status], blank: "All organizations", field_class: field_class %> + + <%= render "events/filter_select", param: :comment_status, label: "Comments", + options: [ [ "None", "none" ], [ "Present", "present" ], [ "Flagged", "flagged" ] ], + selected: params[:comment_status], blank: "Any comments", field_class: field_class %> + + <%= render "events/filter_select", param: :account_status, label: "User account", + options: [ [ "No account", "none" ], [ "Invited", "invited" ], [ "Has access", "has_access" ], [ "No access", "no_access" ] ], + selected: params[:account_status], blank: "Any account status", field_class: field_class %> + + <% if @dashboard&.states&.any? %> + <%= render "events/filter_select", param: :state, label: "State", + options: @dashboard.states, selected: params[:state], blank: "All states", field_class: field_class, wrapper_class: "w-full md:w-32" %> + <% end %> + + <% if @dashboard&.counties&.any? %> + <%= render "events/filter_select", param: :county, label: "County", + options: @dashboard.counties.map { |state, county| [ "#{state} - #{county}", "#{state}|#{county}" ] }, + selected: params[:county], blank: "All counties", field_class: field_class %> + <% end %> + +
<%= link_to "Clear filters", preview_reminder_event_path(@event), - class: "btn btn-utility-outline", + class: "btn btn-utility", data: { action: "collection#clearAndSubmit" } %>
diff --git a/app/views/events/onboarding/_row.html.erb b/app/views/events/onboarding/_row.html.erb index fbb0275718..19ee6ad93f 100644 --- a/app/views/events/onboarding/_row.html.erb +++ b/app/views/events/onboarding/_row.html.erb @@ -8,13 +8,13 @@ <% new_scholarship_link = new_scholarship_path(allocatable_sgid: registration.to_sgid.to_s, return_to: "onboarding") %> <% align_class = { "left" => "text-left", "center" => "text-center", "right" => "text-right" } %> <% highlighted = params[:highlight].to_s == registration.id.to_s %> -
"> +"> <% columns.each do |column| %> <% td_classes = [ "px-4 py-2 text-sm", align_class[column[:align]] ] if column[:sticky] # The row's inset ring is painted over by this sticky cell, so re-draw the - # amber border on the cell itself (same ring utility as the row). - td_classes << (highlighted ? "sticky left-0 bg-amber-50 ring-2 ring-inset ring-amber-300" : "sticky left-0 bg-white") + # highlight border on the cell itself (same ring utility as the row). + td_classes << (highlighted ? "sticky left-0 bg-yellow-50 ring-2 ring-inset ring-yellow-500" : "sticky left-0 bg-white") end %> <% case column[:kind] when :first_name %> diff --git a/app/views/events/public_registrations/show.html.erb b/app/views/events/public_registrations/show.html.erb index 5b5f45a5ca..5189386def 100644 --- a/app/views/events/public_registrations/show.html.erb +++ b/app/views/events/public_registrations/show.html.erb @@ -1,8 +1,18 @@ <% content_for(:page_bg_class, "public") %> <% reg = @form_submission.person.event_registrations.find_by(event: @event) %> -<% back_path = reg&.slug.present? ? registration_ticket_path(reg.slug) : event_path(@event) %> -<% back_label = reg&.slug.present? ? "Back to ticket" : "Back to event" %> +<%# Viewed from the admin Registrants roster (same-tab nav): return to that + registrant's row, not the public ticket. Falls back to ticket/event. %> +<% if params[:return_to] == "registrants" && reg && allowed_to?(:index?, EventRegistration) %> + <% back_path = registrants_event_row_path(@event, params[:return_registration_id].presence || reg.id) %> + <% back_label = "Back to registrants" %> +<% elsif reg&.slug.present? %> + <% back_path = registration_ticket_path(reg.slug) %> + <% back_label = "Back to ticket" %> +<% else %> + <% back_path = event_path(@event) %> + <% back_label = "Back to event" %> +<% end %>
<%= link_to back_path, class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %> diff --git a/app/views/people/show.html.erb b/app/views/people/show.html.erb index d8fdc59d3b..2aaad7ddc3 100644 --- a/app/views/people/show.html.erb +++ b/app/views/people/show.html.erb @@ -2,8 +2,9 @@ <%# Returned from a registrant's profile link on the event registrants page. Shown above the profile topper; needs event_id since the profile isn't event-scoped. %> <% if params[:return_to] == "registrants" && params[:event_id].present? %> + <% back_registration = @person.event_registrations.find_by(event_id: params[:event_id]) %>
- <%= link_to registrants_event_path(params[:event_id]), class: "text-sm text-gray-500 hover:text-gray-700" do %> + <%= link_to(back_registration ? registrants_event_row_path(params[:event_id], back_registration.id) : registrants_event_path(params[:event_id]), class: "text-sm text-gray-500 hover:text-gray-700") do %> Back to registrants <% end %>
diff --git a/app/views/scholarships/_form.html.erb b/app/views/scholarships/_form.html.erb index d0d58f55b6..64906d97c2 100644 --- a/app/views/scholarships/_form.html.erb +++ b/app/views/scholarships/_form.html.erb @@ -247,7 +247,7 @@ elsif @allocatable.respond_to?(:event) && params[:return_to] == "onboarding" onboarding_event_row_path(@allocatable.event, @allocatable.id) elsif @allocatable.respond_to?(:event) - params[:return_to] == "registration" ? edit_event_registration_path(@allocatable) : registrants_event_path(@allocatable.event) + params[:return_to] == "registration" ? edit_event_registration_path(@allocatable) : registrants_event_row_path(@allocatable.event, @allocatable.id) elsif grant.present? grant_path(grant) else diff --git a/app/views/scholarships/edit.html.erb b/app/views/scholarships/edit.html.erb index 4aadfe6b71..27a3311abb 100644 --- a/app/views/scholarships/edit.html.erb +++ b/app/views/scholarships/edit.html.erb @@ -23,7 +23,7 @@ Onboarding <% end %> <% elsif @allocatable.respond_to?(:event) %> - <%= link_to registrants_event_path(@allocatable.event), class: "text-sm text-gray-500 hover:text-gray-700" do %> + <%= link_to registrants_event_row_path(@allocatable.event, @allocatable.id), class: "text-sm text-gray-500 hover:text-gray-700" do %> Registrants <% end %> <% elsif grant.present? %> diff --git a/app/views/scholarships/new.html.erb b/app/views/scholarships/new.html.erb index 12b0797f1a..fa9f8ed7ac 100644 --- a/app/views/scholarships/new.html.erb +++ b/app/views/scholarships/new.html.erb @@ -17,7 +17,7 @@ Onboarding <% end %> <% elsif @allocatable.respond_to?(:event) %> - <%= link_to registrants_event_path(@allocatable.event), class: "text-sm text-gray-500 hover:text-gray-700" do %> + <%= link_to registrants_event_row_path(@allocatable.event, @allocatable.id), class: "text-sm text-gray-500 hover:text-gray-700" do %> Registrants <% end %> <% else %> diff --git a/lib/domain_theme.rb b/lib/domain_theme.rb index e291f9d101..0aeae0ad7a 100644 --- a/lib/domain_theme.rb +++ b/lib/domain_theme.rb @@ -30,6 +30,7 @@ module DomainTheme banners: :yellow, users: :rose, + comments: :purple, # Event dashboard cards payments: :green, diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index c330c32c41..cdc22ea13f 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -174,6 +174,110 @@ expect(EventRegistration.payment_status("bogus")).to include(paid_reg, unpaid_reg) end 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) + 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) } + + 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) + 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) + 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) + 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) + end + + it "returns an unfiltered relation for unknown values" do + expect(EventRegistration.ce_status("bogus")).to include(complete_ce, missing_ce, no_ce) + end + end + + describe ".comment_status" do + let!(:no_comment) { create(:event_registration, event: event) } + let!(:commented) { create(:event_registration, event: event).tap { |r| create(:comment, commentable: r, body: "Hi") } } + let!(:flagged) { create(:event_registration, event: event).tap { |r| create(:comment, commentable: r, body: "Flag", flagged: true) } } + + it "maps 'none' to registrations without comments" do + results = EventRegistration.comment_status("none") + expect(results).to include(no_comment) + expect(results).not_to include(commented, flagged) + end + + it "maps 'present' to registrations with any comment" do + results = EventRegistration.comment_status("present") + expect(results).to include(commented, flagged) + expect(results).not_to include(no_comment) + end + + it "maps 'flagged' to registrations with a flagged comment" do + results = EventRegistration.comment_status("flagged") + expect(results).to include(flagged) + expect(results).not_to include(no_comment, commented) + end + + it "returns an unfiltered relation for unknown values" do + expect(EventRegistration.comment_status("bogus")).to include(no_comment, commented, flagged) + end + end + + describe ".account_status" do + # The person factory auto-builds a confirmed user, so each case sets the + # registrant's account state explicitly (user: nil for no account). + let!(:none_reg) { create(:event_registration, event: event, registrant: create(:person, user: nil)) } + let!(:access_reg) { create(:event_registration, event: event, registrant: create(:person, user: create(:user, confirmed_at: Time.current))) } + let!(:invited_reg) { create(:event_registration, event: event, registrant: create(:person, user: create(:user, confirmed_at: nil, welcome_instructions_sent_at: Time.current))) } + let!(:no_access_reg) { create(:event_registration, event: event, registrant: create(:person, user: create(:user, confirmed_at: nil, welcome_instructions_sent_at: nil))) } + + it "maps 'none' to registrants without an account" do + results = EventRegistration.account_status("none") + expect(results).to include(none_reg) + expect(results).not_to include(access_reg, invited_reg, no_access_reg) + end + + it "maps 'has_access' to confirmed, unlocked, active accounts" do + results = EventRegistration.account_status("has_access") + expect(results).to include(access_reg) + expect(results).not_to include(none_reg, invited_reg, no_access_reg) + end + + it "maps 'invited' to invited accounts that don't yet have access" do + results = EventRegistration.account_status("invited") + expect(results).to include(invited_reg) + expect(results).not_to include(none_reg, access_reg, no_access_reg) + end + + it "maps 'no_access' to accounts that are neither invited nor active" do + results = EventRegistration.account_status("no_access") + expect(results).to include(no_access_reg) + expect(results).not_to include(none_reg, access_reg, invited_reg) + end + + it "returns an unfiltered relation for unknown values" do + expect(EventRegistration.account_status("bogus")).to include(none_reg, access_reg, invited_reg, no_access_reg) + end + end end describe "#scholarship?" do diff --git a/spec/requests/allocations_spec.rb b/spec/requests/allocations_spec.rb index c9526db426..75c26faca5 100644 --- a/spec/requests/allocations_spec.rb +++ b/spec/requests/allocations_spec.rb @@ -30,10 +30,10 @@ expect(response).to have_http_status(:success) end - it "links the back link to the registrants roster when return_to=registrants" do + it "links the back link to the registrants roster (anchored to the row) when return_to=registrants" do get allocations_path(allocatable_sgid: reg.to_sgid.to_s, return_to: "registrants") - expect(response.body).to include("href=\"#{registrants_event_path(event)}\"") + expect(response.body).to include("href=\"#{registrants_event_path(event, highlight: reg.id, anchor: "registrant-row-#{reg.id}")}\"") end it "links the back link to bulk payments, re-expanding the submission row, when return_to=bulk_payments" do diff --git a/spec/requests/events/public_registrations_spec.rb b/spec/requests/events/public_registrations_spec.rb index 54f27f45e5..8d3ea427af 100644 --- a/spec/requests/events/public_registrations_spec.rb +++ b/spec/requests/events/public_registrations_spec.rb @@ -673,5 +673,22 @@ def post_with_scholarship(scholarship_answer) expect(response.body).not_to include("Back to linked organizations") end end + + context "when viewed from the admin registrants roster" do + let(:admin) { create(:user, :admin) } + let!(:registration) { create(:event_registration, event: event, registrant: person) } + + before { sign_in admin } + + it "returns the eyebrow to the registrant's row instead of the ticket" do + get event_public_registration_path(event, person_id: person.id, + return_to: "registrants", return_registration_id: registration.id) + + expect(response.body).to include("Back to registrants") + expect(response.body).to include("registrant-row-#{registration.id}") + expect(response.body).to include("highlight=#{registration.id}") + expect(response.body).not_to include("Back to ticket") + end + end end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index cf0f8e177d..9f879ce72c 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -5,6 +5,13 @@ let(:admin) { create(:user, :admin) } let(:event) { create(:event) } + # Makes the event CE-eligible (offers a positive number of CE hours), which + # gates every CE column/filter/export. + def offer_ce!(target_event) + target_event.update!(ce_hours_offered: 6) + target_event + end + let(:valid_params) do { event: { @@ -555,6 +562,13 @@ context "organization column" do let(:organization) { create(:organization, name: "Helping Hands") } + # Scopes assertions to the registrant's org cell so generic words like + # "None" in unrelated filter dropdowns don't cause false matches. + def org_cell_text + Nokogiri::HTML(response.body) + .at_css("tr#registrant-row-#{registration.id} td[data-column-toggle-col='organization']")&.text&.squish + end + # Stores a submitted "agency_name" answer for the registrant, mirroring what # public registration captures, so the Pending/None chip logic has data. def submit_agency_name(name) @@ -580,15 +594,15 @@ def submit_agency_name(name) get registrants_event_path(event) - expect(response.body).to include(">Pending<") - expect(response.body).not_to include(">None<") + expect(org_cell_text).to include("Pending") + expect(org_cell_text).not_to include("None") end it "shows a 'None' chip when a registrant has no linked org and submitted nothing" do get registrants_event_path(event) - expect(response.body).to include(">None<") - expect(response.body).not_to include(">Pending<") + expect(org_cell_text).to include("None") + expect(org_cell_text).not_to include("Pending") end it "does not show a 'Pending' chip when an org is linked, even if the submitted name differs" do @@ -597,8 +611,8 @@ def submit_agency_name(name) get registrants_event_path(event) - expect(response.body).to include(organization.name) - expect(response.body).not_to include(">Pending<") + expect(org_cell_text).to include(organization.name) + expect(org_cell_text).not_to include("Pending") end it "does not show 'Pending' when the submitted name matches a linked org" do @@ -607,8 +621,8 @@ def submit_agency_name(name) get registrants_event_path(event) - expect(response.body).to include(organization.name) - expect(response.body).not_to include(">Pending<") + expect(org_cell_text).to include(organization.name) + expect(org_cell_text).not_to include("Pending") end end @@ -683,7 +697,6 @@ def submit_agency_name(name) it "shows the due amount and no paid amount when nothing has been paid" do get registrants_event_path(event) - expect(response.body).to include("fa-circle-exclamation") expect(response.body).to include("$10 due") expect(response.body).not_to include("Partial") end @@ -694,7 +707,6 @@ def submit_agency_name(name) get registrants_event_path(event) - expect(response.body).to include("fa-circle-half-stroke") expect(response.body).to include("Partial payment · $6 due") expect(response.body).not_to include(">Partial payment<") expect(response.body).to include("$6 due") @@ -706,7 +718,6 @@ def submit_agency_name(name) get registrants_event_path(event) - expect(response.body).to include("fa-circle-exclamation") expect(response.body).to include("$6 due") expect(response.body).not_to include("Partial") end @@ -717,7 +728,6 @@ def submit_agency_name(name) get registrants_event_path(event) - expect(response.body).to include("fa-circle-check") expect(response.body).to include(">Paid") end @@ -747,23 +757,25 @@ def submit_agency_name(name) context "registration form icon" do let(:reg_form) { create(:form, :standalone, name: "Registration Form") } - it "shows green icon when person submitted the current registration form" do + it "shows a blue outline form icon when person submitted the current registration form" do create(:event_form, event: event, form: reg_form, role: "registration") create(:form_submission, person: person, form: reg_form) get registrants_event_path(event) expect(response).to have_http_status(:ok) - expect(response.body).to include('fa-solid fa-file-lines') + expect(response.body).to include('fa-regular fa-file-lines') + expect(response.body).to include('text-blue-600') end - it "shows gray icon when person has not submitted any form" do + it "reserves an empty slot (no icon) when person has not submitted, keeping later icons aligned" do create(:event_form, event: event, form: reg_form, role: "registration") get registrants_event_path(event) expect(response).to have_http_status(:ok) - expect(response.body).to include('fa-regular fa-file-lines') + expect(response.body).not_to include('fa-file-lines') + expect(response.body).to include('inline-flex w-4 justify-center') end it "does not show any form icon when event has no forms" do @@ -838,6 +850,137 @@ def submit_agency_name(name) end 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(: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) + 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 + end + + it "shows the CE status column and filter when the event offers CE" do + get registrants_event_path(event) + expect(response.body).to include("CE status") + expect(response.body).to include('data-column-toggle-group-value="ce"') + end + + it "renders the CE status column on by default, with a toggle to hide it" do + get registrants_event_path(event) + # CE column markers render visible (no `hidden` class) since the toggle defaults on… + expect(response.body).to include('data-column-toggle-col="ce"') + expect(response.body).not_to match(/class="[^"]*\bhidden\b[^"]*"[^>]*data-column-toggle-col="ce"/) + # …and the toggle switch shows its on-state. + expect(response.body).to include('data-column-toggle-group-value="ce"') + end + + it "filters to all CE requests" 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).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") + end + + it "filters to paid CE requests" do + get registrants_event_path(event, ce_status: "paid") + expect(response.body).to include("Complete Person") + expect(response.body).not_to include("Missing Person") + end + + it "does not crash on an invalid ce_status" do + get registrants_event_path(event, ce_status: "bogus") + expect(response).to have_http_status(:ok) + end + + it "hides CE entirely when the event's registration form doesn't offer CE" do + plain_event = create(:event) + create(:event_registration, event: plain_event, ce_credit_requested: true) + get registrants_event_path(plain_event) + expect(response.body).not_to include("CE status") + end + + 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") + end + end + + describe "GET /events/:id/registrants CE status column states" do + let(:event) { offer_ce!(create(:event, cost_cents: 1_000)) } + let(:person) { create(:person, first_name: "Cee", last_name: "Ee") } + + before { sign_in admin } + + # The CE chip is the only content of the CE column cell, so its squished text + # is the chip label (the trailing link arrow icon contributes no text). + 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) + 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) + 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 #") + 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) + 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") + end + + it "shows Recipient when the CE balance is paid" do + reg = create(:event_registration, event: event, registrant: person, ce_credit_requested: true) + 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") + end + end + describe "GET /events/:id/registrants with state and county filters" do let(:ca_person) { create(:person, first_name: "Cali", last_name: "Person") } let(:ny_person) { create(:person, first_name: "York", last_name: "Person") } @@ -888,7 +1031,10 @@ def submit_agency_name(name) let(:person) { create(:person, first_name: "Onboard", last_name: "Ready") } let!(:registration) { create(:event_registration, event: event, registrant: person) } - before { sign_in admin } + before do + offer_ce!(event) + sign_in admin + end it "renders the onboarding matrix with the checklist columns" do get onboarding_event_path(event) @@ -936,7 +1082,7 @@ def submit_agency_name(name) get onboarding_event_path(event, highlight: registration.id) expect(response.body).to include("id=\"onboarding-row-#{registration.id}\"") - expect(response.body).to include("ring-amber-300") + expect(response.body).to include("ring-yellow-500") end it "shows an Onboarding back-link to the row on registration edit" do diff --git a/spec/requests/scholarships_spec.rb b/spec/requests/scholarships_spec.rb index 3746223ad6..73384e966d 100644 --- a/spec/requests/scholarships_spec.rb +++ b/spec/requests/scholarships_spec.rb @@ -171,10 +171,10 @@ end describe "back link follows the page the user came from" do - it "links the new page back to the registrants roster when return_to=registrants" do + it "links the new page back to the registrants roster (anchored to the row) when return_to=registrants" do get new_scholarship_path(allocatable_sgid: registration.to_sgid.to_s, return_to: "registrants") - expect(response.body).to include("href=\"#{registrants_event_path(event)}\"") + expect(response.body).to include("href=\"#{registrants_event_path(event, highlight: registration.id, anchor: "registrant-row-#{registration.id}")}\"") expect(response.body).not_to include("href=\"#{edit_event_registration_path(registration)}\"") end @@ -184,10 +184,10 @@ expect(response.body).to include("href=\"#{edit_event_registration_path(registration)}\"") end - it "links the edit page back to the registrants roster when return_to=registrants" do + it "links the edit page back to the registrants roster (anchored to the row) when return_to=registrants" do get edit_scholarship_path(scholarship, return_to: "registrants") - expect(response.body).to include("href=\"#{registrants_event_path(event)}\"") + expect(response.body).to include("href=\"#{registrants_event_path(event, highlight: registration.id, anchor: "registrant-row-#{registration.id}")}\"") end it "links the edit page back to the registration when return_to=registration" do diff --git a/spec/services/reminder_recipient_filter_spec.rb b/spec/services/reminder_recipient_filter_spec.rb index f4e6c11a39..140d5cdf31 100644 --- a/spec/services/reminder_recipient_filter_spec.rb +++ b/spec/services/reminder_recipient_filter_spec.rb @@ -110,31 +110,28 @@ def matched(params, registrations) it "filters intends-to-pay registrants" do expect(matched({ payment_status: "intends_to_pay" }, [ paid, due, intends ])).to eq([ intends.id ].to_set) end - - it "filters paid + intends registrants" do - expect(matched({ payment_status: "paid_or_intends" }, [ paid, due, intends ])).to eq([ paid.id, intends.id ].to_set) - end end - context "scholarship status" do - let!(:requested) { registration.tap { |r| r.update!(scholarship_requested: true) } } + # Shares the registrants-roster `scholarship` filter (yes/complete/incomplete). + context "scholarship" do + let!(:none) { registration.tap { |r| r.update!(scholarship_requested: true) } } let!(:allocated) { registration(first_name: "Alloc").tap { |r| award_scholarship(r) } } let!(:completed) { registration(first_name: "Done").tap { |r| award_scholarship(r, tasks_completed: true) } } - it "filters requested" do - expect(matched({ scholarship_status: "requested" }, [ requested, allocated, completed ])) - .to eq([ requested.id ].to_set) - end - - it "filters allocated" do - expect(matched({ scholarship_status: "allocated" }, [ requested, allocated, completed ])) + it "filters all recipients" do + expect(matched({ scholarship: "yes" }, [ none, allocated, completed ])) .to eq([ allocated.id, completed.id ].to_set) end - it "filters tasks completed" do - expect(matched({ scholarship_status: "tasks_completed" }, [ requested, allocated, completed ])) + it "filters tasks complete" do + expect(matched({ scholarship: "complete" }, [ none, allocated, completed ])) .to eq([ completed.id ].to_set) end + + it "filters tasks not complete" do + expect(matched({ scholarship: "incomplete" }, [ none, allocated, completed ])) + .to eq([ allocated.id ].to_set) + end end context "account status" do
+ <%= render "shared/sortable_header", label: "Organization", index: 1 %> - <%= render "shared/sortable_header", label: "Scholarship", index: 2 %> + + <% if ce_col %> + + <%= render "shared/sortable_header", label: "CE", index: ce_index %> + " aria-sort="none" data-column-toggle-col="scholarship"> + <%= render "shared/sortable_header", label: "Scholarship", index: scholarship_index %> - <%= render "shared/sortable_header", label: "Payment", index: 3 %> + <%= render "shared/sortable_header", label: "Payment", index: payment_index %> - <%= render "shared/sortable_header", label: "Date registered", index: date_index %> - + + <%= render "shared/sortable_header", label: "Date registered", index: date_index %> +
<% show_email = person.profile_show_email? || allowed_to?(:manage?, Person) %>
-
- <%= person_profile_button(person, subtitle: (person.preferred_email if show_email), data: { turbo_frame: "_top" }, path_params: { return_to: "registrants", event_id: @event.id }) %> +
+ <%= person_profile_button(person, truncate_at: 30, subtitle: (person.preferred_email if show_email), data: { turbo_frame: "_top" }, path_params: { return_to: "registrants", event_id: @event.id }, compact: true) %>
- <% if event_form_ids.any? && (submissions = form_submissions[person.id]) %> - <% form_show_params = registration.slug.present? ? { reg: registration.slug } : { person_id: person.id } %> - <% tooltip_parts = submissions.map { |name, ts| "#{name} — Submitted #{ts.strftime('%B %d, %Y at %l:%M %P')}" } %> - <% tooltip_parts << "Scholarship requested" if registration.scholarship_requested? %> - <%= link_to event_public_registration_path(@event, **form_show_params), - class: "text-green-600 hover:text-green-800", - title: tooltip_parts.join("\n"), - target: "_blank", - data: { turbo_frame: "_top" } do %> - - <% end %> - <% elsif event_form_ids.any? %> - + <% if event_form_ids.any? %> + <%# Reserve a fixed slot so the comment/warning icons stay aligned + across rows whether or not this registrant submitted a form. %> + + <% if (submissions = form_submissions[person.id]) %> + <% form_show_params = registration.slug.present? ? { reg: registration.slug } : { person_id: person.id } %> + <% tooltip_parts = submissions.map { |name, ts| "#{name} — Submitted #{ts.strftime('%B %d, %Y at %l:%M %P')}" } %> + <% tooltip_parts << "Scholarship requested" if registration.scholarship_requested? %> + <%= link_to event_public_registration_path(@event, **form_show_params, return_to: "registrants", return_registration_id: registration.id), + class: "text-blue-600 hover:text-blue-800", + title: tooltip_parts.join("\n"), + data: { turbo_frame: "_top" } do %> + + <% end %> + <% end %> + <% end %> <% if registration.comments.any? %> <% latest_comment = registration.comments.first %> - + <% any_flagged = registration.comments.any?(&:flagged?) %> + <%# Flagged → orange filled (a warning); otherwise the comments theme lilac. %> + <% comment_icon = any_flagged ? "fa-solid text-orange-500" : "fa-regular #{DomainTheme.text_class_for(:comments, intensity: 400)}" %> + <%= link_to edit_event_registration_path(registration, return_to: "registrants", anchor: "comments-section"), + class: "hover:opacity-80", + title: "#{"Has flagged comments — " if any_flagged}#{registration.comments.size} comment#{"s" if registration.comments.size != 1} — Latest (#{latest_comment.created_at.strftime('%Y-%m-%d')}): #{latest_comment.body.truncate(100)}", + data: { turbo_frame: "_top" } do %> + + <% end %> <% end %> <% if @duplicate_emails&.include?(person.preferred_email&.downcase) %> @@ -115,7 +223,7 @@
+ <% linked_orgs = registration.organizations %> <% submitted_org_name = submitted_org_names[registration.registrant_id].to_s.strip %> <%# A submitted org name needs admin attention only while nothing is linked @@ -123,14 +231,14 @@ submitted name is no longer treated as pending. %> <% needs_linking = submitted_org_name.present? && linked_orgs.none? %> <% editor_path = link_organization_event_registration_path(registration, return_to: "registrants") %> - <% pill_classes = "inline-flex items-center gap-1.5 rounded-full text-xs font-medium border px-3 py-0.5 transition hover:opacity-80 hover:shadow-sm" %> + <% pill_classes = "inline-flex items-center gap-1.5 whitespace-nowrap rounded-full text-xs font-medium border px-3 py-0.5 transition hover:opacity-80 hover:shadow-sm" %>
<% linked_orgs.each do |org| %> <%= link_to editor_path, title: org.name, class: "#{pill_classes} bg-gray-50 text-gray-500 border-gray-200", data: { turbo_frame: "_top" } do %> - <%= truncate(org.name, length: 40) %> + <%= truncate(org.name, length: 31) %> <% end %> <% end %> @@ -154,13 +262,49 @@
+ <% 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. %> + <% cer = registration.continuing_education_registrations.first %> + <% if !registration.ce_credit_requested? %> + <% 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 #" %> + <% 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" %> + <% 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 %> + + <% end %> + " data-column-toggle-col="scholarship"> <% if (s = registration.scholarships.first) %> <% if s.tasks_completed? %> <%= link_to edit_scholarship_path(s, return_to: "registrants"), class: "inline-flex items-center gap-1.5 rounded-full text-xs font-medium border px-5 py-0.5 bg-green-50 text-green-700 border-green-200 transition hover:opacity-80 hover:shadow-sm", data: { turbo_frame: "_top" } do %> - Completed <% end %> @@ -168,7 +312,6 @@ <%= link_to edit_scholarship_path(s, return_to: "registrants"), class: "inline-flex items-center gap-1.5 rounded-full text-xs font-medium border px-5 py-0.5 bg-blue-50 text-blue-700 border-blue-200 transition hover:opacity-80 hover:shadow-sm", data: { turbo_frame: "_top" } do %> - Recipient <% end %> @@ -178,7 +321,6 @@ <%= link_to new_scholarship_path(allocatable_sgid: registration.to_sgid.to_s, return_to: "registrants"), class: "inline-flex items-center gap-1.5 rounded-full text-xs font-medium border px-5 py-0.5 bg-amber-50 text-amber-700 border-amber-200 transition hover:opacity-80 hover:shadow-sm", data: { turbo_frame: "_top" } do %> - Requested <% end %> @@ -186,7 +328,6 @@ <%= link_to new_scholarship_path(allocatable_sgid: registration.to_sgid.to_s, return_to: "registrants"), class: "inline-flex items-center gap-1.5 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 %> @@ -210,15 +351,6 @@ else "bg-amber-50 text-amber-700 border-amber-200" end %> - <% badge_icon = if is_paid - "fa-circle-check" - elsif is_discounted - "fa-tag" - elsif is_partial - "fa-circle-half-stroke" - else - "fa-circle-exclamation" - end %> <% badge_title = if is_paid "Paid in full" elsif is_discounted @@ -231,7 +363,6 @@ <% badge_title = "#{badge_title} · Intends to pay" if !is_paid && registration.intends_to_pay? %> <%= link_to allocations_path(allocatable_sgid: registration.to_sgid.to_s, return_to: "registrants"), title: badge_title, class: "inline-flex items-center gap-1.5 whitespace-nowrap rounded-full text-xs font-medium border px-3 py-0.5 #{badge_classes} transition hover:opacity-80 hover:shadow-sm", data: { turbo_frame: "_top" } do %> - <% if is_paid %> Paid <% else %> @@ -249,7 +380,7 @@ "><%= registration.created_at.strftime("%b %-d, %Y") %><%= render "event_registrations/attendance_status_badge", registration: registration %> <%= link_to "Edit", edit_event_registration_path(registration, return_to: "registrants"), class: "text-gray-500 hover:text-gray-700 underline", data: { turbo_frame: "_top" } %>"><%= registration.created_at.strftime("%b %-d, %Y") %>