# UI Phase 11 — Admin & partner console

> **Mission:** the backoffice has a real primitive layer — `AdminDataTable`, `ConfirmDialog`, `AdminPager`,
> the four-state list pattern — under daily-use ergonomics gaps: no URL-synced state (back/refresh loses
> the queue), audited actions targeting people by hand-typed numeric ID, a trust queue you can't search,
> a ticket console that can never close a case, Gregorian date inputs in a Shamsi product, and a partner
> portal showing raw English `snake_case` statuses to Persian center staff. Make the console **fast and
> safe** for the ops desk and the portal **professional** — while keeping the density of a work tool.
>
> **Track:** frontend · **Depends on:** [Phases 0–2](ui-phase-2-shells-and-navigation.md) ·
> **Unlocks:** the ops desk gets speed and safety, partners get a professional portal
>
> **Before you start, read [../../phases/_shared/agent-operating-rules.md](../../phases/_shared/agent-operating-rules.md) and invoke the frontend-designer skill — both are mandatory.**

## 1. Context — where this sits

Trust, money, and dispute resolution all converge on `/admin`; the partner portal is the only surface an
external business ever sees. The audit found the composite layer unusually good (one table, one pager, one
confirm pattern, zero hard-coded hexes, Shamsi/Toman formatting throughout) and everything *around* it
thin. Phase 2 replaced the starter shell (dense chrome, working sidebar active-state, locale-aware nav);
phase 1 shipped PageHeader, JalaliDatePicker, StatusChip v2. This phase is the **workflow layer**. All
defects below were re-verified in code on 2026-07-16:

- **No URL-synced list state anywhere.** `admin/tickets/page.tsx:39-41` holds applied filters + page
  in `useState` (every queue page does); detail pages hand-roll `router.push` "back" buttons that land
  on page 1 (`tickets/[id]/page.tsx:90`).
- **Rows beyond page 1 are unreachable on config and holidays.** `admin/config/page.tsx:63`
  (`usePlatformConfigs(1)`), `:182` (history drawer), and `admin/holidays/page.tsx:38`
  (`useHolidays({}, 1)`) are hard-wired to page 1 with no pager — later rows are invisible/uneditable.
- **Raw-ID targeting on audited actions.** `admin/roles/page.tsx:176-182` grants roles via a bare
  `type="number"` TextField; same pattern for partner-center admin and sponsored-nurse assignment
  (`partners/page.tsx`, `partners/[id]/page.tsx`). One mistyped digit grants `super_admin` to a
  stranger — and `admin/users/page.tsx` is a `PlaceholderScreen`, so there's nowhere to look an ID up.
  Related: alert assign-to-self silently falls back to user #1 (`admin/alerts/page.tsx:36` —
  `const meId = authState.currentUser?.id ?? 1;`).
- **The flagship trust queue can't search or prioritize.** `AdminVerificationQueueFilters` is
  `{ status?: 'pending' | 'in_review' }` (`services/verification/types.ts:177-179`) — no name/phone
  search, no counts, no age signal; `AdminDataTable.tsx` has no sort affordance at all.
- **A resolved ticket can never leave the queue.** `services/tickets/hooks/` has **no
  close/reopen/assign mutation**; the thread header (`tickets/[id]/page.tsx:118-127`) shows read-only
  chips. The message list (`:150`) opens scrolled to the oldest message; internal-note mode (`:163-190`)
  is only a small toggle — composer and send button look identical in both modes.
- **UTC off-by-one on payout window defaults.** `admin/payouts/page.tsx:56` uses
  `d.toISOString().slice(0, 10)` — near Tehran midnight the prefilled window is yesterday. The final
  run confirm (`:365-371`) is generic copy with no money-movement summary.
- **Partners see raw wire codes.** `partner/bookings/page.tsx:17,45,66-69` renders
  `pending_payment`/`in_progress` literally in the filter menu and table chip — untranslated,
  un-StatusChip'd, shown to external Persian-speaking staff.
- Small verified cleanups: dead ternary `config/page.tsx:152` (both branches `'text'`); misleading
  `holidays/page.tsx:106` (`TODAY_ISO = ''` claims to be seeded — the field starts blank); static
  chevron, no `aria-expanded` on `AuditLogRow.tsx`; pager indicator has no total (callers pass only
  `{ page }`, e.g. `partner/bookings/page.tsx:101`, though every caller computes `pageCount`).

