Skip to content

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=1 Add action; /new routes 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 / AccessGateDetail follow the UserDetail shape — EntityHeader (icon + breadcrumb + status + delete in the dropdown), DetailLayout sidebar of DisplayFields edited via the DetailSection pencil (which opens Update[Entity]Modal), and ConfirmDeleteModal. 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 a DetailTable with 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 StaffFormLayout forms (LocationGroupForm / ResourceGroupForm / AccessGateForm) + tests are deleted; access-gate hardware placements were extracted to AccessGatePlacementsManager (molecule) first. Audit Activity is 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 calls onEdit when 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 for DisplayFields in a section.
  • DetailTable { title, columns, data, onAdd?, onRowClick?, emptyMessage, viewAllHref?, rowActions, loadMore? }wraps BaseTable for rendering; adds the detail-page chrome: section title, optional [+] add, rowActions via .... Truncation requires an escape hatch: it truncates to ~10 rows with a View all <count> → link only when viewAllHref is set (NOT inline pagination); a table with no viewAllHref and no loadMore renders 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 passes loadMore to use a Load more button instead of truncation. Row-navigation convention: a relational row links to that entity's detail page via onRowClick (router.push('/staff/<entity>/<id>')), and inline operations (remove, adjust) live in rowActions. Every relational DetailTable should set onRowClick when 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 when isDirty). When onSave is provided it renders the shared CrudActions footer (Cancel + Save/Create, label follows mode); when omitted, no footer and the body owns its own actions. Standard CRUD (basic info, add item, edit metadata) uses this. Live at organisms/modals/CrudModal. EditModal is 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). variant is width only (pane ≈ 560px drawer, takeover ≈ 85% / max 1400px); a secondaryPane (right column for form-independent aux content — summaries, help, related records, NOT a form-derived preview) implies takeover. When onSave is set it renders the shared CrudActions footer; when omitted it is a shell whose body owns its submit (the heavy invitation creates do this, ending in a result panel). Lives at organisms/sheets/Sheet.tsx; replaced TakeoverSheet and the never-built EditSheet. The bespoke ManageSubscriptionSheet (lockout edit) embeds LockoutForm inside a Sheet.
  • CrudActions (+ DiscardConfirmModal, useDiscardGuard) — the Cancel/Save footer + "Discard changes?" guard, one implementation consumed by both CrudModal and Sheet so they cannot drift. Live at organisms/modals/CrudActions.tsx + DiscardConfirmModal.tsx + hooks/useDiscardGuard.ts.
  • Activity { tableName, recordId } — audit-log feed as the last main-column block; uses DetailTable in loadMore mode against GET /admin/audit-logs?tableName=&recordId= (after the actor-name join lands). Row: <actor link> <action> <summary> · <relative time>; computes the change summary client-side from data/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.invalidateQueries for 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/deleted writes the row), a plain refetch races the webhook and reverts the UI. For these, the mutation hook's onSuccess applies the known result to the cache (patchReservationsCache, and patchPaymentsByReservation when the same action terminalizes a payment — e.g. an immediate lockout cancel flips its uncollected payment to CANCELED) and calls invalidateQueries(..., { 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 CrudModal in mode="create" (managed footer via onSave); a heavy create that ends in a result panel omits onSave and the body owns its actions. Middle-path creates collect the required fields then router.push to 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 does throw new ApiMutationError(result?.message ?? '…', response.status, result)never a bare Error — and returns unwrapEntity(result) so the modal gets { id } for the redirect. A bare Error silently 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) must Sentry.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) uses runActionWithToast instead 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.tsxisAdmin/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]]