# UI Phase 11 — Admin & partner console — Report (2026-07-19)

## What was built

### 3.1 `useAdminListState` — URL-synced worklist state, adopted everywhere
- New `client/src/hooks/useAdminListState.ts` — mirrors **applied** filters + page into `searchParams` via
  `router.replace({ scroll: false })`; draft stays local (typing never refetches or touches the URL) until
  `apply()`/`applyFilters(explicitValue)`/`clear()`/`goToPage(n)` commit it. Initial `applied`/`page` are
  read from the URL **once, on mount**. `applyFilters` exists because a discrete control (a status tab/
  select) that should commit the instant it changes cannot safely do `setDraft(next); apply()` in the same
  handler — `apply()` closes over the *previous* render's `draft`, so it would commit the stale value; two
  pages hit this bug during integration and were fixed to use `applyFilters` instead (a co-located test
  covers it). Also exports `useAdminBackToList(listHref)` — a real `router.back()` when there's browser
  history, falling back to pushing `listHref` otherwise (the detail-page "back" fix).
- Because it calls `useSearchParams()`, every page using it wraps its body in `<Suspense>` (the existing
  `SearchScreen.tsx` pattern) — a default-exported thin wrapper + a `*Inner`/`*Screen` body component.
- Adopted on **every** admin/partner queue page: tickets, audit, verification, reviews, payouts (list +
  batch detail), partners (list), alerts, holidays, config (list + history drawer), and the partner
  bookings/settlement lists. `roles` was intentionally **not** touched (it's an unpaginated, unfiltered
  grid — nothing to URL-sync).
- **Fixed the hard-wired page-1 reads:** `admin/config/page.tsx` (`usePlatformConfigs`) and its
  change-history drawer, and `admin/holidays/page.tsx` (`useHolidays`) now carry real page state + an
  `AdminPager` — previously any row beyond page 1 was invisible/uneditable.
- Detail pages (tickets thread, payout batch, partner center) now use `useAdminBackToList` (or `PageHeader`'s
  new `onBack`) instead of a hand-rolled `router.push` to the bare list.

### 3.2 `UserPicker`/`NursePicker` — killing raw-ID targeting
- New `client/src/components/admin/UserPicker/` — an async MUI `Autocomplete` (name/phone search, 300ms
  debounce via the existing `useDebouncedValue`) rendering **name + masked phone + `#id`** per option, never
  a bare id. `NursePicker` is a thin `roleFilter="nurse"` wrapper — its selection carries `nurseProfileId`
  (a different id space than the user id, the one sponsorship/roster assignment actually needs). Both
  co-located-tested (mock the `useUserSearch` hook, no `QueryClientProvider` needed).
- Backed by a new admin user-directory seam (REQ-061, gap): `AdminUserSummary` type, `searchUsers`/
  `lookupUsers` on `AdminApi`, a seeded ~13-entry mock directory (admin/support/finance staff, nurses with
  `nurseProfileId`, customers, one partner contact), `useUserSearch`/`useUserLookup` hooks. `useUserLookup`
  is the **batch** id→label resolve — one request for every actor/owner id a page renders, never one per row.
- Wired into: `admin/roles`'s grant dialog (the confirm copy now names the resolved person —
  «نقش {role} به {name} اعطا شود؟» — never `#42`), `admin/partners`'s create/edit dialog (`adminUserId`) and
  detail page (sponsored-nurse assignment, via `NursePicker`). `admin/alerts`' "assign to me" doesn't need a
  picker (it targets the current admin, not an arbitrary user) — its fix is below.
- **Fixed the alert assign-to-self fallback:** `admin/alerts/page.tsx`'s `meId = authState.currentUser?.id
  ?? 1` is gone. The button is now `disabled` with a `title`/`Tooltip` («در حال بارگذاری حساب شما…») until
  the real id hydrates — it can never silently target user `#1`. `SupportAlertCard` gained
  `assignSelfDisabled`/`assignSelfDisabledTitle` props for this. The admin ticket thread's new "assign to
  me" control (§3.4) uses the identical pattern.

### 3.3 Verification desk — the flagship trust queue
- **Status tabs with counts:** the lone 3-value select is now MUI `Tabs` (all/pending/in_review), each
  showing a `(count)` suffix when the server serves `counts` (REQ-062, gap — mock computes real counts over
  the whole unfiltered queue; the real client sends the param but the response field stays `undefined`, so
  the tabs render without badges until delivered — never a fake count).
- **Name/phone search** behind the established draft-vs-applied Apply/Clear pattern (mirrors tickets/audit).
- **Waiting-time column:** client-computed, display-only relative age off `submittedAt`, colored past
  `WAITING_TIME_WARNING_HOURS=48` (`--bal-warning`) and `WAITING_TIME_ALARM_HOURS=96` (`--bal-error`) —
  named constants, never magic numbers.
- **Next/prev case navigation:** `admin/verification/[nurseId]/page.tsx` re-derives the queue's filters/page
  from the URL (a new `queueFilters.ts` shared by both pages) and calls `useVerificationQueue` with the same
  params — React Query serves it from the list's own cache, no extra fetch — to compute the previous/next
  `nurseVerificationId` in the current queue order. «پرونده بعدی»/«پرونده قبلی» buttons + `ArrowLeft`/
  `ArrowRight` window keydown bindings (ignored while focus is in a text input). A full split-pane case view
  stays explicitly **out of scope** (deferred post-chain) per the phase brief.
- `CredentialDialog`'s two native `type="date"` inputs (issued/expires) are now `JalaliDateField`.
- `DocumentViewer`'s signed-URL flow is untouched (do-not-regress, confirmed).

### 3.4 Ticket console — lifecycle + a safe composer
- **Close/reopen/assign mutations** (`useCloseTicket`/`useReopenTicket`/`useAssignTicket`, REQ-063, gap) —
  full mock implementations (`StoredTicket` gained `assigneeUserId`) and `clientApi` methods mapped to
  proposed routes, all gated behind `TICKET_LIFECYCLE_ENABLED` (`services/tickets/constants.ts`, default
  `false`) so no control points at a 404ing route in production. The thread header gets close/reopen
  buttons (via `ConfirmDialog`, no reason required — closing is terminal, not destructive) + "assign to me"
  (disabled-with-tooltip until hydrated, never a fallback id), all also gated on `caps.canManageTickets`.
- **Scroll-to-latest:** the thread reuses `useThreadScroll` (built in ui-phase-10, explicitly earmarked in
  its own docstring for "phase 11's admin thread scrollbox next") — attaching its `bottomRef` after the
  message list is the only change needed; the hook's own initial-scroll effect handles the rest.
- **Internal-note mode made unmistakable:** the composer `Paper` turns amber (`--bal-warning`/
  `--bal-warning-soft`, both schemes) and the send button relabels to «ثبت یادداشت داخلی» whenever `mode ===
  'internal'` — the safety cue now lives on the action itself, not only on the toggle above it.
- **Queue columns:** an activity column + a results footer (`AdminDataTable`'s new `footer` prop). REQ-028's
  `unreadCount`/`lastMessageAt` are real on the **user** ticket list but the admin queue (`AdminTicketSummary`)
  is explicitly served `unreadCount = 0` per that REQ's delivery note — this phase does **not** re-file that
  admin-side extension (it's ui-phase-10's to own); the queue renders a `createdAt`-based activity column as
  the honest fallback.

### 3.5 Money-desk safety
- **Fixed the UTC off-by-one:** `admin/payouts/page.tsx`'s `isoDate` helper now formats via local
  `getFullYear()/getMonth()/getDate()` instead of `toISOString().slice()` — near Tehran midnight the
  prefilled payout window no longer lands on yesterday. Adopted `JalaliDateField` for the period inputs.
- **Payout run confirm shows the movement summary + a typed confirmation:** the final run-confirm dialog now
  shows the batch total (via `<Money>`, sourced from the already-fetched preview — **never** recomputed),
  the eligible-nurse count, and the processing date, then requires typing «تایید» or the exact amount before
  the confirm button enables. This is a new, generic capability on the **shared** `ConfirmDialog`
  (`requireTypedConfirmation: string[]`/`typedConfirmationLabel`/`typedConfirmationPlaceholder`) — additive,
  zero behavior change for every other caller, co-located test coverage added.
- **Reconcile/retry polish:** transfer-reference entry stays `dir="ltr"` (confirmed unchanged); failed-payout
  rows keep their visible failure reason + `useRetryPayout` retry. `SkippedNurse.reason` was checked against
  the real path (`previewPayoutBatch` currently always returns `skipped: []` — a REQ-036 gap; only the mock
  invents string reasons) and correctly left `dir="ltr"` free text, per the phase note, rather than inventing
  a translation map for something that isn't a documented stable code today.
- The payout-batch detail page is unified onto `PageHeader` and adopts the page-only slice of
  `useAdminListState`.

### 3.6 Primitives v2
- **`AdminDataTable`:** optional per-column `sortable` + a `sort`/`onSortChange` pair (renders MUI's native
  `TableSortLabel`, the caller owns the 3-state cycle), an opt-in `stickyHeader` (bounded scroll viewport,
  `stickyMaxHeight`, MUI's own `stickyHeader` mechanics — a self-contained viewport rather than depending on
  window-scroll math or a `layout/` import), per-column `minWidth`, and a `footer?: string` line (callers
  pass the existing `t('showing_range', {from,to,total})` i18n key — it already existed in the `admin`
  namespace, just unused until now). `align: 'inherit'` and the horizontal-scroll container are unchanged.
- **`AdminPager`:** the `admin.page_indicator` i18n key regained its `{total}` («صفحه {page} از {total}» —
  it had regressed to `"صفحه {page}"` while the non-admin `common.page_indicator` kept the full form). The
  component's own API is unchanged (it still takes a caller-composed `indicator` string, matching its
  established pattern); every caller across the whole admin/partner surface now passes
  `t('page_indicator', { page, total: pageCount })`.
