Skip to content

Add ProfessionalLicense and ContinuingEducationRegistration models (PR 1: foundation)#1916

Merged
maebeale merged 13 commits into
mainfrom
maebeale/continuing-education-model
Jun 29, 2026
Merged

Add ProfessionalLicense and ContinuingEducationRegistration models (PR 1: foundation)#1916
maebeale merged 13 commits into
mainfrom
maebeale/continuing-education-model

Conversation

@maebeale

@maebeale maebeale commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

🤖 PR, suggested 👤 review level: 🔬 Inspect — new data model + migrations (adds two tables, drops two Person columns)

What is the goal of this PR and why is this important?

CE data lives as three flat columns on EventRegistration, which can't model what AWBW needs: CE hours tracked per professional license, a person holding several licenses, and a CE record with its own payment + certificate lifecycle.

This is PR 1 of 2 — the foundation only (additive, no behavior change). PR 2 reroutes intake, the callout, and read sites, then drops the old ce_* columns.

How did you approach the change?

  • ProfessionalLicense (per Person) — nullable number is a placeholder; find_or_create_for keeps one license per (person, number) and a single placeholder. No status (derived number_known?/expired?).
  • ContinuingEducationRegistration (per EventRegistration, one per license) — allocatable so it reuses the existing Payment/Allocation machinery; string status (requestedpaidissued/unawarded, no enum); hours defaults from the event but stays editable, driving amount_cents.
  • Event gains ce_hours_eligible (the explicit "CE offered" gate) + ce_hours (fractional hours).
  • Allocation gets a CE over-payment guard and a requested→paid status sync.
  • Drops the unused Person#license_number/license_type columns (no data) and their form/controller bits.
  • Adds an "avoid Rails enums — prefer string values" project convention.

Anything else to add?

The existing ce_* columns and the CE form are deliberately left untouched here; suite stays green. Billing model: two licenses charge 2× but bill once (one payment allocated across the CE registrations).

Interface comparison: ContinuingEducationRegistration vs EventRegistration (incl. Allocation cost-validation pair)

CE deliberately mirrors only the allocatable/payment seam of EventRegistration so both behave identically wherever allocations are summed. Everything else diverges because CE is a lightweight, audited child record of an EventRegistration (scoped to one professional license), not a first-class resource.

