Detail Pages
Overview
Staff entity detail pages render read-only (labels-and-text), in a two-column Stripe-style layout: main column = activity/related tables, sidebar = entity properties.
Page width: detail pages are constrained to a centered max-w-7xl mx-auto w-full page container (header + two-column + tables) so they don't sprawl on wide screens — tables fill their column full-width within that container. List/index pages remain full-width.
Edits and creates open an overlay; they never mix inline inputs with display. There are exactly two surfaces: CrudModal (the default) and Sheet (for forms too large for a modal). Both render the same CrudActions footer (Cancel plus Save/Create, with a dirty-discard guard). The decision rule and the per-use-case mapping live in Surfaces below.
Header carries a primary button (EntityHeader.primaryActions) for the main edit action, labelled "Update [entity]" (e.g. "Update reservation", "Update waitlist"), and a secondary actions dropdown (EntityHeader.actions, [...]) for secondary operations (copy link, resend, cancel, impersonate, delete, etc.). Reservation and waitlist detail pages both follow this pattern. Sidebar groups the entity's own fields into a small number of labeled panes sized to the entity (User landed on Details + a couple of action panes; Resource on Details / Pricing / Physical). Each pane's ✎ opens one modal that edits that pane's fields (all hitting the same PATCH). Read-only system fields (id, mgId, created, updated) live in a read-only System pane — or, when there are few, fold into Details rather than carry a near-empty pane. The earlier "single Basic Information pane" rule was the User-shaped minimum, not a ceiling: don't cram a data-rich entity into one pane, and don't shatter a thin one into many. Avoid per-field ✎ (one modal per pane, not per field).
Authority: features/staff-ui-redesign/spec.md is the component/prop contract (with its v1-scope block); plan.md governs scope + rollout (User → Resource → Reservation). This page is the engineering convention for building them — what to reuse, what's new, and the house patterns. When in doubt, reuse an existing primitive over building one.
Surfaces
Two overlay primitives, one shared footer. CrudModal is the default; Sheet holds forms too large for a modal; CrudActions is the Cancel/Save footer that both render, so a modal edit and a sheet edit behave identically.
Two orthogonal choices decide the surface:
Rule 1 — container, by form size. A compact form goes in a CrudModal. A large, multi-step, or two-pane form goes in a Sheet (takeover width when it carries a secondaryPane, otherwise pane). Sheet is not reserved for one entity; it is the choice for any heavy form, and the next heavy form drops in without inventing a new pattern.
Rule 2 — create scope, by required fields (independent of Rule 1). If creation does not require every field, collect the required ones and land on the detail page where the per-pane edits finish or defer the rest (middle-path). If creation requires all fields or fires an immediate action (an invitation, a checkout link), complete the form and end in a result panel (full).
Mapping (target):
| Use case | Container | variant | footer | 2nd pane | create scope |
|---|---|---|---|---|---|
| User create | CrudModal |
— | managed | — | middle-path |
| User / Resource edit (per pane) | CrudModal |
— | managed | — | — |
| Resource create | CrudModal |
— | managed | — | middle-path |
| Tour create | CrudModal |
— | managed | — | middle-path |
| Location group create | CrudModal |
— | managed | — | middle-path |
| Resource group create | CrudModal |
— | managed | — | middle-path |
| Access gate create | CrudModal |
— | managed | — | middle-path |
| Hourly / tour edit | CrudModal |
— | managed | — | — |
| Waitlist edit (prefs) | CrudModal |
— | managed | — | — |
| Waitlist create | CrudModal |
— | body-owned (result panel) | — | full |
| Lockout create | Sheet |
takeover | body-owned (link + result) | preview (in form) | full |
| Lockout edit | Sheet |
takeover | body-owned (form submit) | preview (in form) | — |
The lockout proration preview is not a Sheet.secondaryPane. It is a live derived view of LockoutForm's in-flight state (it reads the form fields and calls preview-transfer), so it lives inside LockoutForm and is reused for free by both the create and edit sheets that embed the form. secondaryPane is reserved for content the layout can be agnostic about (form-independent summaries, help, related records); putting a form-derived preview there would force the form's state up across the layout boundary, which is coupling masquerading as decoupling. The reusable layer is the two-pane layout, not the preview.
Status (2026-06-18): target reached for the primitives. CrudActions is extracted from CrudModal, TakeoverSheet is generalized into Sheet (variant / secondaryPane / optional managed footer), and the three sheets are migrated onto it. EditSheet was specced but never built and is dropped. The lockout proration preview stays inside LockoutForm (see the note above the mapping); ManageSubscriptionSheet embeds the form in a Sheet. The mapping above is fully realized: waitlist create is a CrudModal (CreateWaitlistModal) and tour create is a CrudModal (CreateTourModal); the only sheets left are the two lockout flows.
Rollout (Wave 1, 2026-06-18): the group + config domains — location group, resource group, access gate — are fully migrated (both detail and create), per the per-domain rule (a domain isn't done until detail + create match and the legacy form is gone).
- Create:
CreateLocationGroupModal/CreateResourceGroupModal/CreateAccessGateModal, opened from the list?create=1Addaction;/newroutes removed. Access gate is the conditional case (vendor → device ID vs static code; scope → resource vs resource group; location gates the scope selector) and carries that logic into the modal. - Detail:
LocationGroupDetail/ResourceGroupDetail/AccessGateDetailfollow theUserDetailshape —EntityHeader(icon + breadcrumb + status + delete in the dropdown),DetailLayoutsidebar ofDisplayFields edited via theDetailSectionpencil (which opensUpdate[Entity]Modal), andConfirmDeleteModal. No "Update [entity]" primary button — the section pencil already affords the edit; the primary action is reserved for entities with a large form/sheet (e.g. waitlist, lockout). The relational content (group locations / group resources / gate hardware) is aDetailTablewith an add modal (onAdd) and a Remove row action, matching the other detail pages. The[id]edit-page routes are removed. - Cleanup: the three legacy
StaffFormLayoutforms (LocationGroupForm/ResourceGroupForm/AccessGateForm) + tests are deleted; access-gate hardware placements were extracted toAccessGatePlacementsManager(molecule) first. AuditActivityis omitted on these pages — the tables aren't audited yet.
Reuse map — do NOT rebuild these
| Need | Use the existing primitive | Path |
|---|---|---|
| Main-column tables | BaseTable (columns, items, loading/error/empty, pagination, rowActions) — DetailTable wraps it |
atoms/data-display/BaseTable.tsx |
| A reservations / waitlist table on a detail page | ReservationsTable / WaitlistTable — self-fetching, context-driven ('user'|'resource') named tables; own their columns, row actions, and confirm/cancel modals. When ≥2 detail pages render the same logical table, extract a named component like these rather than re-wiring DetailTable per page. |
organisms/detail/{ReservationsTable,WaitlistTable}.tsx |
| Reservation display status (UPCOMING vs ACTIVE vs COMPLETED vs PENDING_PAYMENT, future-dated) | getReservationDisplayStatus(reservation) + RESERVATION_STATUS_LABEL — derive the badge label from times and payment state. PENDING_PAYMENT derives when hasPaidPayment === false (explicit false, not null) and startTime is null. Future-dated lockouts with a paid payment keep their UPCOMING display. Never render the raw API status. Shared by the reservations list and every detail-page table. |
lib/reservation-status.ts |
| Apply a mutation result to the payments list cache without a refetch | patchPaymentsByReservation(queryClient, reservationId, patch) — sibling of patchReservationsCache; used by useCancelLockout to flip a payment to CANCELED immediately on an immediate cancel. |
hooks/api/usePayments.ts |
| Header (breadcrumbs + title + status + actions) | EntityHeader — reuse directly; extend it if the 3-row/inline-status layout needs a prop. Do not fork a parallel DetailHeader. |
organisms/navigation/EntityHeader.tsx |
Status badge / statusColor |
EntityStatusBadge (the Record<status,{label,color}> map is the spec's status helper) |
molecules/shared/EntityStatusBadge.tsx |
| Header icon for photo-less entities | EntityIcon — Avatar-sized circular lucide icon for EntityHeader's leading slot (waitlist=ClipboardList, invitation=Send, lockout/hourly/tour=KeyRound/Calendar/Clock). Pages with photos keep Avatar. |
molecules/shared/EntityIcon.tsx |
| Sidebar label+value | model DisplayField on LabeledValue ({label,value,href,description}) — add copyable. (LabeledValue is removed in cleanup once entities migrate.) |
atoms/forms/LabeledValue.tsx |
| Sheet field layout | FormFieldGroup (1–4 col responsive grid) |
molecules/forms/FormFieldGroup.tsx |
| Primary-fetch loading | PageSpinner (route loading.tsx + Suspense fallback). No full-page skeletons — they jitter against the server-rendered layout. DetailTable renders its own single-line row placeholders while its hook fetches |
molecules/feedback/PageSpinner.tsx |
| Sheet selectors | LocationSelector/ResourceSelector/UserSelector/… — reuse as-is |
molecules/forms/ |
| Searchable multi-select | MultiSelect (items, values, onValuesChange) — search Input over a CheckboxGroup. HeroUI has no native searchable-multi; do not bend Autocomplete into multi. LocationMultiSelector / ResourceMultiSelector are thin wrappers. |
molecules/forms/MultiSelect.tsx |
| Server-error → field mapping | convertBackendErrorsToValidationErrors |
utils/validation/backend-errors.ts |
| Server errors in an edit modal (state + 4xx mapping + RHF merge) | useServerFieldErrors ({ serverErrors, clear, applyApiError, fieldError }) — wraps the above; every CRUD modal uses it |
hooks/useServerFieldErrors.ts |
| Apply a mutation result to the reservations list cache without a refetch | patchReservationsCache(queryClient, id, patch) |
hooks/api/useReservations.ts |
| Reservation action gating (can-cancel / can-no-show) | canCancelReservation / canMarkNoShow |
lib/reservation-status.ts |
| Lockout end / resume, asset placement, resource-group membership | mutation hooks — useCancelLockout/useUncancelLockout, usePlaceAsset, useAddResourceToGroup/useRemoveResourceFromGroup/useResourceResourceGroups. Never raw-fetch these from a component. |
hooks/api/use{Reservations,AssetPlacements,ResourceGroups}.ts |
| Per-entity resource select options (status/type/wing/size) | resource-options |
lib/resource-options.ts |
BaseTable extensions (use, don't re-add): per-row action gating via BaseTableAction.condition (rows with no applicable action show no menu); rowClassName(item) (e.g. dim cancelled rows); hideViewAction (suppress the redundant "View Details" item when the whole row is click-to-view). DisplayField has truncate (show-more toggle for long text); ConfirmDeleteModal has confirmLabel (type-to-confirm reused for remove/cancel/end, not just delete).
New primitives (the Phase-0 build)
Live under components/molecules/detail/ (barrel index.ts), sheets under components/organisms/sheets/. Named exports, 'use client' where interactive, 2-line // ABOUTME: header, interface props (exported), cn from @heroui/react.
DetailLayout{ children /* main */, sidebar }— two-column grid (main ~65% / sidebar ~35%); single column on mobile (main then sidebar); collapses to full-width sidebar when main is empty.DetailSection{ title, onEdit?, children }— sidebar card; renders a✎button that callsonEditwhen provided (omit → read-only section).DisplayField{ label, value, href?, copyable?, description? }— label + value; link styling for entity refs; copy button for IDs. Renders—for null/empty.DisplayGrid{ children }— spacing container forDisplayFields in a section.DetailTable{ title, columns, data, onAdd?, onRowClick?, emptyMessage, viewAllHref?, rowActions, loadMore? }— wrapsBaseTablefor rendering; adds the detail-page chrome: section title, optional[+]add,rowActionsvia.... Truncation requires an escape hatch: it truncates to ~10 rows with aView all <count> →link only whenviewAllHrefis set (NOT inline pagination); a table with noviewAllHrefand noloadMorerenders all its rows (so a relational table that is the page's own content, e.g. group members or gate hardware, never silently hides rows — load the full set and show it). The Activity block passesloadMoreto use aLoad morebutton instead of truncation. Row-navigation convention: a relational row links to that entity's detail page viaonRowClick(router.push('/staff/<entity>/<id>')), and inline operations (remove, adjust) live inrowActions. Every relationalDetailTableshould setonRowClickwhen the row's entity has a detail page — e.g. group members link to the location/resource, the access-gate hardware row links to the kit asset, reservations/waitlist rows link to their detail. Omit it only when there is no detail page to land on.CrudModal{ title, isOpen, onClose, onSave?, isLoading?, mode?: 'create'|'edit', isDirty?, size?, disableBackdropClose?, trigger?, children }— the default CRUD edit/create surface. HeroUI takeover modal (dimmed backdrop; click-out / Cancel / X cancels, with a discard-confirm whenisDirty). WhenonSaveis provided it renders the sharedCrudActionsfooter (Cancel + Save/Create, label followsmode); when omitted, no footer and the body owns its own actions. Standard CRUD (basic info, add item, edit metadata) uses this. Live atorganisms/modals/CrudModal.EditModalis deleted.Sheet{ title, isOpen, onClose, variant?: 'pane'|'takeover', secondaryPane?, onSave?, isLoading?, mode?, saveLabel?, isDirty?, children }— side/over panel for forms too large for a modal (large, multi-step, or two-pane).variantis width only (pane≈ 560px drawer,takeover≈ 85% / max 1400px); asecondaryPane(right column for form-independent aux content — summaries, help, related records, NOT a form-derived preview) impliestakeover. WhenonSaveis set it renders the sharedCrudActionsfooter; when omitted it is a shell whose body owns its submit (the heavy invitation creates do this, ending in a result panel). Lives atorganisms/sheets/Sheet.tsx; replacedTakeoverSheetand the never-builtEditSheet. The bespokeManageSubscriptionSheet(lockout edit) embedsLockoutForminside aSheet.CrudActions(+DiscardConfirmModal,useDiscardGuard) — the Cancel/Save footer + "Discard changes?" guard, one implementation consumed by bothCrudModalandSheetso they cannot drift. Live atorganisms/modals/CrudActions.tsx+DiscardConfirmModal.tsx+hooks/useDiscardGuard.ts.Activity{ tableName, recordId }— audit-log feed as the last main-column block; usesDetailTableinloadMoremode againstGET /admin/audit-logs?tableName=&recordId=(after the actor-name join lands). Row:<actor link> <action> <summary> · <relative time>; computes the change summary client-side fromdata/previousAttributes.
Data fetching
TanStack Query v5. Do not use @mg/api-client (stale). Pattern (match existing hooks/api/use*.ts):
const headers = await getAuthHeaders() // @/lib/...
const res = await fetch(getApiUrl(`/path?${params}`), { headers })
const json = await res.json()
return unwrapList<T>(json) // or unwrapEntity<T>(json) — from @mg/shared-utils
- Query keys:
['entity', id]for an entity,['entity-table', parentId, page, ...filters]for tables. - Mutations: invalidate-and-refetch (not optimistic) by default. On 2xx →
queryClient.invalidateQueriesfor the entity + affected table keys → success toast → close sheet. Tables fetch independently (a failed table never blanks the page — its own error+Retry). - Exception — async/webhook-mediated effects. When the server's durable state lands later via webhook (e.g. lockout end/resume, where Stripe →
subscription.updated/deletedwrites the row), a plain refetch races the webhook and reverts the UI. For these, the mutation hook'sonSuccessapplies the known result to the cache (patchReservationsCache, andpatchPaymentsByReservationwhen the same action terminalizes a payment — e.g. an immediate lockout cancel flips its uncollected payment toCANCELED) and callsinvalidateQueries(..., { refetchType: 'none' })— marks stale for the next natural refetch without yanking the just-applied value. Keep this inside the hook (e.g.useCancelLockout), not the component, so it's used consistently. This is the only sanctioned optimistic-style path; ordinary edits stay invalidate-and-refetch.
Action toasts
Any mutation fired outside a form submit — cancel, no-show, resume, retry, resend, delete, bulk actions on a table row or a detail-page button — goes through runActionWithToast (lib/action-toast.ts), not a hand-rolled toast.loading/toast.success/toast.error block. It shows a loading toast, runs the action, and reuses the same toast id for the outcome: success (a string, or a function of the result, for messages that need the response) then an optional onSuccess for side effects that should only run once the action lands (close a modal, router.refresh(), invalidate a query); on failure, err.message (or a fallback) in the toast, plus Sentry.captureException(err, { tags: { component, action } }) — except ApiMutationError with status < 500, an expected 4xx already surfaced to the user, which is not captured.
useServerFieldErrors stays the pattern for create/edit forms with field-level validation (see below) — those need per-field error mapping, not just a toast. runActionWithToast is for actions that either fully succeed or fail as a unit.
Sonner's toast.promise is retired — do not reintroduce it. In sonner 2.x it returns a non-thenable, so awaiting it never waits for the underlying promise; several call sites ran their post-action cleanup unconditionally before this migration.
Sheets & forms
RHF + zodResolver. Zod 4 needs the resolver cast (match existing): resolver: zodResolver(schema as any) as Resolver<FormData>. Reuse selectors + FormFieldGroup.
Server-side validation errors: the API returns { success:false, error, message, status, details:[{path,message}] }. Map with convertBackendErrorsToValidationErrors(data) → a Record<string,string|string[]> held in component state and passed down as an errors prop (the house pattern — not RHF setError). On 5xx/network → keep sheet open, form-level toast, preserve input.
Edit vs create field parity (Wave 1 and forward): edit modals expose the full create field set. A middle-path create collects only the required fields and lands on the detail page; the per-pane edit modals then fill the rest. The create form is not a superset of the edit form -- they carry the same fields. Editing an identity-bearing field (location, scope type, group type) writes the new value to the record but does NOT regenerate the mgId. The identifier is stable after creation regardless of field changes; this matches the pre-existing groupType-edit behavior and is the intended contract for all group and gate entities.
Create-flow conventions (apply to every Create<Entity>Modal):
- Container/scope: a create is a
CrudModalinmode="create"(managed footer viaonSave); a heavy create that ends in a result panel omitsonSaveand the body owns its actions. Middle-path creates collect the required fields thenrouter.pushto the new entity's detail page on success (per Surfaces Rule 2). - Mutation hook MUST throw
ApiMutationError. On a non-ok response the create/update hook doesthrow new ApiMutationError(result?.message ?? '…', response.status, result)— never a bareError— and returnsunwrapEntity(result)so the modal gets{ id }for the redirect. A bareErrorsilently breaks 4xx field mapping (this regressed twice:useCreateResource,useCreateStaffTour). - Error handling =
useServerFieldErrors+ always-capture-the-unexpected. In the catch: a handled 4xx field error maps to fields (applyApiError/ the field-error path) with no Sentry; any unexpected failure (5xx, network, unmapped) mustSentry.captureException(err, { tags: { component, action } })before the toast. Never swallow a create/edit failure silently and never leave it unreported. A create/edit that has no field-level errors to map (just a pass/fail save) usesrunActionWithToastinstead of hand-rolling this.
Roles / affordance gating
Server page computes role flags and passes them as props to the client component (pattern: staff/credits/[id]/page.tsx → isAdmin/canMutate). Affordances hide (not disable) when the viewer lacks the role; mutation auth is enforced server-side regardless.
Open (Phase 1, User): verifySession() exposes only global isStaff/isAdmin — there is no per-location scoped check (isStaffOrAboveAt(locationId)) wired in apps/web yet, though the API-side helper exists. Decide when building User edit-gating: add a scoped check to lib/dal.ts or a server action. Phase-0 components don't need it (they take booleans as props).
See also
- [[features/staff-ui-redesign/spec|staff-ui-redesign spec]] · [[features/staff-ui-redesign/plan|plan]]
- [[frontend/components|Components]] · [[frontend/data-fetching|Data Fetching]] · [[frontend/staff-pages|Staff Pages]]