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_byattribution (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-metadataendpoint 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-userendpoint) as a sidebar field.
Assumption corrections (content maps were wrong — verified)
- User has no
authId—User.idis the Supabase auth UUID. System section showsid, not a separate authId. - Credit balances are not location-scoped —
UserCreditBalanceis 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-levelerrorskey.
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
Activitycomponent (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 + anapps/web/CLAUDE.mdsignpost — 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[]onGET /users/[id](or use the existing/users/[id]/roles); land theadmin/audit-logsactor-join for Activity. ThePOST /userscreate flow will trip the lint guard on its bannedcreateNextSuccessResponse— 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 viaby-user. - Edit via
PATCH /users/[id](single endpoint covers profile + status;approvedis 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.mgIdto the GET select; conformasset-placements+resources/[id]/resource-groupslist 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:
type→resourceType,availability→isAvailable,featured→marketingFeatured; prices are display-cents. Resource-group memberships via the existingGET /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 viaPOST /lockouts/[id]/update(+preview-transferfor proration preview). No Transfers history table. - HOURLY: single Payment + Access Code; Booking window (start/end/
durationderived/price-from-payment); edit viaPATCH /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
EditSheetprimitive. Section edits ship asCrudModalmodals (UserBasicInfoModal,ResourceDetailsModal, etc.); the one full-screen takeover (ManageSubscriptionSheet, lockout edit) is bespoke and embedsLockoutForm. LockoutFormretained (contra the cleanup list) — reused inside the takeover rather than rebuilt.ReservationFormandUserFormremoved 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 (thehandle_new_usertrigger preemptscreateFromAuth— see [[../../engineering/gotchas|gotchas]]). Outstanding: Resource create (+ retireResourceForm+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 indocs/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,CreateWaitlistSheetonTakeoverSheet; #807 surface, #808 creates, waitlist→sheet in #814). The legacy/staff/reservations/lockout/new,/staff/waitlist/new,CreateLockoutPage,CreateWaitlistPageare retired. These are sheets because the forms are heavy (the embeddedLockoutForm, 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/newremain 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
INVITEDstatus 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 TakeoverSheet→Sheet, migrate all three sheets onto it ✓ (the CreateWaitlistSheet→CrudModal 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 group →
CreateLocationGroupModal(middle-path: name + groupType; notes / tour-required deferred to the detail edit). - Resource group →
CreateResourceGroupModal(middle-path: name + groupType + location). - Access gate →
CreateAccessGateModal(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 fromAccessGateFormintoAccessGatePlacementsManager(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.