**What already exists (do not rebuild):**

- The composite layer in `client/src/components/admin/` — `AdminDataTable`, `AdminPageHeader`,
  `AdminPager`, `AdminEmptyState`, `AdminErrorState`, `ConfirmDialog` — used by every console and
  unit-tested. **Restyle/extend these; never fork per-page markup.**
- The domain composites: `RefundPanel`, `DocumentViewer` (signed URLs + expired→re-request),
  `AdminMessageBubble`, `SupportAlertCard`, `ConfigRow`, `AuditLogRow`, `PartnerSettlementRow`.
- The draft-vs-applied filter pattern on tickets/audit (typing never refetches; Apply commits the key)
  and the four-state list pattern (skeleton → error-with-retry → empty → table) on every list page.
- Phase 1's `PageHeader`, `JalaliDatePicker`, `StatusChip`; phase 2's admin chrome + locale-aware nav.
- The URL-as-filter-carrier pattern proven in `client/src/services/search/filterParams.ts` (C1 writes
  the URL, C2 reads it back into the query-key object) — the model for 3.1.

## 2. Required reading (do this first)

- [audit/admin-partner.md](audit/admin-partner.md) — the full evidence: 20 problems, 11 opportunities,
  the keep-list §5 restates.
- [../../../.claude/skills/frontend-designer/SKILL.md](../../../.claude/skills/frontend-designer/SKILL.md) —
  invoke the skill; its token/wrapper/icon/RTL rules bind every deliverable below.
- Code, in this order: `client/src/components/admin/` (all of it); `admin/tickets/page.tsx` +
  `tickets/[id]/page.tsx` (draft-vs-applied + the thread); `admin/verification/page.tsx` +
  `verification/[nurseId]/page.tsx` (the case-view bones: StepCard, DocumentViewer, credential form);
  `admin/{payouts,roles,partners,alerts,config,holidays,audit}/…`; the three `partner/*` pages;
  `services/search/filterParams.ts`.
