Files
baya-monorepo/dev/shared-working-context/reports/ui-phase-11-report.md
T
2026-07-19 19:19:44 +03:30

22 KiB
Raw Blame History

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 <Suspense> (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 <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 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 <button>, avoiding a polymorphic-component TS friction point). A new actorLabel prop (+ exported actorDisplay/actorLabelFrom helpers) renders the resolved actor name, falling back to #id until the REQ-061 lookup lands; the audit page and the roles grid both batch-resolve every visible actor id in one useUserLookup call.

3.7 Partner portal — professional, light-touch

  • Localized booking statuses: the 7 wire codes (pending_paymentcancelled) now render through StatusChip + 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.tsx also adopts useAdminListState.
  • Scoped read-only booking detail: a new partner/bookings/[id]/page.tsx — dates, a status timeline (the shared StatusTimeline), and the patient's display name only, no clinical content, address, or money. Backed by a new getMySponsoredBookingDetail on PartnerCenterApi (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 on partner/settlement/page.tsx — a client-side, current-result-set export via a UTF-8-BOM-prefixed Blob download, 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-record StatusChip beside it, so the MoR state is now visible on every portal page, not only the home screen. Additive change to shared layout chrome; ProfileSummary itself is untouched.

3.8 Dead ends & honest nav

  • admin/users is now a real read-first directory (was a PlaceholderScreen) — search by name/phone over the same REQ-061 seam UserPicker uses, 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/notifications stays a placeholder, but it was never in AdminLayout'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 in admin/config/page.tsx is gone (the TextField just has no type prop now — text was always the only outcome); admin/holidays/page.tsx's lying TODAY_ISO = '' constant is replaced by a real todayLocalIso() 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)

  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 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_rate have seeded history).
  3. /fa/admin/roles → «اعطای نقش»: type a partial name → options show name + masked phone + id → pick one → the confirm text names the person, not a number.
  4. /fa/admin/alerts before /me hydrates (throttle the network in devtools): "assign to me" is disabled with a tooltip.
  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; arrow keys work.
  6. /fa/admin/tickets/[id]: open a long thread → scrolled to the newest message; toggle internal mode → composer turns amber, send button reads «ثبت یادداشت داخلی»; close (behind TICKET_LIFECYCLE_ENABLED — flip it locally to demo) → leaves the open queue; reopen restores it.
  7. /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.
  8. /fa/admin/audit: pick from/to with the Jalali picker (no native date input anywhere); expand a row → chevron rotates, aria-expanded toggles, the actor shows a resolved name (or #id until resolved).
  9. /fa/partner/bookings: statuses render as Persian StatusChips 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.
  10. /fa/admin/users: search a seeded name/phone (min 2 chars) → results show role chips + a working audit link.
  11. 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 — the admin//partner/ Project Structure subtrees rewritten for every page's ui-phase-11 changes; a new components/admin/ line describing AdminDataTable v2/AdminPager/ AuditLogRow v2/UserPicker/NursePicker; PageHeader/ConfirmDialog entries updated for their new props; hooks/ and utils/ entries updated (useAdminListState/useAdminBackToList, toCsv.ts); PartnerLayout.tsx's line updated for the MoR chip; the services/admin domain bullet updated for the user-directory hooks.
  • dev/shared-working-context/reports/mocks-registry.mdAdminApi/TicketsApi/VerificationApi/ PartnerCenterApi rows 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): additive meta?: ReactNode (chip-row slot) and onBack?: () => void (callback back-nav, takes precedence over backTo) — both optional, zero behavior change for existing callers, tests updated.
  • components/common/ConfirmDialog/ConfirmDialog.tsx (phase-1-owned): additive requireTypedConfirmation?: 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: additive assignSelfDisabled/assignSelfDisabledTitle props.
  • 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 clientApi is already written and route-shaped; flipping USE_*_MOCK (or TICKET_LIFECYCLE_ENABLED for 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/lastMessageAt enrichment (the REQ-028 admin-side gap) stays ui-phase-10's to file — referenced, not duplicated, here.