22 KiB
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 intosearchParamsviarouter.replace({ scroll: false }); draft stays local (typing never refetches or touches the URL) untilapply()/applyFilters(explicitValue)/clear()/goToPage(n)commit it. Initialapplied/pageare read from the URL once, on mount.applyFiltersexists because a discrete control (a status tab/ select) that should commit the instant it changes cannot safely dosetDraft(next); apply()in the same handler —apply()closes over the previous render'sdraft, so it would commit the stale value; two pages hit this bug during integration and were fixed to useapplyFiltersinstead (a co-located test covers it). Also exportsuseAdminBackToList(listHref)— a realrouter.back()when there's browser history, falling back to pushinglistHrefotherwise (the detail-page "back" fix). - Because it calls
useSearchParams(), every page using it wraps its body in<Suspense>(the existingSearchScreen.tsxpattern) — a default-exported thin wrapper + a*Inner/*Screenbody 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.
roleswas 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, andadmin/holidays/page.tsx(useHolidays) now carry real page state + anAdminPager— previously any row beyond page 1 was invisible/uneditable. - Detail pages (tickets thread, payout batch, partner center) now use
useAdminBackToList(orPageHeader's newonBack) instead of a hand-rolledrouter.pushto the bare list.
3.2 UserPicker/NursePicker — killing raw-ID targeting
- New
client/src/components/admin/UserPicker/— an async MUIAutocomplete(name/phone search, 300ms debounce via the existinguseDebouncedValue) rendering name + masked phone +#idper option, never a bare id.NursePickeris a thinroleFilter="nurse"wrapper — its selection carriesnurseProfileId(a different id space than the user id, the one sponsorship/roster assignment actually needs). Both co-located-tested (mock theuseUserSearchhook, noQueryClientProviderneeded). - Backed by a new admin user-directory seam (REQ-061, gap):
AdminUserSummarytype,searchUsers/lookupUsersonAdminApi, a seeded ~13-entry mock directory (admin/support/finance staff, nurses withnurseProfileId, customers, one partner contact),useUserSearch/useUserLookuphooks.useUserLookupis 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, viaNursePicker).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'smeId = authState.currentUser?.id ?? 1is gone. The button is nowdisabledwith atitle/Tooltip(«در حال بارگذاری حساب شما…») until the real id hydrates — it can never silently target user#1.SupportAlertCardgainedassignSelfDisabled/assignSelfDisabledTitleprops 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 servescounts(REQ-062, gap — mock computes real counts over the whole unfiltered queue; the real client sends the param but the response field staysundefined, 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 pastWAITING_TIME_WARNING_HOURS=48(--bal-warning) andWAITING_TIME_ALARM_HOURS=96(--bal-error) — named constants, never magic numbers. - Next/prev case navigation:
admin/verification/[nurseId]/page.tsxre-derives the queue's filters/page from the URL (a newqueueFilters.tsshared by both pages) and callsuseVerificationQueuewith the same params — React Query serves it from the list's own cache, no extra fetch — to compute the previous/nextnurseVerificationIdin the current queue order. «پرونده بعدی»/«پرونده قبلی» buttons +ArrowLeft/ArrowRightwindow 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 nativetype="date"inputs (issued/expires) are nowJalaliDateField.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 (StoredTicketgainedassigneeUserId) andclientApimethods mapped to proposed routes, all gated behindTICKET_LIFECYCLE_ENABLED(services/tickets/constants.ts, defaultfalse) so no control points at a 404ing route in production. The thread header gets close/reopen buttons (viaConfirmDialog, no reason required — closing is terminal, not destructive) + "assign to me" (disabled-with-tooltip until hydrated, never a fallback id), all also gated oncaps.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 itsbottomRefafter 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
Paperturns amber (--bal-warning/--bal-warning-soft, both schemes) and the send button relabels to «ثبت یادداشت داخلی» whenevermode === '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 newfooterprop). REQ-028'sunreadCount/lastMessageAtare real on the user ticket list but the admin queue (AdminTicketSummary) is explicitly servedunreadCount = 0per 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 acreatedAt-based activity column as the honest fallback.
3.5 Money-desk safety
- Fixed the UTC off-by-one:
admin/payouts/page.tsx'sisoDatehelper now formats via localgetFullYear()/getMonth()/getDate()instead oftoISOString().slice()— near Tehran midnight the prefilled payout window no longer lands on yesterday. AdoptedJalaliDateFieldfor 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 sharedConfirmDialog(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 +useRetryPayoutretry.SkippedNurse.reasonwas checked against the real path (previewPayoutBatchcurrently always returnsskipped: []— a REQ-036 gap; only the mock invents string reasons) and correctly leftdir="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
PageHeaderand adopts the page-only slice ofuseAdminListState.
3.6 Primitives v2
AdminDataTable: optional per-columnsortable+ asort/onSortChangepair (renders MUI's nativeTableSortLabel, the caller owns the 3-state cycle), an opt-instickyHeader(bounded scroll viewport,stickyMaxHeight, MUI's ownstickyHeadermechanics — a self-contained viewport rather than depending on window-scroll math or alayout/import), per-columnminWidth, and afooter?: stringline (callers pass the existingt('showing_range', {from,to,total})i18n key — it already existed in theadminnamespace, just unused until now).align: 'inherit'and the horizontal-scroll container are unchanged.AdminPager: theadmin.page_indicatori18n key regained its{total}(«صفحه {page} از {total}» — it had regressed to"صفحه {page}"while the non-admincommon.page_indicatorkept the full form). The component's own API is unchanged (it still takes a caller-composedindicatorstring, matching its established pattern); every caller across the whole admin/partner surface now passest('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 throughPageHeader. It gained two small, additive props during integration:meta?: ReactNode(a chip-row slot below the title, distinct from the button-orientedactions— the ticket thread's category/status/linked-record chips) andonBack?: () => void(an alternative tobackToforuseAdminBackToList-style back navigation, takes precedence when both are given). Both are additive/optional; no existing caller changed behavior. - Jalali date inputs everywhere:
JalaliDateFieldreplaces every nativetype="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 (CSStransform,--bal-motion-fast), the header carriesrole="button"/tabIndex/aria-expanded+Enter/Spacekeyboard support (kept as aStackwith ARIA semantics rather than a real<button>, avoiding a polymorphic-componentTS friction point). A newactorLabelprop (+ exportedactorDisplay/actorLabelFromhelpers) renders the resolved actor name, falling back to#iduntil the REQ-061 lookup lands; the audit page and the roles grid both batch-resolve every visible actor id in oneuseUserLookupcall.
3.7 Partner portal — professional, light-touch
- Localized booking statuses: the 7 wire codes (
pending_payment…cancelled) now render throughStatusChip+ 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.tsxalso adoptsuseAdminListState. - Scoped read-only booking detail: a new
partner/bookings/[id]/page.tsx— dates, a status timeline (the sharedStatusTimeline), and the patient's display name only, no clinical content, address, or money. Backed by a newgetMySponsoredBookingDetailonPartnerCenterApi(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 onpartner/settlement/page.tsx— a client-side, current-result-set export via a UTF-8-BOM-prefixedBlobdownload, 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-recordStatusChipbeside it, so the MoR state is now visible on every portal page, not only the home screen. Additive change to shared layout chrome;ProfileSummaryitself is untouched.
3.8 Dead ends & honest nav
admin/usersis now a real read-first directory (was aPlaceholderScreen) — search by name/phone over the same REQ-061 seamUserPickeruses, 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/notificationsstays a placeholder, but it was never inAdminLayout'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 inadmin/config/page.tsxis gone (theTextFieldjust has notypeprop now —textwas always the only outcome);admin/holidays/page.tsx's lyingTODAY_ISO = ''constant is replaced by a realtodayLocalIso()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 —
interfaces 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)
/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./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_ratehave seeded history)./fa/admin/roles→ «اعطای نقش»: type a partial name → options show name + masked phone + id → pick one → the confirm text names the person, not a number./fa/admin/alertsbefore/mehydrates (throttle the network in devtools): "assign to me" is disabled with a tooltip./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./fa/admin/tickets/[id]: open a long thread → scrolled to the newest message; toggle internal mode → composer turns amber, send button reads «ثبت یادداشت داخلی»; close (behindTICKET_LIFECYCLE_ENABLED— flip it locally to demo) → leaves the open queue; reopen restores it./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./fa/admin/audit: pick from/to with the Jalali picker (no native date input anywhere); expand a row → chevron rotates,aria-expandedtoggles, the actor shows a resolved name (or#iduntil resolved)./fa/partner/bookings: statuses render as PersianStatusChips 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./fa/admin/users: search a seeded name/phone (min 2 chars) → results show role chips + a working audit link.- 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— theadmin//partner/Project Structure subtrees rewritten for every page's ui-phase-11 changes; a newcomponents/admin/line describingAdminDataTablev2/AdminPager/AuditLogRowv2/UserPicker/NursePicker;PageHeader/ConfirmDialogentries updated for their new props;hooks/andutils/entries updated (useAdminListState/useAdminBackToList,toCsv.ts);PartnerLayout.tsx's line updated for the MoR chip; theservices/admindomain bullet updated for the user-directory hooks.dev/shared-working-context/reports/mocks-registry.md—AdminApi/TicketsApi/VerificationApi/PartnerCenterApirows 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): additivemeta?: ReactNode(chip-row slot) andonBack?: () => void(callback back-nav, takes precedence overbackTo) — both optional, zero behavior change for existing callers, tests updated.components/common/ConfirmDialog/ConfirmDialog.tsx(phase-1-owned): additiverequireTypedConfirmation?: 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: additiveassignSelfDisabled/assignSelfDisabledTitleprops.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
clientApiis already written and route-shaped; flippingUSE_*_MOCK(orTICKET_LIFECYCLE_ENABLEDfor 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/lastMessageAtenrichment (the REQ-028 admin-side gap) stays ui-phase-10's to file — referenced, not duplicated, here.