- Service seams you'll touch: `services/{tickets,verification,payouts,admin,partnerCenter}` — note
  which are mock-primary (`USE_*_MOCK` in each domain's `constants.ts`) before wiring anything.
- `client/CLAUDE.md` "Golden rules" + Project Structure; `product/business/` for verification/payout
  business rules (never infer them from code).
- [../../shared-working-context/frontend/requests/for-backend.md](../../shared-working-context/frontend/requests/for-backend.md)
  — REQ-028's delivery note matters for 3.4 (admin queue unread is explicitly served as `0`).

## 3. Scope — build this

### 3.1 `useAdminListState` — URL-synced worklist state, adopted everywhere

Build `client/src/hooks/useAdminListState.ts` (+ co-located test): a small generic hook that mirrors
**applied** filters + page into `searchParams` (read on mount → initial state; `router.replace` on
apply/page-change) the way `services/search/filterParams.ts` already proves. Draft filter state stays
local — only *applied* state hits the URL. Adopt it on **all** admin queue pages — tickets, audit,
verification, reviews, payouts, partners, alerts, holidays, config — and the three partner lists.
Detail-page "back" becomes a real `router.back()` (falling back to the list route), so queue position
survives back/refresh/share-a-link. While you're in there, **fix the hard-wired page-1 reads**: give
config, its history drawer, and holidays real page state + `AdminPager` (the hooks already accept a
page argument — callers pass `1`).

### 3.2 Kill raw-ID targeting — `UserPicker` / `NursePicker`

Build `client/src/components/admin/UserPicker/` (and a thin `NursePicker` variant), an async MUI
`Autocomplete` that searches by name/phone and renders **name + masked phone + `#id`** per option. Drop
it into role grants (`roles/page.tsx`), partner-center admin assignment (`partners/page.tsx`),
sponsored-nurse assignment (`partners/[id]/page.tsx`), and alert assignment. The resolved **name** is
echoed into the `ConfirmDialog` body (e.g. «اعطای نقش مدیر مالی به مریم احمدی (۰۹۱۲***۴۵۶۷)؟») — never
just `#42`. `services/admin` has **no user-search endpoint** (verified) — file the REQ (§4) and back
the picker with a `mockApi` implementation behind the existing seam.

**Fix the alert assign-to-self fallback:** `alerts/page.tsx:36` must never default to user `1`. Disable
"assign to me" until `authState.currentUser?.id` is hydrated (tooltip: «در حال بارگذاری حساب شما…»).

### 3.3 Verification desk — the flagship trust queue

- **Status tabs with counts** («در انتظار (۱۲)» / «در حال بررسی (۴)») replacing the lone select — counts
  need the queue read extended (REQ, §4); render the tabs without counts until it lands.
- **Name/phone search** — `AdminVerificationQueueFilters` has only `status`; add the search field behind
  the draft-vs-applied pattern and file the REQ for the query param.
- **Waiting-time column** — client-computed age from the served `submittedAt` (display-only): relative
  Shamsi age with SLA coloring (`--bal-warning` past 48h, `--bal-error` past 96h; named constants).
- **Keyboard-friendly next-case flow** — from `verification/[nurseId]`, «پرونده بعدی»/«پرونده قبلی»
  affordances (+ arrow-key bindings) walking the current queue order via the 3.1 URL state — a reviewer
  never round-trips to the list between cases. A full split-pane case view is **(DEFERRED —
  post-chain)**: next/prev delivers the throughput win at a fraction of the layout risk.
- `DocumentViewer`'s signed-URL flow (on-demand fetch, expired→re-request) is **do-not-regress**.

### 3.4 Ticket console — lifecycle + a safe composer

- **Close/reopen + assign-to-me on the thread header.** The b15 contract exposes no close mutation
  (verified: `services/tickets/hooks/` has none). Add `useCloseTicket`/`useReopenTicket`/
  `useAssignTicket` hooks with full `mockApi` implementations and `clientApi` mapped to the proposed
  routes; file the REQ (§4). On the real path, gate the controls behind a `TICKET_LIFECYCLE_ENABLED`
  constant in `services/tickets/constants.ts` — never show a button that can only 404. Close is
  `ConfirmDialog`-guarded.
- **Queue columns: unread + last-activity.** REQ-028 delivered `unreadCount`/`lastMessageAt` for the
  *user* list, but the admin queue is explicitly served `unreadCount = 0` (tracker delivery note).
  [Phase 10](ui-phase-10-messaging-and-notifications.md) owns filing the admin-side extension —
  **reference its REQ, don't double-file**; render the columns when present, fall back to `createdAt`.
- **Scroll-to-latest on open** — anchor the `:150` message Box to the newest message on thread load.
- **Internal-note mode made unmistakable:** when `internal` is active, the composer Paper gets an
  amber surface (`--bal-warning` soft, both schemes) and the send button label swaps to
  «ثبت یادداشت داخلی» — the safety cue lives *on the action*, not only in the toggle above.

### 3.5 Money-desk safety

- **Payout run confirm shows the movement summary:** batch total (Toman via the shared formatter),
  nurse count, and processing date — all from the server preview the dialog already fetched — plus a
  **typed confirmation** (type «تایید» or the exact amount) enabling the final-run confirm button: the
  standard guard for an irreversible money movement.
- **Fix the UTC off-by-one:** replace `payouts/page.tsx:56`'s `toISOString().slice(0,10)` with
  local-date formatting (and adopt phase 1's `JalaliDatePicker` for the window inputs — 3.6).
- **Reconcile/retry polish:** transfer-reference entry stays `dir="ltr"`; failed payout rows get a
  visible failure reason and a retry affordance through `useRetryPayout`; localize skipped-reason
  strings if served as codes (raw free-text reasons stay `dir="ltr"` as today).

### 3.6 Primitives v2 (extend, never fork)

- **`AdminDataTable`:** optional per-column server-param sort (a `sort` callback + direction chevron —
  the filter object is already the query key, so sort is just another applied param through 3.1);
  sticky header for long pages; per-column `minWidth`; a footer line «نمایش ۱–۲۰ از ۱۲۴» — callers
  already hold `total`. Keep `align: 'inherit'` (RTL-safe) and the horizontal-scroll container.
- **`AdminPager`:** take `total`, restore «صفحه {page} از {total}» in the admin i18n namespace (the
  non-admin namespace kept it), locale digits via the existing formatting utils. Update both tests.
- **Detail headers:** unify the four divergent patterns (verification case = `AdminPageHeader` + back;
  ticket thread = h6-in-Paper; payout batch = raw h5; partner-center detail = h5 + a back button
  misusing the `partners` icon) onto phase 1's shared `PageHeader` (title, back affordance, chips
  slot). If `PageHeader` lacks a slot, extend it minimally and note it (ownership rule — no fork).
- **Jalali date inputs everywhere:** adopt phase 1's `JalaliDatePicker` (Shamsi display, ISO-Gregorian
  wire value) for audit from/to, payout windows, holiday date, and credential issued/expires
  (`verification/[nurseId]/page.tsx`). No native `type="date"` remains under `/admin`.
- **`AuditLogRow`:** rotate the chevron on expand, add `aria-expanded` + button semantics; resolve
  actor IDs to names via 3.2's batch id→label lookup (same REQ), falling back to `#id` until it lands.

### 3.7 Partner portal — professional, light-touch

- **Localize booking statuses** (the worst partner-facing defect, verified at
  `partner/bookings/page.tsx:17,45,66-69`): map the seven codes to `StatusChip` kinds + fa/en labels in
  both the filter menu and the table chip. No raw wire code ever reaches a partner's screen again.
- **Scoped read-only booking detail:** link each sponsored-booking row to a summary detail (dates,
  status timeline, patient display name — no clinical data). `services/partnerCenter` has only the
  list read — file the REQ; build the detail mock-tolerant behind the seam.
- **CSV export on settlement** — client-side, current result set, UTF-8 **with BOM** (so Excel renders
  Persian correctly) + CRLF; a small `toCsv` util with a co-located test — for the center's accountant.
- **Portal identity in the chrome:** carry the center's name + MoR state persistently across portal
  pages (home already shows the MoR `StatusChip`; lift a compact identity block via `useMyPartnerCenter`).
  If this touches `layout/`, extend phase 2's shell minimally and note it.

### 3.8 Dead ends & honest nav

- **Users console:** replace `admin/users/page.tsx`'s placeholder with a **read-first directory**
  (search by phone/name via the 3.2 endpoint, role chips, links into audit/tickets) **only if** the
  user-search REQ is granted during this phase. Otherwise **hide the nav entry** (same rule phase 10
  applies to admin notifications) and say so honestly in the report — a production nav ships no
  placeholder dead-ends.
