# 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 `` (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 ``, 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 `