Files
baya-monorepo/archive/post-phase/ui/ui-phase-11-admin-and-partner-console.md
T
2026-08-02 18:48:32 +03:30

285 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 02](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.