# Internal admin — detail page pattern

## Before using this pattern

Surface rules: read
`node_modules/@spark-web/design-system/patterns/internal-admin/CLAUDE.md` in
full first — its rules all apply here.

## What this pattern is

A full page layout for displaying the detail view of a single record in an
internal admin interface. The page shows structured data and contextual
sub-tables in a two-column layout, with record-level actions surfaced through a
header dropdown.

## When to use this pattern

Any single-record detail/profile view — the registry
(`node_modules/@spark-web/design-system/patterns/CLAUDE.md`) owns surface and
feature-type classification.

---

## Component docs to read

Read these before implementing — they own the component-level rules:

- `node_modules/@spark-web/action-dropdown/CLAUDE.md` — dropdown construction,
  ordering, hide vs. disable
- `node_modules/@spark-web/modal-dialog/CLAUDE.md` — ContentDialog API, admin
  modal sizing, destructive modal anatomy
- `node_modules/@spark-web/data-table/CLAUDE.md` — DataTable API, loading/empty
  states, expandable rows
- `node_modules/@spark-web/tabs/CLAUDE.md` — Tabs API, internal-admin background
  override, null guard
- `node_modules/@spark-web/section-card/CLAUDE.md` — SectionCard API
- `node_modules/@spark-web/section-header/CLAUDE.md` — SectionHeader API, for
  SectionCard headers
- `node_modules/@spark-web/badge/CLAUDE.md` — status tone mapping
- `node_modules/@spark-web/columns/CLAUDE.md` — responsive two-column layout
- `node_modules/@spark-web/box/CLAUDE.md` — flex layout utilities
- `node_modules/@spark-web/stack/CLAUDE.md` — vertical stacking and gap
- `node_modules/@spark-web/heading/CLAUDE.md` — detail-page H1
- `node_modules/@spark-web/alert/CLAUDE.md` — page-level feedback Alert

---

## Page structure

```
Outer wrapper Stack              paddingX="xlarge" paddingY="xxlarge" gap="xlarge"
  Header Box                     spaceBetween row: [Heading level="1" + Badge] | [ActionDropdown]
  Page-level feedback Alert      conditional — only for inline (non-modal) action feedback
  Modals                         all ContentDialog modals declared here, controlled by openModal state
  Content Columns                template=[1,1] gap="xlarge" collapseBelow="desktop"
    Left column Stack            gap="xlarge" — primary record fields + primary sub-tables
    Right column Stack           gap="xlarge" — secondary/contextual sections
      SectionCard (per section)
        DataTable                PAGE_SIZE=5 items; see node_modules/@spark-web/data-table/CLAUDE.md
        TablePagination          consumer-supplied — only when total > PAGE_SIZE
```

---

## Section 1 — Outer wrapper

```tsx
<Stack height="full" paddingX="xlarge" paddingY="xxlarge" gap="xlarge">
```

All spacing uses Spark tokens. This is distinct from list-page spacing
(`padding="large"` on a neutral-background Stack) — do not mix the two.

---

## Section 2 — Page header

```tsx
<Box
  display="flex"
  flexDirection={{ mobile: 'column', tablet: 'row' }}
  justifyContent={{ tablet: 'spaceBetween' }}
  gap="medium"
>
  <Box
    display="flex"
    flexDirection={{ mobile: 'columnReverse', tablet: 'row' }}
    alignItems={{ mobile: 'start', tablet: 'center' }}
    gap={{ mobile: 'medium', tablet: 'small' }}
  >
    <Heading level="1">{recordTitle}</Heading>
    <Badge tone={statusTone}>{statusLabel}</Badge>
  </Box>

  {actions.length > 0 && (
    <Box css={{ minWidth: 130 }}>
      <ActionDropdown label="Actions" actions={actions} />
    </Box>
  )}
</Box>
```

Rules:

- Status badge follows the heading — never precedes it
- Only render `ActionDropdown` when `actions.length > 0`
- No action buttons directly in the header — always use `ActionDropdown`

---

## Section 3 — Actions dropdown

See `node_modules/@spark-web/action-dropdown/CLAUDE.md` for full API and
ordering rules.

Page-level decisions:

- **Order**: non-destructive actions first, restore-type actions second,
  `tone: 'critical'` destructive actions always last
- **Hide vs. disable**: hide actions permanently unavailable for the current
  record state (conditional spread); disable actions temporarily unavailable
  (e.g. mutation in-flight)
- **Direct vs. modal**: direct actions (password reset, restore, activate) call
  the mutation inline and surface feedback via page-level `actionStatus` Alert;
  destructive actions (delete, suspend) always open a `ContentDialog` modal
  first

---

## Section 4 — Page-level feedback

```tsx
{
  actionStatus && (
    <Alert tone={actionStatus.isSuccessful ? 'positive' : 'critical'}>
      <Text>{actionStatus.message}</Text>
    </Alert>
  );
}
```

State shape: `{ isSuccessful: boolean; message: string } | undefined`

Rendered between the header and content columns. Only used for direct inline
mutations. Modal confirmations own their own error Alert inside the
`ContentDialog` — see `node_modules/@spark-web/modal-dialog/CLAUDE.md`.

---

## Section 5 — Confirmation modals

See `node_modules/@spark-web/modal-dialog/CLAUDE.md` for ContentDialog API,
sizing, form-in-modal anatomy, and the full destructive modal pattern.

Declare all modals in the component JSX, controlled by a single `openModal`
state union:

```ts
const [openModal, setOpenModal] = useState<'delete' | 'suspend' | null>(null);
```

```tsx
{
  openModal === 'delete' && recordId && (
    <DeleteModal
      isOpen
      onToggle={() => setOpenModal(null)}
      recordId={recordId}
      onSuccess={() => {
        refetch();
        setOpenModal(null);
      }}
    />
  );
}
```

---

## Section 6 — Content columns

```tsx
<Columns gap="xlarge" template={[1, 1]} collapseBelow="desktop">
  <Stack gap="xlarge">{/* left column */}</Stack>
  <Stack gap="xlarge">{/* right column */}</Stack>
</Columns>
```

Always equal columns (`template={[1, 1]}`), always collapse below desktop.

---

## Section 7 — Section cards

Each content section is wrapped in a `SectionCard` from
`@spark-web/section-card`, composed with `SectionHeader` from
`@spark-web/section-header` for the card header:

```tsx
import { SectionCard } from '@spark-web/section-card';
import { SectionHeader } from '@spark-web/section-header';

<SectionCard header={<SectionHeader label="Section Title" />}>
  {/* section content */}
</SectionCard>;
```

| Prop       | Type        | Notes                                                   |
| ---------- | ----------- | ------------------------------------------------------- |
| `children` | `ReactNode` | Required — main content area                            |
| `header`   | `ReactNode` | Optional — renders above the content; use SectionHeader |
| `footer`   | `ReactNode` | Optional — rendered below a Divider                     |

The header text (`label`), optional `action`, and optional `controls` belong to
`SectionHeader` — see `node_modules/@spark-web/section-header/CLAUDE.md`.

Return `null` for sections conditionally hidden — never render an empty card.

Working in portal-hub? Apply the substitutions in
`node_modules/@spark-web/design-system/patterns/internal-admin/portal-hub.md`.

---

## Section 8 — Section data tables

See `node_modules/@spark-web/data-table/CLAUDE.md` for DataTable API, column
definitions, and loading/empty state props.

Detail-page-specific rules (differ from list pages):

- **`PAGE_SIZE = 5`** — always 5 items per section table (not 20 like list
  pages)
- **Pagination threshold**: render `TablePagination` only when
  `total > PAGE_SIZE`. `TablePagination` is NOT a spark-web component (COMPONENT
  GAP) — build a placeholder from `@spark-web/box` + `@spark-web/button`
  (prev/next + "Show N of M") and flag it for product design review before
  production. Props: `total`, `pageSize`, `current`, `dataShowing`,
  `onChange(page)`
- **Reset page**: reset to 1 when the record context changes (e.g. `userId`)

```tsx
const PAGE_SIZE = 5;
const [page, setPage] = useState(1);

useEffect(() => {
  setPage(1);
}, [recordId]);

// Client-side pagination — fetch all, slice
const allItems = data?.items ?? [];
const total = allItems.length;
const pageItems = allItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);

// Server-side pagination — skip/take API
const { data } = useQuery({ skip: (page - 1) * PAGE_SIZE, take: PAGE_SIZE });
const total = countData?.count ?? 0;
const pageItems = data?.items ?? [];
```

```tsx
<Stack gap="large">
  <DataTable
    items={pageItems}
    columns={columns}
    isLoading={isLoading}
    emptyState={
      <Text tone="muted" size="small" align="center">
        No items.
      </Text>
    }
  />
  {/* COMPONENT GAP: TablePagination — see the pagination rules above */}
  {total > PAGE_SIZE && (
    <TablePagination
      total={total}
      pageSize={PAGE_SIZE}
      dataShowing={pageItems.length}
      onChange={setPage}
      current={page}
    />
  )}
</Stack>
```

---

## Section 9 — Tabbed sections

See `node_modules/@spark-web/tabs/CLAUDE.md` for the Tabs API, dynamic tab
construction, the required internal-admin background override, and the null
guard pattern.

Use tabs when a section has multiple sub-views (e.g. Email / SMS history). Each
tab panel follows the same PAGE_SIZE=5 and pagination rules as Section 8.

---

## Structural skeleton