Member EventRegistration ContinuingEducationRegistration Same / Different — why
— Associations —
registrant (Person) belongs_to — (via event_registration.registrant) Diff — CE is one level removed
event belongs_to — (via event_registration.event) Diff — reached through parent
event_registration belongs_to Diff — CE is a child of ER
professional_license belongs_to Diff — ties cert to one license
created_by / updated_by belongs_to … optional: true Diff — CE audits actor (with paper trail)
comments, …_organizations, organizations, notifications, scholarships, checklist_completions has_many Diff — ER-only rich graph
continuing_education_registrations has_many … dependent: :destroy Diff — ER is the parent side
allocations (polymorphic) has_many has_many … dependent: :destroy Same seam — both allocatable; CE adds dependent: :destroy
payments through allocations — (derived ad hoc) has_many … through: :allocations Diff — CE declares it explicitly
accepts_nested_attributes_for comments, notifications, registrant none Diff — driven by ER edit form
— Callbacks —
has_paper_trail Diff — CE is audited
before_create :generate_slug Diff — ER is URL-addressable
before_validation :default_from_event, on: :create Diff — CE snapshots hours/cost_cents
after_commit :send_cancellation_emails Diff — ER emails on cancel
payment-status sync via Allocation callback → sync_payment_status! Diff — CE status is payment-driven; ER status is attendance
— Constants —
Status set ACTIVE/INACTIVE/ATTENDANCE_STATUSES (attendance) STATUSES = requested/paid/issued/unawarded (fulfillment) Diff — different domains
CHECKLIST_STEPS, DAY_FIELDS, CE_HOURLY_RATE_DOLLARS Diff — ER onboarding/CE-summary helpers
— Validations —
registrant_id unique per event_id, event_id presence, slug uniqueness Diff — ER-only
status inclusion in: ATTENDANCE_STATUSES in: STATUSES Same mechanism, different set
numericality ce_hours_requested ≥ 0 hours ≥ 0, cost_cents ≥ 0 Diff — ER stores the request; CE stores the snapshot
license_belongs_to_registrant Diff — CE-only integrity check
— Scopes — ~20 (name/event/active/scholarship/payment/keyword/…) none Diff — ER backs filterable rosters; CE accessed via parent
— Class methods — search_by_params, remote_searchable_by none Diff — ER is searchable
— Payment / allocatable interface (the shared seam) —
allocations_sum ✓ (loaded-aware) ✓ (identical) Same — intentional mirror
payments_sum ✓ (loaded-aware) ✓ (identical) Same — intentional mirror
remaining_cost [event.cost_cents - sum, 0].max [cost_cents - sum, 0].max Same logic, diff cost source (event vs. own snapshot)
paid_in_full? free-event short-circuit (≤ 0 → true), else sum ≥ event.cost_cents sum ≥ cost_cents.to_i (no free short-circuit) Diff — ER special-cases free events
partially_paid? ✓ (identical) Same
sync_payment_status! ✓ (advances requestedpaid, leaves issued/unawarded) Diff — CE-only
paid? ✓ (→ paid_in_full?) Same — mirrored for polymorphic allocatable callers
payment_status_label, discounted?, discount_sum ✓ (mirrored in PR 2 / #1917) Same — CE has no intends_to_pay, so its label's middle state is a real partial payment
payment_access_granted?, intends_to_pay? Diff — event-access affordance; no CE analog
— ER-only domain methods — name, active?, attended?, scholarship?, certificate_available?, joinable?, videoconference_details_visible?, account_status, attendance_status_label, program_statuses, checklist/day helpers, remote_search_label, … Diff — ER is the rich resource
— CE-only private — default_from_event, license_belongs_to_registrant Diff
— Supporting layer —
Controller / routes EventRegistrationsController + full routes none Diff — CE managed indirectly
Decorator / Policy / Services / Mailers none Diff
Factory / model spec Same

Allocation cost-validation pairvalidate_event_registration_cost vs validate_ce_registration_cost are structurally identical (same amount_was/persisted? self-exclusion, same already-paid + over-allocation math, same MoneyFormatter messages). Two intended differences:

Step ER variant CE variant Why
Cost source event_reg.event.cost_cents ce_reg.cost_cents.to_i ER cost lives on the event; CE stores its own snapshot
"No cost" guard cost_cents.blank? → "free event" cost_cents <= 0 → "no cost" Event cost is nullable (blank-guards before arithmetic); CE column is null: false, default: 0

# resolves to the person's single placeholder license (number nil) so a CE
# opt-in without a number on file never spawns duplicate placeholders.
def self.find_or_create_for(person:, number: nil)
find_or_create_by(person: person, number: number.presence)

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: number.presence collapses ""nil, so a CE opt-in with no number on file resolves to the person's single placeholder license rather than spawning a new blank row each time. The DB unique index on (person_id, number) enforces the numbered case (MySQL treats NULLs as distinct, so it intentionally does not block multiple placeholders — the model does).


# Advance requested↔paid to track real payments without clobbering a later
# admin state (issued/unawarded). Called when allocations change.
def sync_payment_status!

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: Auto-advances requestedpaid to track real payments, but only from those two states — a later admin issued/unawarded is never clobbered. Driven from Allocation after_create, so reversals (negative allocations) revert paidrequested too.

self.amount_cents = (hours.to_d * rate_cents).round
end

def license_belongs_to_registrant

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: Integrity guard: the license must belong to the registrant, otherwise one person's CE could be billed against another's license.

Comment thread app/models/allocation.rb
end
end

def validate_ce_registration_cost

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: Mirrors validate_event_registration_cost, but caps allocations at the CE registration's own amount_cents (= hours × rate). With two licenses billed once, a single Payment allocates across both CE registrations and each is capped independently here.

@jmilljr24 jmilljr24 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just the cost calculation is the only thing that gives me pause. Thoughts?

Comment thread app/models/event.rb Outdated
Comment on lines +197 to +200
def ce_amount_owed_cents
return 0 if ce_hours.blank?

(ce_hours * ContinuingEducationRegistration::HOURLY_RATE_DOLLARS * 100).round

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think this aligns with the stakeholders new requirements for CE payments.

Prior to the last meeting, yes it was a direct correlation of ce_hour * hourly_rate but they currently the want essential a flat rate. If you want CE credits you pay $120 for this event (how they come up with that number is irrelevant). No refunds. If you only complete 6 hours, you only get credit for 6 hours.

I'm thinking a column on event for ce_cost. This avoids have a hard coded constant and no math involved.

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.

yes, i was thinking same on event ce_hour_cost, but was going to keep our math stuff, just have it all end up with the same math.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm not hard pressed on this, but I guess my concern is that we are adding an additional step and logic that isn't needed. On the flip side if they change their mind in the future this would open up that ability. But then again that's just guessing and they may come up with some other formula for cost.

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.

yeah totally fair. what i'm hearing is there's a fixed cost for training events. and i've also heard about them doing future kinds of events that aren't training events. so going forward it's a toss-up. but i know in the past they captured partial amounts and payments. since this system needs to accommodate historical data too, i was thinking keep this data structure underneath and have the calculation logic just run in the background to get the current fixed cost handled.

@jmilljr24 jmilljr24 Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Good point on the filemaker data. I wasn't thinking of that.

Maybe a typo on "event"? They are dong a fixed cost for "CE Hours".

I took a look at the filemarker data. There are 167 records on CE hours. The are all a simple payment recording on an "CE hours event". 99% are $120 with a note saying 12 hours.

The handful of outliers I see in notes are:

11 hours - 120 paid
10 hours - 100 paid
30 hours - 30 paid
12 hours - waived
fee comped - no mention of hours
120 paid - did not attented

The only other odd two are a combined payment for a training event and CE hours.

With that said, there is no column for cost_per_hour. So I think as far as filemaker data goes it will be easiest to keep it the 1:1 , payments = X and CE_hours = X. It seems roundabout and brittle to take those two datapoints, divide them to get the hour rate, then take the original hour times the calculated rate to get back the payment we started with.

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.

ok @jmilljr24 cost calculations removed. i extracted the registration interface (payments & certificate) from event_registration into a registerable concern so it can be shared exactly with continuing_education_registration, and added certificate_sent_at for both since it's my understanding they issue those for both (and i wanted to keep them as mirrors of each other)

Comment thread app/models/professional_license.rb Outdated
expires_on.present? && expires_on.past?
end

def label

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe in a decorator in part 2? I don't recall what pattern we've been using the most for stuff like this.

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.

We don't really have a pattern. A better name is prob name.

@maebeale maebeale force-pushed the maebeale/continuing-education-model branch 3 times, most recently from b3b8ea7 to abaee56 Compare June 29, 2026 10:30
@maebeale maebeale requested a review from jmilljr24 June 29, 2026 10:35
maebeale and others added 12 commits June 29, 2026 06:39
CE data was three flat columns on EventRegistration, which can't express
that CE hours are tracked per professional license, that a person holds
several licenses, or that a CE record has its own payment and certificate
lifecycle. This lands the foundation:

- ProfessionalLicense (per Person; nullable number = placeholder;
  find_or_create_for keeps one license per number)
- ContinuingEducationRegistration (per EventRegistration, against one
  license; allocatable like a registration; string status, no enum)
- Event#ce_hours_eligible + ce_hours as the source of CE availability/hours
- Allocation over-payment guard + requested→paid status sync for CE
- Drop the now-unused Person#license_number/license_type columns

Additive: the existing ce_* columns and CE form are untouched (rerouted in
a follow-up PR). Also adds an "avoid Rails enums" project convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The CE per-hour price was hard-coded to the HOURLY_RATE_DOLLARS constant, so
every event billed the same rate. Add a nullable ce_hour_cost_cents column
(stored in cents, mirroring cost_cents) with a ce_hour_cost dollars virtual
attribute for the form. A nil means "use the standard rate": ce_hour_cost_cents
falls back to the constant, so new and existing events show the default until
an admin sets a per-event override. ce_amount_owed_cents now bills off it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-hour CE rate now lives on Event#ce_hour_cost_cents, so a CE
registration no longer needs its own rate_cents — it just snapshots the total
cost it bills. Rename amount_cents → cost_cents and drop rate_cents. The cost
is priced from the event's per-hour rate on create and re-priced only when
hours change, so editing the event rate later never silently re-bills a
registration that's already been paid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pivot away from a per-hour rate that gets multiplied out. Event now carries
ce_hours_available (decimal hours offered) and ce_hours_cost_cents (the total CE
cost, with a ce_hours_cost dollars virtual attribute like #cost). A CE
registration snapshots both — hours and cost_cents default from the event on
create and are otherwise stored as-is — so nothing recomputes a total. Drops the
HOURLY_RATE_DOLLARS constant, the per-hour rate column, and Event#ce_amount_owed_cents.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CE registration is an allocatable, like EventRegistration, but it had
invented its own payment helpers (amount_owed_cents, paid_cents) and counted
only Payment allocations toward paid_in_full? — so a discount would block
further allocation yet never mark it covered. Mirror EventRegistration's
interface instead: allocations_sum, payments_sum, remaining_cost, paid_in_full?
(all allocations count as coverage), and partially_paid?. The over-allocation
guard now reads allocations_sum too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clearer admin-facing wording for the terminal non-issuance state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Polymorphic allocatable callers (rosters, payment partials) message
allocatables with #paid?; CE only exposed #paid_in_full?, so a CE
registration would raise NoMethodError. Alias it like EventRegistration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Allocation guard rejects allocating to a zero-cost CE while
CE#paid_in_full? treats one as paid. Comment both sides so a future
reader doesn't 'fix' one to match the other.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ce_hours_available -> ce_hours_offered reads as the credit the event is
worth (present/possible, not past tense). Drop the redundant
ce_hours_eligible boolean: Event#ce_eligible? now derives from a positive
ce_hours_offered, giving a single source of truth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…elivery

EventRegistration and ContinuingEducationRegistration had a near-identical
allocatable payment interface; extract it into a Registerable concern (driven by
each includer's cost_cents — EventRegistration delegates to event.cost_cents, CE
has its own column) so the two can't drift. The concern also carries the shared
certificate delivery (certificate_sent_at / certificate_sent? / mark_certificate_sent!),
while each model keeps its own certificate_available? eligibility.

CE payment is now computed (paid?) like EventRegistration, so the stored payment
status is gone: drop the CE status column, sync_payment_status!, and the
Allocation after_create sync. Certificates are handled the same way on both —
availability computed, delivery recorded via certificate_sent_at (added to
event_registrations) — so issued_at (redundant: sending the email is issuing) is
dropped. CE spec gains payment-method coverage mirroring EventRegistration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The CE tables and fields are all new in this PR, so the incremental
rename/add/drop migrations were just churn against schema that never shipped.
Fold them into the migrations that introduce each field: events gets
ce_hours_offered + ce_hours_cost_cents directly; continuing_education_registrations
is created with its final columns (cost_cents + certificate_sent_at, no stored
status/rate); event_registrations#certificate_sent_at stands alone (pre-existing
table). Drops the add_ce_hour_cost / rename_ce_amount_to_cost_drop_rate /
rename_event_ce_fields / unify_certificate_columns migrations. schema.rb is
unchanged and the full history replays cleanly from an empty database.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@maebeale maebeale force-pushed the maebeale/continuing-education-model branch from abaee56 to a7e7d3f Compare June 29, 2026 10:40
Comment thread app/models/concerns/registerable.rb Outdated
Comment on lines +51 to +53
def paid?
paid_in_full?
end

@jmilljr24 jmilljr24 Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
def paid?
paid_in_full?
end

I personally would just keep one consistent method name if it's exactly the same.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We may have been using this pattern other places, it just caught my eye in this.

Comment thread app/models/allocation.rb Outdated

def validate_ce_registration_cost
ce_reg = allocatable
return unless ce_reg.is_a?(ContinuingEducationRegistration)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
return unless ce_reg.is_a?(ContinuingEducationRegistration)

nbd but this is redudant to

validate :validate_ce_registration_cost, if: -> { allocatable_type == "ContinuingEducationRegistration" }

private

# Snapshot the hours offered and total cost from the event when they aren't set
# explicitly. Both are plain stored values — no per-hour rate is multiplied out.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
# explicitly. Both are plain stored values — no per-hour rate is multiplied out.
# explicitly.

Comment thread app/models/event.rb

# Virtual attribute for the total CE cost in dollars (converts to/from
# ce_hours_cost_cents), mirroring #cost.
def ce_hours_cost

@jmilljr24 jmilljr24 Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't know the naming convention we've been using but I feel like this might bite us at some point. cost_cents is explicit. Does hours_cost mean dollars or cents. I can't tell unless I go to this method.

Then in registerable.rb we have

def remaining_cost

which returns cents.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks like event "cost" is also passed into the controller as dollars and saved as cents so this consistent with that. Just makes me think it could be easy to mix up.

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.

yeah we have a little pattern going that cost is dollars and user-facing cost_cents is what is stored. i've tried a couple options at various times/models and this seems best split of the difference 🤷‍♀️

@jmilljr24 jmilljr24 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looking good! Nice idea on registerable.

@maebeale

Copy link
Copy Markdown
Collaborator Author

ooo, great points! will do those before merge.

- Remove Registerable#paid? — it was an exact alias of paid_in_full?, which
  is the name used everywhere; switch the two internal callers over.
- Drop the is_a? guards in Allocation's cost validators; the validate :if
  allocatable_type condition already gates them. Behavioral spec replaces the
  white-box one that called the private method directly.
- Trim the default_from_event comment (the per-hour-rate caveat described an
  abandoned design).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@maebeale maebeale merged commit a1ff570 into main Jun 29, 2026
3 checks passed
@maebeale maebeale deleted the maebeale/continuing-education-model branch June 29, 2026 12:18
maebeale added a commit that referenced this pull request Jun 29, 2026
The foundation PR (#1916) squash-merged into main with a later migration
(add_certificate_sent_at, 20260629023519) than this branch's drop migration
(20260625162312), leaving it out of order. Retimestamp it to run last and
bump schema.rb to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
maebeale added a commit that referenced this pull request Jun 29, 2026
Main (#1916) established Event#ce_eligible? (ce_hours_offered > 0) as the
single source of truth for whether an event grants CE credit. Drop the
branch's own offers_ce? (which checked the registration form for the
ce_credit_interest field) and gate every CE surface on ce_eligible? so the
two can't drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
maebeale added a commit that referenced this pull request Jun 29, 2026
* Add CE status column and filter to registrants index

Admins need to see and triage registrants' continuing-education credit
standing at a glance — who requested CE, who is missing a license number
or hours, and who has paid — without opening each registration. Surfaces
this as a column and dropdown filter that only appear once an event has
CE requests, so non-CE events stay uncluttered, and mirrors the column in
the CSV export for parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Gate CE surfaces on the registration form, toggle the index column

CE only matters for events whose registration form actually asks the
magic CE-interest question, so empty CE columns/filters were noise on
every other event. Gate all CE UI — registrants index, onboarding matrix,
bulk-reminder filter, and both CSV exports — on Event#offers_ce? (the form
carries the ce_credit_interest field) instead of "any registrant happened
to have CE data". On the registrants index the CE status column now sits
behind its own slide toggle (defaulting off) alongside "User confirmation",
generalizing the column-toggle controller to support multiple named groups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Gate CE on Event#ce_eligible? instead of a parallel offers_ce?

Main (#1916) established Event#ce_eligible? (ce_hours_offered > 0) as the
single source of truth for whether an event grants CE credit. Drop the
branch's own offers_ce? (which checked the registration form for the
ce_credit_interest field) and gate every CE surface on ce_eligible? so the
two can't drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Polish registrants roster: CE column default-on, layout + density

- Default the CE status column on (toggle on) when the event is ce_eligible;
  add per-column toggles (CE, Attendance off-by-default, User confirmation)
  by generalizing column-toggle to named groups.
- Reorder columns: CE before Scholarship; Date registered moved to the far
  right after Edit; CE filter dropdown before Scholarship; filters wrapped
  in a card.
- Reclaim width: compact registrant profile button (teeny avatar, smaller
  type), drop leading chip icons, retune column widths/truncation.
- CE cell mirrors the scholarship pill and links to the registration edit
  (CE section); not-requested shows a "Create" chip.
- Viewing a submission from the roster now navigates in the same tab and the
  eyebrow returns to that registrant's row (anchored + highlighted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Tune registrant profile button to a uniform w-40 name-display width

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Widen organization chips (truncate at 52)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Size org chips to fit a full agency name; widen profile column to w-44

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Uniform person column sized to ~30 chars (truncate names at 30)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Color the present registration-form icon blue instead of green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Roster toggles: Organization + Scholarship columns, CE label, icon polish

- Add Organization (on) and Scholarship (on when the event charges a fee)
  column toggles; Scholarship column hidden by default on free events.
- Order toggles: Organization, CE, Scholarship, Attendance, User confirmation.
- Rename the CE toggle and column header to "CE".
- Registration-form icon: blue outline (fa-regular), shown only when a form was
  submitted; reserve a fixed slot otherwise so comment/warning icons stay
  aligned. Comments icon switched to outline.
- Person column a uniform w-60 (names truncate at 30).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Recolor CE cell states to mirror scholarship

Requested → orange; once a license is on file show the CE amount due in
orange; paid shows blue Recipient; gray Create when CE wasn't requested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Registrants roster: CE record progression, comment/org filters, eyebrows

- CE cell now walks the ContinuingEducationRegistration lifecycle: Requested →
  No license # → amount due → Recipient (paid); eager-load CE records.
- Comment icon: orange-filled when any comment is flagged, otherwise the new
  `comments` theme lilac; clickable to the registration's comments section.
- Add a `comments` DomainTheme color; reuse it on the registration edit
  comments card. Make the shout-out field a textarea.
- New filters: Comments (None/Present/Flagged) and Organization
  (Pending/Linked), with scopes. Filters restructured into two rows, ordered
  to match the columns (CE, Scholarship, Payment), wider keyword box, grey
  Clear button.
- Reg-edit: org card narrower (even thirds) so scholarship/CE are wider.
- Every "Back to registrants" eyebrow (edit, link-org, public submission,
  scholarships, allocations, person profile) now anchors + highlights the row.
- Index row highlight recolored amber → yellow.
- AI files: prefer decorators over helpers for model-specific presentation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Extract shared events/filter_select partial for roster + reminder filters

Both the registrants roster and the bulk-reminder recipient filters now render
their dropdowns through one events/filter_select partial (each page keeps its
own text inputs, dropdown set, and options). DRYs the select markup without
changing any param names, options, or filtering behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Account-status filter, shared filter layout, yellow highlight fix

- Add a User-account filter (None/Invited/Has access/No access) to the
  registrants roster via an account_status scope (guards NULL person_ids so
  NOT IN works); shares the filter_select partial with the reminder page.
- Align the reminder filter layout to the roster: inputs + dropdowns are flex
  rows of w-48 controls that wrap (so all inputs fit a row); grey Clear button.
- Move the grey-placeholder behavior into the shared filter_select partial so
  both pages get it.
- Narrow the State dropdown so Clear fits on the row.
- Darken the index row highlight ring (yellow-400) so it reads yellow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* CE chip says 'Filed' once a license is on file (amount due in tooltip)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Safelist ring colors so the highlight builds; CE Filed=blue, Recipient=green

The row-highlight ring rendered black because ring-<color> utilities were never
in the Tailwind @source safelist (only bg/border/text were), so the class fell
back to currentColor. Add a ring- safelist line; the highlight now builds as a
real (dark yellow) ring. Also recolor the CE chip to mirror the registration
flow: Filed → blue, Recipient (paid) → green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Shrink the shout-out textarea to a single row

Keep it a textarea (multi-line on demand) but default to one row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Registrants filters: keyword flexes, Payment after Attendance, thinner dropdowns

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Keyword uses min-w-0 so row-1 filters stay one row (wrap only when cramped)

The arbitrary min-w-[16rem] never compiled; min-w-0 lets the keyword flex and
shrink so the dropdowns share one horizontal row on desktop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Sync recipients filters with the registrants roster

Render the same dropdown filter set (attendance, payment, CE, scholarship,
organization, comments, user account, state, county) on the bulk-reminder
recipients page, sharing the events/filter_select partial. ReminderRecipientFilter
now keeps its free-text inputs in memory but delegates the dropdowns to the same
EventRegistration scopes the roster uses, so param names, options, and semantics
stay identical (and filters can carry between the two pages). preview_reminder
builds @dashboard for the state/county options.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
maebeale added a commit that referenced this pull request Jun 29, 2026
…umns

Completes the continuing-education cutover started by the foundation models
(#1916). Reroutes registration intake, the public callout, and every read
site (callouts, onboarding, reminders, CSV) off the flat
EventRegistration#ce_* columns and onto ContinuingEducationRegistration /
ProfessionalLicense, then drops the ce_credit_requested / ce_hours_requested /
ce_license_number columns.

Reconciled with #1833 (registrants-index CE column + filter), which merged to
main while this branch was in flight and built the same scaffolding on the
old columns:

- Kept #1833's scaffolding (CE column, dropdown filter, column toggles,
  layout, onboarding columns, bulk-reminder filter, CSV columns, eyebrow nav)
  and repointed every CE data read to the new models.
- EventRegistration.ce_status is now a derived scope over the CE registrations
  (needs_license / requested / paid / issued / not_issued), replacing the old
  ce_credit_requested-based buckets; the roster + reminder dropdowns follow it.
- EventRegistration#ce_status_label and the aggregators (ce_requested?,
  ce_hours_total, ce_amount_owed_cents, ce_license_provided?, ce_paid_in_full?)
  read the new models; the registrants column gates on a CE record existing
  rather than the dropped boolean (the "requested, no record yet" state is gone
  now that intake creates the record).
