diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e6e6909e38..351b9ce17e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -229,6 +229,7 @@ this). Match the existing pattern: - ES6+ syntax, ESM imports/exports, `const`/`let` (no `var`) - Use `const` for fixed values — not `SCREAMING_SNAKE_CASE` constants (e.g., `const styleId = "foo"` not `const STYLE_ID = "foo"`) +- **Default to no new JavaScript.** Prefer a server-rendered (ERB/decorator/helper) or Turbo solution over adding a new Stimulus controller. Only reach for JS when the behavior genuinely can't be done server-side or with Turbo (e.g. it needs live client-side state, the browser's own time zone, or DOM the server can't produce). If a change seems to need JS, first ask whether rendering it on the server — even with a small trade-off — is acceptable, and flag that trade-off. When JS is truly required, reuse or generalize an existing controller before writing a new one. - **Strongly prefer Stimulus** for JavaScript behavior — do not write raw/inline JS or jQuery - **Always use Tailwind CSS** utility classes for styling — do not write custom CSS unless absolutely necessary - **Prefer static Tailwind classes over dynamically-constructed ones.** Tailwind's JIT scanner only generates classes it finds as complete literal strings in the source — a class built by interpolation (e.g. `bg-#{color}-500`, `text-${size}`, `class="w-#{n}"`) won't be generated and silently renders unstyled. Write the full class names out, and select between complete literals (e.g. a lookup hash mapping a value to a whole class string, or a ternary picking between two literal classes) rather than splicing fragments. Only build a class dynamically when the set of values is open-ended and can't be enumerated; in that case add the candidates to the Tailwind safelist. diff --git a/AGENTS.md b/AGENTS.md index 352c005080..2afa976885 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,7 @@ This codebase (Rails 8.1) | `Event` | Events with registrations, featured/published states | | `EventStaff` | Join model connecting `Person` to `Event` as staff (title, `expected_to_attend`); drives the "Meet the staff" roster and "My events" | | `EventRegistrationChecklistCompletion` | Audited completion row for one manual onboarding step on an `EventRegistration` (`step` from `EventRegistration::CHECKLIST_STEPS`, `completed_by` User, `completed_at`); row-exists = done. Powers the event Onboarding tab's checkbox matrix | +| `EventAttendanceTimeEntry` | One sign-in/sign-out pair for a registrant on a day of an event (`signed_in_at`, `signed_out_at` — nil while "open"/still signed in; `created_by`/`updated_by` stamped only on staff edits, nil for registrant self-service). Generic day-of-event timekeeping (many per day for breaks/lunch), currently surfaced only on the CE callout when CE is paid; `EventAttendanceReport` totals minutes per day. Sign-in window derives from `Event#attendance_sign_in_open?` | | `RegistrationTicketCallout` | Call-outs shown on an event's registration ticket (title, subtitle, HTML description, `callout_type` action/reference, icon/colour, `payment_access_gated` — only shown once the registrant has `payment_access_granted?`, draggable `position`, `hidden` draft/opt-out, `display_from` drip date, and `has_many :resources` through `RegistrationTicketCalloutResource`); each links to its own public detail page. A nil `builtin_key` is an admin-authored callout; a set `builtin_key` is a built-in card materialized by `BuiltinCallouts` (hidden instead of deleted, restorable to default) | | `RegistrationTicketCalloutResource` | Ordered join linking a `RegistrationTicketCallout` to the `Resource`s shown on its detail page | | `Story` | Editorial content with facilitators, primary/gallery assets | @@ -202,6 +203,9 @@ action, or `authorize! :workshop, to: :summary?`). - `TrainingAttendeesBreakdowns` — Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over the whole filtered training-attendee population, profile-sourced, for the shared `events/_registrant_breakdowns` partial on the `events#training_attendees` index - `ReportPeriods` — Shared module (included by `EventRevenueReport` and `EventParticipationReport`) resolving the reporting-hub period toggle (this year / last year / all time) to a metric scope + label for the summary cards - `EventScholarshipReport` — Cross-event scholarship report grouped by calendar year: scholarship dollars and award counts (funded vs unfunded, via `EventDashboard`) per facilitator training, plus an attended-trainee count split into "Training" (scheduled) vs "On-demand" (`event.on_demand?`). Sibling of `EventRevenueReport`/`EventParticipationReport` (includes `ReportPeriods`); powers the `events#scholarships` report page and the statistics-hub scholarship summary card +- `EventAttendanceReport` — Per-event attendance sign-in/out report from `EventAttendanceTimeEntry`, grouped by day then registrant with per-day and grand-total minutes; `ce_only:` scopes to CE registrants and surfaces license number + awarded hours. The in-portal CE hour sign-in sheet, linked from the participation report (`?ce=true`) at `attendance_event_path` +- `EventAttendanceEntriesUpdate` — Applies a batch of submitted sign-in/out rows to one `EventRegistration`'s `event_attendance_time_entries` (add/correct/remove through nested attributes) and stamps `created_by`/`updated_by` with the editing admin. Shared by the CE edit form (`ContinuingEducationRegistrationsController#update`, datetime rows across every day) and the attendance report's inline per-day editor (`EventRegistrationsController#update_attendance`, clock times against one day) +- `AttendanceTimeFormatter` — Renders an attendance time as a clock time ("9:02 AM") and a minutes count as "6h 51m", so the report, callout, decorator, and flash notices all read the same. `EventAttendanceHelper` is the view-side front door; decorators and other callers without a dependable view context call it directly (same split as `MoneyFormatter`/`dollars_from_cents`) - `ScholarshipApplication` — Gathers one person's scholarship-application answers for an event by field across all their submissions, so answers surface whether captured on a dedicated scholarship form, an embedded registration section, or the registration submission itself (used by the scholarship edit page and the public submission view) - `WorkshopSearchService` — Complex filtering, sorting, pagination with ActionPolicy - `WorkshopFromIdeaService` — Converts WorkshopIdea to Workshop with asset migration diff --git a/CLAUDE.md b/CLAUDE.md index ffe2db4f00..742fd55b3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -239,6 +239,7 @@ this). Match the existing pattern: - ES6+ syntax, ESM imports/exports, `const`/`let` (no `var`) - Use `const` for fixed values — not `SCREAMING_SNAKE_CASE` constants (e.g., `const styleId = "foo"` not `const STYLE_ID = "foo"`) +- **Default to no new JavaScript.** Prefer a server-rendered (ERB/decorator/helper) or Turbo solution over adding a new Stimulus controller. Only reach for JS when the behavior genuinely can't be done server-side or with Turbo (e.g. it needs live client-side state, the browser's own time zone, or DOM the server can't produce). If a change seems to need JS, first ask whether rendering it on the server — even with a small trade-off — is acceptable, and flag that trade-off. When JS is truly required, reuse or generalize an existing controller before writing a new one. - **Strongly prefer Stimulus** for JavaScript behavior — do not write raw/inline JS or jQuery - **Always use Tailwind CSS** utility classes for styling — do not write custom CSS unless absolutely necessary - **Prefer static Tailwind classes over dynamically-constructed ones.** Tailwind's JIT scanner only generates classes it finds as complete literal strings in the source — a class built by interpolation (e.g. `bg-#{color}-500`, `text-${size}`, `class="w-#{n}"`) won't be generated and silently renders unstyled. Write the full class names out, and select between complete literals (e.g. a lookup hash mapping a value to a whole class string, or a ternary picking between two literal classes) rather than splicing fragments. Only build a class dynamically when the set of values is open-ended and can't be enumerated; in that case add the candidates to the Tailwind safelist. diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ec032f05e6..287d862034 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -35,6 +35,18 @@ def csv_dollars(cents) cents.positive? ? helpers.dollars_from_cents(cents) : "" end + # A failed save's errors as one flash-ready sentence. Errors on a nested association + # are copied onto the parent keyed ".", so their full message + # pastes the humanized association name in front ("Event attendance time entries + # Sign-out must be after…"). Phrase those the way the child model does instead: a + # whole-sentence message reads verbatim, and an attribute-level one keeps its subject + # ("Signed in at can't be blank") rather than arriving as a bare fragment. + def error_sentence(record) + record.errors.map { |error| + error.is_a?(ActiveModel::NestedError) ? error.inner_error.full_message : error.full_message + }.to_sentence + end + def after_sign_out_path_for(resource_or_scope) if params[:reset_password].present? # needed for custom "log out and reset it" flow new_user_password_path diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb index 79e270de5a..d174e23a9c 100644 --- a/app/controllers/continuing_education_registrations_controller.rb +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -22,8 +22,8 @@ def create @ce_registration.save! end redirect_to helpers.ce_registration_return_path(@ce_registration.event_registration), notice: "CE registration created.", status: :see_other - rescue ActiveRecord::RecordInvalid - flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence + rescue ActiveRecord::RecordInvalid => e + flash.now[:alert] = error_sentence(e.record) render :new, status: :unprocessable_content end @@ -37,10 +37,11 @@ def update ActiveRecord::Base.transaction do apply_ce_params(@ce_registration) @ce_registration.save! + apply_time_entries(@ce_registration.event_registration) end redirect_to helpers.ce_registration_return_path(@ce_registration.event_registration), notice: "CE registration updated.", status: :see_other - rescue ActiveRecord::RecordInvalid - flash.now[:alert] = @ce_registration.errors.full_messages.to_sentence + rescue ActiveRecord::RecordInvalid => e + flash.now[:alert] = error_sentence(e.record) render :edit, status: :unprocessable_content end @@ -96,4 +97,18 @@ def apply_ce_params(ce_registration) .permit(comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ])[:comments_attributes] ce_registration.comments_attributes = comments if comments.present? end + + # Staff corrections to the registrant's attendance times, submitted alongside the + # CE form under continuing_education_registration[time_entries] as full datetimes + # (this form spans every day, unlike the report's per-day editor). + def apply_time_entries(registration) + EventAttendanceEntriesUpdate.new(registration, time_entries_attributes, editor: current_user).save! + end + + def time_entries_attributes + params.fetch(:continuing_education_registration, {}) + .permit(time_entries: [ :id, :signed_in_at, :signed_out_at, :_destroy ]) + .fetch(:time_entries, {}) + .values + end end diff --git a/app/controllers/event_registrations_controller.rb b/app/controllers/event_registrations_controller.rb index 3477ed8671..11b9844f35 100644 --- a/app/controllers/event_registrations_controller.rb +++ b/app/controllers/event_registrations_controller.rb @@ -2,7 +2,7 @@ class EventRegistrationsController < ApplicationController require "csv" # show redirects to slug URL; kept for backwards compatibility - before_action :set_event_registration, only: [ :show, :edit, :update, :destroy, :update_onboarding, :toggle_certificate_issued ] + before_action :set_event_registration, only: [ :show, :edit, :update, :destroy, :update_onboarding, :toggle_certificate_issued, :update_attendance ] def index authorize! @@ -106,6 +106,7 @@ def update when "onboarding" then redirect_to helpers.onboarding_event_row_path(@event_registration.event, @event_registration.id), notice: notice, status: :see_other when "training_attendees" then redirect_to training_attendees_events_path, notice: notice, status: :see_other when "recipients" then redirect_to recipients_event_path(@event_registration.event, anchor: "shout-outs"), notice: notice, status: :see_other + when "attendance" then redirect_to attendance_event_path(@event_registration.event), notice: notice, status: :see_other else # No explicit origin: keep admins in the management context (the # roster) rather than dropping them on the public registration show. @@ -168,6 +169,28 @@ def toggle_certificate_issued end end + # Inline correction of one registrant's sign-in/out times for one training day, from + # the event's attendance report. Rows carry clock times only — the day comes from the + # report section the editor was opened in — plus a blank row to add a session and a + # Remove box to drop one. Registrants still stamp their own times from the CE callout; + # this is where staff fix a missed sign-in or a forgotten sign-out without leaving the + # report. + def update_attendance + authorize! @event_registration, to: :update_attendance? + date = attendance_date_param + return head :unprocessable_content unless date + + rows = attendance_rows(date) + EventAttendanceEntriesUpdate.new(@event_registration, rows, editor: current_user).save! + redirect_to attendance_report_path(date), notice: "Attendance times updated.", status: :see_other + rescue ActiveRecord::RecordInvalid => e + flash[:alert] = error_sentence(e.record) + # Hand the submitted times back so a rejected save doesn't cost the admin what + # they typed; the editor reopens on this cell prefilled with them. + flash[:attendance_rows] = submitted_attendance_rows + redirect_to attendance_report_path(date, reopen: true), status: :see_other + end + def confirm @event_registration = EventRegistration.includes(registrant: :user, event: :location).find(params[:id]) authorize! @event_registration, to: :confirm? @@ -333,6 +356,51 @@ def set_event_registration @event_registration = EventRegistration.includes({ registrant: [ :user, { affiliations: :organization } ] }, { event: [ :location, :event_forms ] }, :organizations, comments: [ :created_by, :updated_by ]).find(params[:id]) end + # The training day the inline editor was opened on. Nil for anything unparseable — + # the date comes from the report's own sections, so a bad one is a broken request. + def attendance_date_param + Date.iso8601(params[:date].to_s) + rescue ArgumentError + nil + end + + # The submitted sessions as attendance-entry attributes. Clock times ("08:50") are + # combined with the editor's day, since a session belongs to the day it's listed + # under; the CE edit page stays the place to enter a pair that crosses midnight. + def attendance_rows(date) + submitted_attendance_rows.map do |row| + { "id" => row["id"], + "signed_in_at" => attendance_time(date, row["in"]), + "signed_out_at" => attendance_time(date, row["out"]), + "_destroy" => row["_destroy"] } + end + end + + def submitted_attendance_rows + params.fetch(:attendance, {}) + .permit(entries: [ :id, :in, :out, :_destroy ]) + .fetch(:entries, {}) + .values + .map { |row| row.to_h.stringify_keys } + end + + # Blank stays blank: an empty sign-in marks an untouched row (dropped by the + # association's reject_if), an empty sign-out leaves the session open. + def attendance_time(date, clock) + return nil if clock.blank? + + Time.zone.parse("#{date.iso8601} #{clock}") + end + + # Back to the report in read mode, scrolled to the day cell that was edited, keeping + # whichever view the admin had open. `reopen:` puts that cell back into edit mode. + def attendance_report_path(date, reopen: false) + cell = helpers.attendance_cell_id(@event_registration, date) + attendance_event_path(@event_registration.event, + ce: params[:ce].presence, group: params[:group].presence, return_to: params[:return_to].presence, + edit: (cell if reopen), anchor: cell) + end + # Creates the audited completion row for a checklist step (recording who/when), # or removes it — so an unchecked step leaves no trace. def toggle_checklist_step(step, completed) diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 64353b4487..660f8de363 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -137,6 +137,49 @@ def request_ce redirect_to registration_ce_path(@event_registration.slug), notice: "Continuing education credit requested." end + # Record the registrant signing in from their CE callout. Self-service and + # public (no login), so created_by stays nil — only staff edits are attributed. + # Gated on CE being paid in full and the day's sign-in window being open; a + # second sign-in while already signed in is a no-op. + def sign_in_ce + return redirect_to(registration_ce_path(@event_registration.slug)) if sample_preview? + unless attendance_enabled? + return redirect_to registration_ce_path(@event_registration.slug), alert: "Signing in isn't available yet." + end + if @event_registration.signed_in? + return redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), notice: "You're already signed in." + end + unless @event.attendance_sign_in_open? + return redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), + alert: "Sign-in is only open during the training day." + end + + entry = @event_registration.event_attendance_time_entries.create!(signed_in_at: Time.current) + redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), + notice: "Signed in at #{local_time(entry.signed_in_at)}." + rescue ActiveRecord::RecordInvalid => e + redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), + alert: e.record.errors.full_messages.to_sentence + end + + # Close an open attendance entry. Not windowed — a forgotten sign-out can always + # be recorded. Two cases: today's entry (stamped now) and the catch-up button for + # a day the registrant left open (stamped that day's scheduled end) — see + # #sign_out_target. + def sign_out_ce + return redirect_to(registration_ce_path(@event_registration.slug)) if sample_preview? + entry, signed_out_at = sign_out_target + unless entry + return redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), alert: "You're not signed in." + end + + entry.update!(signed_out_at: signed_out_at) + redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), notice: sign_out_notice(entry) + rescue ActiveRecord::RecordInvalid => e + redirect_to registration_ce_path(@event_registration.slug, anchor: "attendance"), + alert: e.record.errors.full_messages.to_sentence + end + # Handouts page: callout-card links to the training worksheet/handout # resources, in display order, each opening its own registrant resource page # (PDF preview + download, with a back-to-handouts eyebrow). Cards read their @@ -189,6 +232,39 @@ def faq private + # Attendance sign-in/out is offered only once CE is paid in full — it's the CE + # sign-in sheet, so it follows the CE payment, and mirrors the callout view's gate. + def attendance_enabled? + @event_registration.ce_registered? && @event_registration.ce_paid_in_full? + end + + # A datetime rendered in the app zone as "9:02 AM", for sign-in/out flash notices. + def local_time(time) + helpers.attendance_clock_time(time) + end + + # Which open entry this sign-out closes, and the time to stamp it with. The + # catch-up button names an earlier day's entry explicitly (?entry_id) so it can't + # be confused with today's — it lands on that day's scheduled end rather than now, + # which would bank every hour since. Anything else closes today's entry at now. + def sign_out_target + return [ @event_registration.open_attendance_entry, Time.current ] if params[:entry_id].blank? + + forgotten = @event_registration.forgotten_sign_out_entry + return [] unless forgotten && forgotten.id.to_s == params[:entry_id].to_s + + [ forgotten, @event_registration.forgotten_sign_out_at(forgotten) ] + end + + # Name the day when the sign-out isn't for today, so a catch-up close reads as + # what it is rather than looking like a stray time. + def sign_out_notice(entry) + time = local_time(entry.signed_out_at) + return "Signed out at #{time}." if entry.attendance_date == Time.zone.today + + "Signed out for #{entry.attendance_date.strftime("%a, %b %-d")} at #{time}." + end + # Whether the event's built-in callout for this key is materialized and # published (visible). These public pages gate on that alone now — the admin's # published/hidden choice on the row decides whether the page is reachable, so diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 813bd55301..cc8604253c 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -2,7 +2,7 @@ class EventsController < ApplicationController include AhoyTracking, TagAssignable skip_before_action :authenticate_user!, only: [ :index, :show, :staff ] skip_before_action :verify_authenticity_token, only: [ :preview ] - before_action :set_event, only: %i[ show edit update destroy preview dashboard sample_ticket background registrants onboarding staff edit_staff update_staff recipients feature_recipient_shoutout preview_reminder confirm_reminder send_reminder copy_registration_form ] + before_action :set_event, only: %i[ show edit update destroy preview dashboard attendance sample_ticket background registrants onboarding staff edit_staff update_staff recipients feature_recipient_shoutout preview_reminder confirm_reminder send_reminder copy_registration_form ] before_action :set_report_filters, only: %i[ revenue participation statistics scholarships ] def index @@ -92,6 +92,14 @@ def training_attendees render :training_attendees_results end + # Per-event attendance sign-in/out report, grouped by day then registrant — the + # in-portal CE hour sign-in sheet. `?ce=true` scopes to CE registrants and shows + # their license number and awarded hours. + def attendance + authorize! @event + @report = EventAttendanceReport.new(@event, ce_only: params[:ce] == "true") + end + def new authorize! @event = Event.new.decorate diff --git a/app/decorators/event_attendance_time_entry_decorator.rb b/app/decorators/event_attendance_time_entry_decorator.rb new file mode 100644 index 0000000000..073aebc819 --- /dev/null +++ b/app/decorators/event_attendance_time_entry_decorator.rb @@ -0,0 +1,25 @@ +class EventAttendanceTimeEntryDecorator < ApplicationDecorator + delegate_all + + # Formatting comes straight from AttendanceTimeFormatter rather than through `h`: + # the report decorates entries from a service, where the current view context can be + # a leftover mailer one that doesn't carry EventAttendanceHelper. + + # Clock time of the sign-in, in the app zone — e.g. "8:50 AM". + def signed_in_label + AttendanceTimeFormatter.clock_time(signed_in_at) + end + + # Clock time of the sign-out, or an em dash while still signed in. + def signed_out_label + signed_out_at ? AttendanceTimeFormatter.clock_time(signed_out_at) : "—" + end + + # Elapsed time as "1h 44m" (or "44m" under an hour); "In progress" while open. + def duration_label + minutes = duration_minutes + return "In progress" unless minutes + + AttendanceTimeFormatter.duration_label(minutes) + end +end diff --git a/app/helpers/continuing_education_registrations_helper.rb b/app/helpers/continuing_education_registrations_helper.rb index 02f79b96d4..232dd166d2 100644 --- a/app/helpers/continuing_education_registrations_helper.rb +++ b/app/helpers/continuing_education_registrations_helper.rb @@ -2,10 +2,23 @@ module ContinuingEducationRegistrationsHelper # One source of truth for where a CE registration form (new or edit) returns to — # the eyebrow, Cancel, and the controller's post-save/create/destroy redirects all # agree. Reached from the registrants roster (return_to=registrants) it lands back - # on that registrant's row (scroll + highlight); any other origin falls back to the - # registration's own edit page. + # on that registrant's row (scroll + highlight); from the CE sign-in report + # (return_to=attendance) on the report's totals table; any other origin falls back + # to the registration's own edit page. def ce_registration_return_path(registration) - return registrants_event_row_path(registration.event, registration.id) if params[:return_to] == "registrants" - edit_event_registration_path(registration) + case params[:return_to] + when "registrants" then registrants_event_row_path(registration.event, registration.id) + when "attendance" then attendance_event_path(registration.event, ce: "true", anchor: "totals") + else edit_event_registration_path(registration) + end + end + + # The eyebrow/Cancel wording for that origin, kept in step with the path above. + def ce_registration_return_label + case params[:return_to] + when "registrants" then "Registrants" + when "attendance" then "CE sign-in report" + else "Registration" + end end end diff --git a/app/helpers/event_attendance_helper.rb b/app/helpers/event_attendance_helper.rb new file mode 100644 index 0000000000..5c880c2e9e --- /dev/null +++ b/app/helpers/event_attendance_helper.rb @@ -0,0 +1,34 @@ +module EventAttendanceHelper + # The view-side front door to AttendanceTimeFormatter, which decorators and other + # callers without a dependable view context use directly. + def attendance_duration_label(minutes) + AttendanceTimeFormatter.duration_label(minutes) + end + + def attendance_clock_time(time) + AttendanceTimeFormatter.clock_time(time) + end + + # Identifies one registrant's sessions on one training day — the report's editable + # unit. Doubles as the cell's DOM id, the `edit` param that opens it, and the anchor + # the page returns to after a save. + def attendance_cell_id(registration, date) + "attendance-#{registration.id}-#{date.iso8601}" + end + + # The rows the inline day editor renders: one per logged session plus a trailing + # blank to add another (fill it and save, same as the CE edit page's table). After a + # rejected save the submitted values come back through the flash, so a validation + # error doesn't cost the admin what they typed. + def attendance_editor_rows(entries) + rows = flash[:attendance_rows].presence || entries.map { |entry| + { "id" => entry.id.to_s, "in" => attendance_input_time(entry.signed_in_at), "out" => attendance_input_time(entry.signed_out_at) } + } + rows.reject { |row| row.values_at("id", "in", "out").all?(&:blank?) } + [ {} ] + end + + # A datetime as the "HH:MM" an expects, in the app zone. + def attendance_input_time(time) + time&.in_time_zone(Time.zone)&.strftime("%H:%M") + end +end diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 4b5bee69b2..0c420eff38 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -40,13 +40,36 @@ class ContinuingEducationRegistration < ApplicationRecord # Payment interface (allocations_sum / paid_in_full? / remaining_cost / …) comes from # Registerable, driven by this record's own cost_cents column. + # The logged sign-in time must cover at least this fraction of the awarded CE + # contact hours before the certificate unlocks — a little slack for slightly-late + # sign-ins/early sign-outs. You can't certify hours the sign-in sheet doesn't support. + ATTENDANCE_COVERAGE_THRESHOLD = 0.9 + # CE certificate eligibility — its own rule (not shared): the event grants CE, - # the registrant attended, the training has ended, and the CE balance is paid. + # the registrant attended, the training has ended, the CE balance is paid, and + # (when attendance was tracked) the logged time approximately covers the hours. def certificate_available? event = event_registration&.event return false unless event&.ce_eligible? + return false unless event.end_date&.past? && event_registration.attended? && paid_in_full? + + attendance_time_sufficient? + end + + # When attendance time has been logged for this registrant, it must approximately + # cover the awarded hours before the certificate unlocks. With nothing logged (the + # portal sign-in wasn't used for this event), day-level attendance alone governs, + # so this doesn't block — it never retroactively gates events that never tracked time. + def attendance_time_sufficient? + logged = event_registration.attendance_minutes_total + return true if logged.zero? + + logged >= required_attendance_minutes + end - event.end_date&.past? && event_registration.attended? && paid_in_full? + # Minutes of logged attendance needed to certify the awarded hours (with tolerance). + def required_attendance_minutes + (hours.to_d * 60 * ATTENDANCE_COVERAGE_THRESHOLD).round end # Point this registration at a license for the typed type + number. `license_id` diff --git a/app/models/event.rb b/app/models/event.rb index ae277f908b..adb40c3dc5 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -199,6 +199,52 @@ def day_count span.clamp(1, 5) end + # Attendance sign-in opens this long before a training day's start, so early + # arrivals can sign in (the CE sheet shows people arriving ~10 min early). Sign-out + # isn't windowed — an open entry can always be closed, since forgetting to sign out + # is the common failure. + ATTENDANCE_SIGN_IN_LEAD = 30.minutes + + # The calendar dates this event runs, inclusive, capped to day_count. Events store + # only one start_date/end_date, so multi-day events are assumed to run on + # consecutive days — the same assumption day_count already makes. + def event_dates + return [] if start_date.blank? + + first = start_date.in_time_zone(Time.zone).to_date + (0...day_count).map { |offset| first + offset } + end + + # A day's start datetime: that date at start_date's time-of-day, in the app zone. + # Every event day is assumed to start at the same time (the only time we have). + def daily_start_at(date) + combine_date_and_time(date, start_date) + end + + # A day's end datetime: that date at end_date's time-of-day (falling back to the + # start time for events with no end), in the app zone. + def daily_end_at(date) + combine_date_and_time(date, end_date.presence || start_date) + end + + # Whether a registrant may start a new sign-in right now: it's an event day and + # now falls within [day start − lead, day end]. Sign-out is deliberately not + # gated by this (see ATTENDANCE_SIGN_IN_LEAD). + def attendance_sign_in_open?(at = Time.current) + date = event_dates.find { |d| d == at.in_time_zone(Time.zone).to_date } + return false unless date + + at.between?(daily_start_at(date) - ATTENDANCE_SIGN_IN_LEAD, daily_end_at(date)) + end + + # When sign-in next becomes available — the earliest upcoming day's window opening + # (day start − lead). Nil once the last day's window has already opened (or passed). + def next_attendance_sign_in_opens_at(at = Time.current) + event_dates + .map { |date| daily_start_at(date) - ATTENDANCE_SIGN_IN_LEAD } + .find { |opens_at| opens_at > at } + end + def time_title "(#{ start_text }) #{ name }" end @@ -338,6 +384,13 @@ def merge_date_time(field) self[field] = build_datetime(date_val, time_val) end + # Combine a Date with the time-of-day of a datetime source, in the app zone — + # e.g. "day 2's date" + "the event's 9:00am start" → that day at 9:00am. + def combine_date_and_time(date, source) + time = source.in_time_zone(Time.zone) + Time.zone.local(date.year, date.month, date.day, time.hour, time.min) + end + def build_datetime(date_str, time_str) return nil if date_str.blank? && time_str.blank? return Time.zone.parse(date_str) if date_str.present? && time_str.blank? diff --git a/app/models/event_attendance_time_entry.rb b/app/models/event_attendance_time_entry.rb new file mode 100644 index 0000000000..161ce0e2d9 --- /dev/null +++ b/app/models/event_attendance_time_entry.rb @@ -0,0 +1,125 @@ +# One sign-in/sign-out pair for a registrant on a day of an event. Generic +# attendance timekeeping — many entries per day (people sign out for breaks and +# lunch and back in) — surfaced today only on the CE callout, but not CE-specific +# so any event day can use it. `signed_out_at` is nil while the person is still +# signed in (an "open" entry). Times are stored UTC and displayed in the app zone +# (Pacific), matching the paper CE sign-in sheet this replaces. +class EventAttendanceTimeEntry < ApplicationRecord + belongs_to :event_registration + # Registrant self-service sign-ins happen on the public (login-free) callout, so + # created_by is nil for those; it's stamped only when staff add/edit an entry on + # the CE edit form. + belongs_to :created_by, class_name: "User", optional: true + belongs_to :updated_by, class_name: "User", optional: true + + # A single day can't hold more than a real day's worth of logged time. + MAX_DAILY_MINUTES = 24 * 60 + + validates :signed_in_at, presence: true + validate :signed_out_after_signed_in + validate :within_daily_limit + validate :does_not_overlap_same_day + + scope :open, -> { where(signed_out_at: nil) } + scope :closed, -> { where.not(signed_out_at: nil) } + scope :chronological, -> { order(:signed_in_at) } + + # Still signed in — no sign-out recorded yet. + def open? + signed_out_at.nil? + end + + # Whole minutes between sign-in and sign-out; nil while still open. Rounded to + # the minute like the paper sheet, which staff totalled by the minute — except + # a sub-minute pair counts as 1 (rounded up in the attendee's favor), never 0. + def duration_minutes + return nil unless signed_out_at && signed_in_at + [ ((signed_out_at - signed_in_at) / 60).round, 1 ].max + end + + # The event day (a Date, in the app zone) this entry's sign-in falls on — how + # the report groups entries into days. + def attendance_date + signed_in_at&.in_time_zone(Time.zone)&.to_date + end + + private + + # On :base and phrased as a whole sentence like the other two guards: these reach + # the admin through the parent registration's nested attributes, which pastes the + # humanized association name in front of anything keyed to an attribute. + def signed_out_after_signed_in + return if signed_out_at.blank? || signed_in_at.blank? + return if signed_out_at > signed_in_at + + errors.add(:base, "Sign-out must be after the sign-in time.") + end + + # The day's total logged time (this entry plus its same-day siblings) can't exceed + # 24 hours — catches fat-fingered edits like a 19-hour session. + def within_daily_limit + return unless own_range_valid? + + total = duration_minutes.to_i + same_day_siblings.sum { |entry| entry.duration_minutes.to_i } + return if total <= MAX_DAILY_MINUTES + + errors.add(:base, "Total time on #{day_label} can't exceed 24 hours.") + end + + # An entry can't fall within (or straddle) another sign-in's timeframe on the same + # day — you can't be signed in twice at once. + def does_not_overlap_same_day + return unless own_range_valid? + + my_end = signed_out_at || signed_in_at + clash = same_day_siblings.find do |entry| + entry_end = entry.signed_out_at || entry.signed_in_at + signed_in_at < entry_end && entry.signed_in_at < my_end + end + return unless clash + + errors.add(:base, "This sign-in overlaps another entry on #{day_label}.") + end + + # Only run the cross-entry guards on a well-formed range (presence + order are + # checked separately), so we never compare against a backwards interval. + def own_range_valid? + return false if signed_in_at.blank? + + signed_out_at.blank? || signed_out_at > signed_in_at + end + + # This registration's other entries on the same day. Starts from the persisted + # rows (queried fresh, not the possibly-stale association cache) and overlays the + # in-memory collection when it's loaded — so the CE edit form, which assigns every + # row through nested attributes, compares against siblings-in-progress (and their + # unsaved edits) too. Excludes self and rows being removed. + def same_day_siblings + registration = event_registration + return [] unless registration && attendance_date + + by_key = {} + if registration.persisted? + EventAttendanceTimeEntry.where(event_registration_id: registration.id).find_each do |entry| + by_key[entry.id] = entry + end + end + if registration.event_attendance_time_entries.loaded? + registration.event_attendance_time_entries.target.each do |entry| + by_key[entry.id || entry.object_id] = entry + end + end + + by_key.values.reject do |entry| + entry.equal?(self) || + (persisted? && entry.id == id) || + entry.marked_for_destruction? || + entry.signed_in_at.blank? || + entry.attendance_date != attendance_date + end + end + + def day_label + attendance_date.strftime("%b %-d") + end +end diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index bd1a50f2da..50a8b98f98 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -16,12 +16,17 @@ class EventRegistration < ApplicationRecord has_many :organizations, through: :event_registration_organizations has_many :allocations, as: :allocatable has_many :continuing_education_registrations, dependent: :destroy + has_many :event_attendance_time_entries, dependent: :destroy has_many :scholarships, -> { distinct }, through: :allocations, source: :source, source_type: "Scholarship" has_many :checklist_completions, class_name: "EventRegistrationChecklistCompletion", dependent: :destroy accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: proc { |attrs| attrs["body"].blank? } accepts_nested_attributes_for :notifications, allow_destroy: true, reject_if: proc { |attrs| attrs["email_subject"].blank? } + # Staff correct/add attendance times on the CE edit form; a row with no sign-in + # time is an untouched blank and dropped. + accepts_nested_attributes_for :event_attendance_time_entries, allow_destroy: true, + reject_if: proc { |attrs| attrs["signed_in_at"].blank? } # Lets the registration edit form edit the registrant's shout-out text (which # lives on the Person) inline, alongside the registration's own shout-out flag. accepts_nested_attributes_for :registrant @@ -564,6 +569,56 @@ def cost_cents event.cost_cents end + # The registrant's currently-open attendance entry (signed in, not yet out) for + # one day, or nil when they're not signed in that day. Drives which sign-in/out + # button the CE callout shows. Deliberately day-scoped: an entry left open when + # someone forgets to sign out must not carry into the next training day, where it + # would block the new day's sign-in and, once closed, bank every hour since. The + # earlier day is closed separately, through #forgotten_sign_out_entry. + # Uses the most recent open entry if more than one somehow exists. + def open_attendance_entry(date = Time.zone.today) + attendance_entries_on(date).select(&:open?).last + end + + # A sign-out the registrant forgot on an earlier day: the most recent entry still + # open from before `date`. Offered on today's callout as its own catch-up button, + # separate from today's sign-in/out, so the two days can't be confused. Only when + # #forgotten_sign_out_at has something sensible to stamp — otherwise it's a staff + # correction on the attendance report, not a one-click fix. + def forgotten_sign_out_entry(date = Time.zone.today) + entry = event_attendance_time_entries.chronological + .select { |candidate| candidate.open? && candidate.attendance_date && candidate.attendance_date < date } + .last + entry if entry && forgotten_sign_out_at(entry) + end + + # The time a forgotten sign-out is stamped with: the scheduled end of the training + # day it belongs to — what staff wrote on the paper sheet — never "now", which would + # bank every hour since. Nil when that end isn't after the sign-in (someone signed in + # after the day was over), leaving it for staff. + def forgotten_sign_out_at(entry) + close_at = event.daily_end_at(entry.attendance_date) + close_at if close_at > entry.signed_in_at + end + + # Whether the registrant is currently signed in (today). + def signed_in? + open_attendance_entry.present? + end + + # This registration's attendance entries for one event day (a Date), in + # sign-in order — the day's rows on the CE callout and the report. + def attendance_entries_on(date) + event_attendance_time_entries.chronological.select { |entry| entry.attendance_date == date } + end + + # Total completed (signed-out) attendance minutes across all days — the figure the + # CE certificate gate compares against the awarded hours. Open entries contribute + # nothing until they're signed out. + def attendance_minutes_total + event_attendance_time_entries.sum { |entry| entry.duration_minutes.to_i } + end + # CE is now tracked as one or more ContinuingEducationRegistration records, # each against a professional license. These aggregate across them so callers # (callouts, onboarding, CSV) read a single registration-level figure. diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index decc78ab42..f5126517fc 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -94,6 +94,12 @@ def dashboard? admin? || owner? end + # The per-event attendance (CE sign-in) report shows registrant PII, so it's + # gated like the dashboard — admins and the event's owner. + def attendance? + admin? || owner? + end + def background? admin? || owner? end diff --git a/app/policies/event_registration_policy.rb b/app/policies/event_registration_policy.rb index dc82897d61..7a95e78e1f 100644 --- a/app/policies/event_registration_policy.rb +++ b/app/policies/event_registration_policy.rb @@ -22,6 +22,10 @@ def update_onboarding? = admin? || event_owner? # action, so mirror the roster's audience (admins and the event's owner). def toggle_certificate_issued? = admin? || event_owner? + # Correcting attendance times inline on the event's sign-in report — same reach as + # reading that report (EventPolicy#attendance?). + def update_attendance? = admin? || event_owner? + relation_scope do |relation| return relation if admin? diff --git a/app/services/attendance_time_formatter.rb b/app/services/attendance_time_formatter.rb new file mode 100644 index 0000000000..6ceb317b4f --- /dev/null +++ b/app/services/attendance_time_formatter.rb @@ -0,0 +1,25 @@ +# Renders attendance times and totals the one way they should read everywhere — the +# CE sign-in report, the registrant's callout, the entry decorator, and the +# sign-in/out flash notices. +# +# A PORO rather than the helper alone because decorators reach these from wherever +# they happen to be called. Draper resolves `h` against whichever view context is +# current, and outside a request that can be a mailer's (ApplicationMailer pulls in +# ApplicationHelper only), which doesn't carry EventAttendanceHelper. Same split as +# MoneyFormatter: EventAttendanceHelper is the view-side front door, this is what +# callers without a dependable view context use. +class AttendanceTimeFormatter + # A datetime as its clock time in the app zone — "9:02 AM". + def self.clock_time(time) + time.in_time_zone(Time.zone).strftime("%-l:%M %p") + end + + # A minutes count as "6h 51m" (or "51m" under an hour, "0m" for zero) — how the CE + # sign-in report totals attended time, replacing the paper sheet's minute math. + def self.duration_label(minutes) + hours, mins = minutes.to_i.divmod(60) + return "#{mins}m" if hours.zero? + + "#{hours}h #{mins}m" + end +end diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index bf776917ed..b7e39b254d 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -271,16 +271,47 @@ def ce_hours_card # An outstanding CE balance turns the card orange (an action card), matching # the payment card, rather than the resting teal. due = registration.continuing_education_registrations.first&.remaining_cost.to_i.positive? - Card.new(icon_class: "fa-solid fa-graduation-cap", color: due ? "orange" : "teal", + # Once CE is paid, on a training day the badge becomes a live sign-in nudge + # (like the payment card's "$X due"), overriding the resting CE status chip. + reminder = ce_attendance_reminder + Card.new(icon_class: "fa-solid fa-graduation-cap", color: ce_card_color(due, reminder), title: event.ce_hours_label, subtitle: ce_hours_subtitle, href: registration_ce_path(registration.slug), target: nil, trailing_icon: "fa-solid fa-arrow-right", - badge: ce_hours_badge(complete), - # Amber while money is due or hours/license are still needed (nil - # badge_classes falls back to amber in _callout_card); teal once it's - # complete and paid. - badge_classes: complete && !due ? "bg-teal-100 text-teal-800 border border-teal-300" : nil) + badge: ce_hours_reminder_badge(reminder) || ce_hours_badge(complete), + # Amber while money is due, hours/license are still needed, or it's time + # to sign in (nil badge_classes falls back to amber in _callout_card); + # teal once complete and paid, or while currently signed in. + badge_classes: ce_card_badge_classes(complete, due, reminder)) + end + + # The live attendance nudge for the CE card on a training day, once CE is paid — + # :signed_in while an entry is open, :sign_in while sign-in is open and they're + # not signed in, nil otherwise (so the resting CE status chip shows instead). + def ce_attendance_reminder + return unless registration.ce_paid_in_full? + return :signed_in if registration.signed_in? + + :sign_in if event.attendance_sign_in_open? + end + + def ce_hours_reminder_badge(reminder) + { signed_in: "Signed in", sign_in: "Sign in for today" }[reminder] + end + + def ce_card_color(due, reminder) + return "teal" if reminder == :signed_in + return "orange" if reminder == :sign_in || due + + "teal" + end + + def ce_card_badge_classes(complete, due, reminder) + return "bg-teal-100 text-teal-800 border border-teal-300" if reminder == :signed_in + return if reminder == :sign_in # amber default (action) via _callout_card + + complete && !due ? "bg-teal-100 text-teal-800 border border-teal-300" : nil end # Before the registrant has requested CE, an invite card linking to the CE page diff --git a/app/services/event_attendance_entries_update.rb b/app/services/event_attendance_entries_update.rb new file mode 100644 index 0000000000..ec70d2fc0e --- /dev/null +++ b/app/services/event_attendance_entries_update.rb @@ -0,0 +1,51 @@ +# Applies a batch of submitted sign-in/out rows to one registration's attendance +# entries — add, correct, remove — through the registration's nested-attributes +# setter, then attributes the change to the editing admin. Shared by the CE edit +# form (datetime rows spanning every day) and the attendance report's per-day inline +# editor (clock times against one known day): different row shapes on the way in, the +# same write on the way out. +# +# Rows are hashes (or permitted params) of "id", "signed_in_at", "signed_out_at" and +# "_destroy"; a row with no sign-in is an untouched blank and is dropped by the +# association's reject_if. +class EventAttendanceEntriesUpdate + def initialize(registration, rows, editor:) + @registration = registration + @rows = rows + @editor = editor + end + + # Saves the batch, raising ActiveRecord::RecordInvalid so callers can roll back and + # report. A no-op when there's nothing left to apply. + def save! + applicable = applicable_rows + return if applicable.blank? + + @registration.assign_attributes(event_attendance_time_entries_attributes: applicable) + attribute_to_editor + @registration.save! + end + + private + + # Drop rows pointing at an entry that's no longer on this registration — a stale + # form or double-submit (it was already removed). Left in, nested attributes raise + # RecordNotFound and blow up the save. + def applicable_rows + return [] if @rows.blank? + + existing_ids = @registration.event_attendance_time_entries.pluck(:id).map(&:to_s) + @rows.reject { |row| row["id"].present? && existing_ids.exclude?(row["id"].to_s) } + end + + # Staff edits are the only attributed entries — registrant self-service sign-ins on + # the public callout leave created_by nil. + def attribute_to_editor + @registration.event_attendance_time_entries.each do |entry| + next if entry.marked_for_destruction? + + entry.created_by ||= @editor if entry.new_record? + entry.updated_by = @editor if entry.new_record? || entry.changed? + end + end +end diff --git a/app/services/event_attendance_report.rb b/app/services/event_attendance_report.rb new file mode 100644 index 0000000000..fee3347275 --- /dev/null +++ b/app/services/event_attendance_report.rb @@ -0,0 +1,118 @@ +# Attendance sign-in/out for one event, grouped by day then registrant — the +# in-portal record that replaces AWBW's paper CE hour sign-in sheet (per-day tabs, +# each in/out pair, minutes totalled by staff). Generic: `ce_only:` scopes to CE +# registrants and surfaces their license number and awarded hours (what the CE +# board audits); the plain report covers anyone who logged time on any event day. +# +# All grouping/totals run over preloaded associations in Ruby (a training is a few +# dozen people with a handful of entries each), so building the whole report is a +# fixed handful of queries. Times use Time.zone — during a request that's the +# viewing admin's zone (Pacific), matching the callout and the paper sheet. +class EventAttendanceReport + def initialize(event, ce_only: false) + @event = event + @ce_only = ce_only + end + + attr_reader :event + + def ce_only? + @ce_only + end + + # The event's calendar days — the report's top-level grouping (Day 1, Day 2, …). + def dates + event.event_dates + end + + # Reported registrations, sorted by registrant name. The CE report lists every CE + # registrant even before they've logged anything (so staff can chase sign-ins + # during the training); the generic report lists only people who logged time. + def registrations + @registrations ||= scoped_registrations.sort_by { |reg| reg.registrant.full_name.to_s.downcase } + end + + def any? + registrations.any? + end + + # Whether the event actually runs past the last reported date — event_dates is + # capped at 5 days (Event#day_count's clamp), so a longer event has no sign-in + # window or day section past day 5. The view warns when this is true. + def dates_truncated? + last_day = event.end_date&.in_time_zone(Time.zone)&.to_date + return false unless last_day && dates.any? + + last_day > dates.last + end + + # One registration's entries on one date, decorated and in sign-in order. + def entries_for(registration, date) + entries_on(registration, date).sort_by(&:signed_in_at).map(&:decorate) + end + + def day_minutes(registration, date) + entries_on(registration, date).sum { |entry| entry.duration_minutes.to_i } + end + + # Sum of the per-day (event-day) minutes, so a registrant's Total logged always + # equals its day columns. Time logged on dates outside the training's days isn't + # part of the training, so it's excluded here (the certificate gate keeps its own + # broader tally on EventRegistration). + def total_minutes(registration) + dates.sum { |date| day_minutes(registration, date) } + end + + def grand_total_minutes + registrations.sum { |reg| total_minutes(reg) } + end + + # Everyone's logged minutes on one day — the day column's total in the All row. + def day_grand_minutes(date) + registrations.sum { |reg| day_minutes(reg, date) } + end + + # Total CE hours awarded across all reported registrants — the All row's awarded figure. + def total_hours_awarded + registrations.sum { |reg| ce_hours(reg) } + end + + # A registration with an entry still open (signed in, no sign-out) — flagged on + # the report so a forgotten sign-out is fixable rather than silently under-counted. + def open?(registration) + registration.event_attendance_time_entries.any?(&:open?) + end + + # CE-only columns. + def license_numbers(registration) + registration.continuing_education_registrations.filter_map { |ce| ce.professional_license&.number }.uniq + end + + def ce_hours(registration) + registration.continuing_education_registrations.sum { |ce| ce.hours.to_d } + end + + # The registration's CE record, for the report's per-row "Edit" link to the CE + # edit page. Nil for a non-CE registrant on the generic report. + def ce_registration_for(registration) + registration.continuing_education_registrations.first + end + + private + + def entries_on(registration, date) + registration.event_attendance_time_entries.select { |entry| entry.attendance_date == date } + end + + def scoped_registrations + list = event.event_registrations + .includes(:registrant, :event_attendance_time_entries, + continuing_education_registrations: :professional_license) + .to_a + if ce_only? + list.select { |reg| reg.continuing_education_registrations.any? } + else + list.select { |reg| reg.event_attendance_time_entries.any? } + end + end +end diff --git a/app/views/continuing_education_registrations/_attendance_entries.html.erb b/app/views/continuing_education_registrations/_attendance_entries.html.erb new file mode 100644 index 0000000000..152bc107b5 --- /dev/null +++ b/app/views/continuing_education_registrations/_attendance_entries.html.erb @@ -0,0 +1,64 @@ +<%# + Staff-editable attendance times, submitted with the CE form under + continuing_education_registration[time_entries][…] and mapped onto the + registration's event_attendance_time_entries in the controller. Registrants log + their own in/out on the CE callout; here staff correct a forgotten sign-out or a + wrong time, remove a stray entry, or backfill a missed one. Times are in the + event's local (Pacific) zone, matching the callout and report. + locals: registration (EventRegistration). +%> +<%# The in-memory rows, not a fresh query: when a save is rejected (overlapping or + backwards times) the controller has already applied the submitted values to this + association, and re-reading the database here would throw the admin's edits away. %> +<% entries = registration.event_attendance_time_entries.to_a.sort_by { |entry| entry.signed_in_at || Time.zone.at(0) } %> +<% dt = ->(time) { time&.in_time_zone(Time.zone)&.strftime("%Y-%m-%dT%H:%M") } %> +<% input_class = "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + +
+
+ + + +

Attendance sign-in times

+
+ +
+ + + <% entries.each_with_index do |entry, i| %> +
+ <% if entry.persisted? %> + + <% end %> + + +
<%= entry.decorate.duration_label %>
+ +
+ <% end %> + + <%# One blank row to add an entry — fill it and save; a fresh blank returns for the next. %> + <% idx = entries.size %> +
+ + +
+
+
+ +

Fill the blank row and save to add an entry. Tick Remove to delete one. Sign-out must be after sign-in.

+
+
diff --git a/app/views/continuing_education_registrations/edit.html.erb b/app/views/continuing_education_registrations/edit.html.erb index c7ee05640a..77b8ac40a9 100644 --- a/app/views/continuing_education_registrations/edit.html.erb +++ b/app/views/continuing_education_registrations/edit.html.erb @@ -6,7 +6,7 @@ <%# Top bar: back link + secondary links, matching the scholarship edit page %>
<%= link_to ce_registration_return_path(registration), class: "text-sm text-gray-500 hover:text-gray-700" do %> - <%= params[:return_to] == "registrants" ? "Registrants" : "Registration" %> + <%= ce_registration_return_label %> <% end %>
<%# Admin jump to the registrant-facing CE callout (what the registrant sees / @@ -32,8 +32,11 @@
<%= simple_form_for @ce_registration, url: continuing_education_registration_path(@ce_registration, return_to: params[:return_to].presence), html: { id: "ce_registration_form", data: { turbo: false } } do |f| %> - <%= render "details_section", f: f, license: license, ce_registration: @ce_registration, event: registration.event %> - <%= render "payment_history", ce_registration: @ce_registration %> +
+ <%= render "details_section", f: f, license: license, ce_registration: @ce_registration, event: registration.event %> + <%= render "attendance_entries", registration: registration %> + <%= render "payment_history", ce_registration: @ce_registration %> +
<%# ---- Comments (saved with the CE registration, like the other forms) ---- %>
diff --git a/app/views/continuing_education_registrations/new.html.erb b/app/views/continuing_education_registrations/new.html.erb index 937022ed78..7094e25f8d 100644 --- a/app/views/continuing_education_registrations/new.html.erb +++ b/app/views/continuing_education_registrations/new.html.erb @@ -6,7 +6,7 @@ <%# Top bar: back link + Home, matching the CE edit page. %>
<%= link_to ce_registration_return_path(registration), class: "text-sm text-gray-500 hover:text-gray-700" do %> - <%= params[:return_to] == "registrants" ? "Registrants" : "Registration" %> + <%= ce_registration_return_label %> <% end %> <%= link_to "Home", root_path, class: "text-sm text-gray-500 hover:text-gray-700" %>
diff --git a/app/views/event_registrations/edit.html.erb b/app/views/event_registrations/edit.html.erb index 6b9d52be16..da1ed3af9e 100644 --- a/app/views/event_registrations/edit.html.erb +++ b/app/views/event_registrations/edit.html.erb @@ -30,6 +30,10 @@ <%= link_to event_registrations_path, class: "text-sm text-gray-500 hover:text-gray-700" do %> Registrations <% end %> + <% elsif params[:return_to] == "attendance" %> + <%= link_to attendance_event_path(@event_registration.event), class: "text-sm text-gray-500 hover:text-gray-700" do %> + Sign-in report + <% end %> <% else %> <%= link_to registrants_event_row_path(@event_registration.event, @event_registration.id), class: "text-sm text-gray-500 hover:text-gray-700" do %> Registrants diff --git a/app/views/events/_attendance_day_form.html.erb b/app/views/events/_attendance_day_form.html.erb new file mode 100644 index 0000000000..720d309a5a --- /dev/null +++ b/app/views/events/_attendance_day_form.html.erb @@ -0,0 +1,39 @@ +<%# + Inline editor for one registrant's sessions on one training day. Times are clock + times, not datetimes — the day is the section this sits in, so a correction is two + fields rather than two dates the admin has to retype and can get wrong. The trailing + blank row adds a session (fill it and save; a fresh blank comes back), and Remove + drops one, matching the CE edit page's table. + locals: registration, date, entries (decorated), state (page params), cell. +%> +<% rows = attendance_editor_rows(entries) %> +<% input_class = "w-28 rounded-lg border border-gray-300 px-2 py-1 text-xs text-gray-900 shadow-sm tabular-nums focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + +<%= form_with url: update_attendance_event_registration_path(registration, date: date.iso8601, **state), + method: :patch, data: { turbo: false }, class: "space-y-1.5" do %> + <% rows.each_with_index do |row, index| %> +
+ <% if row["id"].present? %> + <%= hidden_field_tag "attendance[entries][#{index}][id]", row["id"], id: nil %> + <% end %> + <%= text_field_tag "attendance[entries][#{index}][in]", row["in"], type: "time", id: nil, + class: input_class, "aria-label": "Time in" %> + + <%= text_field_tag "attendance[entries][#{index}][out]", row["out"], type: "time", id: nil, + class: input_class, "aria-label": "Time out" %> + <% if row["id"].present? %> + + <% end %> +
+ <% end %> + +
+ + <%= link_to "Cancel", attendance_event_path(@event, **state, anchor: cell), class: "text-xs text-gray-500 hover:text-gray-700" %> + Leave the sign-out blank to leave a session open. +
+<% end %> diff --git a/app/views/events/_attendance_sessions.html.erb b/app/views/events/_attendance_sessions.html.erb new file mode 100644 index 0000000000..1baf80cd85 --- /dev/null +++ b/app/views/events/_attendance_sessions.html.erb @@ -0,0 +1,37 @@ +<%# + One registrant's sign-in/out sessions on one training day — the report's editable + unit, shared by the by-person and by-day groupings (same cell, different axis). + Read mode shows a chip per session; Edit swaps the cell for a form so staff can + correct a time, add the pair for a day nobody signed in on, or drop a stray row, + without leaving the report. Registrants still stamp their own times from the CE + callout — this is the correction surface, not a replacement for it. + locals: registration, date, entries (decorated), editing (Boolean), state (page params). +%> +<% cell = attendance_cell_id(registration, date) %> +<%# z-10 lifts the cell above the row's whole-row link so the controls are clickable. %> +
+ <% if editing %> + <%= render "events/attendance_day_form", registration: registration, date: date, + entries: entries, state: state, cell: cell %> + <% else %> +
+ <% if entries.any? %> + <% entries.each do |entry| %> + + <%= entry.signed_in_label %>–<%= entry.signed_out_label %> · <%= entry.duration_label %> + + <% end %> + <% else %> + Not signed in + <% end %> + <%# Muted until the row is hovered, rather than hidden: 30 people × 5 days of Edit + links shouldn't drown out the times, but a hover-only control is unreachable + on a touch screen. %> + <%= link_to attendance_event_path(@event, **state, edit: cell, anchor: cell), + class: "ml-1 rounded px-1.5 py-0.5 text-xs font-medium text-teal-700 opacity-50 transition-opacity hover:bg-teal-50 hover:underline group-hover:opacity-100 focus:opacity-100", + "aria-label": "Edit #{registration.registrant.full_name}'s times for #{date.strftime("%b %-d")}" do %> + Edit + <% end %> +
+ <% end %> +
diff --git a/app/views/events/_bulk_actions_menu.html.erb b/app/views/events/_bulk_actions_menu.html.erb index 92242a7f92..d35eeba518 100644 --- a/app/views/events/_bulk_actions_menu.html.erb +++ b/app/views/events/_bulk_actions_menu.html.erb @@ -13,6 +13,11 @@ data-dropdown-target="content" class="hidden absolute right-0 z-10 mt-1 bg-white border border-gray-200 rounded-md shadow-lg py-1 min-w-[180px]"> <%= link_to "Onboarding tracker", onboarding_event_path(@event), class: item_class %> + <% if @event.ce_eligible? %> + <%= link_to "CE sign-in report", attendance_event_path(@event, ce: "true", return_to: "registrants"), class: item_class %> + <% else %> + <%= link_to "Sign-in report", attendance_event_path(@event, return_to: "registrants"), class: item_class %> + <% end %> <% reminder_filters = reminder_recipient_filters %> <%= link_to reminder_filters.any? ? "Send bulk emails (filtered)" : "Send bulk emails", preview_reminder_event_path(@event, reminder_filters), class: item_class %> diff --git a/app/views/events/attendance.html.erb b/app/views/events/attendance.html.erb new file mode 100644 index 0000000000..b457d6ac4a --- /dev/null +++ b/app/views/events/attendance.html.erb @@ -0,0 +1,258 @@ +<% content_for(:page_bg_class, "admin-only bg-blue-100") %> +<% content_for(:full_width, true) %> +<% title = @report.ce_only? ? "CE sign-in report" : "Attendance sign-in" %> +<% content_for(:page_title, "#{title} — #{@event.title}") %> +<% event = @event.decorate %> + +
+
+
+ <%# Return to wherever the report was opened from (the participation report by + default; the event dashboard when linked from there). %> + <% case params[:return_to] %> + <% when "dashboard" %> + <%= link_to "← Dashboard", dashboard_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> + <% when "registrants" %> + <%= link_to "← Registrants", registrants_event_path(@event), class: "text-sm text-gray-500 hover:text-gray-700" %> + <% else %> + <%= link_to "← Events participation", participation_events_path, class: "text-sm text-gray-500 hover:text-gray-700" %> + <% end %> +
+ +
+
+