```tsx
<Stack height="full" paddingX="xlarge" paddingY="xxlarge" gap="xlarge">
  {/* Header */}
  <Box
    display="flex"
    flexDirection={{ mobile: 'column', tablet: 'row' }}
    justifyContent={{ tablet: 'spaceBetween' }}
    gap="medium"
  >
    <Box
      display="flex"
      flexDirection={{ mobile: 'columnReverse', tablet: 'row' }}
      alignItems={{ mobile: 'start', tablet: 'center' }}
      gap={{ mobile: 'medium', tablet: 'small' }}
    >
      <Heading level="1">{recordTitle}</Heading>
      <Badge tone={statusTone}>{statusLabel}</Badge>
    </Box>
    {actions.length > 0 && (
      <Box css={{ minWidth: 130 }}>
        <ActionDropdown label="Actions" actions={actions} />
      </Box>
    )}
  </Box>

  {/* Page-level feedback — direct actions only */}
  {actionStatus && (
    <Alert tone={actionStatus.isSuccessful ? 'positive' : 'critical'}>
      <Text>{actionStatus.message}</Text>
    </Alert>
  )}

  {/* Modals */}
  {openModal === 'delete' && recordId && (
    <DeleteModal
      isOpen
      onToggle={() => setOpenModal(null)}
      recordId={recordId}
      onSuccess={() => {
        refetch();
        setOpenModal(null);
      }}
    />
  )}

  {/* Content */}
  <Columns gap="xlarge" template={[1, 1]} collapseBelow="desktop">
    <Stack gap="xlarge">
      <SectionCard header={<SectionHeader label="Details" />}>
        {/* fields */}
      </SectionCard>
    </Stack>
    <Stack gap="xlarge">
      <SectionCard header={<SectionHeader label="History" />}>
        {/* table + pagination */}
      </SectionCard>
    </Stack>
  </Columns>
</Stack>
```

---

## Documented exceptions summary

These raw CSS values are required and have no Spark token equivalent. Use them
exactly as written.

| Value           | Property                     | Reason                                                               |
| --------------- | ---------------------------- | -------------------------------------------------------------------- |
| `minWidth: 130` | ActionDropdown container Box | Prevents dropdown collapse on narrow content; no Spark minWidth prop |

---

## Do NOTs

- NEVER apply list-page spacing to a detail page — outer wrapper uses
  `paddingX="xlarge" paddingY="xxlarge"`, not `padding="large"`
- NEVER place a destructive action before non-destructive actions in the
  dropdown
- NEVER call a destructive mutation directly from a dropdown item — open a modal
- NEVER surface modal errors as page-level Alerts — see
  `node_modules/@spark-web/modal-dialog/CLAUDE.md`
- NEVER use `PAGE_SIZE = 20` on section tables — always 5 on detail pages
- NEVER render `TablePagination` when `total <= PAGE_SIZE`
- NEVER render an empty `SectionCard` for hidden sections — return `null`
- NEVER render `ActionDropdown` when `actions` is empty — gate with
  `actions.length > 0`
- NEVER render `Tabs` inside `SectionCard` without the background override — see
  `node_modules/@spark-web/tabs/CLAUDE.md`
- NEVER use `Container` as the outer page wrapper

---

## Validation checklist

Run before marking any detail-page task complete and fix every violation; the
uplift protocol in `node_modules/@spark-web/design-system/CLAUDE.md` runs this
list PASS/FAIL against existing pages.

1. Outer wrapper is
   `Stack height="full" paddingX="xlarge" paddingY="xxlarge" gap="xlarge"` — not
   list-page spacing, not Container.
2. Header: `Heading level="1"` first, `Badge` after it; ActionDropdown only when
   `actions.length > 0`; no inline action buttons in the header.
3. Dropdown action order: non-destructive → restore-type → critical destructive
   last; permanently unavailable actions hidden, temporarily unavailable actions
   disabled.
4. Destructive actions open a ContentDialog modal; direct actions surface
   feedback via the page-level Alert between header and content — modal errors
   render inside the modal, never as page-level Alerts.
5. All modals declared in JSX, controlled by a single `openModal` union state.
6. Content is `Columns template={[1, 1]} gap="xlarge" collapseBelow="desktop"`.
7. Every content section is wrapped in a SectionCard (or the documented
   consumer-overlay equivalent); conditionally hidden sections return `null`.
8. Section tables use `PAGE_SIZE = 5`; pagination only when `total > PAGE_SIZE`;
   page resets when the record context changes.
9. Tabs inside SectionCard use the internal-admin background override per
   `node_modules/@spark-web/tabs/CLAUDE.md`.
10. No raw CSS beyond the Documented exceptions table; every component is
    `@spark-web/*` or an explicit consumer-overlay substitute; gaps flagged with
    `// COMPONENT GAP:`.
