Skip to content

Staff Entity Detail Redesign — Implementation Plan

Status: scope locked 2026-06-08. This plan is authoritative for v1 execution and supersedes the implementation phases in design.md. spec.md remains the component/contract reference (with the v1 reductions noted in its status block). API shapes here are verified against the live code (2026-06-08).

Scope (locked)

  • Three entities only: Resource, Reservation, User. Every other staff entity page is left as-is — permanent coexistence, no cutover, no "no-tail" rewrite.
  • Build order: Foundation → User (prototype) → Resource → Reservation. User first = highest staff usage and exercises the core primitives; Reservation last = heaviest (lockout subscription complexity), built once the pattern is proven.
  • Activity section: IN (audit-log powered, Load more).
  • Cut from v1: User.mgId (User breadcrumb = name/email), updated_by attribution (System = Created/Updated only; plain last-write-wins).
  • Per-entity reductions:
  • Reservation / MONTHLY (lockout): full, minus the Transfers history table — deferred. The Subscription sidebar shows last-transfer + destination from the existing subscription-metadata endpoint instead.
  • Reservation / HOURLY: full.
  • Reservation / TOUR: minimal — customer, scheduled-at, notes, referrer, reschedule/cancel/no-show. Outcome + tour guide deferred (not modeled; would need schema + endpoints — that's net-new CRM capability, not a UI port).
  • User: Reservations / Transactions / Credit Balances / Waitlist tables. Access Codes cross-reservation table deferred (no aggregation endpoint exists; staff reach codes via the reservation). Optional cheap add: show the user's single personal PIN (existing by-user endpoint) as a sidebar field.

Assumption corrections (content maps were wrong — verified)

  • User has no authIdUser.id is the Supabase auth UUID. System section shows id, not a separate authId.
  • Credit balances are not location-scopedUserCreditBalance is per credit-type, user/org-scoped. The User "Credit Balances" table shows credit-type + balance, not location.
  • Error envelope: field errors are in error.details [{path,message}] (already corrected in spec.md), not a top-level errors key.

Backend prerequisites (verified gaps — build with the consuming entity)

Prereq Needed by Size Detail
Staff access to GET /reservations/[id]/access-codes Reservation (lockout + hourly) Small Today rejects non-owner (route.ts:57). Relax to allow isStaffOrAbove for any reservation and bypass the member reveal-window for staff.
admin/audit-logs actor-name join + conform Activity (every page, first needed by User) Small Today returns userId UUID only. Add user: {id, name} join; switch createSuccessResponse({items,pagination})createListSuccessResponse. UI computes the change-summary client-side from data/previousAttributes.
GET /resources/[id] add location.mgId Resource Trivial Location select (route.ts:562-575) omits mgId.
GET /users/[id] include organization + roles[] User Small Default path returns bare user. Add includes, or use the existing GET /users/[id]/roles.

Conform-at-seam (the guard/SOP handles as each file is touched): asset-placements, resources/[id]/resource-groups, admin/audit-logs, user-credit-balances use createSuccessResponse instead of createListSuccessResponse; POST /users uses the banned createNextSuccessResponse (lines 9/137/152 — the lint guard will flag it the moment we open that file).

Explicitly deferred (NOT built in v1): per-reservation transfers history endpoint; user cross-reservation access-code aggregation; tour outcome/guide schema + endpoints.

Phase 0 — Foundation

  • Build the 7 primitives: DetailLayout, DetailHeader, DetailSection, DetailTable, DisplayField, DisplayGrid, EditSheet (Vaul, already installed).
  • Build the shared Activity component (audit-log table, Load more) + land the audit-logs actor-join prereq.
  • Write the frontend conventions doc (docs/engineering/architecture/frontend/detail-pages.md) formalizing the component contract + an apps/web/CLAUDE.md signpost — the staff-UI parallel to what we did for the API (keeps the ~N pages consistent across AI-authored sessions). Pointer-only; doc is source of truth.
  • Confirm the role-scoping helper (isStaffOrAboveAt(locationId)) exists and supports per-location edit gating before building affordance-hiding on it.

Phase 1 — User (prototype)

Prototype because it's the highest staff-traffic page and exercises the core primitives (multiple DetailTables, sidebar DetailSections with sheets, EditSheet create+edit, Activity, sensitive actions). The only primitive it defers is the images grid — built with Resource in Phase 2.

  • Backend prereqs: include organization + roles[] on GET /users/[id] (or use the existing /users/[id]/roles); land the admin/audit-logs actor-join for Activity. The POST /users create flow will trip the lint guard on its banned createNextSuccessResponse — conform it at that seam.
  • Frontend: UserDetail (two-column) + UserSheet (create + primary edit) + section sheets (Profile, Status, Billing, Notes) + Activity. List page → [+ Add] create sheet; remove /new.
  • Tables: Reservations (?userId), Transactions (?userId), Credit Balances (user-credit-balances?userId — per credit-type, not location), Waitlist (?userId) — all verified to exist. No Access Codes table.
  • Sidebar: Profile / Status / Billing (stripe-balance) / Notes / System (id, Created, Last Login — no authId, no mgId). Optional: personal PIN field via by-user.
  • Edit via PATCH /users/[id] (single endpoint covers profile + status; approved is staff-gated). Sensitive actions (impersonate, password reset, delete) wire to existing endpoints — good to nail the actions-menu pattern on a high-stakes case early.
  • Gate: prove the full loop (read → sheet → mutate → invalidate-and-refetch → Activity) on User before replicating. This is the pattern-validation checkpoint.

Phase 2 — Resource

Adds the images-grid component the prototype didn't need.

  • Backend: add location.mgId to the GET select; conform asset-placements + resources/[id]/resource-groups list helpers at this seam.
  • Frontend: ResourceDetail + ResourceSheet (create + primary edit) + section sheets (Physical, Pricing, Notes) + images grid + Activity. List page → create sheet; remove /new.
  • Field aliases the UI must map: typeresourceType, availabilityisAvailable, featuredmarketingFeatured; prices are display-cents. Resource-group memberships via the existing GET /resources/[id]/resource-groups (second call).

Phase 3 — Reservation (heaviest, last)

Single shell, branches on type — verified one uniform GET shape. Built last so the pattern is well-proven before the lockout subscription complexity.

  • Backend prereq: staff access-codes fix (relax owner-gate + bypass reveal-window for staff).
  • MONTHLY (lockout): Payments + Access Codes tables; Subscription sidebar (Stripe sub, anchor, monthly total + insurance breakdown, last-transfer + destination from subscription-metadata). Primary edit via POST /lockouts/[id]/update (+ preview-transfer for proration preview). No Transfers history table.
  • HOURLY: single Payment + Access Code; Booking window (start/end/duration derived/price-from-payment); edit via PATCH /reservations/[id].
  • TOUR: customer, scheduled-at, notes, referrer; reschedule (PATCH startTime), cancel, no-show. No outcome/guide.

Cleanup (scoped — not the design.md "remove all forms")

Remove old form components only for the three migrated entities: ResourceForm, the lockout/reservation forms, UserForm, and their /new routes. Leave LocationForm, AssetForm, OrganizationForm, StaffFormLayout, FormSection, LabeledValue in place — they still serve the coexisting (unmigrated) pages.

Verification

  • Per-entity acceptance: renders two-column desktop / single-column mobile; primary + section edits open prefilled sheets and persist; create flow POSTs and redirects; actions menu items work; cross-entity links navigate; empty states render.
  • Automated coverage decision: recommend an e2e happy-path (create + primary edit) per migrated entity; manual for the long tail of section sheets. (Verification is the bottleneck — you're the one signing off; keep it scoped.)
  • Regression: existing suites pass; coexisting pages untouched.

Build order (locked)

User (prototype) → Resource → Reservation. Decided 2026-06-08 by staff usage/pain: User is the most-used staff page and the cleanest prototype; Resource second (adds the images grid); Reservation last (heaviest — lockout subscription/proration/access-codes), built once the pattern is proven.

Status vs plan (2026-06-10)

Phases 0–3 shipped (PR #783 foundation + User/Resource, PR #789 Reservation). Where execution deliberately diverged from the plan above:

  • No EditSheet primitive. Section edits ship as CrudModal modals (UserBasicInfoModal, ResourceDetailsModal, etc.); the one full-screen takeover (ManageSubscriptionSheet, lockout edit) is bespoke and embeds LockoutForm.
  • LockoutForm retained (contra the cleanup list) — reused inside the takeover rather than rebuilt. ReservationForm and UserForm removed as planned.
  • Create flows split out to PR #796. User create shipped as CreateUserModal (CrudModal, not a sheet) from the list [+ Add] / ?create=1; captures admin notes; ensures the Stripe customer (the handle_new_user trigger preempts createFromAuth — see [[../../engineering/gotchas|gotchas]]). Outstanding: Resource create (+ retire ResourceForm + resources/new), lockout/tour creates.
  • Sent-email-log shipped as an adjunct on PR #789 — own spec at docs/features/sent-email-log/spec.md.
  • Outstanding from Verification: per-entity e2e happy paths not written; deferred items tracked in resource-handoff.md (activity timeline, payment-method on rows, per-record audit scoping). Pre-merge: run the full api + web suites on the stack head — see the "When to run what" section in docs/engineering/testing/overview.md (added after the 2026-06 sweep caught five failures scoped runs missed).

Status update (2026-06-18)

The three detail surfaces (User, Resource, Reservation) are all shipped. Create flows and two adjacent status-model arcs landed since the 2026-06-10 note above; reconciled against code today.

Create flows (the original "remove all forms" tail):

  • User create → CreateUserModal (#796), from the list [+ Add] / ?create=1.
  • Lockout + waitlist creates → built under the separate staff-invitations arc as sheets (CreateLockoutSheet, CreateWaitlistSheet on TakeoverSheet; #807 surface, #808 creates, waitlist→sheet in #814). The legacy /staff/reservations/lockout/new, /staff/waitlist/new, CreateLockoutPage, CreateWaitlistPage are retired. These are sheets because the forms are heavy (the embedded LockoutForm, a result panel), not because creates are sheets: container is chosen by form size, not by create-vs-edit. See the Surfaces rule.
  • Resource create → still outstanding. CreateResourcePage + ResourceForm + /staff/resources/new remain live. Tracked in staff-invitations Deferred.
  • Tour create → still outstanding (same tracker).

Adjacent arcs that joined the redesign (outside the original three-entity lock, same component conventions):

  • Waitlist gained a redesigned detail page + a single-table INVITED/ACTIVE status UI — see waitlist-status-model (#810/#811/#814).
  • Reservations gained an INVITED status so a lockout invitation creates a real reservation that holds the studio and expires in 7 days — see reservation-invited-status (#815, expiry notice #834).
  • Sent-email log shipped as an adjunct — sent-email-log.

Surface-primitive consolidation (decided 2026-06-18). The edit/create surfaces accreted into a mess: CrudModal + TakeoverSheet + a bespoke ManageSubscriptionSheet + a doc-only EditSheet that was never built. Target is two primitives sharing one footer: CrudModal (default) and Sheet (large/multi-step/two-pane), both rendering an extracted CrudActions. Staged so the new-entity work lands on clean primitives: (1) doc the decision in detail-pages.md ✓; (2) extract CrudActions + DiscardConfirmModal + useDiscardGuard from CrudModal, generalize TakeoverSheetSheet, migrate all three sheets onto it ✓ (the CreateWaitlistSheetCrudModal UX change is deferred to its own verified step; it is a pane Sheet for now); (3) ~~hoist the lockout preview into Sheet.secondaryPane~~ — rejected as an antipattern: the proration preview is a derived view of LockoutForm's state and is already reused across lockout create+edit by living in the form; secondaryPane is for form-independent content only. Preview stays in LockoutForm; no managed footer forced on the lockout edit; (4) then Resource create. Rule + mapping: Surfaces.

Done (this branch, PR #838): surface consolidation (CrudModal + Sheet + CrudActions); Resource create modal; waitlist create → CrudModal (CreateWaitlistModal); tour create modal (CreateTourModal, retires TourForm/CreateTourPage//tour/new). The surface mapping in detail-pages.md is now fully realized — the only sheets left are the two lockout flows.

Net remaining for this redesign: per-entity e2e happy-paths, and the documented deferrals in resource-handoff.md (account-activity timeline, payment-method on payment rows, per-record audit scoping). The engineering convention doc docs/engineering/architecture/frontend/detail-pages.md (updated 2026-06-18) is the canonical pattern reference for the remaining work.

Rollout to more domains — Wave 1 (2026-06-18)

A domain "rolls out" when both its detail page and its create UI match the pattern and its legacy StaffFormLayout form is deleted — not create-only. (The earlier "permanent coexistence with all other pages" note was wrong; coexistence is transitional, per-wave.) Wave 1 = the cheap "group + config" cluster (cross-location or single-required-field), chosen to harden the pattern on more domains before the heavier location-scoped batch.

Create surfaces (?create=1 from the list Add, redirect to detail):

  • Location groupCreateLocationGroupModal (middle-path: name + groupType; notes / tour-required deferred to the detail edit).
  • Resource groupCreateResourceGroupModal (middle-path: name + groupType + location).
  • Access gateCreateAccessGateModal (the conditional case: vendor → device ID vs static code; scope → resource vs resource group; location gates the scope selector).

Detail pages (EntityHeader + DetailLayout sidebar edited via the DetailSection pencil + ConfirmDeleteModal + a DetailTable of relational content in the main column). No "Update [entity]" primary button — the section pencil opens the edit modal; the primary action is reserved for large form/sheet entities (waitlist, lockout). The relational managers were refactored off the legacy RelatedEntityManager onto DetailTable (add modal via onAdd, Remove row action), matching the other detail pages.

  • LocationGroupDetail + UpdateLocationGroupModal (full field set); LocationGroupLocationsManager → DetailTable.
  • ResourceGroupDetail + UpdateResourceGroupModal (name + type; location immutable); ResourceGroupResourcesManager → DetailTable.
  • AccessGateDetail + UpdateAccessGateModal (name, active, static code for manual; vendor/scope/location immutable). The hardware-placements logic was extracted from AccessGateForm into AccessGatePlacementsManager (DetailTable) so kit assignment survives the form's removal.

Mechanics for all: create + update hooks throw ApiMutationError + return unwrapEntity; modals use useServerFieldErrors for 4xx + Sentry on unexpected. The /new and [id] edit-page routes are removed, and the three legacy forms (LocationGroupForm, ResourceGroupForm, AccessGateForm) + their tests are deleted (the original "remove all forms" tail, now done for these domains). Audit Activity is omitted — these tables aren't audited yet. E2e: create-location-group + create-resource-group (full submit → redirect → asserts the detail "Update" action), create-access-gate (render smoke).

Next waves (not started): Wave 2 = locations as the flagship Sheet (largest form, second non-lockout sheet) + the location-scoped batch (assets, then access-gate/resource visibility) — which is where role-scoping (isStaffOrAboveAt(locationId), not yet wired in apps/web) stops being optional. Sequencing of Wave 2 awaits a decision on wiring role-scoping before vs alongside the location-scoped domains.