<%= title %>

+

<%= @event.title %> · <%= event.date_range %> · <%= event.times %>

+
+ <% if @report.ce_only? %> + + + Continuing education + + <% end %> +
+ + <% if @report.dates_truncated? %> +
+ +

This event runs past <%= @report.dates.last.strftime("%b %-d") %>, but sign-in and this report cover only the first 5 days. Times entered for later days aren't shown or totalled here.

+
+ <% end %> + + <% if @report.any? %> + <%# Totals across all days lead the report — the headline figures the CE board + certifies — set apart from the per-day tables with a teal treatment. The id + anchors the return trip from the per-row CE edit links. %> + <%# One "Day N logged" column per event day, between Hours awarded and Total + logged. Shared column template keeps header, rows, and the All row aligned. %> + <% day_col = "minmax(5rem,auto) " %> + <% totals_cols = @report.ce_only? ? + "2fr minmax(6rem,1fr) minmax(6rem,auto) #{day_col * @report.dates.size}minmax(6rem,auto)" : + "2fr #{day_col * @report.dates.size}minmax(6rem,auto)" %> +
+

Totals

+
+
+ <%# Two-line headers: the caps eyebrow on top, a smaller lowercase + qualifier below, so the "… logged" columns stay readable when narrow. %> + <% qualifier = "block normal-case font-normal tracking-normal text-[0.625rem] text-teal-600" %> +
+
Name
+ <% if @report.ce_only? %> +
License #
+
Hoursawarded
+ <% end %> + <% @report.dates.each_with_index do |_date, index| %> +
Day <%= index + 1 %>logged
+ <% end %> +
Totallogged
+
+
+ <% @report.registrations.each do |reg| %> + <% ce_reg = @report.ce_registration_for(reg) %> +
" + style="grid-template-columns: <%= totals_cols %>"> + <%# Whole row → CE edit page; the name link (z-10, above) → the + registrant's CE callout page instead. %> + <% if @report.ce_only? && ce_reg %> + <%= link_to "", edit_continuing_education_registration_path(ce_reg, return_to: "attendance"), + class: "absolute inset-0", "aria-label": "Edit CE registration for #{reg.registrant.full_name}" %> + <% end %> +
+ <% if @report.ce_only? && ce_reg %> + <%= link_to reg.registrant.full_name, registration_ce_path(reg.slug, return_to: "attendance"), + class: "font-medium text-gray-900 hover:text-teal-700 hover:underline" %> + <% else %> + <%= link_to reg.registrant.full_name, edit_event_registration_path(reg, return_to: "attendance"), + class: "font-medium text-gray-900 hover:text-teal-700 hover:underline" %> + <% end %> +
+ <% if @report.ce_only? %> +
<%= @report.license_numbers(reg).join(", ").presence || "—" %>
+
<%= plain_number(@report.ce_hours(reg)) %>
+ <% end %> + <% @report.dates.each do |date| %> +
<%= attendance_duration_label(@report.day_minutes(reg, date)) %>
+ <% end %> +
<%= attendance_duration_label(@report.total_minutes(reg)) %>
+
+ <% end %> +
+
+
All registrants
+ <% if @report.ce_only? %> +
+
<%= plain_number(@report.total_hours_awarded) %>
+ <% end %> + <% @report.dates.each do |date| %> +
<%= attendance_duration_label(@report.day_grand_minutes(date)) %>
+ <% end %> +
<%= attendance_duration_label(@report.grand_total_minutes) %>
+
+
+
+
+ + <%# Session detail grouped by person (default) or by day, toggled like the + registrants page's active/inactive filter — a full-page reload carrying + the ce/return_to params. %> + <% group_by_day = params[:group] == "day" %> + <% toggle_params = { ce: params[:ce].presence, return_to: params[:return_to].presence }.compact %> + <%# The page state every session-cell link has to carry so editing, saving and + cancelling all land back on the view the admin was looking at. %> + <% cell_state = toggle_params.merge(group: params[:group].presence).compact %> +
+

