# Internal admin — portal-hub consumer overlay

Consumer overlay for the **portal-hub** repo (see Consumer overlays in
`node_modules/@spark-web/design-system/patterns/CLAUDE.md`) — it overrides the
internal-admin pattern files and component-level CLAUDE.md files ONLY where it
explicitly says so; every other rule applies to portal-hub unchanged.

---

## Filter fields (overrides list-page.md Section 4)

`@spark-web/multi-select` is not available to app code in portal-hub (it is a
dependency of the internal ui-components package only). Filter and search fields
come from `@brighte/ui-components` instead. This override applies ONLY to
component substitutions; all other Section 4 rules — search input always first,
filter ordering broadest to most specific, hidden labels, omit the section when
no filtering exists — apply unchanged. The substitutions:

- Multi-select filter dropdowns use `MultiSelectField` from
  `@brighte/ui-components`
- Single-select filter dropdowns use `SelectField` from `@brighte/ui-components`
- The search input uses `TextInputField` from `@brighte/ui-components` with the
  same start-adornment `SearchIcon`

Always pass `fieldProps={{ labelVisibility: 'hidden' as const }}` on both
`MultiSelectField` and `SelectField` — define it as a constant outside the
component to avoid re-renders:

```tsx
const FieldProps = { labelVisibility: 'hidden' as const };

<Columns gap="large" collapseBelow="desktop">
  <TextInputField
    control={control}
    name="search"
    label="Search"
    placeholder="Search by..."
    FieldProps={FieldProps}
  >
    <InputAdornment placement="start">
      <SearchIcon size="xxsmall" tone="muted" />
    </InputAdornment>
  </TextInputField>
  <MultiSelectField
    control={control}
    name="fieldName"
    label="Label"
    options={options}
    placeholder="Filter by..."
    fieldProps={FieldProps}
  />
</Columns>;
```

Note the casing: the prop is `FieldProps` (capital F) on `TextInputField` and
`fieldProps` (lowercase f) on `MultiSelectField`/`SelectField` — match the
snippet exactly.

Do NOTs (portal-hub):

- NEVER import `@spark-web/multi-select` directly in portal-hub app code; use
  `MultiSelectField` from `@brighte/ui-components`
- NEVER omit the hidden-label `fieldProps` constant on SelectField filter
  dropdowns — define it once outside the component and pass it everywhere

---

## Text inputs (overrides the 'Controlled usage with form state' sections in text-input/text-area CLAUDE.md)

In portal-hub, prefer the `Field`-wrapped bindings from `@brighte/ui-components`
over hand-wiring `@spark-web/text-input` / `@spark-web/text-area` into forms:

- Prefer `TextInputField` from `@brighte/ui-components` when used with
  `react-hook-form`. Pass `placeholder` directly, and use `FieldProps` to pass
  `description` (renders as muted hint text below the label):

```tsx
<TextInputField
  control={control}
  name="subject"
  label="Subject"
  placeholder="Type here..."
  FieldProps={{ description: 'Enter a brief summary of the issue' }}
/>
```

- Prefer `TextAreaField` from `@brighte/ui-components` when used with
  `react-hook-form`. Pass `placeholder` directly:

```tsx
<TextAreaField
  control={control}
  name="note"
  label="Note"
  placeholder="Type your note here..."
/>
```

---

## Section cards (overrides detail-page.md Section 7)

portal-hub uses a custom wrapper at `@components/PortalTable/SectionCard` — not
`@spark-web/section-card`. The API differs from the Spark component:

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

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

| Prop       | Type        | Notes                                                                        |
| ---------- | ----------- | ---------------------------------------------------------------------------- |
| `label`    | `string`    | Optional — card header text; usually provided — header only renders when set |
| `tag`      | `TagProps`  | Optional — tag rendered in the card header                                   |
| `action`   | `ReactNode` | Optional — right-side header control                                         |
| `controls` | `ReactNode` | Optional — additional header controls                                        |

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

---

## Modal sizing (ACCREDITATION_MODAL_CSS)

The shared admin modal size constant described in
`node_modules/@spark-web/modal-dialog/CLAUDE.md` already exists in portal-hub —
do not define a new one. The standard size constant used across all admin
confirmation modals is defined in `apps/admin-portal/src/utils/constants.tsx`:

```ts
export const ACCREDITATION_MODAL_CSS = {
  width: '100vw',
  maxWidth: '550px',
} as const;
```

Always pass this constant via `css={ACCREDITATION_MODAL_CSS}` — never set width
inline or use a raw pixel value.

---

## Row-as-link reference implementation

The row-as-link navigation pattern documented in
`node_modules/@spark-web/data-table/CLAUDE.md` has a reference implementation in
portal-hub at `apps/admin-portal/src/components/RowLink` (uses TanStack Router).

---

## TablePagination

portal-hub supplies its own `TablePagination` component. It is the
`TablePagination` referenced by both the list-page and detail-page patterns.
Props used by the patterns:

| Prop          | Notes                                                                    |
| ------------- | ------------------------------------------------------------------------ |
| `total`       | Total record count — from a dedicated count query                        |
| `pageSize`    | 20 on list pages and embedded tab lists; 5 on detail-page section tables |
| `dataShowing` | Number of rows on the current page                                       |
| `onChange`    | Page change handler                                                      |
| `current`     | Current page number                                                      |

This is a confirmed COMPONENT GAP — no `@spark-web` pagination component exists
yet. Until one ships, every consumer supplies its own pagination component.

---

## Multi-select with per-item toggle rows

When a multi-select picks N items and each selected item also needs its own
on/off flag, pair the selector with a list of **toggle rows generated from the
current selection** — one bordered row per item, label on the left and an
`@spark-web/switch` `Switch` on the right. The toggle list binds to a
react-hook-form field whose value is a `string[]` of the IDs currently switched
on.

Use it when a per-item boolean accompanies a multi-select choice (e.g. flagging
some of the selected vendors as Authority Contacts). For a single form-wide
boolean use `ToggleField`; for choosing items with no per-item flag, the
multi-select alone is enough.

Two parts:

- **Selector** — `ComboboxField isMulti` or `MultiSelectField` from
  `@brighte/ui-components` (never `@spark-web/multi-select` directly — see
  Filter fields above). Its value is the set of selected items.
- **Toggle rows** — derive `{ id, name }[]` from the current selection and
  render one row each, bound to a separate `string[]` field of the "on" IDs.
  Render nothing when the selection is empty.

Each row is a bordered card wrapping a space-between flex row, with the Switch's
accessible name supplied via `aria-label` (so the name isn't rendered twice;
target a row with `getByRole('switch', { name })`).

### Row content

Each row's children follow a consistent layout: a label stack on the left and an
optional `Switch` on the right. The label stack contains the item name and an
optional description line (`Text size="small" tone="muted"`) providing context
such as the item's current status or effective date:

```tsx
<ToggleRow variant="active">
  <Stack gap="small">
    <Text>{item.name}</Text>
    <Text size="small" tone="muted">
      Current authority contact
    </Text>
  </Stack>
  <Switch
    aria-label={item.name}
    checked
    onCheckedChange={() => toggle(item.id)}
  >
    {''}
  </Switch>
</ToggleRow>
```

The description is optional — when no secondary line is needed, drop the `Stack`
wrapper and render a single `<Text>{item.name}</Text>` directly as the left-side
child of `ToggleRow`.

### Row variants

A toggle row has three visual variants. Use a reusable `ToggleRow` component
(see Reference implementation below) to encapsulate these — never hand-write the
border/background per call site.

**Default** — standard field border, surface background. Used when the switch is
off (item not currently active):

```tsx
<Box border="field" borderRadius="medium" padding="medium" background="surface">
  <Box
    display="flex"
    alignItems="center"
    justifyContent="spaceBetween"
    gap="medium"
  >
    {children}
  </Box>
</Box>
```

**Active** — primary border with a green tint background. Used when the switch
is on (item currently active):

```tsx
<Box
  border="primary"
  borderRadius="medium"
  padding="medium"
  background="primaryLow"
>
  <Box
    display="flex"
    alignItems="center"
    justifyContent="spaceBetween"
    gap="medium"
  >
    {children}
  </Box>
</Box>
```

**Ended** — field border, reduced opacity, no switch. Used for historical items
that can no longer be changed (e.g. an authority contact whose term has ended).
Show these as read-only rows alongside active toggles when the modal displays
history for context:

```tsx
<Box
  border="field"
  borderRadius="medium"
  padding="medium"
  background="surface"
  opacity={0.6}
>
  <Box
    display="flex"
    alignItems="center"
    justifyContent="spaceBetween"
    gap="medium"
  >
    {children}
  </Box>
</Box>
```

**Important:** always provide a `background` value (even `"surface"` for
non-active variants) so the Box's internal `BackgroundProvider` stays mounted
across variant changes — toggling between `background={undefined}` and a value
unmounts the provider and recreates the subtree, which breaks Switch state in
tests and can cause flicker.

### Keeping the toggle field consistent

- **Prune on change** — when an item is deselected (or an upstream condition
  turns the section off), drop its id from the toggle field. Do this in the
  consumer (a small `useEffect`/watch) and keep the row list presentational.
- **Shape the payload in one place** — map `(selectedIds, onIds)` →
  `[{ id, <flag>: onIds.includes(id) }]` in a single helper so the field's wire
  shape lives in one spot.