- Small verified cleanups: delete the dead ternary at `config/page.tsx:152`; fix the misleading
  `TODAY_ISO` constant/comment at `holidays/page.tsx:106` (seed today's local date or drop the lie).

## 4. Mocks & seams in this phase

No new seams. Every backend gap becomes a REQ appended to
[../../shared-working-context/frontend/requests/for-backend.md](../../shared-working-context/frontend/requests/for-backend.md)
— **REQ-001…038 are taken; check the tracker's current tail (other UI phases may have appended) and
number onward.** File, at minimum: **(1) admin user lookup** — search by name/phone →
`{ id, displayName, maskedPhone, roles }` + a batch id→label endpoint (powers 3.2 and 3.6);
**(2) verification queue enrichment** — name/phone search + per-status counts (3.3), optionally a
`submittedAt` sort param; **(3) ticket lifecycle mutations** — close/reopen + assign (3.4);
**(4) partner scoped booking detail** — read-only summary (3.7). For each: build the UI complete against
the domain's `mockApi`, map `clientApi` to the proposed route, and gate real-path controls that would
otherwise 404. Do **not** re-file phase 10's admin ticket-queue enrichment — reference it.

## 5. Critical rules you must not get wrong

- **Server-authority posture stays.** `useAdminCapabilities` flags only *hide* controls; money is never
  recomputed client-side (payout eligibility and the holiday shift come from the server; the 3.5
  summary re-renders served preview numbers, never sums).
- **`ConfirmDialog` on every audited/irreversible action** — approve/reject, moderate, revoke,
  run/retry payout, resolve alert, close ticket — with required-reason gating where it exists today,
  loading that disables both buttons, destructive color. The 3.2 pickers make its body *human*.
- **`is_internal` never enters user-facing types** — the admin-only typing boundary from f14/f15 stays;
  3.4's composer work touches presentation only.
- **Draft-vs-applied filtering stays** — typing never refetches; Apply commits the query key (and now
  the URL). 3.1 syncs *applied* state only.
- **Density is a feature.** Keep `size="small"`, dense tables, tight vertical rhythm — no consumer-app
  whitespace or hero moments in a worklist tool.
- **PII discipline stays:** write-then-masked settlement IBAN, never-echoed credential numbers, signed
  document URLs, non-leaking partner access-denied state, masked phones in the new pickers.
- Design contract: i18n in **both** catalogs; tokens not hexes (`--bal-*` in both scheme blocks); RTL
  logical props (`borderInlineStart`, `align: 'inherit'`, `dir="ltr"` islands for IBANs/references);
  dark mode by construction; MUI v9 API only; co-located tests for every shared component/hook touched;
  `clientFetch`/cookie rules untouched.

## 6. Definition of Done

On top of the shared [definition-of-done.md](../../phases/_shared/definition-of-done.md):

- [ ] `npm run check` green; `npm run test:ci` green including updated tests for `AdminDataTable`,
      `AdminPager`, `UserPicker`, `useAdminListState`, and the CSV util; `en.json`/`fa.json` in sync.
- [ ] Filter + page state on every admin/partner queue survives refresh, browser back from a detail
      page, and a pasted URL; config and holidays can page past page 1.
- [ ] No `type="number"` user-ID input remains on any audited action; every confirm body names the
      resolved person; alert assign-to-me can never target user #1.
- [ ] Verification queue: search field + SLA-colored waiting-time column render; next/prev case
      navigation works. Ticket thread: opens scrolled to the latest message; internal mode shows the
      amber composer and «ثبت یادداشت داخلی» send label; close/reopen works on the mock path.
- [ ] Payout run confirm shows batch total/count/date and requires the typed confirmation; window
      defaults are correct at local midnight (no UTC drift).
- [ ] Partner bookings show localized `StatusChip` statuses in filter and table; settlement exports a
      CSV that opens correctly in Excel with Persian text.
- [ ] No placeholder screen is reachable from the admin nav; REQs filed with correct sequential
      numbers, no duplicate of phase 10's REQ.
- [ ] Visual verification on all four axes (`/fa` + `/en` × light + dark), desktop-first (this is a
      desk tool) with a mobile sanity pass on the partner portal.

## 7. How to test (what a human can verify after this phase)

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 or seeded rows) → edit a row from page 2 → saves; the
   history drawer pages too.
3. `/fa/admin/roles` → «اعطای نقش»: type a partial name → options show name + masked phone + id →
   pick one → the ConfirmDialog names the person, not a number.
4. `/fa/admin/alerts` before `/me` hydrates (throttle the network): "assign to me" is disabled.
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.
6. Open a long ticket thread → scrolled to the newest message; internal mode → composer turns amber,
   send button reads «ثبت یادداشت داخلی»; close (mock path) → it leaves the open queue; reopen restores.
7. `/fa/admin/payouts`: window defaults match today's local date; preview → run → the confirm shows
   total/count/date and stays disabled until «تایید» (or the amount) is typed.
8. `/fa/admin/audit`: pick from/to with the Jalali picker (no Gregorian native input); expand a row →
   chevron rotates, `aria-expanded` toggles, actor shows a name (or `#id` fallback).
9. `/fa/partner/bookings`: statuses render as Persian `StatusChip`s in table and filter;
   `/fa/partner/settlement` → «خروجی CSV» opens in Excel with correct Persian.
10. Every pager/footer reads «صفحه ۲ از ۷» / «نمایش ۱–۲۰ از ۱۲۴» with locale digits; verify the four
    axes; no admin nav item leads to a placeholder.

## 8. Hand off & document (close the phase)

- Update `client/CLAUDE.md` (Project Structure) for new files: `useAdminListState`, `UserPicker`/
  `NursePicker`, the CSV util, any `PageHeader` extension, hidden/added routes.
- Write the report at `dev/shared-working-context/reports/ui-phase-11-report.md`: what shipped, the
  exact REQ numbers filed, which controls are gated awaiting delivery, any minimal foundation
  extensions to phase 1/2 files, and the users-console decision (built vs nav hidden).
- Save a memory note per operating-rules §8: URL-synced admin list state, the picker pattern replacing
  raw IDs, the ticket-lifecycle gating constant, the REQ numbers — phase 12 (copy/motion) sweeps these
  surfaces.