<%= group_by_day ? "Sessions by day" : "Sessions by person" %>

+ +
+ + <% unless group_by_day %> + <% @report.registrations.each do |reg| %> + <% ce_reg = @report.ce_registration_for(reg) %> + <%# Whole row → the CE edit page on the CE report, the registration edit + page otherwise (kept in sync with those pages' return_to handling). %> + <% row_path = @report.ce_only? && ce_reg ? + edit_continuing_education_registration_path(ce_reg, return_to: "attendance") : + edit_event_registration_path(reg, return_to: "attendance") %> +
+
+

+ <%= reg.registrant.full_name %> + <% if @report.open?(reg) %> + signed in + <% end %> +

+ Total <%= attendance_duration_label(@report.total_minutes(reg)) %> +
+ +
+
+
Day
+
Sessions
+
Day total
+
+
+ <% @report.dates.each_with_index do |date, index| %> + <% entries = @report.entries_for(reg, date) %> + <% editing = params[:edit] == attendance_cell_id(reg, date) %> +
+ <%# No whole-row link while the cell is being edited — a stray click + outside the inputs would navigate away mid-correction. %> + <% unless editing %> + <%= link_to "", row_path, class: "absolute inset-0", + "aria-label": "Edit #{@report.ce_only? ? "CE registration" : "registration"} for #{reg.registrant.full_name}" %> + <% end %> +
Day <%= index + 1 %> · <%= date.strftime("%a, %b %-d") %>
+ <%= render "events/attendance_sessions", registration: reg, date: date, + entries: entries, editing: editing, state: cell_state %> +
<%= attendance_duration_label(@report.day_minutes(reg, date)) %>
+
+ <% end %> +
+
+
+ <% end %> + <% else %> + <% @report.dates.each_with_index do |date, index| %> + <% signed_in_count = @report.registrations.count { |reg| @report.entries_for(reg, date).any? } %> +
+
+