- ReminderRecipientFilter's CE matching flows through the shared ce_status
  scope (its per-record matchers were redundant with main's scope approach).

Migration timestamped to run after the foundation migrations now in main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
maebeale added a commit that referenced this pull request Jun 29, 2026
…umns

Completes the continuing-education cutover started by the foundation models
(#1916). Reroutes registration intake, the public callout, and every read
site (callouts, onboarding, reminders, CSV) off the flat
EventRegistration#ce_* columns and onto ContinuingEducationRegistration /
ProfessionalLicense, then drops the ce_credit_requested / ce_hours_requested /
ce_license_number columns.

Reconciled with #1833 (registrants-index CE column + filter), which merged to
main while this branch was in flight and built the same scaffolding on the
old columns:

- Kept #1833's scaffolding (CE column, dropdown filter, column toggles,
  layout, onboarding columns, bulk-reminder filter, CSV columns, eyebrow nav)
  and repointed every CE data read to the new models.
- EventRegistration.ce_status is now a derived scope over the CE registrations
  (needs_license / requested / paid / issued / not_issued), replacing the old
  ce_credit_requested-based buckets; the roster + reminder dropdowns follow it.
- EventRegistration#ce_status_label and the aggregators (ce_requested?,
  ce_hours_total, ce_amount_owed_cents, ce_license_provided?, ce_paid_in_full?)
  read the new models; the registrants column gates on a CE record existing
  rather than the dropped boolean (the "requested, no record yet" state is gone
  now that intake creates the record).
