Commit 9c54a882 authored by DevPilot's avatar DevPilot

docs: extreme-detail reference for SA bookings, passes, scheduling, waitlist, transfers

Classic hourly booking form, booking passes (confirmed orphaned gate-scan
API), daily/weekly schedule, blackout dates, conflict detection scope,
locker rentals, group schedule/waitlist/transfer (including the separate
TrainingGroups parallel system), makeup sessions, and Service Desk. Flags
a consistent cash-collected-outside-the-system pattern across three
screens for a deliberate finance decision rather than three silent fixes.
parent 28ea8431
# SportsActivity — Bookings, Scheduling & Related Screens
Covers everything around the hourly-booking wizard, pool-reservation wizard, and registration wizard (documented separately in this folder). This chapter is the classic (non-wizard) booking screens, passes, scheduling, blackout dates, conflict detection, lockers, group schedule/waitlist/transfer, makeup sessions, and Service Desk.
---
## Classic Bookings (`/sa/bookings`) — `BookingController`
The non-wizard hourly booking form. Fields: facility unit (grouped select), date (default today), start/end time, booker name (required, ≤300 chars), booker type (guest/member/employee — note **"employee" isn't a real booker type** anywhere else in the system; it silently falls through to non-member pricing since `SaConstants` only defines member/guest/organization), participant count, notes. No booker_id, organization fields, or per-participant breakdown on this form — those only exist in the wizard.
**Every booking created here is `participant_mode='passes'` by default and auto-generates that many booking passes** — even though this classic form has no passes-related UI at all (no mention of them, no price preview populated either — the preview panel is a static placeholder that nothing fills in on this screen, unlike the wizard's live one).
**Confirmed gap — classic bookings can never be marked paid through the app.** `createHourlyBooking()` never creates a payment request (contrast: the wizard's `book()` does). The one function that would flip a booking's `payment_status` to paid, `SaPaymentService::payBooking()`, has zero callers anywhere in the codebase. Every booking made from this screen sits `payment_status='unpaid'` permanently unless something outside SportsActivity touches it directly. Worth a decision: if payment for classic bookings is genuinely meant to be collected at the desk and just recorded, that's consistent with the same pattern seen in Locker Rentals and Service Desk tickets below — but nothing currently *records* that it was collected, unlike those two.
**Postpone**: hard cap of 3 (hardcoded, not configurable), re-checks slot availability against the new time (excluding itself) but **does not re-check coach conflicts** even for training bookings — a real asymmetry, since the same booking's original creation would have checked the coach.
**Cancel**: the confirm() dialog on the show screen collects no reason text, so cancelling from that button always stores an empty `cancellation_reason` — a reason can only be recorded by calling the endpoint some other way. If the booking was paid with a `payment_id` set, cancellation attempts to void the payment automatically; if that void fails, the booking is still marked cancelled but `payment_status` silently stays `paid` — no error surfaced. Given the payment gap above, this refund path is effectively dead for classic bookings today; it can only fire for a booking that got a `payment_id` some other way (e.g. via the wizard's flow).
**Check-in/check-out**: two independent status flips with no service layer — checkout can be called directly from `confirmed`, skipping check-in. Neither is tied to attendance recording at all (that's a separate screen).
## Booking Passes (`/sa/bookings/{id}/passes`)
Auto-generated at `<booking_number>-P01`, `-P02`, etc. The passes screen itself is **read-only — a printable manifest, no "use" button anywhere on it.**
**Confirmed orphaned API surface:** `POST /sa/booking-passes/use` and `GET /api/sa/booking-passes/validate` are fully implemented server-side but **called from nowhere in this codebase** — no view, no JS. Unlike player cards, which have a working gate-scan screen, there is no equivalent scanning UI for booking passes anywhere in the system. These two endpoints look built for an external kiosk/scanner client that either isn't in this repo or was never finished. `usePass()` also has no upper bound check against `passes_total` and no locking — a race could double-count usage, though this is moot while nothing calls it. `BookingPassService::expireBookingPasses()` is likewise dead code — never wired into cancellation.
## Schedule (`/sa/schedule/daily/{date}`, `/sa/schedule/weekly`)
Daily view: card-per-facility, row-per-unit, chip-per-booking, color-coded by type. Shows `no_show` bookings but hides `cancelled` ones (an inconsistency with most other screens' filters). Only units with an actual booking that day appear — a fully-free unit doesn't render as an empty row.
**Permission mismatch worth knowing:** the "Generate Schedule" form at the bottom of this page is visible to anyone who can merely *view* the schedule (`sa.schedule.view`), even though the route it posts to requires `sa.schedule.manage` — a view-only user sees and can click the button but gets a 403 back.
**Generation silently drops its own diagnostics:** `generate()` reports how many sessions were generated/skipped, but the actual *reasons* individual dates were skipped (conflict, blackout, duplicate) are collected internally and never shown — an admin generating a month of sessions has no way to see why specific days didn't get a session.
Weekly view is booking-count-only (a heat map), no per-booking detail.
## Blackout Dates
Enforced centrally through `SlotAvailabilityService`, which every booking-creation and postpone path in the module goes through — so blackout enforcement is consistent everywhere.
**Two confirmed UI gaps, not enforcement gaps:**
1. The only way to create one (`FacilityController::addBlackout`) only ever sets facility + date + reason — **it never sets a specific unit, start time, or end time**, even though all three columns exist and are read by the availability check. Every blackout created through the UI is whole-facility (blocks every unit under it, not just one court/lane) and whole-day. Partial-day or single-unit blackouts aren't reachable from any screen.
2. **There is no list or delete screen for blackout dates at all.** Once created, an admin cannot see what's currently blacked out for a facility, and cannot remove one, from anywhere in the app.
## Conflict Detection
Coach double-booking is checked **only** for training bookings created via the group-schedule generation path (`createTrainingBooking()`), and only if a coach is actually attached. It is **never invoked for classic hourly bookings** (they have no coach), and — matching the postpone gap above — **never re-checked when a training booking is postponed**, even though it was checked at creation. `checkPlayerConflict()` (would catch a player double-booked into two overlapping sessions) is fully implemented but has zero callers anywhere — dead code.
## Locker Rentals
Rental types (monthly/6-months/yearly) compute an end date automatically. Renewal creates a **new** rental row linked back to the old one (a chain, not an in-place update) rather than extending the existing row.
**Confirmed gap — no payment integration at all.** Nothing in this controller ever creates a payment request or otherwise transitions `payment_status` away from `unpaid`. `amount` is just a stored field.
**Confirmed gap — the grace-period/eviction lifecycle has no automatic trigger.** `grace_period` and `pending_eviction` are real, defined statuses that every query defensively checks for, but **nothing in the codebase ever sets a rental to either state** — no expiry sweep, no cron. An expired rental just sits `status='active'` forever. Worse: eviction itself only works *from* `grace_period`/`pending_eviction` — an `active` rental (which is what every expired-but-untouched rental actually is) **cannot be evicted through the UI at all**, since the evict screen's query explicitly excludes `active`. As shipped, reclaiming an expired locker requires a direct database action, not anything reachable in the app.
## Group Schedule, Waitlist & Transfers
**Group Schedule** (the recurring weekly template a group meets on): saving it does a full replace-by-diff (old rows deactivated, not deleted, so history survives) and **automatically generates training bookings for the next 4 weeks as a side effect of every save** — not a separate opt-in step. Copying a schedule from one group to another, or shifting its time, only rewrites the *template***neither operation touches already-generated future bookings**. Shifting a group's time in the UI does not move its already-booked upcoming sessions; you have to separately regenerate, and because generation is idempotent on `(group, unit, date, start_time)`, regenerating after a time-shift can leave the old-time bookings sitting alongside new ones rather than replacing them.
**Group Waitlist — confirmed non-functional for its core purpose.** A full-repo search finds **no code path anywhere that ever inserts a row into `sa_waitlist`.** When `EnrollmentService::enroll()` detects a full group, it returns a flag suggesting the caller offer a waitlist join — but `GroupController::enroll()`, its only caller, discards that flag entirely and just shows a plain "group is full" error. There is no "join waitlist" button anywhere in the group screens. The admin-facing offer/cancel actions and the automatic promote-next-in-line service are all fully built and functional, but only if a waitlist row already exists — and nothing in this codebase can create one. On top of that, `acceptOffer()` — the method that would convert an offered waitlist slot into a real enrollment — also has zero callers, so even a manually-offered entry has no path to actually becoming an enrollment. **This entire feature currently only works if someone inserts rows directly into the database.**
**Group Transfers — two entirely separate systems, easy to confuse.** SportsActivity's own transfer (`GroupTransferService::transfer`, used by the group screen's "transfer player" button) is immediate — no request/approval step, no waitlist fallback; a full target group either force-overflows (with an explicit override checkbox) or the whole transfer is rejected outright. **The request/approve flow with the "full → waitlisted" rule you may be thinking of lives in the separate `TrainingGroups` module**, which runs on its own parallel `training_groups`/`group_memberships`/`group_waitlist` tables — not the same ones SportsActivity uses. That approval logic has a real cross-module bug: when checking whether the target group is full, it counts occupants from **SportsActivity's `sa_group_players` table**, keyed by the *TrainingGroups* group id — since the two group systems are separate id spaces with no guaranteed correspondence, this capacity check is very likely comparing against the wrong rows in practice. This mirrors a risk the Members/TrainingGroups architecture notes already flag: two duplicate group systems existing side by side.
## Makeup Sessions
Can only be created from an actual recorded absence — never automatically. The eligibility window is anchored to the *missed* date, not the request date, so a late request has a correspondingly shorter usable window. A makeup can only be rescheduled into another group of the *same discipline*, and only onto a day that group's own schedule template actually runs.
**Two gaps worth knowing:** scheduling a makeup does **not** check the target group's capacity (unlike regular enrollment/transfer, which both do) — a makeup can be scheduled into an already-full group with no rejection. And the automatic expiry sweep for overdue eligible/scheduled makeups exists as a method but has no cron/CLI wiring anywhere — overdue makeups keep showing as actionable until something calls it manually.
## Service Desk (`/sa/service-desk`)
Mostly a front-desk **launcher**, not a screen with its own logic — three of its four tabs are just links out to the registration wizard, booking wizard, and locker-rental screens. Only the "Activity Ticket" tab does real work: it issues a Carnet guest-entry ticket (checks the carnet's remaining invitation balance, delegates entirely to the Carnets module's `GuestEntryService`). **Same payment pattern as Locker Rentals**`amount_paid` is a manually-entered record, not an enforced or collected charge; there's no Cashier integration and no receipt/print view for a ticket once issued.
## Recurring pattern worth flagging as a group, not one-off bugs
Classic bookings, locker rentals, and service-desk tickets **all** record an amount without ever routing it through the Cashier/Payments pipeline the rest of the system uses. If this is intentional (cash collected in person, recorded for the record), it's consistent — but it means none of these three screens' revenue is reconcilable against the treasury the way scheduled/institutional payments are. Worth a deliberate decision with finance rather than three separate silent fixes.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment