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