- ReminderRecipientFilter's CE matching flows through the shared ce_status
  scope (its per-record matchers were redundant with main's scope approach).

Migration timestamped to run after the foundation migrations now in main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
maebeale added a commit that referenced this pull request Jun 30, 2026
…umns

Completes the continuing-education cutover started by the foundation models
(#1916). Reroutes registration intake, the public callout, and every read
site (callouts, onboarding, reminders, CSV) off the flat
EventRegistration#ce_* columns and onto ContinuingEducationRegistration /
ProfessionalLicense, then drops the ce_credit_requested / ce_hours_requested /
ce_license_number columns.

Reconciled with #1833 (registrants-index CE column + filter), which merged to
main while this branch was in flight and built the same scaffolding on the
old columns:

- Kept #1833's scaffolding (CE column, dropdown filter, column toggles,
  layout, onboarding columns, bulk-reminder filter, CSV columns, eyebrow nav)
  and repointed every CE data read to the new models.
- EventRegistration.ce_status is now a derived scope over the CE registrations
  (needs_license / requested / paid / issued / not_issued), replacing the old
  ce_credit_requested-based buckets; the roster + reminder dropdowns follow it.
- EventRegistration#ce_status_label and the aggregators (ce_requested?,
  ce_hours_total, ce_amount_owed_cents, ce_license_provided?, ce_paid_in_full?)
  read the new models; the registrants column gates on a CE record existing
  rather than the dropped boolean (the "requested, no record yet" state is gone
  now that intake creates the record).
