cleanup phases 6
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
# 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_payment`…`cancelled`) 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 —
|
||||
`interface`s 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 `StatusChip`s 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.md` — `AdminApi`/`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.
|
||||
Reference in New Issue
Block a user