Day <%= index + 1 %> · <%= date.strftime("%A, %b %-d") %> · <%= event.times %>

+ <%= signed_in_count %> of <%= @report.registrations.size %> signed in +
+ +
+
"> +
Name
+ <% if @report.ce_only? %>
License #
<% end %> +
Sessions
+
Day total
+
+ +
+ <% @report.registrations.each do |reg| %> + <% entries = @report.entries_for(reg, date) %> + <% ce_reg = @report.ce_registration_for(reg) %> + <% row_path = @report.ce_only? && ce_reg ? + edit_continuing_education_registration_path(ce_reg, return_to: "attendance") : + edit_event_registration_path(reg, return_to: "attendance") %> + <% editing = params[:edit] == attendance_cell_id(reg, date) %> +
"> + <%# No whole-row link while the cell is being edited — a stray click + outside the inputs would navigate away mid-correction. %> + <% unless editing %> + <%= link_to "", row_path, class: "absolute inset-0", + "aria-label": "Edit #{@report.ce_only? && ce_reg ? "CE registration" : "registration"} for #{reg.registrant.full_name}" %> + <% end %> +
+ <% if @report.ce_only? && ce_reg %> + <%= link_to reg.registrant.full_name, registration_ce_path(reg.slug, return_to: "attendance"), + class: "font-medium text-gray-900 hover:text-teal-700 hover:underline" %> + <% else %> + <%= link_to reg.registrant.full_name, edit_event_registration_path(reg, return_to: "attendance"), + class: "font-medium text-gray-900 hover:text-teal-700 hover:underline" %> + <% end %> + <%# Day-scoped: this row is one day, so the flag has to be about + this day's entries, not any open entry on any day. %> + <% if entries.any?(&:open?) %> + signed in + <% end %> +
+ <% if @report.ce_only? %> +
<%= @report.license_numbers(reg).join(", ").presence || "—" %>
+ <% end %> + <%= render "events/attendance_sessions", registration: reg, date: date, + entries: entries, editing: editing, state: cell_state %> +
<%= attendance_duration_label(@report.day_minutes(reg, date)) %>
+
+ <% end %> +
+
+
+ <% end %> + <% end %> + +

+ Registrants sign in and out from their private CE page; times here are in Pacific. + Hover a day and hit Edit to correct its times, add a session, + or fill in a day nobody signed in on. A signed in tag means + that day has an entry with no sign-out yet. Totals sum every completed in/out pair. +

+ <% else %> +
+ <%= @report.ce_only? ? "No one has registered for CE credit on this event yet." : "No attendance has been logged for this event yet." %> +
+ <% end %> +
+
diff --git a/app/views/events/callouts/_callout_page.html.erb b/app/views/events/callouts/_callout_page.html.erb index 02ac778268..bc50450eef 100644 --- a/app/views/events/callouts/_callout_page.html.erb +++ b/app/views/events/callouts/_callout_page.html.erb @@ -31,7 +31,10 @@ <%# @event is decorated in some callout actions and raw in others; the date range lives on the decorator, so decorate only when it isn't already. %> <% event = @event.respond_to?(:short_date_range) ? @event : @event.decorate %> -

<%= event.title %><% if event.start_date.present? %> · <%= event.short_date_range %><% end %>

+

<%= event.title %>

+ <% if event.start_date.present? %> +

<%= event.short_date_range %> · <%= event.times %>

+ <% end %>
diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 585acf77c5..909a078fd8 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -3,16 +3,23 @@ <% ce_registration = @event_registration.continuing_education_registrations.first %> <%# - Reached from the admin CE registration edit page (return_to=ce_registration): - point the eyebrow back there instead of the registrant's ticket. Gated on edit - access so a registrant who lands on this URL still gets the default ticket back. + Reached from an admin page (return_to): point the eyebrow back at that page + instead of the registrant's ticket — the CE registration edit page, or the event's + CE sign-in report, whose registrant links land here. Each gated on access to the + destination so a registrant who lands on one of these URLs still gets the default + ticket back. %> -<% callout_eyebrow = if params[:return_to] == "ce_registration" && ce_registration && allowed_to?(:edit?, ce_registration) - { back_path: edit_continuing_education_registration_path(ce_registration), back_label: "Back to CE registration" } - else - {} - end %> +<% callout_eyebrow = case params[:return_to] + when "ce_registration" + if ce_registration && allowed_to?(:edit?, ce_registration) + { back_path: edit_continuing_education_registration_path(ce_registration), back_label: "Back to CE registration" } + end + when "attendance" + if allowed_to?(:attendance?, @event) + { back_path: attendance_event_path(@event, ce: "true", anchor: "totals"), back_label: "Back to CE sign-in report" } + end + end || {} %> <%= render layout: "events/callouts/callout_page", locals: { title: @event.ce_hours_label, **callout_eyebrow } do %> <%# Requesting CE flips this frame in place: the POST redirects back here and @@ -33,51 +40,58 @@ <% simulate_ce_paid = params[:admin] == "true" && ce_registration && allowed_to?(:edit?, ce_registration) %> <%# Admin-only jump to the management surface for this CE registration. Hidden - from registrants; opens in a new tab so the registrant view is kept. + from registrants; opens in a new tab so the registrant view is kept. Carries + this page's origin through so the new tab's eyebrow returns where the admin + started rather than to the registration edit default. %> <% if ce_registration && allowed_to?(:edit?, ce_registration) && !sample_preview? %>
- <%= render "events/callouts/admin_edit_link", path: edit_continuing_education_registration_path(ce_registration), label: "Edit CE registration" %> + <%= render "events/callouts/admin_edit_link", path: edit_continuing_education_registration_path(ce_registration, return_to: params[:return_to].presence), label: "Edit CE registration" %>
<% end %> <%= turbo_frame_tag "license_section" do %>
-

Your CE credit

+ <%# Heading row carries the status chip; the stats sit together on one + wrapping row beneath it, all at the same scale. %> +
+

Your CE credit

+
+ Status + <%= render "event_registrations/ce_status_badge", registration: @event_registration, simulate_paid: simulate_ce_paid %> +
+
-
+
Hours
-
<%= plain_number(ce_registration&.hours) || "—" %>
+
<%= plain_number(ce_registration&.hours) || "—" %>
-
-
Status
-
<%= render "event_registrations/ce_status_badge", registration: @event_registration, simulate_paid: simulate_ce_paid %>
+
+
Cost
+
<%= dollars_from_cents(ce_registration&.cost_cents) %>
-
- -

- Cost - <%= dollars_from_cents(ce_registration&.cost_cents) %> -

- - <% if ce_registration&.discounted? %> -

- Discount - −<%= dollars_from_cents(ce_registration.discount_sum) %> -

- <% end %> -

- Amount paid - <%= dollars_from_cents(ce_registration&.payments_sum.to_i) %> - <% if ce_registration&.paid_in_full? || simulate_ce_paid %> - <%= render "shared/badge", - label: "Paid in full", - classes: "bg-green-50 text-green-700 border-green-200", - icon: "fa-solid fa-circle-check text-[0.6rem]" %> + <% if ce_registration&.discounted? %> +

+
Discount
+
−<%= dollars_from_cents(ce_registration.discount_sum) %>
+
<% end %> -

+ +
+
Amount paid
+
+ <%= dollars_from_cents(ce_registration&.payments_sum.to_i) %> + <% if ce_registration&.paid_in_full? || simulate_ce_paid %> + <%= render "shared/badge", + label: "Paid in full", + classes: "bg-green-50 text-green-700 border-green-200", + icon: "fa-solid fa-circle-check text-[0.6rem]" %> + <% end %> +
+
+
<% if ce_registration && ce_registration.remaining_cost.positive? %> <% if (due_text = @event.decorate.ce_payment_due_deadline_display) %> @@ -130,26 +144,26 @@
<% if license_locked || (license_on_file && !editing_license) %> - <%# Each license field on its own row, labels in a fixed column so the values line up. %> -
+ <%# License fields on one wrapping row, matching the CE credit stats above. %> +
-
License type
-
<%= license_kind.presence || "—" %>
+
License type
+
<%= license_kind.presence || "—" %>
-
License number
-
<%= license_number %>
+
License number
+
<%= license_number %>
-
Issuing state
-
<%= license_issuing_state.presence || "—" %>
+
Issuing state
+
<%= license_issuing_state.presence || "—" %>
-
Expires
-
<%= license_expires_on&.to_fs(:long) || "—" %>
+
Expires
+
<%= license_expires_on&.to_fs(:long) || "—" %>
<% if license_locked %> @@ -231,6 +245,132 @@ <% end %> <% end %> + + <%# + Training sign-in/out — the in-portal replacement for the paper CE hour + sign-in sheet, shown only once CE is paid in full. One button at a time for + today: Sign in when signed out (only inside the day's window), Sign out while + signed in (always, so a forgotten sign-out can still be closed that day). Many + entries per day is expected (breaks, lunch). An entry left open overnight + doesn't carry into today's state — it gets its own catch-up button below, + stamped with that day's scheduled end rather than now. + %> + <% attendance_offered = ce_registration&.paid_in_full? %> + <% open_entry = attendance_offered ? @event_registration.open_attendance_entry : nil %> + <% signed_in = open_entry.present? %> + <% forgotten_entry = attendance_offered ? @event_registration.forgotten_sign_out_entry : nil %> + <% forgotten_at = forgotten_entry && @event_registration.forgotten_sign_out_at(forgotten_entry) %> + <% todays_entries = attendance_offered ? @event_registration.attendance_entries_on(Time.zone.today) : [] %> + <% todays_minutes = todays_entries.sum { |entry| entry.duration_minutes.to_i } %> + <% opens_at = @event.next_attendance_sign_in_opens_at %> + <%# Once the last day's window has passed there's no sign-in left to offer or + announce, so the section drops rather than nagging about a training that's + over — unless a day is still hanging open and waiting to be closed. %> + <% if attendance_offered && (signed_in || forgotten_entry || @event.attendance_sign_in_open? || opens_at || todays_entries.any?) %> +
+
+

Training sign-in

+ <%# Admin-only jump to the event's attendance report, mirroring the + "Edit CE registration" chip up top. %> + <% if allowed_to?(:attendance?, @event) && !sample_preview? %> + <%= render "events/callouts/admin_edit_link", path: attendance_event_path(@event, ce: "true"), label: "Attendance report" %> + <% end %> +
+ + <%# A day left open: its own prompt above today's controls, naming the day and + the time it will be recorded as, so closing it can't be mistaken for + signing out of today. %> + <% if forgotten_entry %> +
+

+ + You never signed out on <%= forgotten_entry.attendance_date.strftime("%A, %B %-d") %>, after signing in at <%= forgotten_entry.decorate.signed_in_label %>. + Signing out records <%= attendance_clock_time(forgotten_at) %>, when that day's training ended. + +

+ <% if sample_preview? %> + + <% else %> + <%= button_to "Sign out for #{forgotten_entry.attendance_date.strftime("%b %-d")}", + registration_ce_sign_out_path(@event_registration.slug, entry_id: forgotten_entry.id), data: { turbo: false }, + class: "shrink-0 rounded-lg bg-amber-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-300 cursor-pointer" %> + <% end %> +
+ <% end %> + +
+ <% if signed_in %> + + + Signed in at <%= open_entry.decorate.signed_in_label %> + + <%# Signing out is the one action left while signed in, so it gets the + primary CTA treatment, same as Sign in. %> + <% if sample_preview? %> + + <% else %> + <%= button_to "Sign out", registration_ce_sign_out_path(@event_registration.slug), data: { turbo: false }, + class: "shrink-0 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %> + <% end %> + <% elsif @event.attendance_sign_in_open? %> + <%# "Signed out" only makes sense after a session today — first-time + visitors just get the Sign in button. %> + <% if todays_entries.any? %> + Signed out + <% end %> + <% sign_in_label = todays_entries.any? ? "Sign in again" : "Sign in" %> + <% if sample_preview? %> + + <% else %> + <%= button_to sign_in_label, registration_ce_sign_in_path(@event_registration.slug), data: { turbo: false }, + class: "shrink-0 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-teal-300 cursor-pointer" %> + <% end %> + <% elsif opens_at %> + <%# Sign-in window not open yet: standard gating notice (matching the + videoconference pending-note style) naming the concrete opening time, + with the event's own start beneath it. Shown in the event's zone + (Pacific), labeled — no JS to detect the viewer's own zone. %> +

+ + Sign-in opens + . + Event begins 30 min later, at <%= (opens_at + Event::ATTENDANCE_SIGN_IN_LEAD).strftime("%-I:%M %p %Z") %>. + +

+ <% end %> +
+ + <% if todays_entries.any? %> +
+ <% tz_abbr = Time.zone.now.strftime("%Z") %> +
+
Time in (<%= tz_abbr %>)
+
Time out (<%= tz_abbr %>)
+
Duration
+
+ <% todays_entries.each do |entry| %> + <% entry = entry.decorate %> +
+
<%= entry.signed_in_label %>
+
<%= entry.signed_out_label %>
+
<%= entry.duration_label %>
+
+ <% end %> +
+
Today's total
+
<%= attendance_duration_label(todays_minutes) %>
+
+
+ <% end %> + + <% if signed_in || @event.attendance_sign_in_open? %> +

Sign in when you arrive and sign out when you leave.
Include breaks and lunch so your hours are recorded accurately.

+ <% end %> +
+ <% end %> <% else %>

You haven't requested continuing education credit for this training. <%= @event.ce_hours_cost_cents.to_i.positive? ? "CE hours are available for #{dollars_from_cents(@event.ce_hours_cost_cents)}." : "CE hours are available for an additional fee." %>

diff --git a/app/views/events/participation.html.erb b/app/views/events/participation.html.erb index df3422c9da..bf85c23f37 100644 --- a/app/views/events/participation.html.erb +++ b/app/views/events/participation.html.erb @@ -162,8 +162,17 @@ <% end %> <% end %> - <%= link_to "Open event dashboard →", dashboard_event_path(row.event), - class: "text-xs font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline" %> +
+ <%= link_to "Open event dashboard →", dashboard_event_path(row.event), + class: "text-xs font-medium #{DomainTheme.text_class_for(:events, intensity: 700)} hover:underline" %> + <% if row.event.ce_eligible? %> + <%= link_to "CE sign-in report →", attendance_event_path(row.event, ce: "true", return_to: "participation"), + class: "text-xs font-medium text-teal-700 hover:underline" %> + <% else %> + <%= link_to "Sign-in report →", attendance_event_path(row.event, return_to: "participation"), + class: "text-xs font-medium text-teal-700 hover:underline" %> + <% end %> +
<% end %> diff --git a/config/routes.rb b/config/routes.rb index 7c1e2521c5..06db9a691d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -88,6 +88,8 @@ post "registration/:slug/ce/license", to: "events/callouts#update_ce_license", as: :registration_ce_license post "registration/:slug/ce/request", to: "events/callouts#request_ce", as: :registration_ce_request post "registration/:slug/ce/pay", to: "events/callouts#pay_ce", as: :registration_ce_pay + post "registration/:slug/ce/sign-in", to: "events/callouts#sign_in_ce", as: :registration_ce_sign_in + post "registration/:slug/ce/sign-out", to: "events/callouts#sign_out_ce", as: :registration_ce_sign_out get "registration/:slug/handouts", to: "events/callouts#handouts", as: :registration_handouts get "registration/:slug/resource/:resource_id", to: "events/callouts#resource", as: :registration_resource get "registration/:slug/videoconference", to: "events/callouts#videoconference", as: :registration_videoconference @@ -106,6 +108,7 @@ delete :unlink_organization patch :update_onboarding patch :toggle_certificate_issued + patch :update_attendance end resources :comments, only: [ :index, :create, :update ] end @@ -158,6 +161,7 @@ end member do get :dashboard + get :attendance get :sample_ticket # Admin-only in-memory previews of the behavioral built-in callout pages, # linked from the sample ticket. They reuse Events::CalloutsController's diff --git a/db/migrate/20260804000438_create_event_attendance_time_entries.rb b/db/migrate/20260804000438_create_event_attendance_time_entries.rb new file mode 100644 index 0000000000..7fe034b1b0 --- /dev/null +++ b/db/migrate/20260804000438_create_event_attendance_time_entries.rb @@ -0,0 +1,27 @@ +class CreateEventAttendanceTimeEntries < ActiveRecord::Migration[7.2] + def up + create_table :event_attendance_time_entries do |t| + # No index on the reference itself: the FK rides the composite index below + # (leftmost prefix), which must be declared in-table so MySQL doesn't + # auto-create a redundant one for the FK. + t.references :event_registration, null: false, foreign_key: true, index: false + t.datetime :signed_in_at, null: false + t.datetime :signed_out_at + t.integer :created_by_id + t.integer :updated_by_id + + t.timestamps + + # Fetching a registration's open (not-yet-signed-out) entry is the hot path. + t.index [ :event_registration_id, :signed_out_at ], + name: "index_attendance_entries_on_registration_and_signed_out" + end + + add_index :event_attendance_time_entries, :created_by_id + add_index :event_attendance_time_entries, :updated_by_id + end + + def down + drop_table :event_attendance_time_entries, if_exists: true + end +end diff --git a/db/schema.rb b/db/schema.rb index b96f6d27d3..5a59969a2c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -447,6 +447,19 @@ t.datetime "updated_at", null: false end + create_table "event_attendance_time_entries", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.integer "created_by_id" + t.bigint "event_registration_id", null: false + t.datetime "signed_in_at", null: false + t.datetime "signed_out_at" + t.datetime "updated_at", null: false + t.integer "updated_by_id" + t.index ["created_by_id"], name: "index_event_attendance_time_entries_on_created_by_id" + t.index ["event_registration_id", "signed_out_at"], name: "index_attendance_entries_on_registration_and_signed_out" + t.index ["updated_by_id"], name: "index_event_attendance_time_entries_on_updated_by_id" + end + create_table "event_forms", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t| t.datetime "created_at", null: false t.bigint "event_id", null: false @@ -1795,6 +1808,7 @@ add_foreign_key "contact_methods", "addresses" add_foreign_key "continuing_education_registrations", "event_registrations" add_foreign_key "continuing_education_registrations", "professional_licenses" + add_foreign_key "event_attendance_time_entries", "event_registrations" add_foreign_key "event_forms", "events" add_foreign_key "event_forms", "forms" add_foreign_key "event_registration_checklist_completions", "event_registrations" diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb new file mode 100644 index 0000000000..8294339090 --- /dev/null +++ b/spec/controllers/application_controller_spec.rb @@ -0,0 +1,33 @@ +require "rails_helper" + +RSpec.describe ApplicationController do + # Autosave copies a nested record's errors onto its parent keyed + # ".", where the default full message pastes the humanized + # association name in front. The flash has to read as the child model wrote it. + describe "#error_sentence" do + subject(:sentence) do + registration.valid? + described_class.new.send(:error_sentence, registration) + end + + let(:registration) { create(:event_registration) } + + before { registration.event_attendance_time_entries.build(entry_attributes) } + + context "with a child error written as a whole sentence" do + let(:entry_attributes) { { signed_in_at: Time.current, signed_out_at: 1.hour.ago } } + + it "reads verbatim" do + expect(sentence).to eq("Sign-out must be after the sign-in time.") + end + end + + context "with a child error on one attribute" do + let(:entry_attributes) { { signed_in_at: nil } } + + it "keeps the child's own subject rather than dropping to a bare fragment" do + expect(sentence).to eq("Signed in at can't be blank") + end + end + end +end diff --git a/spec/decorators/event_attendance_time_entry_decorator_spec.rb b/spec/decorators/event_attendance_time_entry_decorator_spec.rb new file mode 100644 index 0000000000..6139308741 --- /dev/null +++ b/spec/decorators/event_attendance_time_entry_decorator_spec.rb @@ -0,0 +1,30 @@ +require "rails_helper" + +RSpec.describe EventAttendanceTimeEntryDecorator do + # The attendance report decorates entries from a service, so the view context Draper + # has cached is whatever ran last — often a mailer's, which carries ApplicationHelper + # and nothing else. Pin that here so these labels can't quietly go back to reaching + # EventAttendanceHelper through `h` and blowing up depending on spec order. + before { Draper::ViewContext.current = ApplicationMailer.new.view_context } + + let(:entry) do + build(:event_attendance_time_entry, + signed_in_at: Time.zone.local(2026, 7, 23, 8, 50), + signed_out_at: Time.zone.local(2026, 7, 23, 10, 34)) + end + + it "renders the sign-in and sign-out clock times" do + expect(entry.decorate.signed_in_label).to eq("8:50 AM") + expect(entry.decorate.signed_out_label).to eq("10:34 AM") + end + + it "renders the elapsed time" do + expect(entry.decorate.duration_label).to eq("1h 44m") + end + + it "marks an entry that's still open" do + open_entry = build(:event_attendance_time_entry, :open) + expect(open_entry.decorate.signed_out_label).to eq("—") + expect(open_entry.decorate.duration_label).to eq("In progress") + end +end diff --git a/spec/factories/event_attendance_time_entries.rb b/spec/factories/event_attendance_time_entries.rb new file mode 100644 index 0000000000..72a7757bc1 --- /dev/null +++ b/spec/factories/event_attendance_time_entries.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :event_attendance_time_entry do + association :event_registration + signed_in_at { 2.hours.ago } + signed_out_at { 1.hour.ago } + + trait :open do + signed_out_at { nil } + end + end +end diff --git a/spec/models/continuing_education_registration_spec.rb b/spec/models/continuing_education_registration_spec.rb index f341e4ae50..180d29c3f0 100644 --- a/spec/models/continuing_education_registration_spec.rb +++ b/spec/models/continuing_education_registration_spec.rb @@ -130,6 +130,27 @@ def ce_reg_for(event:, status:, cost_cents: 0) expect(ce_reg_for(event: event, status: "attended", cost_cents: 10_000).certificate_available?).to be(false) end + it "requires logged attendance to approximately cover the awarded hours once time is tracked" do + event = create(:event, ce_hours_offered: 6, start_date: 3.days.ago, end_date: 1.day.ago) + ce_reg = ce_reg_for(event: event, status: "attended") # 6h awarded → needs 324 min (90%) + reg = ce_reg.event_registration + + # Only 5 hours (300 min) logged — short of the 324-minute threshold. + create(:event_attendance_time_entry, event_registration: reg, + signed_in_at: 2.days.ago.change(hour: 9), signed_out_at: 2.days.ago.change(hour: 14)) + expect(ce_reg.certificate_available?).to be(false) + + # Another 30 minutes clears the threshold (330 ≥ 324). + create(:event_attendance_time_entry, event_registration: reg, + signed_in_at: 2.days.ago.change(hour: 14), signed_out_at: 2.days.ago.change(hour: 14, min: 30)) + expect(ce_reg.reload.certificate_available?).to be(true) + end + + it "isn't gated on logged time when no attendance was tracked" do + event = create(:event, ce_hours_offered: 6, start_date: 3.days.ago, end_date: 1.day.ago) + expect(ce_reg_for(event: event, status: "attended").certificate_available?).to be(true) + end + it "records delivery via certificate_sent_at" do ce_reg = create(:continuing_education_registration) expect(ce_reg.certificate_sent?).to be(false) diff --git a/spec/models/event_attendance_time_entry_spec.rb b/spec/models/event_attendance_time_entry_spec.rb new file mode 100644 index 0000000000..542a64e34b --- /dev/null +++ b/spec/models/event_attendance_time_entry_spec.rb @@ -0,0 +1,145 @@ +require "rails_helper" + +RSpec.describe EventAttendanceTimeEntry, type: :model do + describe "validations" do + it "requires a sign-in time" do + entry = build(:event_attendance_time_entry, signed_in_at: nil) + expect(entry).not_to be_valid + expect(entry.errors[:signed_in_at]).to be_present + end + + it "is valid while still open (no sign-out yet)" do + expect(build(:event_attendance_time_entry, :open)).to be_valid + end + + it "rejects a sign-out at or before the sign-in" do + at = Time.current + expect(build(:event_attendance_time_entry, signed_in_at: at, signed_out_at: at)).not_to be_valid + expect(build(:event_attendance_time_entry, signed_in_at: at, signed_out_at: at - 1.minute)).not_to be_valid + end + end + + describe "#open?" do + it "is true only without a sign-out time" do + expect(build(:event_attendance_time_entry, :open)).to be_open + expect(build(:event_attendance_time_entry)).not_to be_open + end + end + + describe "#duration_minutes" do + it "returns whole minutes between sign-in and sign-out" do + entry = build(:event_attendance_time_entry, + signed_in_at: Time.zone.local(2026, 7, 23, 8, 50), + signed_out_at: Time.zone.local(2026, 7, 23, 10, 34)) + expect(entry.duration_minutes).to eq(104) + end + + it "rounds to the nearest minute" do + entry = build(:event_attendance_time_entry, + signed_in_at: Time.zone.local(2026, 7, 23, 8, 50, 0), + signed_out_at: Time.zone.local(2026, 7, 23, 8, 51, 40)) + expect(entry.duration_minutes).to eq(2) + end + + it "counts a sub-minute pair as a full minute (rounds up for the attendee)" do + entry = build(:event_attendance_time_entry, + signed_in_at: Time.zone.local(2026, 7, 23, 9, 0, 0), + signed_out_at: Time.zone.local(2026, 7, 23, 9, 0, 20)) + expect(entry.duration_minutes).to eq(1) + end + + it "is nil while open" do + expect(build(:event_attendance_time_entry, :open).duration_minutes).to be_nil + end + end + + describe "#attendance_date" do + it "is the sign-in's calendar date in the app zone" do + entry = build(:event_attendance_time_entry, signed_in_at: Time.zone.local(2026, 7, 23, 8, 50)) + expect(entry.attendance_date).to eq(Date.new(2026, 7, 23)) + end + end + + describe "cross-entry guards" do + let(:registration) { create(:event_registration) } + + def at(hour, min, day: 23) + Time.zone.local(2026, 7, day, hour, min) + end + + describe "24-hour daily limit" do + it "rejects a single entry longer than 24 hours" do + entry = build(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(0, 0), signed_out_at: at(1, 0, day: 24)) + expect(entry).not_to be_valid + expect(entry.errors[:base].join).to match(/24 hours/) + end + + it "rejects when same-day siblings push the total past 24 hours" do + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(0, 0), signed_out_at: at(3, 0)) # 3h on the 23rd + # 22h more, still the 23rd (attendance date = sign-in day) and adjacent, so no + # overlap — but 25h total on the day. + cross = build(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(3, 0), signed_out_at: at(1, 0, day: 24)) + expect(cross).not_to be_valid + expect(cross.errors[:base].join).to match(/24 hours/) + end + + it "allows a day that totals exactly 24 hours" do + entry = build(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(0, 0), signed_out_at: at(0, 0, day: 24)) + expect(entry).to be_valid + end + end + + describe "same-day overlap" do + before do + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(9, 0), signed_out_at: at(12, 0)) + end + + it "rejects an entry that overlaps an existing session" do + entry = build(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(11, 0), signed_out_at: at(13, 0)) + expect(entry).not_to be_valid + expect(entry.errors[:base].join).to match(/overlaps/) + end + + it "rejects an entry fully inside an existing session" do + entry = build(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(10, 0), signed_out_at: at(11, 0)) + expect(entry).not_to be_valid + end + + it "rejects an open (not-yet-signed-out) entry inside an existing session" do + entry = build(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: at(10, 0)) + expect(entry).not_to be_valid + end + + it "allows a back-to-back entry that only touches at the edge" do + entry = build(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(12, 0), signed_out_at: at(13, 0)) + expect(entry).to be_valid + end + + it "allows the same clock times on a different day" do + entry = build(:event_attendance_time_entry, event_registration: registration, + signed_in_at: at(9, 0, day: 24), signed_out_at: at(12, 0, day: 24)) + expect(entry).to be_valid + end + end + end + + describe "scopes" do + it "separates open from closed entries" do + reg = create(:event_registration) + open_entry = create(:event_attendance_time_entry, :open, event_registration: reg) + closed_entry = create(:event_attendance_time_entry, event_registration: reg) + + expect(reg.event_attendance_time_entries.open).to contain_exactly(open_entry) + expect(reg.event_attendance_time_entries.closed).to contain_exactly(closed_entry) + end + end +end diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb index 3efd911b00..332a17bda2 100644 --- a/spec/models/event_registration_spec.rb +++ b/spec/models/event_registration_spec.rb @@ -1271,4 +1271,89 @@ def registration_for(person) expect(preloaded.paid_in_full?).to be(true) end end + + describe "attendance time entries" do + let(:registration) { create(:event_registration) } + + describe "#signed_in? / #open_attendance_entry" do + it "is signed in while today's entry has no sign-out" do + entry = create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.zone.now.change(hour: 9)) + expect(registration.signed_in?).to be(true) + expect(registration.open_attendance_entry).to eq(entry) + end + + it "is not signed in once every entry is closed" do + create(:event_attendance_time_entry, event_registration: registration) + expect(registration.signed_in?).to be(false) + expect(registration.open_attendance_entry).to be_nil + end + + # A forgotten sign-out must not follow the registrant into the next training + # day, where it would block the new day's sign-in and, once closed, bank a + # ~24-hour session. Staff close it from the attendance report instead. + it "ignores an entry left open on an earlier day" do + stale = create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: 1.day.ago.change(hour: 9)) + expect(registration.signed_in?).to be(false) + expect(registration.open_attendance_entry).to be_nil + expect(registration.open_attendance_entry(1.day.ago.to_date)).to eq(stale) + end + end + + describe "#attendance_entries_on" do + it "returns that day's entries in sign-in order" do + second = create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 11, 0), signed_out_at: Time.zone.local(2026, 7, 23, 12, 0)) + first = create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 8, 50), signed_out_at: Time.zone.local(2026, 7, 23, 10, 34)) + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 24, 8, 50), signed_out_at: Time.zone.local(2026, 7, 24, 10, 0)) + + expect(registration.attendance_entries_on(Date.new(2026, 7, 23))).to eq([ first, second ]) + end + end + + describe "#forgotten_sign_out_entry / #forgotten_sign_out_at" do + # A two-day training running 9:00–16:00 each day. + let(:event) do + create(:event, start_date: Time.zone.local(2026, 7, 23, 9, 0), end_date: Time.zone.local(2026, 7, 24, 16, 0)) + end + let(:registration) { create(:event_registration, event: event) } + + around { |example| travel_to(Time.zone.local(2026, 7, 24, 10, 0)) { example.run } } + + it "stamps an earlier day's forgotten sign-out with that day's scheduled end" do + stale = create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 9, 5)) + + expect(registration.forgotten_sign_out_entry).to eq(stale) + expect(registration.forgotten_sign_out_at(stale)).to eq(Time.zone.local(2026, 7, 23, 16, 0)) + end + + it "ignores today's open entry — that one is closed by the ordinary Sign out" do + create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 24, 9, 5)) + + expect(registration.forgotten_sign_out_entry).to be_nil + end + + it "ignores earlier days that were closed properly" do + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 9, 0), signed_out_at: Time.zone.local(2026, 7, 23, 16, 0)) + + expect(registration.forgotten_sign_out_entry).to be_nil + end + + # Nothing sensible to stamp, so it isn't offered as a one-click close — staff + # correct it on the attendance report instead. + it "declines a sign-in recorded after that day had already ended" do + late = create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 18, 0)) + + expect(registration.forgotten_sign_out_at(late)).to be_nil + expect(registration.forgotten_sign_out_entry).to be_nil + end + end + end end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index 5526cbfa76..2444d90d2e 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -470,4 +470,72 @@ expect(create(:event, cost_cents: nil)).not_to be_scholarship_eligible end end + + describe "attendance sign-in window" do + # A two-day training running 9:00am–4:00pm each day. + let(:event) do + create(:event, + start_date: Time.zone.local(2026, 7, 23, 9, 0), + end_date: Time.zone.local(2026, 7, 24, 16, 0), + registration_close_date: Time.zone.local(2026, 7, 20, 9, 0)) + end + + describe "#event_dates" do + it "lists each consecutive calendar day, inclusive" do + expect(event.event_dates).to eq([ Date.new(2026, 7, 23), Date.new(2026, 7, 24) ]) + end + + it "is empty without a start date" do + expect(build(:event, start_date: nil).event_dates).to eq([]) + end + end + + describe "#daily_start_at / #daily_end_at" do + it "applies the event's start/end time-of-day to each day" do + day2 = Date.new(2026, 7, 24) + expect(event.daily_start_at(day2)).to eq(Time.zone.local(2026, 7, 24, 9, 0)) + expect(event.daily_end_at(day2)).to eq(Time.zone.local(2026, 7, 24, 16, 0)) + end + end + + describe "#attendance_sign_in_open?" do + it "opens 30 minutes before a day's start" do + expect(event.attendance_sign_in_open?(Time.zone.local(2026, 7, 23, 8, 30))).to be(true) + expect(event.attendance_sign_in_open?(Time.zone.local(2026, 7, 23, 8, 29))).to be(false) + end + + it "stays open through the day's end time" do + expect(event.attendance_sign_in_open?(Time.zone.local(2026, 7, 23, 16, 0))).to be(true) + expect(event.attendance_sign_in_open?(Time.zone.local(2026, 7, 23, 16, 1))).to be(false) + end + + it "applies the same window to every event day" do + expect(event.attendance_sign_in_open?(Time.zone.local(2026, 7, 24, 9, 0))).to be(true) + end + + it "is closed overnight between event days" do + expect(event.attendance_sign_in_open?(Time.zone.local(2026, 7, 23, 20, 0))).to be(false) + end + + it "is closed on non-event days" do + expect(event.attendance_sign_in_open?(Time.zone.local(2026, 7, 25, 9, 0))).to be(false) + end + end + + describe "#next_attendance_sign_in_opens_at" do + it "returns the first day's opening before the event" do + expect(event.next_attendance_sign_in_opens_at(Time.zone.local(2026, 7, 23, 7, 0))) + .to eq(Time.zone.local(2026, 7, 23, 8, 30)) + end + + it "skips to the next day's opening once the current window has opened" do + expect(event.next_attendance_sign_in_opens_at(Time.zone.local(2026, 7, 23, 10, 0))) + .to eq(Time.zone.local(2026, 7, 24, 8, 30)) + end + + it "is nil once the last day's window has opened" do + expect(event.next_attendance_sign_in_opens_at(Time.zone.local(2026, 7, 24, 12, 0))).to be_nil + end + end + end end diff --git a/spec/policies/event_policy_spec.rb b/spec/policies/event_policy_spec.rb index fa3a1d7f15..81250f61e3 100644 --- a/spec/policies/event_policy_spec.rb +++ b/spec/policies/event_policy_spec.rb @@ -174,6 +174,34 @@ def policy_for(record: nil, user:) end end + describe "#attendance?" do + let(:owned_event) { build_stubbed :event, created_by: regular_user } + + context "with admin user" do + subject { policy_for(record: published_event, user: admin_user) } + + it { is_expected.to be_allowed_to(:attendance?) } + end + + context "with owner" do + subject { policy_for(record: owned_event, user: regular_user) } + + it { is_expected.to be_allowed_to(:attendance?) } + end + + context "with non-owner regular user" do + subject { policy_for(record: published_event, user: regular_user) } + + it { is_expected.not_to be_allowed_to(:attendance?) } + end + + context "with no user" do + subject { policy_for(record: published_event, user: nil) } + + it { is_expected.not_to be_allowed_to(:attendance?) } + end + end + describe "#form_submissions?" do let(:owned_event) { build_stubbed :event, created_by: regular_user } diff --git a/spec/requests/continuing_education_registrations_spec.rb b/spec/requests/continuing_education_registrations_spec.rb index 0c871f7b91..607521d008 100644 --- a/spec/requests/continuing_education_registrations_spec.rb +++ b/spec/requests/continuing_education_registrations_spec.rb @@ -82,6 +82,25 @@ expect(ce_registration.reload.professional_license).to eq(other) end + describe "opened from the CE sign-in report (return_to=attendance)" do + it "points the back and cancel links at the report" do + get edit_continuing_education_registration_path(ce_registration, return_to: "attendance") + expect(response.body).to include("CE sign-in report") + expect(response.body).to include(attendance_event_path(event, ce: "true", anchor: "totals")) + end + + it "returns to the report after saving" do + patch continuing_education_registration_path(ce_registration, return_to: "attendance"), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120" } } + expect(response).to redirect_to(attendance_event_path(event, ce: "true", anchor: "totals")) + end + + it "returns to the report after deleting" do + delete continuing_education_registration_path(ce_registration, return_to: "attendance") + expect(response).to redirect_to(attendance_event_path(event, ce: "true", anchor: "totals")) + end + end + it "marks the certificate issued and back to not issued" do patch toggle_certificate_continuing_education_registration_path(ce_registration) expect(ce_registration.reload.certificate_sent_at).to be_present @@ -227,6 +246,111 @@ expect(response).to redirect_to(row_path) end end + + describe "attendance time entries" do + it "adds an entry from a blank row, attributed to the editing admin" do + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { signed_in_at: "2026-07-23T08:50", signed_out_at: "2026-07-23T10:34" } } } } + }.to change { registration.event_attendance_time_entries.count }.by(1) + + entry = registration.event_attendance_time_entries.last + # Datetime-local values are parsed in the editing admin's zone (Pacific). + pt = ActiveSupport::TimeZone["Pacific Time (US & Canada)"] + expect(entry.signed_in_at.in_time_zone(pt).strftime("%FT%R")).to eq("2026-07-23T08:50") + expect(entry.signed_out_at.in_time_zone(pt).strftime("%FT%R")).to eq("2026-07-23T10:34") + expect(entry.created_by).to eq(admin) + expect(entry.updated_by).to eq(admin) + end + + it "corrects an existing entry's time and stamps updated_by" do + entry = create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 8, 50), signed_out_at: Time.zone.local(2026, 7, 23, 10, 0)) + + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { id: entry.id, signed_in_at: "2026-07-23T08:50", signed_out_at: "2026-07-23T10:34" } } } } + + expect(entry.reload.signed_out_at.in_time_zone("Pacific Time (US & Canada)").strftime("%FT%R")).to eq("2026-07-23T10:34") + expect(entry.updated_by).to eq(admin) + end + + it "removes an entry when its _destroy box is ticked" do + entry = create(:event_attendance_time_entry, event_registration: registration) + + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { id: entry.id, signed_in_at: "2026-07-23T08:50", _destroy: "1" } } } } + }.to change { registration.event_attendance_time_entries.count }.by(-1) + end + + it "ignores a remove for an entry that no longer exists (stale form / double submit)" do + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { id: "999999", signed_in_at: "2026-07-23T08:50", signed_out_at: "2026-07-23T10:34", _destroy: "1" } } } } + }.not_to raise_error + + expect(response).to redirect_to(edit_event_registration_path(registration)) + end + + it "ignores blank rows" do + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { signed_in_at: "", signed_out_at: "" } } } } + }.not_to change { registration.event_attendance_time_entries.count } + end + + it "rejects overlapping times on the same day with a helpful error" do + pt = ActiveSupport::TimeZone["Pacific Time (US & Canada)"] + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: pt.local(2026, 7, 23, 9, 0), signed_out_at: pt.local(2026, 7, 23, 12, 0)) + + expect { + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { signed_in_at: "2026-07-23T11:00", signed_out_at: "2026-07-23T13:00" } } } } + }.not_to change { registration.event_attendance_time_entries.count } + + expect(response).to have_http_status(:unprocessable_content) + # Verbatim: entry messages are whole sentences, so the nested-attributes + # association prefix ("Event attendance time entries …") must not be pasted on. + expect(flash[:alert]).to eq("This sign-in overlaps another entry on Jul 23.") + end + + it "rejects a sign-out before the sign-in with a helpful error" do + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { signed_in_at: "2026-07-23T10:00", signed_out_at: "2026-07-23T09:00" } } } } + + expect(response).to have_http_status(:unprocessable_content) + expect(flash[:alert]).to eq("Sign-out must be after the sign-in time.") + expect(registration.event_attendance_time_entries).to be_empty + end + + it "still spells out the CE record's own attribute errors in full" do + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "", cost_dollars: "120" } } + + expect(response).to have_http_status(:unprocessable_content) + expect(flash[:alert]).to match(/\AHours /) + end + + # The rejected save re-renders the form, so the admin's typed times have to + # survive it — otherwise their correction is thrown away with only the flash + # to explain, and they have to retype it from memory. + it "keeps the submitted times on screen when the save is rejected" do + patch continuing_education_registration_path(ce_registration), + params: { continuing_education_registration: { hours: "6", cost_dollars: "120", + time_entries: { "0" => { signed_in_at: "2026-07-23T10:00", signed_out_at: "2026-07-23T09:00" } } } } + + expect(response.body).to include('value="2026-07-23T10:00"') + expect(response.body).to include('value="2026-07-23T09:00"') + end + end end it "forbids non-admins" do diff --git a/spec/requests/event_registrations_spec.rb b/spec/requests/event_registrations_spec.rb index d8bd3f93ed..af8b4c5058 100644 --- a/spec/requests/event_registrations_spec.rb +++ b/spec/requests/event_registrations_spec.rb @@ -493,6 +493,20 @@ def toggle_certificate(value) expect(existing_registration.reload.event_id).to eq(new_event.id) end + it "returns to the attendance report after saving when opened from it" do + patch event_registration_path(existing_registration, return_to: "attendance"), + params: { event_registration: { expected_payment_method: "Check" } } + + expect(response).to redirect_to(attendance_event_path(existing_registration.event)) + end + + it "shows a sign-in report eyebrow when opened from the attendance report" do + get edit_event_registration_path(existing_registration, return_to: "attendance") + + expect(response.body).to include("Sign-in report") + expect(response.body).to include(attendance_event_path(existing_registration.event)) + end + it "sets the shout-out flag and stores the shout-out text on the registrant" do patch event_registration_path(existing_registration), params: { event_registration: { diff --git a/spec/requests/events/attendance_spec.rb b/spec/requests/events/attendance_spec.rb new file mode 100644 index 0000000000..8141eb43ec --- /dev/null +++ b/spec/requests/events/attendance_spec.rb @@ -0,0 +1,257 @@ +require "rails_helper" + +RSpec.describe "Events attendance report", type: :request do + let(:admin) { create(:user, :admin) } + let(:event) do + create(:event, ce_hours_offered: 6, + start_date: Time.zone.local(2026, 7, 23, 9, 0), + end_date: Time.zone.local(2026, 7, 23, 16, 0), + registration_close_date: Time.zone.local(2026, 7, 20, 9, 0)) + end + let(:registration) do + create(:event_registration, event: event, registrant: create(:person, first_name: "Alice", last_name: "Adams")) + end + + def log_ce_time! + license = create(:professional_license, person: registration.registrant, number: "AAA111") + create(:continuing_education_registration, event_registration: registration, professional_license: license) + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 8, 50), signed_out_at: Time.zone.local(2026, 7, 23, 10, 34)) + end + + describe "as an admin" do + before { sign_in admin } + + it "renders the CE report with license number and hours when ce=true" do + log_ce_time! + get attendance_event_path(event, ce: "true") + expect(response).to have_http_status(:ok) + expect(response.body).to include("CE sign-in report") + expect(response.body).to include("Alice Adams") + expect(response.body).to include("AAA111") + end + + it "renders the generic attendance report without CE scoping" do + get attendance_event_path(event) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Attendance sign-in") + expect(response.body).not_to include("CE sign-in report") + end + + it "makes each row link to the CE edit page and the name link to the CE callout" do + log_ce_time! + ce = registration.continuing_education_registrations.first + get attendance_event_path(event, ce: "true") + expect(response.body).to include(edit_continuing_education_registration_path(ce)) # whole-row link + expect(response.body).to include(registration_ce_path(registration.slug)) # name link + end + + it "groups sessions by person by default" do + log_ce_time! + get attendance_event_path(event, ce: "true") + expect(response.body).to include("Sessions by person") + expect(response.body).not_to include("Sessions by day") + end + + it "groups sessions by day when toggled" do + log_ce_time! + get attendance_event_path(event, ce: "true", group: "day") + expect(response.body).to include("Sessions by day") + expect(response.body).to include("Day 1 ·") + end + + it "links session rows to the registration edit page on the generic report" do + log_ce_time! + get attendance_event_path(event) + expect(response.body).to include("#{edit_event_registration_path(registration)}?return_to=attendance") + + get attendance_event_path(event, group: "day") + expect(response.body).to include("#{edit_event_registration_path(registration)}?return_to=attendance") + end + + it "gives CE-report name links a path back to the report via the CE page eyebrow" do + log_ce_time! + get attendance_event_path(event, ce: "true") + expect(response.body).to include("#{registration_ce_path(registration.slug)}?return_to=attendance") + expect(response.body).not_to include("return_to=ce_registration") + end + + it "links session rows to the CE edit page on the CE report" do + log_ce_time! + ce = registration.continuing_education_registrations.first + get attendance_event_path(event, ce: "true") + expect(response.body).to include("#{edit_continuing_education_registration_path(ce)}?return_to=attendance") + expect(response.body).not_to include("#{edit_event_registration_path(registration)}?return_to=attendance") + end + + # The name link opens the registrant-facing callout; its eyebrow has to lead back + # to the report, not to the registration edit default two hops away. + it "sends the name link to a CE callout that points back at the report" do + log_ce_time! + get attendance_event_path(event, ce: "true") + expect(response.body).to include("#{registration_ce_path(registration.slug)}?return_to=attendance") + + get registration_ce_path(registration.slug, return_to: "attendance") + expect(response.body).to include("Back to CE sign-in report") + expect(response.body).to include(attendance_event_path(event, ce: "true", anchor: "totals")) + end + + it "returns to the registrants page when opened from there" do + get attendance_event_path(event, ce: "true", return_to: "registrants") + expect(response.body).to include("← Registrants") + end + + it "shows the event's daily times in the page header and each day header" do + # Pin the viewer to UTC so the times render exactly as the event was built + # (requests otherwise display in the admin's zone, Pacific by default). + sign_in create(:user, :admin, time_zone: "UTC") + log_ce_time! + get attendance_event_path(event, ce: "true", group: "day") + expect(response.body).to include("#{event.decorate.date_range} · 9 am - 4 pm UTC") + expect(response.body).to include("Day 1 · #{Date.new(2026, 7, 23).strftime("%A, %b %-d")} · 9 am - 4 pm UTC") + end + + # The chip flags an entry with no sign-out, so on a per-day table it has to be + # about that day — otherwise one forgotten sign-out lights up every later day too, + # which is exactly what staff are scanning the report to find. + it "flags 'signed in' only on the day whose entry is still open" do + event.update!(end_date: Time.zone.local(2026, 7, 24, 16, 0)) + license = create(:professional_license, person: registration.registrant, number: "AAA111") + create(:continuing_education_registration, event_registration: registration, professional_license: license) + create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 9, 0)) + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 24, 9, 0), signed_out_at: Time.zone.local(2026, 7, 24, 12, 0)) + + get attendance_event_path(event, ce: "true", group: "day") + + sections = Capybara.string(response.body).all("section") + day_one = sections.find { |section| section.text.squish.start_with?("Day 1 ·") } + day_two = sections.find { |section| section.text.squish.start_with?("Day 2 ·") } + expect(day_one).to have_css("span.bg-teal-50", text: "signed in") + expect(day_two).to have_no_css("span.bg-teal-50") + end + + it "warns when the event runs longer than the report's 5-day cap" do + event.update!(end_date: Time.zone.local(2026, 7, 30, 16, 0)) + get attendance_event_path(event) + expect(response.body).to include("only the first 5 days") + end + + # Staff fix a missed sign-in or a forgotten sign-out on the report itself rather + # than clicking out to the CE edit page for a single correction. + describe "editing a day's times in place" do + let(:day) { Date.new(2026, 7, 23) } + let(:cell) { "attendance-#{registration.id}-#{day.iso8601}" } + + it "offers an Edit link on every day's sessions cell, including empty days" do + log_ce_time! + event.update!(end_date: Time.zone.local(2026, 7, 24, 16, 0)) + empty_cell = "attendance-#{registration.id}-2026-07-24" + + get attendance_event_path(event, ce: "true") + + page = Capybara.string(response.body) + expect(page).to have_link(href: attendance_event_path(event, ce: "true", edit: cell, anchor: cell)) + expect(page).to have_link(href: attendance_event_path(event, ce: "true", edit: empty_cell, anchor: empty_cell)) + end + + it "opens the editor for one cell and leaves the rest read-only" do + log_ce_time! + get attendance_event_path(event, ce: "true", edit: cell) + + expect(response.body).to include("attendance[entries][0][in]") + # One editor: the cell asked for, not every cell on the page. + expect(response.body.scan("attendance[entries][0][in]").size).to eq(1) + end + + it "adds both times for a day nobody signed in on" do + license = create(:professional_license, person: registration.registrant, number: "AAA111") + create(:continuing_education_registration, event_registration: registration, professional_license: license) + + expect { + patch update_attendance_event_registration_path(registration, date: day.iso8601, ce: "true"), + params: { attendance: { entries: { "0" => { in: "08:50", out: "16:00" } } } } + }.to change { registration.event_attendance_time_entries.count }.by(1) + + entry = registration.event_attendance_time_entries.last + expect(attendance_clock(entry.signed_in_at)).to eq("08:50") + expect(attendance_clock(entry.signed_out_at)).to eq("16:00") + expect(entry.attendance_date).to eq(day) + expect(entry.created_by).to eq(admin) + expect(response).to redirect_to(attendance_event_path(event, ce: "true", anchor: cell)) + end + + it "corrects an existing time and closes a forgotten sign-out" do + log_ce_time! + entry = registration.event_attendance_time_entries.first + + patch update_attendance_event_registration_path(registration, date: day.iso8601), + params: { attendance: { entries: { "0" => { id: entry.id, in: "08:50", out: "16:00" } } } } + + expect(attendance_clock(entry.reload.signed_out_at)).to eq("16:00") + expect(entry.updated_by).to eq(admin) + end + + it "removes a session when its Remove box is ticked" do + log_ce_time! + entry = registration.event_attendance_time_entries.first + + expect { + patch update_attendance_event_registration_path(registration, date: day.iso8601), + params: { attendance: { entries: { "0" => { id: entry.id, in: "08:50", out: "10:34", _destroy: "1" } } } } + }.to change { registration.event_attendance_time_entries.count }.by(-1) + end + + it "keeps a session open when the sign-out is left blank" do + log_ce_time! + expect { + patch update_attendance_event_registration_path(registration, date: day.iso8601), + params: { attendance: { entries: { "0" => { in: "13:00", out: "" } } } } + }.to change { registration.event_attendance_time_entries.count }.by(1) + + expect(registration.event_attendance_time_entries.order(:signed_in_at).last).to be_open + end + + # A rejected save reopens the cell with what was typed, rather than making the + # admin reconstruct it from the flash alone. + it "reopens the cell with the submitted times when the save is rejected" do + log_ce_time! + + patch update_attendance_event_registration_path(registration, date: day.iso8601, ce: "true"), + params: { attendance: { entries: { "0" => { in: "16:00", out: "09:00" } } } } + + expect(response).to redirect_to(attendance_event_path(event, ce: "true", edit: cell, anchor: cell)) + expect(flash[:alert]).to eq("Sign-out must be after the sign-in time.") + + follow_redirect! + expect(response.body).to include('value="16:00"') + expect(response.body).to include('value="09:00"') + end + + it "rejects an unparseable date rather than guessing a day" do + patch update_attendance_event_registration_path(registration, date: "not-a-date"), + params: { attendance: { entries: { "0" => { in: "08:50", out: "16:00" } } } } + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + it "forbids users who are neither admin nor the event owner" do + sign_in create(:user) + get attendance_event_path(event, ce: "true") + expect(response).not_to have_http_status(:ok) + end + + it "forbids a non-admin from editing attendance times" do + sign_in create(:user) + patch update_attendance_event_registration_path(registration, date: "2026-07-23"), + params: { attendance: { entries: { "0" => { in: "08:50", out: "16:00" } } } } + expect(registration.event_attendance_time_entries).to be_empty + end + + # Times are stored UTC and rendered in the viewing admin's zone (Pacific by default). + def attendance_clock(time) + time.in_time_zone("Pacific Time (US & Canada)").strftime("%H:%M") + end +end diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index 01403b7658..a4d1202809 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -117,12 +117,13 @@ describe "callout page header" do let(:event) { create(:event, title: "Windows workshop", start_date: Date.new(2020, 1, 12), end_date: Date.new(2099, 12, 12)) } - it "shows the event title and short date range under the callout title" do + it "shows the event title with the date range and daily times on the line below" do create(:registration_ticket_callout, event:, builtin_key: "staff", hidden: false) get registration_staff_path(registration.slug) - # title · " - " — the short_date_range format - # (no weekday, with year); the exact day depends on the request time zone. - expect(response.body).to match(/Windows workshop · \w{3} \d{1,2}, 2020 - \w{3} \d{1,2}, 2099/) + expect(response.body).to include("Windows workshop") + # " - · " — the short_date_range format + # (no weekday, with year); the exact day/time depend on the request time zone. + expect(response.body).to match(/\w{3} \d{1,2}, 2020 - \w{3} \d{1,2}, 2099 · .+m [A-Z]{3}/) end end diff --git a/spec/requests/events/ce_attendance_spec.rb b/spec/requests/events/ce_attendance_spec.rb new file mode 100644 index 0000000000..f340ea7f5d --- /dev/null +++ b/spec/requests/events/ce_attendance_spec.rb @@ -0,0 +1,253 @@ +require "rails_helper" + +# Registrant self-service CE sign-in/out from the public CE callout (slug is the +# authorization, no login). The paper CE hour sign-in sheet, moved into the portal. +RSpec.describe "Events::Callouts CE attendance", type: :request do + # A one-day training running 9:00am–4:00pm; "now" is mid-morning, inside the window. + let(:event) do + create(:event, + ce_hours_offered: 6, ce_hours_cost_cents: 15_000, + start_date: Time.zone.local(2026, 7, 23, 9, 0), + end_date: Time.zone.local(2026, 7, 23, 16, 0), + registration_close_date: Time.zone.local(2026, 7, 20, 9, 0)) + end + let(:registration) { create(:event_registration, event: event) } + + before { travel_to Time.zone.local(2026, 7, 23, 10, 0) } + after { travel_back } + + # A CE registration paid in full — the gate for the whole attendance surface. + def pay_ce! + license = create(:professional_license, person: registration.registrant, number: "LIC123") + ce = create(:continuing_education_registration, event_registration: registration, professional_license: license) + create(:allocation, source: create(:payment), allocatable: ce, amount: ce.cost_cents) + registration.reload + end + + describe "POST /registration/:slug/ce/sign-in" do + it "records an open entry and redirects with a notice while CE is paid and in-window" do + pay_ce! + expect { + post registration_ce_sign_in_path(registration.slug) + }.to change { registration.event_attendance_time_entries.count }.by(1) + + entry = registration.event_attendance_time_entries.last + expect(entry).to be_open + expect(entry.signed_in_at).to eq(Time.current) + expect(entry.created_by).to be_nil # public self-service isn't attributed + expect(response).to redirect_to(registration_ce_path(registration.slug, anchor: "attendance")) + expect(flash[:notice]).to include("Signed in") + end + + it "does nothing when CE isn't paid in full" do + expect { + post registration_ce_sign_in_path(registration.slug) + }.not_to change { registration.event_attendance_time_entries.count } + expect(flash[:alert]).to be_present + end + + it "does nothing outside the day's sign-in window" do + pay_ce! + travel_to Time.zone.local(2026, 7, 23, 6, 0) + expect { + post registration_ce_sign_in_path(registration.slug) + }.not_to change { registration.event_attendance_time_entries.count } + expect(flash[:alert]).to include("training day") + end + + it "doesn't open a second entry while already signed in" do + pay_ce! + create(:event_attendance_time_entry, :open, event_registration: registration) + expect { + post registration_ce_sign_in_path(registration.slug) + }.not_to change { registration.event_attendance_time_entries.count } + end + end + + describe "POST /registration/:slug/ce/sign-out" do + it "closes the open entry" do + pay_ce! + entry = create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.current - 1.hour) + + post registration_ce_sign_out_path(registration.slug) + + expect(entry.reload.signed_out_at).to eq(Time.current) + expect(response).to redirect_to(registration_ce_path(registration.slug, anchor: "attendance")) + expect(flash[:notice]).to include("Signed out") + end + + it "reports when there's nothing to sign out of" do + pay_ce! + post registration_ce_sign_out_path(registration.slug) + expect(flash[:alert]).to be_present + end + end + + # A sign-out someone forgot on day one must not carry into day two: it would block + # the new day's sign-in, and closing it at "now" would bank a ~24-hour session + # against day one. Day two starts fresh, and the open day gets its own catch-up + # button stamped with that day's scheduled end. + describe "an entry left open on an earlier day" do + let(:event) do + create(:event, + ce_hours_offered: 6, ce_hours_cost_cents: 15_000, + start_date: Time.zone.local(2026, 7, 23, 9, 0), + end_date: Time.zone.local(2026, 7, 24, 16, 0), + registration_close_date: Time.zone.local(2026, 7, 20, 9, 0)) + end + let!(:stale) do + create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.zone.local(2026, 7, 23, 9, 0)) + end + + before do + pay_ce! + travel_to Time.zone.local(2026, 7, 24, 10, 0) + end + + it "starts day two signed out, offering Sign in for today" do + get registration_ce_path(registration.slug) + expect(response.body).to include(registration_ce_sign_in_path(registration.slug)) + end + + it "lets the registrant sign in for the new day" do + expect { + post registration_ce_sign_in_path(registration.slug) + }.to change { registration.event_attendance_time_entries.count }.by(1) + end + + it "prompts to close day one, naming the day and the time it will record" do + get registration_ce_path(registration.slug) + expect(response.body).to include("You never signed out on") + expect(response.body).to include("Thursday, July 23") + expect(response.body).to include("Sign out for Jul 23") + # The 16:00 UTC end of that training day, rendered in the page's Pacific zone. + expect(response.body).to include("Signing out records 9:00 AM, when that day's training ended.") + expect(response.body).to include(registration_ce_sign_out_path(registration.slug, entry_id: stale.id)) + end + + it "closes day one at that day's scheduled end, not now" do + post registration_ce_sign_out_path(registration.slug, params: { entry_id: stale.id }) + + expect(stale.reload.signed_out_at).to eq(Time.zone.local(2026, 7, 23, 16, 0)) + expect(flash[:notice]).to include("Signed out for Thu, Jul 23") + end + + it "leaves the stale entry alone when today's Sign out is used instead" do + post registration_ce_sign_out_path(registration.slug) + expect(stale.reload.signed_out_at).to be_nil + expect(flash[:alert]).to be_present + end + + it "ignores an entry_id that isn't the registrant's open earlier day" do + other = create(:event_attendance_time_entry, :open, event_registration: create(:event_registration), + signed_in_at: Time.zone.local(2026, 7, 23, 9, 0)) + + post registration_ce_sign_out_path(registration.slug, params: { entry_id: other.id }) + + expect(other.reload.signed_out_at).to be_nil + expect(stale.reload.signed_out_at).to be_nil + expect(flash[:alert]).to be_present + end + + # Nothing sensible to stamp when the sign-in is after the day was already over, + # so the one-click close isn't offered and staff correct it on the report. + it "doesn't offer the catch-up close for a sign-in after that day ended" do + stale.update_columns(signed_in_at: Time.zone.local(2026, 7, 23, 20, 0)) + + get registration_ce_path(registration.slug) + expect(response.body).not_to include("You never signed out on") + end + end + + describe "GET /registration/:slug/ce (attendance section)" do + it "shows a Sign in button once CE is paid and the window is open" do + pay_ce! + get registration_ce_path(registration.slug) + expect(response.body).to include("Training sign-in") + expect(response.body).to include("Sign in") + end + + it "omits the Signed out chip until something has been logged today" do + pay_ce! + get registration_ce_path(registration.slug) + expect(response.body).not_to include("Signed out") + end + + it "shows the Signed out chip and a Sign in again button after signing out today" do + pay_ce! + create(:event_attendance_time_entry, event_registration: registration, + signed_in_at: Time.current - 2.hours, signed_out_at: Time.current - 1.hour) + get registration_ce_path(registration.slug) + expect(response.body).to include("Signed out") + expect(response.body).to include("Sign in again") + end + + it "styles Sign out as the primary CTA while signed in" do + pay_ce! + create(:event_attendance_time_entry, :open, event_registration: registration, + signed_in_at: Time.current - 30.minutes) + get registration_ce_path(registration.slug) + expect(response.body).to match(/]*bg-teal-600[^>]*>Sign out "admin-only bg-blue-100", "app/views/category_types/index.html.erb" => "admin-only bg-blue-100", "app/views/events/dashboard.html.erb" => "admin-only bg-blue-100", + "app/views/events/attendance.html.erb" => "admin-only bg-blue-100", "app/views/events/sample_ticket.html.erb" => "admin-only bg-blue-100", "app/views/events/bulk_payments/index.html.erb" => "admin-only bg-blue-100", "app/views/events/background.html.erb" => "admin-only bg-blue-100",