### Reference implementation

> **Note:** paths below are in the **portal-hub** consumer repo
> (`brighte-labs/portal-hub`), not this repository.

`ToggleRow` — a reusable row container at
`apps/admin-portal/src/components/ToggleRow/ToggleRow.tsx` that accepts a
`variant` prop (`'default' | 'active' | 'ended'`) and renders the correct
border/background/opacity combination using only Spark Box tokens. Children are
placed inside the inner flex Box.

Usage in Authority Contacts flows (portal-hub):

- **Edit AC modal** (vendor Overview) —
  `apps/admin-portal/src/pages/Vendors/VendorDetail/Overview/EditAuthorityContactsModal.tsx`
  — active contacts get `variant="active"` with a Switch, ended contacts get
  `variant="ended"` with no Switch.
- **Create/Edit vendor-user** —
  `apps/admin-portal/src/components/AuthorityContactToggleList` — the row list
  (props: `name`, `control`, `vendors: { id; name }[]`, optional
  `label`/`description`/`rules`).
- `apps/admin-portal/src/utils/authorityContact` → `buildVendorAcPayload` —
  payload shaping.
- `apps/admin-portal/src/hooks/useAuthorityContactSync` — prunes the field on
  role/selection change.

### Do NOTs (portal-hub)

- NEVER pass raw CSS to Spark `Box` layout props — they take token KEYS:
  `justifyContent="spaceBetween"` (NOT `"space-between"`),
  `alignItems`=`start|center|end|stretch`. An unrecognised value is silently
  dropped and the row falls back to `flex-start` (the switch clumps next to the
  label instead of aligning right).
- NEVER hand-write border/background per toggle row — use the `ToggleRow`
  component with the appropriate `variant`.
- NEVER put visibility/eligibility logic inside the row list — the consumer
  gates it and owns pruning the field.

---

## Detail summary inside a modal (`DescriptionList`)

When a modal shows a fetched record's fields for the operator to confirm before
saving (e.g. a verified register lookup), render them as a key/value list with
**`@spark-web/description-list`** `DescriptionList` — the first-party component
for labelled data rows. Give each item `layout: 'auto'` for muted-label-left /
value-right rows with a bottom divider; item `value` accepts any node (badges,
chips). Also use it for detail-page sections.

- Add a section heading (`<Text weight="semibold">…</Text>`) above the list if
  the modal needs one — `DescriptionList` renders only the rows.
- Tag-like values (classes/tags): `@spark-web/status-badge` `StatusBadge` (tone
  `accent`) inside `<Inline gap="xsmall">` as the item `value` — not `Badge`.
- One overall status → a single `Alert` (`positive`/`caution`) beneath the list,
  not a badge per row.

```tsx
import { DescriptionList } from '@spark-web/description-list';
import { StatusBadge } from '@spark-web/status-badge';
import { Alert } from '@spark-web/alert';

<Stack gap="medium">
  <Stack gap="small">
    <Text weight="semibold">Licence details</Text>
    <DescriptionList
      items={[
        { label: 'Licensee', value: data.licenseeName ?? '—', layout: 'auto' },
        { label: 'ABN', value: data.abn, layout: 'auto' },
        {
          label: 'Classes',
          layout: 'auto',
          value: (
            <Inline gap="xsmall" alignY="center">
              {classes.map(c => (
                <StatusBadge key={c} tone="accent">
                  {c}
                </StatusBadge>
              ))}
            </Inline>
          ),
        },
      ]}
    />
  </Stack>
  <Alert tone={verified ? 'positive' : 'caution'}>…</Alert>
</Stack>;
```

### Do NOTs

- Don't hand-roll `<Box justifyContent="spaceBetween">` label/value rows — use
  `DescriptionList` (proper `<dl>/<dt>/<dd>` semantics, right defaults).
- Don't reach for portal-hub's `PortalTable`/`TableRow` for new key/value lists
  — it is a **fork** of `@spark-web/description-list` (same tokens/layout,
  weaker semantics) and is being migrated out. Prefer `DescriptionList`; use
  `SectionCard` only for the surrounding card chrome. (`spaceBetween` on
  `TableRow` == `layout: 'auto'` on a `DescriptionList` item.)
- Don't use `@spark-web/badge` `Badge` for tag chips — use `StatusBadge` (tone
  `accent`).
- Don't scatter per-field status badges when one overall verdict suffices — use
  a single `Alert`.

### Reference implementation

> **Note:** paths below are in the **portal-hub** consumer repo
> (`brighte-labs/portal-hub`), not this repository.

`apps/admin-portal/src/pages/Vendors/Licenses/NswLicensePreview.tsx` — the
verify-then-save preview block (currently on the legacy `TableRow`; migrating to
`@spark-web/description-list`).