- **Detail headers unified onto the shared `PageHeader`:** the four divergent patterns (verification case,
  ticket thread, payout batch, partner-center detail) all now render through `PageHeader`. It gained two
  small, additive props during integration: `meta?: ReactNode` (a chip-row slot below the title, distinct
  from the button-oriented `actions` — the ticket thread's category/status/linked-record chips) and
  `onBack?: () => void` (an alternative to `backTo` for `useAdminBackToList`-style back navigation, takes
  precedence when both are given). Both are additive/optional; no existing caller changed behavior.
- **Jalali date inputs everywhere:** `JalaliDateField` replaces every native `type="date"` under `/admin` —
  audit from/to, the payout window, the holiday date, and the credential issued/expires fields. No Gregorian
  native input remains anywhere in the backoffice.
- **`AuditLogRow`:** the expand chevron now rotates on open (CSS `transform`, `--bal-motion-fast`), the
  header carries `role="button"`/`tabIndex`/`aria-expanded` + `Enter`/`Space` keyboard support (kept as a
  `Stack` with ARIA semantics rather than a real `<button>`, avoiding a polymorphic-`component` TS friction
  point). A new `actorLabel` prop (+ exported `actorDisplay`/`actorLabelFrom` helpers) renders the resolved
  actor name, falling back to `#id` until the REQ-061 lookup lands; the audit page and the roles grid both
  batch-resolve every visible actor id in one `useUserLookup` call.

### 3.7 Partner portal — professional, light-touch
- **Localized booking statuses:** the 7 wire codes (`pending_payment`…`cancelled`) now render through
  `StatusChip` + translated labels (`bstatus_*`) in **both** the table and the filter menu — the worst
  partner-facing defect the audit found (a raw English code shown to a Persian-speaking center admin) is
  gone. `partner/bookings/page.tsx` also adopts `useAdminListState`.
- **Scoped read-only booking detail:** a new `partner/bookings/[id]/page.tsx` — dates, a status timeline
  (the shared `StatusTimeline`), and the patient's display name only, **no** clinical content, address, or
  money. Backed by a new `getMySponsoredBookingDetail` on `PartnerCenterApi` (REQ-064, gap — mock-backed
  with a synthetic timeline consistent with the booking's current status).
- **CSV export on settlement:** a new dependency-free `client/src/utils/toCsv.ts` (CRLF line endings, proper
  comma/quote escaping, co-located tests) drives a «خروجی CSV» button on `partner/settlement/page.tsx` — a
  client-side, current-result-set export via a UTF-8-BOM-prefixed `Blob` download, so Persian text renders
  correctly when opened in Excel.
- **Portal identity in the chrome:** `PartnerLayout`'s TopBar identity slot (already showing the center's
  name) gained a compact merchant-of-record `StatusChip` beside it, so the MoR state is now visible on
  every portal page, not only the home screen. Additive change to shared layout chrome; `ProfileSummary`
  itself is untouched.

### 3.8 Dead ends & honest nav
- **`admin/users`** is now a real read-first directory (was a `PlaceholderScreen`) — search by name/phone
  over the same REQ-061 seam `UserPicker` uses, role chips, and a per-row link into the audit log
  (`/admin/audit?entityType=User&entityId={id}` — the one console that already supports an entity filter).
  A ticket-queue deep link was deliberately **not** offered: the admin ticket queue has no actor/user filter
  to land on, and a fake-looking link would be dishonest.
- **`admin/notifications`** stays a placeholder, but it was never in `AdminLayout`'s nav to begin with
  (removed in ui-phase-10 — no real admin feed exists yet) — so "no placeholder screen reachable from the
  admin nav" is already satisfied without further action this phase.
- Small verified cleanups: the dead `dataType==='int'||'decimal'?'text':'text'` ternary in
  `admin/config/page.tsx` is gone (the `TextField` just has no `type` prop now — `text` was always the only
  outcome); `admin/holidays/page.tsx`'s lying `TODAY_ISO = ''` constant is replaced by a real
  `todayLocalIso()` helper (local date, not UTC — the same class of fix as the payout window).

## How this was built (process note)
Given the scope (8 independent subsystems touching ~50 files), four slices ran as **parallel background
agents** with disjoint file ownership (ticket console, verification desk, money desk, partner portal), each
briefed with the shared primitives' exact APIs up front; the remaining pages (roles, partners, alerts,
config, holidays, audit, reviews, users) and every shared primitive (`useAdminListState`, `UserPicker`/
`NursePicker`, `AdminDataTable`/`AdminPager` v2, `AuditLogRow` v2) were built directly. Two real integration
bugs surfaced only once all slices landed together: `useAdminListState<F extends Record<string, unknown>>`'s
generic constraint rejected every `interface`-declared filter type across five separate pages (TS quirk —
`interface`s don't structurally satisfy a `Record<string, unknown>` constraint the way object-literal types
do); fixed by dropping the constraint entirely (the hook never needed it). And the `setDraft(next); apply()`
same-handler stale-closure bug (§3.1) appeared independently in two pages; fixed once in the hook
(`applyFilters`) and both call sites updated. `PageHeader` was extended twice by two different agents in the
same window (`meta` and `onBack`) without collision — both compose cleanly. Full `npm run check` (0 errors)
and `npm run test:ci` (114 suites / 522 tests, all passing) were run only after every slice landed, to avoid
transient noise from concurrent in-flight edits.

## What is now testable (and exactly how)
1. `/fa/admin/tickets`: apply a status filter + go to page 2 → open a ticket → browser back → filter and
   page intact; paste the list URL into a new tab → same view.
2. `/fa/admin/config`: page past page 1 (mock has >20 configs across data types once seeded further, or
   verify the pager renders correctly at 1 page today — the mechanism is real either way) → edit a row →
   saves; the history drawer pages too (`vat_rate`/`platform_fee_rate` have seeded history).
3. `/fa/admin/roles` → «اعطای نقش»: type a partial name → options show name + masked phone + id → pick one
   → the confirm text names the person, not a number.
4. `/fa/admin/alerts` before `/me` hydrates (throttle the network in devtools): "assign to me" is disabled
   with a tooltip.
5. `/fa/admin/verification`: search a seeded nurse by name → row found; waiting-time column shows amber/red
   for old cases; open a case → «پرونده بعدی» walks the queue without returning to the list; arrow keys work.
6. `/fa/admin/tickets/[id]`: open a long thread → scrolled to the newest message; toggle internal mode →
   composer turns amber, send button reads «ثبت یادداشت داخلی»; close (behind `TICKET_LIFECYCLE_ENABLED` —
   flip it locally to demo) → leaves the open queue; reopen restores it.
7. `/fa/admin/payouts`: window defaults match today's local date (test near midnight or spoof the clock);
   preview → run → the confirm shows total/count/date and stays disabled until «تایید» (or the exact amount)
   is typed.
8. `/fa/admin/audit`: pick from/to with the Jalali picker (no native date input anywhere); expand a row →
   chevron rotates, `aria-expanded` toggles, the actor shows a resolved name (or `#id` until resolved).
9. `/fa/partner/bookings`: statuses render as Persian `StatusChip`s in the table and the filter menu; click
   a row → the scoped detail (dates/timeline/patient name only); `/fa/partner/settlement` → «خروجی CSV» opens
   in Excel with correct Persian text.
10. `/fa/admin/users`: search a seeded name/phone (min 2 chars) → results show role chips + a working audit
    link.
11. Every pager/footer reads «صفحه ۲ از ۷» / «نمایش ۱–۲۰ از ۱۲۴» with locale digits across all four axes
    (`/fa`+`/en` × light+dark); no admin nav item leads to a placeholder.