- ReminderRecipientFilter's CE matching flows through the shared ce_status
  scope (its per-record matchers were redundant with main's scope approach).

Migration timestamped to run after the foundation migrations now in main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
maebeale added a commit that referenced this pull request Jun 30, 2026
…umns

Completes the continuing-education cutover started by the foundation models
(#1916). Reroutes registration intake, the public callout, and every read
site (callouts, onboarding, reminders, CSV) off the flat
EventRegistration#ce_* columns and onto ContinuingEducationRegistration /
ProfessionalLicense, then drops the ce_credit_requested / ce_hours_requested /
ce_license_number columns.

Reconciled with #1833 (registrants-index CE column + filter), which merged to
main while this branch was in flight and built the same scaffolding on the
old columns:

- Kept #1833's scaffolding (CE column, dropdown filter, column toggles,
  layout, onboarding columns, bulk-reminder filter, CSV columns, eyebrow nav)
  and repointed every CE data read to the new models.
- EventRegistration.ce_status is now a derived scope over the CE registrations
  (needs_license / requested / paid / issued / not_issued), replacing the old
  ce_credit_requested-based buckets; the roster + reminder dropdowns follow it.
- EventRegistration#ce_status_label and the aggregators (ce_requested?,
  ce_hours_total, ce_amount_owed_cents, ce_license_provided?, ce_paid_in_full?)
  read the new models; the registrants column gates on a CE record existing
  rather than the dropped boolean (the "requested, no record yet" state is gone
  now that intake creates the record).
- ReminderRecipientFilter's CE matching flows through the shared ce_status
  scope (its per-record matchers were redundant with main's scope approach).

Migration timestamped to run after the foundation migrations now in main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants