Staff Entity Detail Page Redesign
Overview
Replace the current mixed edit/display detail pages across every entity under apps/web/src/app/(app)/staff/ with detail views that separate display from edit, in a Stripe-inspired two-column layout. Values render as labels-and-text only — never as inline form inputs mixed with display fields. Mutations route through Vaul drawer sheets and an actions menu. Create flows also use sheets, eliminating /new routes.
This spec describes the contract — invariants, component shapes, per-entity content maps, backend data requirements. Design rationale, wireframes, header examples, and visual layout patterns live in design.md.
Scope
In scope: all detail-bearing routes under apps/web/src/app/(app)/staff/. The redesign applies uniformly — there is no "old pattern" tail.
Originally specified (canonical content maps below): Lockout, User, Location, Resource, Asset, Organization.
Newly in scope (content maps to be added per-entity in plan.md):
- Mutable: Access Code, Access Gate, Credit Package, Credit, Inquiry, Location Group, Migration, Referral Code, Resource Group, Waitlist
- Read-only / financial: Payment, Charge, Ledger entry, Lock, Studio Reference (treated per Read-only invariant below)
Reservation as a single shell
Reservations are not split per type. A single Reservation detail page uses one DetailLayout shell and branches its content map on reservation.type. The ReservationType enum (from apps/api/prisma/schema.prisma) is:
| Type | Main column | Sidebar additions |
|---|---|---|
MONTHLY (Lockout) |
Payments · Transfers · Access Codes (multiple) | Subscription section |
HOURLY |
Payment · Access Code (single) | Booking window |
TOUR |
Tour notes · Outcome | Scheduled at, Tour guide |
"Lockout" is shorthand for
Reservation.type === 'MONTHLY'. Note this is distinct fromResourceType.STUDIO_MONTHLY(a resource attribute). A Lockout always involves aSTUDIO_MONTHLYresource, but the branching driver in this redesign is the reservation type.
Header status chip names the variant (Lockout / Hourly / Tour). The lockout subroute (reservations/lockout/) collapses into the unified reservations/[id]/ route.
Read-only / financial entities
Payments, charges, ledger entries, locks, and studio references render through the same DetailLayout + DetailHeader + sidebar primitives, but:
- No
[Edit X]button, no✎on sidebar sections. - The actions menu may still appear for record-acting operations (
Refund,Void,Resend receipt,View in Stripe →) — these act on the immutable record, they do not edit it. - Sidebar fields are all
noneeditable.
Single-column fallback for sparse entities
DetailLayout may render single-column when the main column has nothing to show (e.g., a referral code with no usage history). The sidebar promotes to full width. This is part of the same component contract — no separate SingleColumnDetail shape.
Invariants
- Display is separated from edit. Detail surfaces render values as labels-and-text only. No HeroUI
Input/Select/Textarea/Autocompleteis mixed with display fields on a detail page. Mutations route through sheets or the actions menu — never through form inputs embedded inside the display. - All edits and creates use a single
EditSheet(Vaul drawer, already installed viavaul: ^1.1.2). - Two-column desktop, single-column mobile. Main column ≈ 65% (activity / related entities), sidebar ≈ 35% (entity properties). Sidebar stacks below main on mobile. When the main column has no content (sparse entities), the layout collapses to a single full-width sidebar — see Single-column fallback.
- Status badge is inline with the title, not on the right side competing with header buttons. Each entity's content map declares its
statusfield source (e.g.,resource.status); the badge reads the enum and applies a deterministic color via a single sharedstatusColor(entity, status)helper. Adding a new enum value does not require a spec edit. - Three edit affordances per page:
[Edit X]button in header → primary sheet with core editable fields.✎button on sidebar sections → focused metadata sheet for that section.[...]actions menu → rare or destructive operations (cancel, delete, pause, impersonate, "View in Stripe →").- Create = sheet, not route. List pages have
[+ Add X]opening the same sheet component in create mode. On save: POST → close sheet → redirect to new entity detail page. Existing/newroutes are removed. - MG ID surfaces in the breadcrumb for every entity. (User MG ID is added as part of this redesign — see MG ID System.)
- Insurance is a billing concern, not a resource property. Insurance fields appear on the Lockout (subscription) sidebar, never on the underlying Resource.
- Type fields are immutable post-create:
LocationType,ResourceType,AssetCategory. Section sheets must not expose them. - Read-only
Systemsection present on every entity sidebar: ID, MG ID (where applicable), Created, Updated.
MG ID System
Existing service. Surfaces in breadcrumbs and the sidebar System section for every entity that has one. Formats verified against the integration-test regex (apps/api/src/services/shared/MgIdPresence.integration.test.ts, MgIdAllocation.integration.test.ts).
| Entity | Format | Example | Source |
|---|---|---|---|
| Reservation | RSV{seq} |
RSV22 |
Global sequence |
| Transaction | TXN{seq} |
TXN89 |
Global sequence |
| Waitlist Entry | WL{seq} |
WL15 |
Global sequence |
| Asset | AST{seq} |
AST42 |
Global sequence |
| Location | {TypeCode}{seq} |
MG7 |
LOCATION_TYPE_CODE + local seq |
| Resource | {locMgId}-{TypeCode}{seq} |
MG7-SHR248 |
Location MG ID + RESOURCE_TYPE_CODE + local seq |
| Resource Group | RGP{seq} |
RGP12 |
RESOURCE_GROUP_TYPE_CODE + local seq |
| Location Group | LGP{seq} |
LGP3 |
Global sequence |
| Access Gate | {resourceMgId}-DOR{seq} |
MG7-SHR248-DOR1 |
Resource MG ID + DOR + local seq |
| User | USR{seq} |
USR42 |
Global sequence (new — backend prereq) |
design.md and the original spec showed
RSV-22(with dash). The actual format isRSV22(no dash). Same correction for all global-sequence entities.
User MG ID is a new addition. The original spec excluded users from the MG ID system. This redesign adds them so breadcrumbs, sidebar System sections, and verbal/Slack references work uniformly. Backend prerequisites the plan must include before any User-detail UI work:
- Add
User.mgIdcolumn (Prisma schema + migration). - Add
'USER'entry toMgIdServiceallocator with prefixUSR. - Add
'USR'to type-code constants. - Backfill existing users with sequential MG IDs in the migration (or via a one-shot script).
References: apps/api/src/services/shared/MgIdService.ts · packages/shared-constants/src/mg-id.ts
Sheet interaction contract
Behavior shared by every EditSheet (create or edit, primary or section).
Save flow
- User clicks Save / Update / Create. Submit button enters loading state (spinner, disabled). Other footer buttons disable.
- PATCH/POST fires.
- On 2xx: close sheet, invalidate the entity's primary query and any affected table queries (e.g., a primary edit on Resource invalidates
useResource(id)anduseResources({ locationId })), show a success toast ("<Entity> updated"/"<Entity> created"). - On 4xx with field errors: keep sheet open, surface field-level errors inline; show a generic form-level error if no field mapping.
- On 5xx / network error: keep sheet open, show form-level error toast (
"Couldn't save — try again"), preserve user input. Submit re-enabled.
This is invalidate-and-refetch, not optimistic. Optimistic updates are out of scope for sheets; if any future inline-edit affordance needs them, that surface specifies its own pattern.
Validation error rendering
- Backend returns errors as
{ errors: [{ path: 'fieldName', message: 'string' }, ...] }(existing shape fromcreateErrorResponse). Sheet mapspath→ field, sets RHF error viasetError. - Errors not mapped to a known field render as a form-level error banner at the top of the sheet body.
Sheet anchoring
| Breakpoint | Vaul direction | Size | Close affordances |
|---|---|---|---|
Desktop ≥md |
right |
fixed ~480px width | X (top-right) · Cancel button (footer) · ESC · overlay click |
Mobile <md |
bottom |
~90vh, drag handle top | X (top-right) · Cancel button (footer) · swipe-down · overlay tap |
Every dismiss vector — including swipe-down on mobile — triggers the dirty-state guard. Sticky footer holds Cancel + Save / Update / Create. No multi-detent snap points; one large detent is enough.
Concurrent edits
Last-write-wins. No version checks, no 409 handling, no polling. To give operators visibility when conflicts do happen, every entity's sidebar System section includes Updated by <user link> at <timestamp> (in addition to Updated). The user link goes to the User detail page.
Backend: primary GET endpoints must include the most recent updater's user ref in their response. If a Postgres-level updated_by column doesn't exist for an entity, the plan must add a migration to populate it (or use audit-log lookup if cheap enough — plan decides).
Dirty-state guard on close
If the form is dirty (RHF formState.isDirty), all three close vectors prompt before closing:
- Cancel button
- ESC key
- Overlay (outside) click
Prompt copy: Discard changes? with Discard (destructive) and Keep editing buttons. Clean forms close immediately without the prompt.
Detail tables
DetailTable shape ({ title, columns, data, onAdd?, emptyMessage, viewAllHref? }) describes the props. The behavior contract:
Pagination
- Default: truncated-with-link. Each table shows up to 10 rows; if more exist, the table footer shows
View all <count> →linking to the entity's filtered list page (e.g.,/staff/reservations?userId=<id>). Per-table override of the row cap is allowed. - No inline pagination, no infinite scroll on regular detail-page tables. "I need to see all of them" belongs on the list page.
- Exception: the Activity table uses a
Load morebutton instead, since audit history grows unbounded and staff often dig chronologically.
Sort and filter
- Default sort: newest first on every detail-page table (newest
createdAtor table-appropriate equivalent — e.g., upcoming first for future-dated tables like scheduled tours). - No search or filter UI inside detail-page tables. They're a glance, not a tool. If a user needs to slice the data, "View all →" goes to the list page where filtering already exists.
- Per-table sort override is allowed when "newest first" doesn't fit the column shape (e.g., Resource Groups → alphabetical).
Loading and error states
Three states per table, standardized:
- Loading: skeleton rows (3 placeholders matching column count) using
Skeletonfromapps/web/src/components/atoms/feedback/Skeleton.tsx. - Error: error message in the table body with a
Retrybutton. A failed table fetch never blanks the whole detail page — tables fetch independently. - Empty:
emptyMessageprop renders as muted text in the body.
Same triplet for the primary entity fetch:
- Loading: full-page skeleton (header + sidebar + main column placeholders).
- Error: full-page error with Retry.
- Not found: 404 page (existing convention).
Per-row actions
Every row table — on a detail page or a list page — exposes a per-row ... menu sourced from the row entity's canonical actions function.
- One definition per entity type. Each entity owns a single
rowActions(entity, viewerRoles) → MenuItem[]function (e.g.,apps/web/src/lib/entities/payment/rowActions.ts). BothDetailTableand list-page tables consume the same function for their entity type. No surface-specific overrides — a Payment row's menu on Customer → Payments is identical to its menu on/staff/payments. - Role gating lives inside the function. It filters its returned items by the role booleans passed in. UI hides items it didn't return (no disabled state).
- Click behavior: the row itself navigates to the entity's detail page; the
...menu is a separate hit target. - Destructive actions (delete, cancel) are placed last with a divider above them.
- Spec does not enumerate menus per (entity, table). Each entity's
rowActionsfile is the single source.
DetailTable props gain a required rowActions: (row) => MenuItem[] prop.
Activity table
Every detail page renders an Activity section as the last block of the main column, populated from the existing AuditLog model.
- Source:
GET /admin/audit-logs?tableName=<table>&recordId=<id>(route lives at/admin/audit-logsbut is gated byisStaffOrAbove, so visible to any staff). Plan must verify or add thetableName+recordIdfilter params. - Row format:
<actor link> <action> <field summary> · <relative time>. Impersonated actions surface as<impersonator> as <actor>. Click row →/admin/audit-logs/[id]. - Pagination:
Load morebutton (the only place this pattern is allowed). Initial 10, +10 per click. - Visibility: same as the backend — any staff.
Permissions
The redesign defers entirely to the existing role system — see roles.md and apps/api/src/utils/auth/role-check.ts. There is no new permission catalog or per-action gating layer.
Conventions detail pages must follow:
- Server pages pass role booleans down. Detail pages compute role flags from
sessionDataand passisAdmin,canMutate(or similar entity-specific flags) into the client component — same pattern asapps/web/src/app/(app)/staff/credits/[id]/page.tsx. - Affordances hide, not disable, when the user lacks the role. A
[Edit X]button, a✎section button, or an actions-menu item the user can't use is omitted from the rendered tree. - Read-all, edit-scoped. Matches the existing backend pattern: read endpoints gate on
isStaffOrAbove(scope-blind), so any staff member can view any detail page or cross-location data. Mutation endpoints gate onisStaffOrAboveAt(locationId)(orisAdmin), so location-scoped staff can only edit entities at their scoped locations. The UI mirrors this: detail pages render fully for any staff, but[Edit X]/✎/ mutating actions-menu items are hidden when the viewer's role doesn't satisfy the entity's location scope. - Mutation gating is enforced server-side. Sheet save handlers do their own role check inside the route handler; the UI gate is for affordance, not security.
Per-entity action visibility (which actions appear in which actions menu, who can see them) is derived from the existing role helpers, not enumerated here.
Frontend contract
Component shapes
File locations and the proposed directory tree live in design.md. The contract is the props and responsibility per component:
| Component | Props | Responsibility |
|---|---|---|
DetailLayout |
children (main), sidebar |
Two-column responsive grid; collapses to single column on mobile (main first, sidebar after). |
DetailHeader |
breadcrumbs, title, subtitle, status, primaryAction, actionsMenu |
3-row header: breadcrumb + buttons / title + status / subtitle. |
DetailSection |
title, onEdit?, children |
Sidebar card with optional ✎ button. Calls onEdit to open the section's sheet. |
DetailTable |
title, columns, data, onAdd?, emptyMessage, viewAllHref? |
Main column table. Optional [+] add, optional "View all →" link, row actions via .... |
DisplayField |
label, value, href?, copyable?, description? |
Label + value sidebar field. Link styling for entity refs, copy button for IDs. |
DisplayGrid |
children |
Layout container for DisplayField groups. |
EditSheet |
title, isOpen, onClose, onSave, isLoading, mode: 'create' \| 'edit', children |
Vaul drawer from right. Sticky footer (Cancel + Save / Update / Create). Same component for both modes. |
Per-entity content map
Each detail page renders the sections below. Editable column: primary = via header [Edit X] sheet · section = via sidebar ✎ · none = read-only.
Reservation (single shell, content branches on type)
ReservationType enum: MONTHLY | HOURLY | TOUR (source: apps/api/prisma/schema.prisma). One detail page handles all three; the lockout subroute (reservations/lockout/) collapses into the unified reservations/[id]/.
Common to all types
Sidebar always includes Details (Customer, Resource, Location) and System (ID, MG ID RSV-{globalSeq}, Created, Updated). Header status chip names the variant.
Type-specific content
| Type | Main column | Type-specific sidebar sections | Primary sheet fields | Actions menu |
|---|---|---|---|---|
MONTHLY (Lockout) |
Payments · Transfers · Access Codes (multiple) | Subscription (Stripe Sub link, Billing Anchor, Monthly Total + insurance breakdown, Insurance, Promo); Notes (section) |
Location, Resource, Card Price, ACH Price, Insurance, Timing (immediate | next_cycle). Proration preview via POST /lockouts/[id]/preview-transfer. |
View in Stripe → · Cancel subscription |
HOURLY |
Payment (single) · Access Code (single) | Booking (Start, End, Duration, Price); Notes (section) |
Resource, Start, End, Notes | Mark no-show · Cancel reservation · View in Stripe → |
TOUR |
Tour Notes · Outcome | Tour (Scheduled at, Tour guide, Source/referrer); Notes (section) |
Scheduled at, Tour guide, Notes | Mark no-show · Cancel tour |
The original spec referenced
STUDIO_MONTHLY— corrected here. The actual enum value isMONTHLY. Lockout-vs-not is determined bytype === 'MONTHLY'.
User
Main: Reservations · Transactions · Credit Balances · Access Codes · Waitlist.
Sidebar:
| Section | Fields | Editable |
|---|---|---|
| Profile | Name, Email (read-only), Phone, Bio | section |
| Status | Approved, Organization (link) | section |
| Billing | Stripe Customer (link), Stripe Balance | section |
| Notes | Admin notes | section |
| System | ID, MG ID, Auth ID, Created, Last Login | none |
Primary sheet fields: Name, Email (read-only), Phone, Bio, Approved status.
Actions menu: Send password reset · Impersonate · View in Stripe → · Delete user.
Location
Main: Resources · Access Gates · Images (grid) · Directions (list).
Sidebar:
| Section | Fields | Editable |
|---|---|---|
| Details | Type (immutable), Opened, Slug, Description | section |
| Address | Street, City, State, ZIP, Timezone, Coordinates, Parking | section |
| Contact | Phone, Email, Website, Manager (link) | section |
| Pricing | Monthly Rate, Hourly Rate, Peak Multiplier, Insurance settings | section |
| Integrations | Stripe Account (link), UniFi Group | section |
| System | ID, MG ID, Created, Updated | none |
Primary sheet fields: Name, Status, Description, Notes, Slug, Opened date.
Actions menu: View in Stripe → · Deactivate.
Resource
Main: Reservations · Asset Placements · Resource Groups (membership list) · Images (grid).
Sidebar:
| Section | Fields | Editable |
|---|---|---|
| Details | Type (immutable), Status, Availability, Description, Featured | primary |
| Location | Location (link), Acuity Calendar | primary |
| Physical | Dimensions, Area, Max Occupancy, Floor, Wing | section |
| Pricing | Card Price, ACH Price, Rate per Sq Ft, Peak settings | section |
| Notes | Admin notes | section |
| System | ID, MG ID, Created, Updated | none |
Primary sheet fields: Name, Status, Description, Availability, Featured, Acuity Calendar ID.
Actions menu: Delete resource.
Asset
Main: Placement History · Component Installations (when applicable).
Sidebar:
| Section | Fields | Editable |
|---|---|---|
| Details | Category (immutable), Subcategory, Status, Condition, Consumable, Description | primary |
| Equipment | Manufacturer, Model, Serial Number | section |
| Purchase | Date, Price, Vendor, Order Number | section |
| Warranty | Expiration, Notes | section |
| System | ID, MG ID, Created, Updated | none |
Primary sheet fields: Name, Status, Condition, Description, Consumable flag.
Actions menu: Delete asset.
Organization
Main: Members (users matching email domain) · Credit Balances.
Sidebar:
| Section | Fields | Editable |
|---|---|---|
| Details | Name, Email Domain | primary |
| Owner | Owner (link) | section |
| System | ID, Created, Updated | none |
Primary sheet fields: Name, Email Domain.
Actions menu: Delete organization.
Pending content maps
The entities below are in scope but their content maps are deferred to plan.md (or per-entity sub-specs). Each map must follow the template above (sections + editable column + primary sheet fields + actions menu).
| Entity | Route | Mutability | Notes |
|---|---|---|---|
| Access Code | staff/access-codes/[id] |
mutable | Per-reservation (already on Reservation main column); standalone view too |
| Access Gate | staff/access-gates/[id] |
mutable | UniFi device binding |
| Credit Package | staff/credit-packages/[id] |
mutable | Pricing + active flag |
| Credit | staff/credits/[id] |
mutable | Balance ledger; consider read-only treatment |
| Inquiry | staff/inquiries/[id] |
mutable | Status transitions (new → contacted → converted) |
| Location Group | staff/location-groups/[id] |
mutable | Sets of locations for grouping |
| Migration | staff/migrations/[id] |
mutable | Tenant migration submissions |
| Referral Code | staff/referral-codes/[id] |
mutable | Code, partner, payout config |
| Resource Group | staff/resource-groups/[id] |
mutable | Sets of resources for floor/wing |
| Waitlist Entry | staff/waitlist/[id] and staff/waitlist/invitations/[id] |
mutable | Two related sub-entities |
| Payment | staff/payments/[id] |
read-only | Per Read-only invariant |
| Charge | staff/charges/[id] |
read-only | Per Read-only invariant |
| Ledger entry | staff/ledger/[id] |
read-only | Per Read-only invariant |
| Lock | staff/locks/[id] |
read-only | Per Read-only invariant |
| Studio Reference | staff/studio-reference/[id] |
read-only | Reference data; verify route shape |
Reused field components
These existing molecules are used inside sheets. Do not rewrite — sheets reuse Zod schemas and selectors as-is:
LocationSelector · ResourceSelector · UserSelector · InsuranceSelector · CouponSelector · AccessCodeSelector · RecipientInput · AddressAutocomplete · AssetSelector · FormFieldGroup · PeakHours
Backend contract
Each detail page hydrates from a single primary endpoint plus per-table secondary endpoints. Tables load separately so they paginate independently.
Primary endpoints — required relations
| Entity | Endpoint | Must include |
|---|---|---|
| Lockout | GET /reservations/[id] |
reservation + customer + resource + location + payments[] + stripeSubscriptionId (string only — live Stripe data comes from subscription-metadata, see Live Stripe data is a separate fetch) |
| User | GET /users/[id] |
user + organization (if any) + roles[] |
| Location | GET /locations/[id] |
location + manager + Stripe account ref |
| Resource | GET /resources/[id] |
resource + location + resource group memberships[] |
| Asset | GET /assets/[id] |
asset + current placement (if any) |
| Organization | GET /organizations/[id] |
organization + owner |
View in Stripe URL helper
View in Stripe → items across every actions menu use a single shared helper. Avoids 15+ slightly-wrong implementations.
- Location:
packages/shared-utils/src/stripe/dashboardUrl.ts(or co-located withStripeService— plan decides). - Signature:
stripeDashboardUrl(resource: 'customer' | 'subscription' | 'payment' | 'invoice' | 'price' | ..., id: string): string - Output:
https://dashboard.stripe.com/<test/?><resource-path>/<id>— thetest/segment appears iff the env is non-prod (env source: same flag the existingStripeServiceuses). - All transfers are on a single platform account; no Connect account routing in the URL. Live transfers to other Connect accounts read through that platform.
The actions menu wires View in Stripe → to window.open(stripeDashboardUrl(...), '_blank'). No external resolution needed in the backend.
Action endpoints policy
Every action listed in any actions menu must have a working backend endpoint at ship time. Items without an endpoint are stripped from the UI; no "Coming soon" tooltips, no disabled placeholders. Backend gaps stay in Future actions backlog until their endpoints land in a follow-up spec.
Future actions backlog
Stripped from launch menus pending backend work. Each gets re-added once its endpoint exists.
| Action | Entity | Notes |
|---|---|---|
| Pause billing | Reservation (MONTHLY) | No /lockouts/[id]/pause endpoint today |
| Send payment link | Reservation (MONTHLY) | No payment-link generator endpoint |
| Refund | Payment / Reservation | No refund endpoint |
| Resend receipt | Payment | No resend-receipt endpoint |
| Convert tour to lockout | Reservation (TOUR) | No conversion endpoint |
| Duplicate location | Location | No duplicate endpoint |
| Duplicate resource | Resource | No duplicate endpoint |
| Duplicate asset | Asset | No duplicate endpoint |
| Export resources | Location | No export endpoint |
| Mark tour completed | Reservation (TOUR) | Probably PATCH status; verify in plan |
Live Stripe data is a separate fetch
Primary entity GETs return DB-stored columns only — they do not call Stripe. Display values that require a live Stripe API call (insurance breakdown, promo code display, current period dates, default payment method) live behind dedicated endpoints (e.g., GET /reservations/[id]/subscription-metadata, GET /users/[id]/stripe-balance). Detail pages render the DB-known parts immediately and lazy-fetch the Stripe-dependent parts. A failed Stripe fetch does not blank the page — it surfaces an inline error in the affected sidebar section, per the Loading and error states contract.
Confirmed table endpoints (existing)
| Endpoint | Used by |
|---|---|
GET /reservations/[id]/subscription-metadata |
Lockout subscription card |
GET /reservations/[id]/access-codes |
Lockout access codes table |
POST /lockouts/[id]/preview-transfer |
Lockout primary edit sheet preview |
GET /resources?locationId=X |
Location resources table |
GET /access-gates?locationId=X |
Location access gates table |
GET /asset-placements?resourceId=X |
Resource asset placements table |
GET /users/[id]/stripe-balance |
User billing card |
GET /locations/[id]/direction-steps |
Location directions list |
GET /locations/[id]/images |
Location images grid |
Endpoints to verify or add
The frontend content map references the following queries that may not exist in the needed shape today. The plan must enumerate which exist, which need parameter changes, and which are new:
- Transfers per reservation — Lockout main column needs a transfers table. Confirm shape:
GET /reservations/[id]/transfersor surface from existing payment/transaction endpoints. - User reservations / transactions / credit balances / access codes / waitlist — User detail needs five tables filtered by
userId. Some hooks exist (usePayments,useReservations,useCredits); confirm each acceptsuserIdand returns the staff-needed fields. - Cross-reservation access codes for a user — Distinct from per-reservation access codes; aggregation may not exist.
- Resource reservations — Verify
GET /reservations?resourceId=Xreturns the staff-needed fields. - Asset placement history —
asset-placements?assetId=Xmay need anincludeRemovedflag and removed-date in response. - Asset component installations — Shape unclear; confirm before relying on it.
- Organization members — Users matching email domain; may need
GET /organizations/[id]/members. - Organization credit balances —
GET /organizations/[id]/credit-balancesor similar; verify.
Mutation endpoints
Sheets PATCH to existing entity endpoints (PATCH /resources/[id], etc.) with partial payloads scoped to the section. Plan must enumerate any handlers that don't accept the full field set the section sheet exposes (notably Location section sheets, which currently route through one large form).
Create sheets POST to existing collection endpoints (POST /resources, etc.) and on success redirect to the new detail page.
Testing strategy
Per-entity acceptance criteria the plan must verify:
- Render. Detail page renders two-column on desktop, single column on mobile. MG ID present in breadcrumb (where applicable).
- Primary edit.
[Edit X]opens sheet pre-filled with current values. Save mutates and refreshes view. - Section edit. Each
✎opens scoped sheet. Save mutates only the section's fields. - Actions menu. Each item routes correctly (modal confirm, external link, mutation).
- Create flow.
[+ Add X]on list opens sheet in create mode. Save POSTs and redirects to new detail page. - Empty states. Tables show empty messages; sidebar fields show
—for null values. - Cross-entity links. Sidebar entity links navigate correctly (customer → user, resource → resource).
- Regressions. Existing test suites continue to pass.
Known gaps
Spec leads code. Tracked in plan.md.
Open items the plan must resolve:
- Author per-entity content maps for the newly in-scope entities (see Scope). Each follows the same template as the original 6.
- Confirm or design the endpoints flagged in Endpoints to verify or add.
- Decide whether to split implementation into separate frontend / backend plans, or combine.
- Confirm entity rollout ordering (design.md proposes Resource as the prototype because it has variety without subscription complexity). Reservation's three-type branching is a candidate second prototype.
StaffFormLayout/FormSection/LabeledValueare removed once all staff entities migrate. Until then they remain available to in-flight migrations.