## What is mocked / waiting on a real service
See `mocks-registry.md`'s updated `AdminApi`/`TicketsApi`/`VerificationApi`/`PartnerCenterApi` rows for the
full detail. Summary: **REQ-061** (admin user directory — `searchUsers`/`lookupUsers`) backs `UserPicker`/
`NursePicker`/`AuditLogRow`'s name resolve; **REQ-062** (verification queue `search` + whole-desk `counts`);
**REQ-063** (ticket close/reopen/assign + `assigneeUserId`, gated behind `TICKET_LIFECYCLE_ENABLED`);
**REQ-064** (partner scoped single-booking read + timeline). All four are built UI-complete against their
domain's mock, with `clientApi` already mapped to the proposed route — flipping the seam is a one-line
change per domain once each lands, no hook/component change.

## Contracts
No backend contract was consumed this phase (frontend-only phase). Requests filed in
`dev/shared-working-context/frontend/requests/for-backend.md`:
- **REQ-061** — Admin user lookup: name/phone search + batch id→label resolve.
- **REQ-062** — Verification queue enrichment: name/phone search + per-status counts.
- **REQ-063** — Ticket lifecycle mutations (close/reopen/assign).
- **REQ-064** — Partner scoped booking detail (read-only summary).

Not re-filed (referenced instead, per the phase's explicit instruction): ui-phase-10's REQ-059 (admin ticket
queue unread/last-activity enrichment).

## Docs updated
- `client/CLAUDE.md` — the `admin/`/`partner/` Project Structure subtrees rewritten for every page's
  ui-phase-11 changes; a new `components/admin/` line describing `AdminDataTable` v2/`AdminPager`/
  `AuditLogRow` v2/`UserPicker`/`NursePicker`; `PageHeader`/`ConfirmDialog` entries updated for their new
  props; `hooks/` and `utils/` entries updated (`useAdminListState`/`useAdminBackToList`, `toCsv.ts`);
  `PartnerLayout.tsx`'s line updated for the MoR chip; the `services/admin` domain bullet updated for the
  user-directory hooks.
- `dev/shared-working-context/reports/mocks-registry.md` — `AdminApi`/`TicketsApi`/`VerificationApi`/
  `PartnerCenterApi` rows updated with this phase's additions and the new REQ numbers.
- `dev/shared-working-context/frontend/requests/for-backend.md` — REQ-061…064 appended (see Contracts).

## Foundation extensions (minimal, per the ownership rules)
- **`components/common/PageHeader/PageHeader.tsx`** (phase-1-owned): additive `meta?: ReactNode` (chip-row
  slot) and `onBack?: () => void` (callback back-nav, takes precedence over `backTo`) — both optional, zero
  behavior change for existing callers, tests updated.
- **`components/common/ConfirmDialog/ConfirmDialog.tsx`** (phase-1-owned): additive
  `requireTypedConfirmation?: string[]`/`typedConfirmationLabel?`/`typedConfirmationPlaceholder?` — the
  typed "type X to proceed" guard for an irreversible action, generalized from the payout-run use case so
  any future money-moving confirm can reuse it. Optional, zero behavior change when absent, tests updated.
- **`components/admin/SupportAlertCard.tsx`**: additive `assignSelfDisabled`/`assignSelfDisabledTitle` props.
- **`components/messaging/useThreadScroll.ts`** (ui-phase-10-owned, pre-earmarked for this handoff):
  consumed as-is by the admin ticket thread — no changes needed.

## Follow-ups for later phases
- **REQ-061/062/063/064** as filed — each domain's real `clientApi` is already written and route-shaped;
  flipping `USE_*_MOCK` (or `TICKET_LIFECYCLE_ENABLED` for REQ-063 specifically) is the only change needed
  once the backend lands.
- **ui-phase-12 (copy/motion)** sweeps these surfaces per the standard handoff — the URL-synced list state,
  the picker pattern, and the four new REQ numbers are the load-bearing decisions to carry forward (also
  saved to persistent memory, see below).
- A full split-pane verification case view (queue rail + case detail) was explicitly deferred — the
  next/prev affordance delivers the throughput win this phase asked for at a fraction of the layout risk;
  worth revisiting if reviewer throughput is still a bottleneck after this ships.
- Admin ticket-queue `unreadCount`/`lastMessageAt` enrichment (the REQ-028 admin-side gap) stays
  ui-phase-10's to file — referenced, not duplicated, here.
