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