# Internal admin — details page pattern

## Before using this pattern

Read node_modules/@spark-web/design-system/patterns/internal-admin/CLAUDE.md
fully before implementing this pattern. The interaction rules, badge tone
mapping, and action dropdown rules defined there all apply to this pattern.

## 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

Use this pattern when the PRD describes any of the following:

- A single-record view reachable by clicking a row on a list page
- A page showing a record's fields, history, or associated sub-records
- The words "detail", "profile", "view", "record page", or "user/vendor page"

---

## Component docs to read

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

- `packages/action-dropdown/CLAUDE.md` — dropdown construction, ordering, hide
  vs. disable
- `packages/modal-dialog/CLAUDE.md` — ContentDialog API,
  ACCREDITATION_MODAL_CSS, destructive modal anatomy
- `packages/data-table/CLAUDE.md` — DataTable API, loading/empty states,
  expandable rows
- `packages/tabs/CLAUDE.md` — Tabs API, internal-admin background override, null
  guard
- `packages/section-card/CLAUDE.md` — SectionCard API (note: portal-hub uses a
  custom wrapper; see Section 6 below)
- `packages/badge/CLAUDE.md` — status tone mapping
- `packages/columns/CLAUDE.md` — responsive two-column layout
- `packages/box/CLAUDE.md` — flex layout utilities
- `packages/stack/CLAUDE.md` — vertical stacking and gap

---

## 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 data-table/CLAUDE.md
        TablePagination          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 className={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 `packages/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 `packages/modal-dialog/CLAUDE.md`.

---

## Section 5 — Confirmation modals

See `packages/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`.

**Portal-hub uses a custom wrapper** at `@components/PortalTable/SectionCard`
(not `@spark-web/section-card`). The API differs:

```tsx
import { SectionCard } from '@components/PortalTable/SectionCard';

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

| Prop       | Type        | Notes                                 |
| ---------- | ----------- | ------------------------------------- |
| `label`    | `string`    | Required — card header text           |
| `action`   | `ReactNode` | Optional — right-side header control  |
| `controls` | `ReactNode` | Optional — additional header controls |

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

---

## Section 8 — Section data tables

See `packages/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`
- **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>
    }
  />
  {total > PAGE_SIZE && (
    <TablePagination
      total={total}
      pageSize={PAGE_SIZE}
      dataShowing={pageItems.length}
      onChange={setPage}
      current={page}
    />
  )}
</Stack>
```

---

## Section 9 — Tabbed sections

See `packages/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 className={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 label="Details">{/* fields */}</SectionCard>
    </Stack>
    <Stack gap="xlarge">
      <SectionCard label="History">{/* table + pagination */}</SectionCard>
    </Stack>
  </Columns>
</Stack>
```

---

## 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 `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
  `tabs/CLAUDE.md`
- NEVER use `Container` as the outer page wrapper
