diff --git a/client/CLAUDE.md b/client/CLAUDE.md
index 2b95ea2..02cafa2 100644
--- a/client/CLAUDE.md
+++ b/client/CLAUDE.md
@@ -185,31 +185,31 @@ client/
│ │ │ ├── earnings/ # /nurse/earnings — f12 nurse earnings (read-only), ui-phase-7 pass: page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + a «برداشت بعدی» ForecastLine (server-served only) + an accessible ButtonBase ExplainerCard (aria-expanded, registered `expand` chevron) + state-segmented EarningsRow list (deep-links to /nurse/visits/[id], shared Pager) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links); failed-payout reasons now map through `services/payouts/failureReasons.ts` (mapped label headline, raw code demoted to a secondary LTR caption)
│ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx
│ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here
- │ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces).
+ │ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces). ui-phase-11: every queue page adopts `useAdminListState` (URL-synced filters+page, `@/hooks`) behind a `` wrapper.
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=admin) → AdminLayout (capability-gated nav)
│ │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
│ │ │ ├── page.tsx # Thin RSC — generateMetadata (admin.overview_title) + renders AdminOverviewScreen
│ │ │ ├── AdminOverviewScreen.tsx # 'use client' — f15 overview landing: a capability-gated grid of console cards
- │ │ │ ├── verification/ # /admin/verification — f15 review queue (page.tsx: status-filtered nurse worklist) ↔ [nurseId]/page.tsx per-nurse case (DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry, Approve enabled only when all steps pass — client never writes is_verified)
- │ │ │ ├── tickets/ # /admin/tickets — f15 global ticket queue (page.tsx: filter status/category/referenceCode) ↔ [id]/page.tsx admin thread (AdminMessageBubble renders isInternal notes distinctly; internal-note composer; RefundPanel opens from a refund ticket)
- │ │ │ ├── payouts/ # /admin/payouts — f15 batch dashboard (page.tsx: batches + preview-next-batch dialog → run, idempotency-keyed) ↔ [batchId]/page.tsx per-nurse rows + failed-payout retry + transfer-reference reconcile
- │ │ │ ├── reviews/page.tsx # /admin/reviews — f15 moderation queue: publish/hide/reject (reason on hide/reject); low-rating flag; client never computes the aggregate
- │ │ │ ├── config/page.tsx # /admin/config — f15 config editor: typed input by data_type + 0–1 rate validation + audited-save dialog + change-history drawer
- │ │ │ ├── holidays/page.tsx # /admin/holidays — f15 Iranian-holiday manager (is_bank_closed toggle; client never computes the payout shift)
- │ │ │ ├── alerts/page.tsx # /admin/alerts — f15 internal support-alert worklist (assign/resolve); NEVER surfaced to a non-admin
- │ │ │ ├── audit/page.tsx # /admin/audit — f15 append-only audit viewer (filtered, paginated, expandable changedFields diff; no edit/delete)
- │ │ │ ├── partners/ # /admin/partners — f15 partner-center management (page.tsx: list + create) ↔ [id]/page.tsx detail (verify/activate/suspend + edit + sponsored-nurse roster + assign-nurse; IBAN write-then-masked)
- │ │ │ ├── roles/page.tsx # /admin/roles — f15 RBAC grant/revoke grid (DEFERRED-IF-MISSING — mock-backed until the b15 role endpoints land)
- │ │ │ ├── users/page.tsx # /admin/users
- │ │ │ └── notifications/page.tsx # /admin/notifications
+ │ │ │ ├── verification/ # /admin/verification — ui-phase-11 rebuild: page.tsx = status **Tabs** with server counts (when served, REQ-062) + a name/phone search behind the draft-vs-applied Apply/Clear pattern + a client-computed SLA-colored waiting-time column (`WAITING_TIME_WARNING_HOURS`/`_ALARM_HOURS`) ↔ [nurseId]/page.tsx per-nurse case (unchanged DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry with `JalaliDateField` issued/expires, Approve enabled only when all steps pass) + «پرونده بعدی/قبلی» next/prev case nav (re-derives the queue's cached page via `queueFilters.ts`) + arrow-key bindings — client never writes is_verified
+ │ │ │ ├── tickets/ # /admin/tickets — ui-phase-11: page.tsx adds an activity column + a results footer ↔ [id]/page.tsx admin thread gains close/reopen/assign-to-me (`useCloseTicket`/`useReopenTicket`/`useAssignTicket`, gated behind `TICKET_LIFECYCLE_ENABLED` + `canManageTickets` — REQ-063, no live route yet), opens scrolled to the newest message (`useThreadScroll`), and the composer turns amber (`--bal-warning-soft`) + relabels its send button in internal-note mode so a note can't be posted publicly by mistake; AdminMessageBubble still renders isInternal notes distinctly; RefundPanel opens from a refund ticket
+ │ │ │ ├── payouts/ # /admin/payouts — ui-phase-11: page.tsx fixes the local-midnight UTC off-by-one on the window default, adopts `JalaliDateField` for the period inputs, and the run-confirm shows the batch total/count/date (from the already-fetched preview) behind `ConfirmDialog`'s new typed-confirmation gate (type «تایید» or the amount) ↔ [batchId]/page.tsx per-nurse rows + failed-payout retry + transfer-reference reconcile, unified onto `PageHeader`
+ │ │ │ ├── reviews/page.tsx # /admin/reviews — f15 moderation queue: publish/hide/reject (reason on hide/reject); low-rating flag; client never computes the aggregate; ui-phase-11 adopts `useAdminListState`
+ │ │ │ ├── config/page.tsx # /admin/config — f15 config editor: typed input by data_type + 0–1 rate validation + audited-save dialog + change-history drawer; ui-phase-11 gives both the list and the history drawer real page state (was hard-wired to page 1) and drops the dead `dataType==='int'||'decimal'?'text':'text'` ternary
+ │ │ │ ├── holidays/page.tsx # /admin/holidays — f15 Iranian-holiday manager (is_bank_closed toggle; client never computes the payout shift); ui-phase-11 adopts `JalaliDateField` for the holiday date, seeds new-holiday date from a real local-date helper (was a lying `TODAY_ISO = ''`), and real page state
+ │ │ │ ├── alerts/page.tsx # /admin/alerts — f15 internal support-alert worklist (assign/resolve); NEVER surfaced to a non-admin; ui-phase-11 fixes assign-to-self's `?? 1` fallback — the button disables with a "loading your account" tooltip until the real id hydrates — and adopts `useAdminListState`
+ │ │ │ ├── audit/page.tsx # /admin/audit — f15 append-only audit viewer (filtered, paginated, expandable changedFields diff; no edit/delete); ui-phase-11 adopts `useAdminListState` + `JalaliDateField` for from/to + batch-resolves actor names (`useUserLookup`) for `AuditLogRow`'s new `actorLabel` prop (falls back to `#id`)
+ │ │ │ ├── partners/ # /admin/partners — f15 partner-center management (page.tsx: list + create) ↔ [id]/page.tsx detail (verify/activate/suspend + edit + sponsored-nurse roster + assign-nurse; IBAN write-then-masked); ui-phase-11 replaces both the admin-user and sponsored-nurse raw-id `TextField`s with `UserPicker`/`NursePicker` (name+masked-phone+id search, §3.2) and unifies the detail header onto `PageHeader`
+ │ │ │ ├── roles/page.tsx # /admin/roles — f15 RBAC grant/revoke grid (DEFERRED-IF-MISSING — mock-backed until the b15 role endpoints land); ui-phase-11 replaces the raw numeric-id grant `TextField` with `UserPicker` — the confirm/revoke copy now names the resolved person, never `#42`
+ │ │ │ ├── users/page.tsx # /admin/users — ui-phase-11: a real read-first directory (was a `PlaceholderScreen`) — search by name/phone over the same admin user-directory seam `UserPicker` uses (REQ-061, mock-backed), role chips, a per-row link into the audit log filtered to that user
+ │ │ │ └── notifications/page.tsx # /admin/notifications — still a placeholder; NOT in `AdminLayout`'s nav (no real feed yet, ui-phase-10/11) so it satisfies "no placeholder reachable from admin nav"
│ │ └── partner/ # Partner-center portal (/partner/…) — a SEPARATE authz scope (f15). A center admin is not a Balinyaar admin; each page resolves the caller's OWN center (useMyPartnerCenter → access-denied on 403/404).
│ │ ├── layout.tsx # 'use client' — RoleGuard (no expected role — hydration-only) → PartnerLayout (own partner nav; self-gates via useMyPartnerCenter)
│ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
│ │ ├── page.tsx # Thin RSC — generateMetadata (partner.home_title) + renders PartnerHomeScreen
│ │ ├── PartnerHomeScreen.tsx # 'use client' — center home: onboarding/verification state banner + license fields + is_merchant_of_record indicator
│ │ ├── nurses/page.tsx # /partner/nurses — the center's sponsored nurses (verification badge)
- │ │ ├── bookings/page.tsx # /partner/bookings — the bookings the center legally covers (read-only summaries)
- │ │ └── settlement/page.tsx # /partner/settlement — rendered ONLY when is_merchant_of_record: per-booking commission invoices (commission/VAT decomposition via PartnerSettlementRow, signed-URL PDF, masked IBAN); non-MoR shows the "settlement via Balinyaar" state
+ │ │ ├── bookings/ # /partner/bookings — ui-phase-11: page.tsx localizes the 7 booking-status codes onto `StatusChip` (was raw English wire codes, e.g. `pending_payment`) in both the table and filter, adopts `useAdminListState`, and rows link to ↔ [id]/page.tsx (new) — a scoped read-only detail (dates, status timeline via the shared `StatusTimeline`, patient display name only — no clinical data; REQ-064, mock-backed)
+ │ │ └── settlement/page.tsx # /partner/settlement — rendered ONLY when is_merchant_of_record: per-booking commission invoices (commission/VAT decomposition via PartnerSettlementRow, signed-URL PDF, masked IBAN); non-MoR shows the "settlement via Balinyaar" state; ui-phase-11 adds a client-side «خروجی CSV» export (`utils/toCsv.ts`, UTF-8 BOM + CRLF for Excel) of the current result set
│ ├── (customer-focused)/ # ui-phase-3 — chrome-free counterpart to (customer) for can't-tab-away flows; same URL space (route groups add no segment)
│ │ ├── layout.tsx # 'use client' — RoleGuard(expected=customer) → FocusedLayout (no BottomBar/bell/sidebar)
│ │ └── onboarding/ # /onboarding — moved here from (customer) so the A3→A4 wizard can't be tabbed away from mid-setup
@@ -230,8 +230,8 @@ client/
│ │ ├── EmptyState/ # icon+title+body+action — the one "nothing here" pattern (replaces hand-rolled dashed-border Paper blocks)
│ │ ├── ErrorState/ # message+retryLabel(required)+onRetry — the one "this query failed" pattern; PRESENTATIONAL, no next-intl import (same reason as ErrorBoundary)
│ │ ├── QueryStateGate/ # wraps a query's skeleton/error/empty/children branching in the fixed skeleton→error→empty→children order; requires retryLabel
- │ │ ├── PageHeader/ # title+subtitle+actions+optional back button (backTo/backLabel)
- │ │ ├── ConfirmDialog/ # promoted from admin/ — required-reason gating + busy-disable, now usable by any actor
+ │ │ ├── PageHeader/ # title+subtitle+actions+optional back button (backTo/backLabel); ui-phase-11 added `meta` (a chip-row slot below the title, distinct from the button-oriented `actions`) and `onBack` (a callback alternative to `backTo` — pairs with `useAdminBackToList` for `router.back()`-with-fallback semantics; takes precedence over `backTo` when both are given)
+ │ │ ├── ConfirmDialog/ # promoted from admin/ — required-reason gating + busy-disable, now usable by any actor; ui-phase-11 added `requireTypedConfirmation`/`typedConfirmationLabel`/`typedConfirmationPlaceholder` — confirm stays disabled until the typed value matches one of the given strings, the guard for an irreversible money-moving action (the admin payout run confirm)
│ │ ├── SurfaceCard/ # flat Paper wrapper, padding: 'sm'|'md'|'lg'
│ │ ├── AccentCard/ # SurfaceCard + tone → 4px borderInlineStart accent (primary/secondary/success/error/warning/info/trust/neutral)
│ │ ├── Money/ # — the one money-rendering primitive (wraps utils/money.ts); size gained `xl` (h4) in ui-phase-6 for the checkout/confirmation prominent-total hero; imports next-intl (see jest.config.ts transformIgnorePatterns note below)
@@ -245,6 +245,7 @@ client/
│ │ ├── InitialsAvatar/ # ui-phase-9 — warm auto-colored initials for a person with no photo: deterministic name-hash → one of 6 `--bal-avatar-*` token pairs (tokens.css, both scheme blocks); `aria-hidden` (decorative next to a visible name); used by `PatientHeader` and (via `ProfileSummary`'s new `initialsFallback` prop) the customer account hub (tested)
│ │ ├── FormDialogShell/ # ui-phase-9 — full-screen-below-`sm` form dialog (app-bar header + close) with a dirty-gated discard-confirm on close/backdrop/escape; the shared primitive behind the patient/address add-edit dialogs (the hosted form reports `dirty` via an `onDirtyChange` prop) (tested)
│ │ └── index.tsx # barrel — keep next-intl-importing primitives (Money) below the presentational ones so the poisoning risk stays visible in review
+ │ ├── admin/ # f15 backoffice + partner composites (import from `@/components/admin`): AdminDataTable (v2, ui-phase-11 — optional per-column server-param `sort`/`sortable` + `TableSortLabel`, `stickyHeader` scroll viewport, `minWidth`, a `footer` line), AdminPager (page/pageCount; the admin `page_indicator` i18n key regained its `{total}`), AdminPageHeader/AdminEmptyState/AdminErrorState, ConfirmDialog (thin alias), ConfigRow/AuditLogRow (ui-phase-11 — expand chevron rotates + `aria-expanded` + button semantics, new `actorLabel` prop resolved via a batch id→name lookup)/SupportAlertCard (ui-phase-11 — `assignSelfDisabled`/`assignSelfDisabledTitle` so "assign to me" never falls back to a guessed user)/PartnerSettlementRow, DocumentViewer, RefundPanel, AdminMessageBubble, and (ui-phase-11) `UserPicker`/`NursePicker` — async name/phone `Autocomplete` over the admin user directory (REQ-061, mock-backed), replacing every raw numeric-id `TextField` on an audited action; each option renders name+masked-phone+id, never a bare id
│ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens
│ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe)
│ ├── PhoneNumberField/ # Iranian mobile field (digit-normalizing, LTR-in-RTL, maskIranMobile)
@@ -300,7 +301,7 @@ client/
│ ├── CustomerLayout.tsx # 'use client' — customer shell: contextual TopBar (brand lockup on the 5 root tabs, title+back on pushed routes) + mobile BottomBar, replaced by an inline desktop top-nav (CustomerDesktopNav) at ≥md; ui-phase-10 added a `useSupportUnreadTotal`-driven Badge on the root-tab support icon (renders only when a signal exists — mock-only until REQ-059)
│ ├── NurseLayout.tsx # 'use client' — nurse workspace via TopBarAndSideBarLayout: grouped sidebar (امروز/حرفهٔ من/مالی/پشتیبانی) + ProfileSummary identity card + ActorSwitcher, 5-tab mobile BottomBar («بیشتر» opens the same sidebar drawer); ui-phase-10 added `badgeCount` (useSupportUnreadTotal) on the support sidebar item
│ ├── AdminLayout.tsx # 'use client' — admin shell via TopBarAndSideBarLayout: sectioned sidebar (اعتماد/مالی/پشتیبانی/سیستم, useAdminCapabilities-gated, unchanged gating), TopBar identity chip (fine-grained role); no notification bell (ui-phase-10 — admin has no real feed yet, re-add via `NotificationBellPopover` once phase 11 ships one)
- │ ├── PartnerLayout.tsx # 'use client' — partner portal via TopBarAndSideBarLayout; TopBar identity chip shows the center's own name (useMyPartnerCenter, skeleton while resolving)
+ │ ├── PartnerLayout.tsx # 'use client' — partner portal via TopBarAndSideBarLayout; TopBar identity chip shows the center's own name (useMyPartnerCenter, skeleton while resolving); ui-phase-11 adds a compact merchant-of-record `StatusChip` beside it, persistent across every portal page
│ ├── PublicLayout.tsx # unauthenticated shell — minimal corner strip (logo + LocaleSwitcher + dark toggle), no sidebar/bottom bar; AuthCard renders its own larger BrandMark
│ ├── FocusedLayout.tsx # ui-phase-3 — chrome-free shell for can't-tab-away flows (today: onboarding): a slim logo strip + content, no BottomBar/bell/sidebar; the route group above it still applies RoleGuard
│ ├── TopBarAndSideBarLayout.tsx # 'use client' — the nurse/admin/partner engine: a fixed TopBar (useRouteTitle) + SideBar rendered as flex-row siblings (mobile temporary Drawer + desktop `variant="permanent"` Drawer switched by CSS `sx` breakpoints only — no `useIsMobile` structural branching, so desktop first paint already has the sidebar); optional `identity`/`sidebarIdentity`/`mobileBottomBar` slots
@@ -357,7 +358,7 @@ client/
│ ├── patientRecords/ # F13 continuity-of-care (b14) — patient-scoped, NOT booking-scoped. usePatientCareRecord(family record)/useRecordAccess(gates before any clinical fetch)/usePatientHistory(paged visit-note history)/useUpdateCareRecord(CUSTOMER-only edit → setQueryData)/useCreateVisitNote(NURSE-only append → invalidates history). seam+mock(PRIMARY)+client. The nurse-authored visit-note history/append (getPatientHistory/createVisitNote) are REAL b14 (GET/POST patients/{id}/care_records, mapped 1:1; the append folds the ticked task checklist into the note body); the family-owned editable record (medications/routine/tasks) + the access check have NO backend (REQ-027) and are mocked. Nurse is APPEND-ONLY (never wires useUpdateCareRecord). Access-denied (canView=false / 403) is a first-class non-leaking state; MOCK_FOREIGN_PATIENT_ID=8888 exercises it. Clinical text is never logged/localStorage/query-string
│ ├── tickets/ # F14 tickets — the ONLY sanctioned post-booking channel (b15). useMyTickets/useTicket+useTicketThread(select over detail; both poll every TICKET_THREAD_REFETCH_INTERVAL while a thread is mounted, ui-phase-10)/useOpenTicket(invalidates lists)/usePostMessage(OPTIMISTIC + retry-in-place, ui-phase-10: onMutate is idempotent on clientMessageId — a retry flips an existing `failed` bubble back to `sending` instead of appending a duplicate; onError no longer rolls back, it flips the bubble to `sendStatus:'failed'` in place so the text + a retry/discard affordance survive; onSettled also invalidates the unread-total badge)/useDiscardFailedMessage(non-mutation cache removal for the composer's discard-and-retype)/useSupportUnreadTotal(chrome badge, §3.1 — mock sums unread, real returns null until REQ-059). seam+mock(PRIMARY)+client(maps b15 1:1). **is_internal NEVER modelled in the user-app types** — both mappers DROP any internal message (server-strip mimic); no internal affordance anywhere. Mock stores an internal note it never returns (no-leak demo), seeds a booking-linked coordination ticket (idempotent for coordination+bookingId → "jump to existing"), tracks the last viewer so an optimistic message reconciles as mine, MOCK_SEND_FAIL_SENTINEL='/fail' drives the failure→retry path. `unreadCount`/`lastMessageAt` are REAL (REQ-028, delivered); `lastMessagePreview`/`lastAuthorRole` are the newer REQ-059 gap → mock-only, card degrades gracefully without them
│ ├── notifications/ # F14 in-app notification center (b1) — polled, no push. useNotifications(unread-first, growing limit, now takes an optional `{enabled}` so the ui-phase-10 bell popover can fetch only on open)/useUnreadCount(the POLLING bell: refetchInterval 60s + staleTime 45s + refetchOnFocus, auth-gated — count only, list never polled)/useMarkNotificationRead+useMarkAllRead(OPTIMISTIC setQueryData flips isRead + decrements/zeros the cached count, rollback on error, invalidate on settle). seam+mock(PRIMARY)+client(maps b1 1:1). data_json is a TYPED contract: parseNotificationData(type,dataJson)→discriminated NotificationData union (snake/camel tolerant, degrades to {kind:'none'} on malformed/unknown/missing id — never trusts a blob); notificationDeepLink(n,role) centralises the role-aware route (null when nothing to open). Mock seeds every deep-link class + __mockPushNotification for the bell-increment demo
- │ ├── admin/ # F15 backoffice-owned data (b1 + b15): config, holidays, audit, support-alerts, RBAC. usePlatformConfigs/useUpdatePlatformConfig/useConfigChangeHistory/useHolidays/useUpsertHoliday/useAuditLogs/useSupportAlerts/useAssignSupportAlert/useResolveSupportAlert/useAdminRoles/useGrantRole/useRevokeRole; seam+mock(PRIMARY)+client. Filters+page in each key (worklist filters cache separately). Mock-primary: config updatedAt/updatedBy + rich audit filters + the whole RBAC surface are gaps (REQ-029/030/031). support_alerts are internal-only — never rendered outside an admin route
+ │ ├── admin/ # F15 backoffice-owned data (b1 + b15): config, holidays, audit, support-alerts, RBAC, and (ui-phase-11) a user directory. usePlatformConfigs/useUpdatePlatformConfig/useConfigChangeHistory/useHolidays/useUpsertHoliday/useAuditLogs/useSupportAlerts/useAssignSupportAlert/useResolveSupportAlert/useAdminRoles/useGrantRole/useRevokeRole/useUserSearch/useUserLookup (ui-phase-11 — `searchUsers`/`lookupUsers` on `AdminApi`, backing `UserPicker`/`NursePicker` + `AuditLogRow`'s actor-name resolve); seam+mock(PRIMARY)+client. Filters+page in each key (worklist filters cache separately). Mock-primary: config updatedAt/updatedBy + rich audit filters + the whole RBAC surface + the user directory are gaps (REQ-029/030/031/061). support_alerts are internal-only — never rendered outside an admin route
│ ├── partnerCenter/ # F15 partner centers (b15): admin management + the center-scoped portal. usePartnerCenters/usePartnerCenter/useCenterSponsoredNurses/useCreate/useUpdate/useVerify/useSetActive/useAssignNurse (admin) + useMyPartnerCenter/useMySponsoredNurses/useMySponsoredBookings/useMySettlement (portal); seam+mock(PRIMARY)+client. settlement_iban masked last-4 (write-then-masked); merchant-of-record gates the settlement view; VAT on the commission line only (config vat_rate); deriveCenterState(isActive,verifiedAt). Mock-primary: portal split reads + activate/suspend + invoice total are gaps (REQ-032/033)
│ │ # Admin-endpoint ADDITIONS to existing domains (the staff lens — NOT new domains):
│ │ # verification → useVerificationQueue/useVerificationCase/useVerificationDocumentUrl(on-demand signed URL)/useDecideStep/useApproveVerification/useRejectVerification (b6; REQ-034)
@@ -387,8 +388,8 @@ client/
│ ├── typography.ts # TYPOGRAPHY_LTR (Space Grotesk) / TYPOGRAPHY_RTL (Mikhak) — shared size scale, 500/700 weight system
│ └── index.ts # Public re-exports (ThemeProvider, getDirection, APP_THEME_*)
├── constants/ # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts)
- ├── hooks/ # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome)
- ├── utils/ # incl. money.ts (IRR/Toman, integer-safe) + date.ts (Shamsi display) + number.ts (localeTag/formatNumber/formatRelativeTime/formatClock — the one home for locale-ternary formatting) + toEnglishDigits
+ ├── hooks/ # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome); ui-phase-11 added `useAdminListState.ts` — URL-synced worklist state (`useSearchParams`-based; callers need a `` boundary) mirroring **applied** filters+page into the URL (draft-vs-applied; `apply`/`applyFilters`/`clear`/`goToPage`) + `useAdminBackToList` (a real `router.back()` with a list-route fallback), adopted by every admin/partner queue page
+ ├── utils/ # incl. money.ts (IRR/Toman, integer-safe) + date.ts (Shamsi display) + number.ts (localeTag/formatNumber/formatRelativeTime/formatClock — the one home for locale-ternary formatting) + toEnglishDigits + toCsv.ts (ui-phase-11 — dependency-free CSV serializer, CRLF + comma/quote escaping; backs the partner settlement CSV export, UTF-8-BOM-prefixed for Excel)
└── config.ts
```
diff --git a/client/messages/en.json b/client/messages/en.json
index fee0f22..d6371b9 100644
--- a/client/messages/en.json
+++ b/client/messages/en.json
@@ -1491,7 +1491,7 @@
"actor": "Actor",
"timestamp": "Time",
"showing_range": "Showing {from}–{to} of {total}",
- "page_indicator": "Page {page}",
+ "page_indicator": "Page {page} of {total}",
"prev_page": "Previous",
"next_page": "Next",
"search_placeholder": "Search…",
@@ -1506,10 +1506,15 @@
"ver_col_step": "Step",
"ver_col_status": "Status",
"ver_col_submitted": "Submitted",
+ "ver_col_waiting": "Waiting",
+ "ver_search_label": "Search nurse",
+ "ver_search_ph": "Name or phone",
"ver_progress": "{done} of {total}",
"ver_next_step": "Next: {step}",
"ver_open_case": "Review",
"ver_case_title": "Verification case",
+ "ver_next_case": "Next case",
+ "ver_prev_case": "Previous case",
"ver_identity_name": "Identity on file",
"ver_steps_title": "Steps",
"ver_documents_title": "Documents",
@@ -1564,6 +1569,9 @@
"payout_run": "Run batch",
"payout_run_confirm_title": "Run this payout batch?",
"payout_run_confirm_body": "Money moves to nurses. This is protected by an idempotency key — a double-click can’t pay a booking twice.",
+ "payout_run_summary_intro": "This batch will run and the amount below will be transferred:",
+ "payout_run_count_label": "Nurse count",
+ "payout_run_type_to_confirm": "Type “CONFIRM” or the amount to proceed",
"payout_running": "Submitting to the bank rail…",
"payout_skipped": "Skipped nurses",
"payout_batch_title": "Batch #{id}",
@@ -1710,6 +1718,16 @@
"ticket_sent": "Message sent.",
"ticket_participants": "Participants",
"ticket_linked_refund": "Refund #{id}",
+ "ticket_activity_col": "Last activity",
+ "ticket_send_internal": "Save internal note",
+ "ticket_close": "Close ticket",
+ "ticket_close_confirm": "This ticket will close and leave the open queue. Continue?",
+ "ticket_reopen": "Reopen ticket",
+ "ticket_reopen_confirm": "This ticket will reopen. Continue?",
+ "ticket_assign_me": "Assign to me",
+ "ticket_assigned_me": "Ticket assigned to you.",
+ "ticket_closed_ok": "Ticket closed.",
+ "ticket_reopened_ok": "Ticket reopened.",
"partner_title": "Partner centers",
"partner_subtitle": "Licensed centers that sponsor nurses and may be merchant-of-record.",
"partner_empty": "No partner centers yet.",
@@ -1744,6 +1762,20 @@
"partner_assign_nurse_ph": "Nurse profile id",
"partner_unlink_nurse": "Remove",
"partner_nurse_assigned": "Nurse assignment updated.",
+ "user_title": "Users",
+ "user_subtitle": "Search users by name or phone number.",
+ "user_col_name": "Name",
+ "user_col_phone": "Phone",
+ "user_col_id": "ID",
+ "user_col_roles": "Roles",
+ "user_view_audit": "View audit trail",
+ "user_search_label": "Search",
+ "user_search_hint": "Type at least 2 characters of a name or phone number to search.",
+ "user_empty": "No users found",
+ "user_role_customer": "Family",
+ "user_role_nurse": "Nurse",
+ "user_role_admin": "Admin",
+ "user_role_partner": "Partner",
"role_title": "Roles & access",
"role_subtitle": "Grant or revoke admin roles. (Awaiting backend role endpoints.)",
"role_deferred": "This console is served by a client-side placeholder until the RBAC endpoints ship.",
@@ -1752,9 +1784,13 @@
"role_col_granted": "Granted",
"role_grant": "Grant role",
"role_revoke": "Revoke",
- "role_grant_confirm": "Grant {role} to user #{id}?",
- "role_revoke_confirm": "Revoke {role} from user #{id}?",
+ "role_grant_confirm": "Grant {role} to {name}?",
+ "role_revoke_confirm": "Revoke {role} from {name}?",
"role_updated": "Role updated.",
+ "user_picker_search_ph": "Search by name or phone…",
+ "user_picker_no_options": "No users found",
+ "user_picker_loading": "Searching…",
+ "assign_me_loading": "Loading your account…",
"agg_not_started": "Not started",
"agg_pending": "Pending",
"agg_in_review": "In review",
@@ -1860,6 +1896,19 @@
"bookings_col_patient": "Patient",
"bookings_col_date": "Date",
"bookings_col_status": "Status",
+ "bstatus_pending_payment": "Pending payment",
+ "bstatus_confirmed": "Confirmed",
+ "bstatus_in_progress": "In progress",
+ "bstatus_completed": "Completed",
+ "bstatus_disputed": "Disputed",
+ "bstatus_closed": "Closed",
+ "bstatus_cancelled": "Cancelled",
+ "booking_detail_title": "Booking detail",
+ "booking_detail_back": "Back to bookings",
+ "booking_detail_not_found": "This booking could not be found.",
+ "booking_detail_patient": "Patient",
+ "booking_detail_scheduled_date": "Scheduled date",
+ "booking_detail_timeline_title": "Status timeline",
"settlement_title": "Settlement & invoices",
"settlement_not_mor": "This center is not merchant-of-record — settlement runs through Balinyaar, and no commission invoices are issued here.",
"settlement_empty": "No invoices yet.",
@@ -1877,7 +1926,15 @@
"invoice_download": "Download PDF",
"invoice_pdf_error": "Couldn’t open the invoice PDF.",
"invoice_number": "Invoice #{number}",
- "settlement_iban": "Settlement IBAN"
+ "settlement_iban": "Settlement IBAN",
+ "settlement_export_csv": "Export CSV",
+ "csv_col_invoice_number": "Invoice number",
+ "csv_col_booking_id": "Booking",
+ "csv_col_gross": "Gross (Toman)",
+ "csv_col_commission": "Commission (Toman)",
+ "csv_col_vat": "VAT (Toman)",
+ "csv_col_total": "Total (Toman)",
+ "csv_col_issued_at": "Issued at"
},
"routeChrome": {
"error_title": "Something went wrong",
diff --git a/client/messages/fa.json b/client/messages/fa.json
index 16d42ba..a0e4471 100644
--- a/client/messages/fa.json
+++ b/client/messages/fa.json
@@ -1491,7 +1491,7 @@
"actor": "عامل",
"timestamp": "زمان",
"showing_range": "نمایش {from}–{to} از {total}",
- "page_indicator": "صفحه {page}",
+ "page_indicator": "صفحه {page} از {total}",
"prev_page": "قبلی",
"next_page": "بعدی",
"search_placeholder": "جستجو…",
@@ -1506,10 +1506,15 @@
"ver_col_step": "مرحله",
"ver_col_status": "وضعیت",
"ver_col_submitted": "زمان ارسال",
+ "ver_col_waiting": "زمان انتظار",
+ "ver_search_label": "جستجوی پرستار",
+ "ver_search_ph": "نام یا شماره تماس",
"ver_progress": "{done} از {total}",
"ver_next_step": "بعدی: {step}",
"ver_open_case": "بررسی",
"ver_case_title": "پرونده احراز هویت",
+ "ver_next_case": "پروندهٔ بعدی",
+ "ver_prev_case": "پروندهٔ قبلی",
"ver_identity_name": "نام ثبتشده",
"ver_steps_title": "مراحل",
"ver_documents_title": "مدارک",
@@ -1564,6 +1569,9 @@
"payout_run": "اجرای دسته",
"payout_run_confirm_title": "این دستهٔ تسویه اجرا شود؟",
"payout_run_confirm_body": "مبلغ به پرستاران منتقل میشود. این اقدام با کلید یکتا محافظت میشود — کلیک دوباره باعث پرداخت مضاعف نمیشود.",
+ "payout_run_summary_intro": "این دسته اجرا و مبلغ زیر واریز میشود:",
+ "payout_run_count_label": "تعداد پرستاران",
+ "payout_run_type_to_confirm": "برای تایید، «تایید» یا مبلغ را تایپ کنید",
"payout_running": "در حال ارسال به سامانهٔ بانکی…",
"payout_skipped": "پرستاران نادیدهگرفتهشده",
"payout_batch_title": "دسته #{id}",
@@ -1710,6 +1718,16 @@
"ticket_sent": "پیام ارسال شد.",
"ticket_participants": "شرکتکنندگان",
"ticket_linked_refund": "بازپرداخت #{id}",
+ "ticket_activity_col": "آخرین فعالیت",
+ "ticket_send_internal": "ثبت یادداشت داخلی",
+ "ticket_close": "بستن گفتگو",
+ "ticket_close_confirm": "این گفتگو بسته و از صف باز خارج میشود. ادامه میدهید؟",
+ "ticket_reopen": "بازگشایی گفتگو",
+ "ticket_reopen_confirm": "این گفتگو دوباره باز میشود. ادامه میدهید؟",
+ "ticket_assign_me": "واگذاری به من",
+ "ticket_assigned_me": "گفتگو به شما واگذار شد.",
+ "ticket_closed_ok": "گفتگو بسته شد.",
+ "ticket_reopened_ok": "گفتگو دوباره باز شد.",
"partner_title": "مراکز همکار",
"partner_subtitle": "مراکز دارای مجوز که پرستاران را پشتیبانی میکنند و میتوانند فروشندهٔ رسمی باشند.",
"partner_empty": "هنوز مرکزی ثبت نشده.",
@@ -1744,6 +1762,20 @@
"partner_assign_nurse_ph": "شناسهٔ پروفایل پرستار",
"partner_unlink_nurse": "حذف",
"partner_nurse_assigned": "وابستگی پرستار بهروزرسانی شد.",
+ "user_title": "کاربران",
+ "user_subtitle": "جستجوی کاربران با نام یا شماره موبایل.",
+ "user_col_name": "نام",
+ "user_col_phone": "موبایل",
+ "user_col_id": "شناسه",
+ "user_col_roles": "نقشها",
+ "user_view_audit": "مشاهده گزارش تغییرات",
+ "user_search_label": "جستجو",
+ "user_search_hint": "برای جستجو، حداقل ۲ نویسه از نام یا شماره موبایل را وارد کنید.",
+ "user_empty": "کاربری یافت نشد",
+ "user_role_customer": "خانواده",
+ "user_role_nurse": "پرستار",
+ "user_role_admin": "مدیر",
+ "user_role_partner": "شریک تجاری",
"role_title": "نقشها و دسترسی",
"role_subtitle": "اعطا یا لغو نقشهای مدیریتی. (در انتظار سرویسهای نقش سمت سرور.)",
"role_deferred": "این بخش تا آمادهشدن سرویسهای نقش، با دادههای موقتِ سمتکاربر نمایش داده میشود.",
@@ -1752,9 +1784,13 @@
"role_col_granted": "اعطا شده",
"role_grant": "اعطای نقش",
"role_revoke": "لغو",
- "role_grant_confirm": "نقش {role} به کاربر #{id} اعطا شود؟",
- "role_revoke_confirm": "نقش {role} از کاربر #{id} لغو شود؟",
+ "role_grant_confirm": "نقش {role} به {name} اعطا شود؟",
+ "role_revoke_confirm": "نقش {role} از {name} لغو شود؟",
"role_updated": "نقش بهروزرسانی شد.",
+ "user_picker_search_ph": "جستجو با نام یا شماره موبایل…",
+ "user_picker_no_options": "کاربری یافت نشد",
+ "user_picker_loading": "در حال جستجو…",
+ "assign_me_loading": "در حال بارگذاری حساب شما…",
"agg_not_started": "شروعنشده",
"agg_pending": "در انتظار",
"agg_in_review": "در حال بررسی",
@@ -1860,6 +1896,19 @@
"bookings_col_patient": "بیمار",
"bookings_col_date": "تاریخ",
"bookings_col_status": "وضعیت",
+ "bstatus_pending_payment": "در انتظار پرداخت",
+ "bstatus_confirmed": "تایید شده",
+ "bstatus_in_progress": "در حال انجام",
+ "bstatus_completed": "تکمیلشده",
+ "bstatus_disputed": "مورد اختلاف",
+ "bstatus_closed": "بستهشده",
+ "bstatus_cancelled": "لغوشده",
+ "booking_detail_title": "جزئیات رزرو",
+ "booking_detail_back": "بازگشت به رزروها",
+ "booking_detail_not_found": "این رزرو یافت نشد.",
+ "booking_detail_patient": "بیمار",
+ "booking_detail_scheduled_date": "تاریخ ویزیت",
+ "booking_detail_timeline_title": "روند وضعیت",
"settlement_title": "تسویه و فاکتورها",
"settlement_not_mor": "این مرکز فروشندهٔ رسمی نیست — تسویه از طریق بالینیار انجام میشود و فاکتور کارمزدی اینجا صادر نمیشود.",
"settlement_empty": "هنوز فاکتوری نیست.",
@@ -1877,6 +1926,14 @@
"invoice_download": "دانلود PDF",
"invoice_pdf_error": "باز کردن فایل فاکتور ناموفق بود.",
"invoice_number": "فاکتور #{number}",
+ "settlement_export_csv": "خروجی CSV",
+ "csv_col_invoice_number": "شماره فاکتور",
+ "csv_col_booking_id": "رزرو",
+ "csv_col_gross": "ناخالص (تومان)",
+ "csv_col_commission": "کارمزد (تومان)",
+ "csv_col_vat": "مالیات (تومان)",
+ "csv_col_total": "مبلغ کل (تومان)",
+ "csv_col_issued_at": "تاریخ صدور",
"settlement_iban": "شبای تسویه"
},
"routeChrome": {
diff --git a/client/src/app/[locale]/(private-routes)/admin/alerts/page.tsx b/client/src/app/[locale]/(private-routes)/admin/alerts/page.tsx
index 956267e..47f5f59 100644
--- a/client/src/app/[locale]/(private-routes)/admin/alerts/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/admin/alerts/page.tsx
@@ -1,15 +1,21 @@
'use client';
-import { useState } from 'react';
+import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
+import { AppLoading } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog, SupportAlertCard } from '@/components/admin';
-import { useAdminCapabilities } from '@/hooks';
+import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { useAuth } from '@/context/auth';
import { ADMIN_PAGE_SIZE } from '@/services/admin/constants';
import type { SupportAlert, SupportAlertStatus, SupportAlertType } from '@/services/admin/types';
import { useSupportAlerts, useAssignSupportAlert, useResolveSupportAlert } from '@/services/admin';
+interface AlertFilters {
+ status?: SupportAlertStatus;
+ type?: SupportAlertType;
+}
+
const STATUSES: readonly SupportAlertStatus[] = ['open', 'assigned', 'resolved'];
const TYPES: readonly SupportAlertType[] = [
'low_rating',
@@ -23,32 +29,60 @@ const TYPES: readonly SupportAlertType[] = [
'emergency',
];
+const EMPTY: AlertFilters = { status: 'open' };
+
+function parseFilters(params: URLSearchParams): AlertFilters {
+ const status = params.get('status') as SupportAlertStatus | null;
+ const type = params.get('type') as SupportAlertType | null;
+ return {
+ status: status && STATUSES.includes(status) ? status : undefined,
+ type: type && TYPES.includes(type) ? type : undefined,
+ };
+}
+
+function serializeFilters(filters: AlertFilters): Record {
+ const r: Record = {};
+ if (filters.status) r.status = filters.status;
+ if (filters.type) r.type = filters.type;
+ return r;
+}
+
/**
* Support-alert triage board (f15) — the **internal-only** worklist over `support_alerts`. Filter by
* type/status; assign to self or resolve with a note. This data appears in **no** customer/nurse/partner
* surface (phase §5). Server enforces the role scope; `canManageAlerts` only hides the controls.
*/
export default function AdminAlertsPage() {
+ return (
+ }>
+
+
+ );
+}
+
+function AdminAlertsPageInner() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
const [authState] = useAuth();
- const meId = authState.currentUser?.id ?? 1;
+ const meId = authState.currentUser?.id;
- const [status, setStatus] = useState('open');
- const [type, setType] = useState('');
- const [page, setPage] = useState(1);
+ const listState = useAdminListState({ parse: parseFilters, serialize: serializeFilters, empty: EMPTY });
+ const { applied: filters, page } = listState;
const [resolving, setResolving] = useState(null);
- const filters = { status: status || undefined, type: type || undefined };
const alerts = useSupportAlerts(filters, page);
const assign = useAssignSupportAlert();
const resolve = useResolveSupportAlert();
const items = alerts.data?.items ?? [];
- const pageCount = Math.max(1, Math.ceil((alerts.data?.total ?? 0) / ADMIN_PAGE_SIZE));
+ const total = alerts.data?.total ?? 0;
+ const pageCount = Math.max(1, Math.ceil(total / ADMIN_PAGE_SIZE));
+ // Assign-to-self MUST NEVER default to a guessed user (the fixed defect: `?? 1`) — the button is simply
+ // disabled with a "loading your account" tooltip until the real id has hydrated.
const onAssignSelf = (alert: SupportAlert) => {
+ if (meId == null) return;
assign.mutate(
{ alertId: alert.id, ownerUserId: meId },
{ onSuccess: () => enqueueSnackbar(t('alert_assigned'), { variant: 'success' }) },
@@ -68,6 +102,11 @@ export default function AdminAlertsPage() {
);
};
+ const setStatusFilter = (value: SupportAlertStatus | '') =>
+ listState.applyFilters({ ...filters, status: value || undefined });
+ const setTypeFilter = (value: SupportAlertType | '') =>
+ listState.applyFilters({ ...filters, type: value || undefined });
+
return (
{
- setStatus(e.target.value as SupportAlertStatus | '');
- setPage(1);
- }}
+ value={filters.status ?? ''}
+ onChange={(e) => setStatusFilter(e.target.value as SupportAlertStatus | '')}
sx={{ minWidth: 140 }}
>
@@ -97,11 +133,8 @@ export default function AdminAlertsPage() {
select
size="small"
label={t('alert_col_type')}
- value={type}
- onChange={(e) => {
- setType(e.target.value as SupportAlertType | '');
- setPage(1);
- }}
+ value={filters.type ?? ''}
+ onChange={(e) => setTypeFilter(e.target.value as SupportAlertType | '')}
sx={{ minWidth: 180 }}
>
@@ -130,6 +163,8 @@ export default function AdminAlertsPage() {
canAct={caps.canManageAlerts}
onAssignSelf={onAssignSelf}
onResolve={setResolving}
+ assignSelfDisabled={meId == null}
+ assignSelfDisabledTitle={t('assign_me_loading')}
/>
))}
@@ -138,11 +173,11 @@ export default function AdminAlertsPage() {
setPage((p) => Math.max(1, p - 1))}
- onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
+ onPrev={() => listState.goToPage(Math.max(1, page - 1))}
+ onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
- indicator={t('page_indicator', { page })}
+ indicator={t('page_indicator', { page, total: pageCount })}
/>
{
+ const r: Record = {};
+ if (filters.entityType) r.entityType = filters.entityType;
+ if (filters.entityId) r.entityId = filters.entityId;
+ if (filters.from) r.from = filters.from;
+ if (filters.to) r.to = filters.to;
+ return r;
+}
+
/**
* Append-only audit-log viewer (f15) — a read-only, filtered, paginated table of every admin state change,
* each row expandable to its `changed_fields` diff. There is **no** edit/delete affordance (phase §5). The
@@ -17,24 +37,33 @@ const EMPTY: AuditFilters = {};
* page are the cache key, so switching filters/pages never refetches data already held.
*/
export default function AdminAuditPage() {
+ return (
+ }>
+
+
+ );
+}
+
+function AdminAuditPageInner() {
const t = useTranslations('admin');
- const [draft, setDraft] = useState(EMPTY);
- const [applied, setApplied] = useState(EMPTY);
- const [page, setPage] = useState(1);
+ const listState = useAdminListState({ parse: parseFilters, serialize: serializeFilters, empty: EMPTY });
+ const { draft, setDraft, applied, page } = listState;
const audit = useAuditLogs(applied, page);
const items = audit.data?.items ?? [];
- const pageCount = Math.max(1, Math.ceil((audit.data?.total ?? 0) / AUDIT_PAGE_SIZE));
+ const total = audit.data?.total ?? 0;
+ const pageCount = Math.max(1, Math.ceil(total / AUDIT_PAGE_SIZE));
+ const from = items.length === 0 ? 0 : (page - 1) * AUDIT_PAGE_SIZE + 1;
+ const to = (page - 1) * AUDIT_PAGE_SIZE + items.length;
- const apply = () => {
- setApplied(draft);
- setPage(1);
- };
- const clear = () => {
- setDraft(EMPTY);
- setApplied(EMPTY);
- setPage(1);
- };
+ // Batch id→name resolve (3.2/3.6) — one request for every actor rendered on this page, never one per row.
+ const actorIds = useMemo(
+ () => [...new Set((audit.data?.items ?? []).map((e) => e.actorUserId).filter((id): id is number => id != null))].sort(
+ (a, b) => a - b,
+ ),
+ [audit.data?.items],
+ );
+ const userLookup = useUserLookup(actorIds);
return (
@@ -60,26 +89,24 @@ export default function AdminAuditPage() {
onChange={(e) => setDraft((d) => ({ ...d, entityId: e.target.value || undefined }))}
sx={{ minWidth: 120 }}
/>
- setDraft((d) => ({ ...d, from: e.target.value || undefined }))}
- slotProps={{ inputLabel: { shrink: true } }}
+ value={draft.from ?? null}
+ onChange={(iso) => setDraft((d) => ({ ...d, from: iso }))}
+ max={draft.to}
+ sx={{ minWidth: 160 }}
/>
- setDraft((d) => ({ ...d, to: e.target.value || undefined }))}
- slotProps={{ inputLabel: { shrink: true } }}
+ value={draft.to ?? null}
+ onChange={(iso) => setDraft((d) => ({ ...d, to: iso }))}
+ min={draft.from}
+ sx={{ minWidth: 160 }}
/>
-
+
{t('apply')}
-
+
{t('clear')}
@@ -93,19 +120,22 @@ export default function AdminAuditPage() {
) : (
{items.map((entry) => (
-
+
))}
)}
+ {items.length > 0 ? (
+ {t('showing_range', { from, to, total })}
+ ) : null}
setPage((p) => Math.max(1, p - 1))}
- onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
+ onPrev={() => listState.goToPage(Math.max(1, page - 1))}
+ onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
- indicator={t('page_indicator', { page })}
+ indicator={t('page_indicator', { page, total: pageCount })}
/>
);
diff --git a/client/src/app/[locale]/(private-routes)/admin/config/page.tsx b/client/src/app/[locale]/(private-routes)/admin/config/page.tsx
index 66ed78c..19f8e42 100644
--- a/client/src/app/[locale]/(private-routes)/admin/config/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/admin/config/page.tsx
@@ -1,5 +1,5 @@
'use client';
-import { useMemo, useState } from 'react';
+import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
@@ -17,10 +17,10 @@ import {
TextField,
Typography,
} from '@mui/material';
-import { AppButton, AppIcon } from '@/components';
-import { AdminEmptyState, AdminErrorState, AdminPageHeader, ConfigRow } from '@/components/admin';
+import { AppButton, AppIcon, AppLoading } from '@/components';
+import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfigRow } from '@/components/admin';
import { formatShamsiDateTime } from '@/utils';
-import { useAdminCapabilities } from '@/hooks';
+import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { CONFIG_GROUPS, RATE_CONFIG_KEYS } from '@/services/admin/constants';
import type { PlatformConfig } from '@/services/admin/types';
import { usePlatformConfigs, useUpdatePlatformConfig, useConfigChangeHistory } from '@/services/admin';
@@ -58,9 +58,18 @@ function validate(config: PlatformConfig, value: string): string | null {
* `data_type` (phase §5).
*/
export default function AdminConfigPage() {
+ return (
+ }>
+
+
+ );
+}
+
+function AdminConfigPageInner() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
- const configs = usePlatformConfigs(1);
+ const listState = useAdminListState>({ parse: () => ({}), serialize: () => ({}), empty: {} });
+ const configs = usePlatformConfigs(listState.page);
const [editing, setEditing] = useState(null);
const [historyKey, setHistoryKey] = useState(null);
@@ -101,6 +110,19 @@ export default function AdminConfigPage() {
))
)}
+ listState.goToPage(Math.max(1, listState.page - 1))}
+ onNext={() => listState.goToPage(listState.page + 1)}
+ prevLabel={t('prev_page')}
+ nextLabel={t('next_page')}
+ indicator={t('page_indicator', {
+ page: listState.page,
+ total: Math.max(1, Math.ceil((configs.data?.total ?? 0) / (configs.data?.pageSize ?? 1))),
+ })}
+ />
+
{editing ? setEditing(null)} /> : null}
setHistoryKey(null)} />
@@ -149,7 +171,6 @@ function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose
autoFocus
multiline={config.dataType === 'json'}
minRows={config.dataType === 'json' ? 4 : 1}
- type={config.dataType === 'int' || config.dataType === 'decimal' ? 'text' : 'text'}
value={value}
onChange={(e) => setValue(e.target.value)}
label={t('cfg_col_value')}
@@ -179,15 +200,22 @@ function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose
function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null; onClose: () => void }) {
const t = useTranslations('admin');
const locale = useLocale();
- const history = useConfigChangeHistory(configKey, 1, configKey != null);
+ const [page, setPage] = useState(1);
+ const history = useConfigChangeHistory(configKey, page, configKey != null);
// RTL-aware: the drawer slides from the reading-end (left on fa/RTL, right on en/LTR).
const anchor = locale === 'fa' ? 'left' : 'right';
+ const pageCount = Math.max(1, Math.ceil((history.data?.total ?? 0) / (history.data?.pageSize ?? 1)));
+
+ const close = () => {
+ setPage(1);
+ onClose();
+ };
return (
-
+
{configKey ? t('cfg_history_title', { key: configKey }) : ''}
-
+
@@ -213,6 +241,16 @@ function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null;
))}
)}
+
+ setPage((p) => Math.max(1, p - 1))}
+ onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
+ prevLabel={t('prev_page')}
+ nextLabel={t('next_page')}
+ indicator={t('page_indicator', { page, total: pageCount })}
+ />
);
}
diff --git a/client/src/app/[locale]/(private-routes)/admin/holidays/page.tsx b/client/src/app/[locale]/(private-routes)/admin/holidays/page.tsx
index 9779039..d4a4f1c 100644
--- a/client/src/app/[locale]/(private-routes)/admin/holidays/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/admin/holidays/page.tsx
@@ -1,5 +1,5 @@
'use client';
-import { useState } from 'react';
+import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
@@ -16,13 +16,24 @@ import {
Switch,
TextField,
} from '@mui/material';
-import { AppButton } from '@/components';
-import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, type AdminTableColumn } from '@/components/admin';
+import { AppButton, AppLoading, JalaliDateField } from '@/components';
+import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, type AdminTableColumn } from '@/components/admin';
import { formatShamsiDate } from '@/utils';
-import { useAdminCapabilities } from '@/hooks';
+import { useAdminCapabilities, useAdminListState } from '@/hooks';
+import { ADMIN_PAGE_SIZE } from '@/services/admin/constants';
import type { Holiday, HolidayInput, HolidayType } from '@/services/admin/types';
import { useHolidays, useUpsertHoliday } from '@/services/admin';
+/** Today's LOCAL date as ISO `YYYY-MM-DD` — never `toISOString()`, which converts to UTC first and can
+ * land on the wrong day near local midnight (the same class of bug fixed in the payout window default). */
+function todayLocalIso(): string {
+ const d = new Date();
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ return `${y}-${m}-${day}`;
+}
+
const HOLIDAY_TYPES: readonly HolidayType[] = ['official', 'religious', 'national'];
/**
@@ -32,11 +43,21 @@ const HOLIDAY_TYPES: readonly HolidayType[] = ['official', 'religious', 'nationa
* computes the shift itself (phase §5).
*/
export default function AdminHolidaysPage() {
+ return (
+ }>
+
+
+ );
+}
+
+function AdminHolidaysPageInner() {
const t = useTranslations('admin');
const locale = useLocale();
const caps = useAdminCapabilities();
- const holidays = useHolidays({}, 1);
+ const listState = useAdminListState>({ parse: () => ({}), serialize: () => ({}), empty: {} });
+ const holidays = useHolidays({}, listState.page);
const [editing, setEditing] = useState(null);
+ const pageCount = Math.max(1, Math.ceil((holidays.data?.total ?? 0) / ADMIN_PAGE_SIZE));
const columns: AdminTableColumn[] = [
{ key: 'date', header: t('hol_col_date'), render: (h) => formatShamsiDate(h.holidayDate, locale) },
@@ -96,6 +117,16 @@ export default function AdminHolidaysPage() {
h.id} ariaLabel={t('hol_title')} />
)}
+ listState.goToPage(Math.max(1, listState.page - 1))}
+ onNext={() => listState.goToPage(Math.min(pageCount, listState.page + 1))}
+ prevLabel={t('prev_page')}
+ nextLabel={t('next_page')}
+ indicator={t('page_indicator', { page: listState.page, total: pageCount })}
+ />
+
{editing ? (
setEditing(null)} />
) : null}
@@ -103,18 +134,16 @@ export default function AdminHolidaysPage() {
);
}
-const TODAY_ISO = ''; // seeded below via state default so no Date at module load
-
function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose: () => void }) {
const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertHoliday();
- const [form, setForm] = useState({
- holidayDate: holiday?.holidayDate?.slice(0, 10) ?? TODAY_ISO,
+ const [form, setForm] = useState(() => ({
+ holidayDate: holiday?.holidayDate?.slice(0, 10) ?? todayLocalIso(),
nameFa: holiday?.nameFa ?? '',
type: holiday?.type ?? 'official',
isBankClosed: holiday?.isBankClosed ?? true,
- });
+ }));
const valid = form.holidayDate.length > 0 && form.nameFa.trim().length > 0;
@@ -136,13 +165,11 @@ function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose:
{holiday ? t('hol_edit') : t('hol_add')}
- setForm((f) => ({ ...f, holidayDate: e.target.value }))}
+ value={form.holidayDate || null}
+ onChange={(iso) => setForm((f) => ({ ...f, holidayDate: iso }))}
disabled={!!holiday}
- slotProps={{ inputLabel: { shrink: true } }}
/>
();
const parsed = Number(params?.id);
@@ -43,7 +44,7 @@ export default function AdminPartnerCenterDetailPage() {
const [confirmVerify, setConfirmVerify] = useState(false);
const [editing, setEditing] = useState(false);
- const [assignId, setAssignId] = useState('');
+ const [assignNurseUser, setAssignNurseUser] = useState(null);
const data = center.data;
@@ -57,14 +58,14 @@ export default function AdminPartnerCenterDetailPage() {
};
const onAssign = () => {
- const nurseProfileId = Number(assignId);
- if (!Number.isFinite(nurseProfileId) || nurseProfileId <= 0) return;
+ const nurseProfileId = assignNurseUser?.nurseProfileId;
+ if (nurseProfileId == null) return;
assignNurse.mutate(
{ nurseProfileId, unlink: false },
{
onSuccess: () => {
enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' });
- setAssignId('');
+ setAssignNurseUser(null);
},
},
);
@@ -77,17 +78,7 @@ export default function AdminPartnerCenterDetailPage() {
);
};
- const back = (
- router.push(`/${locale}${ROUTES.ADMIN_PARTNERS}`)}
- sx={{ alignSelf: 'flex-start' }}
- >
- {t('back')}
-
- );
+ const back = ;
if (center.isLoading) {
return (
@@ -124,18 +115,13 @@ export default function AdminPartnerCenterDetailPage() {
return (
-
- {back}
-
-
- {t('partner_detail_title')}
-
-
-
-
- {data.name}
-
-
+ }
+ />
} sx={{ gap: 1.25 }}>
@@ -221,21 +207,22 @@ export default function AdminPartnerCenterDetailPage() {
{caps.canManagePartners ? (
- setAssignId(e.target.value)}
- slotProps={{ htmlInput: { min: 1, step: 1 } }}
- sx={{ minWidth: 200 }}
- />
+
+
+
{t('partner_assign_nurse')}
diff --git a/client/src/app/[locale]/(private-routes)/admin/partners/page.tsx b/client/src/app/[locale]/(private-routes)/admin/partners/page.tsx
index b0859b3..fb20a96 100644
--- a/client/src/app/[locale]/(private-routes)/admin/partners/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/admin/partners/page.tsx
@@ -1,5 +1,5 @@
'use client';
-import { useState } from 'react';
+import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack';
@@ -16,7 +16,7 @@ import {
TextField,
Typography,
} from '@mui/material';
-import { AppButton, StatusChip } from '@/components';
+import { AppButton, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
@@ -24,14 +24,21 @@ import {
AdminErrorState,
AdminPageHeader,
AdminPager,
+ UserPicker,
type AdminTableColumn,
} from '@/components/admin';
-import { useAdminCapabilities } from '@/hooks';
+import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { adminPartnerCenterPath } from '@/constants';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import type { CenterOnboardingState, PartnerCenter, PartnerCenterInput } from '@/services/partnerCenter/types';
+import type { AdminUserSummary } from '@/services/admin/types';
+import { useUserLookup } from '@/services/admin';
import { usePartnerCenters, useCreatePartnerCenter, useUpdatePartnerCenter } from '@/services/partnerCenter';
+/** No list-level filters today (the list is unfiltered) — `useAdminListState` still URL-syncs the page. */
+type PartnersListFilters = Record;
+const EMPTY_FILTERS: PartnersListFilters = {};
+
/** State → semantic chip color. verified = green, pending = amber, suspended = red, draft = neutral. */
export const CENTER_STATE_KIND: Record = {
verified: 'verified',
@@ -48,16 +55,33 @@ export const CENTER_STATE_KIND: Record = {
* it is only ever entered here, never displayed (the list carries no IBAN at all).
*/
export default function AdminPartnersPage() {
+ return (
+ }>
+
+
+ );
+}
+
+function AdminPartnersPageInner() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities();
- const [page, setPage] = useState(1);
const [creating, setCreating] = useState(false);
+ const listState = useAdminListState({
+ parse: () => EMPTY_FILTERS,
+ serialize: () => ({}),
+ empty: EMPTY_FILTERS,
+ });
+ const page = listState.page;
+
const centers = usePartnerCenters({}, page);
const items = centers.data?.items ?? [];
- const pageCount = Math.max(1, Math.ceil((centers.data?.total ?? 0) / PARTNER_PAGE_SIZE));
+ const total = centers.data?.total ?? 0;
+ const pageCount = Math.max(1, Math.ceil(total / PARTNER_PAGE_SIZE));
+ const from = items.length === 0 ? 0 : (page - 1) * PARTNER_PAGE_SIZE + 1;
+ const to = (page - 1) * PARTNER_PAGE_SIZE + items.length;
const columns: AdminTableColumn[] = [
{ key: 'name', header: t('partner_col_name'), render: (c) => c.name },
@@ -97,17 +121,18 @@ export default function AdminPartnersPage() {
getRowKey={(c) => c.id}
ariaLabel={t('partner_title')}
onRowClick={(c) => router.push(`/${locale}${adminPartnerCenterPath(c.id)}`)}
+ footer={total > 0 ? t('showing_range', { from, to, total }) : undefined}
/>
)}
setPage((p) => Math.max(1, p - 1))}
- onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
+ onPrev={() => listState.goToPage(Math.max(1, page - 1))}
+ onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
- indicator={t('page_indicator', { page })}
+ indicator={t('page_indicator', { page, total: pageCount })}
/>
{creating ? setCreating(false)} /> : null}
@@ -115,7 +140,9 @@ export default function AdminPartnersPage() {
);
}
-/** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs. */
+/** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs — except
+ * `adminUser`, the resolved picker selection (3.2), never a hand-typed id. `undefined` = untouched (the
+ * edit-mode existing admin, once resolved, still shows); `null` = the admin explicitly cleared the field. */
interface CenterFormState {
name: string;
legalEntityType: string;
@@ -125,7 +152,7 @@ interface CenterFormState {
settlementIban: string;
isMerchantOfRecord: boolean;
commissionRate: string;
- adminUserId: string;
+ adminUser: AdminUserSummary | null | undefined;
}
function initialForm(center: PartnerCenter | null): CenterFormState {
@@ -139,7 +166,7 @@ function initialForm(center: PartnerCenter | null): CenterFormState {
settlementIban: '',
isMerchantOfRecord: center?.isMerchantOfRecord ?? false,
commissionRate: center != null ? String(center.commissionRate) : '',
- adminUserId: center?.adminUserId != null ? String(center.adminUserId) : '',
+ adminUser: undefined,
};
}
@@ -158,6 +185,14 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
const mutation = isEdit ? update : create;
const [form, setForm] = useState(() => initialForm(center));
+ // Edit mode: the center already has an adminUserId (a plain number) — resolve it to a name so the picker
+ // opens pre-filled with a person, never a bare id (3.2). Derived in render (never synced into state via an
+ // effect): once the admin actually picks someone, `form.adminUser` wins over the resolved existing one.
+ const existingAdminId = center?.adminUserId ?? null;
+ const existingAdminLookup = useUserLookup(existingAdminId != null ? [existingAdminId] : []);
+ const resolvedExistingAdmin = existingAdminId != null ? (existingAdminLookup.data?.get(existingAdminId) ?? null) : null;
+ const displayedAdminUser = form.adminUser !== undefined ? form.adminUser : resolvedExistingAdmin;
+
const set = (key: K, value: CenterFormState[K]) =>
setForm((f) => ({ ...f, [key]: value }));
@@ -179,7 +214,7 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
settlementIban: form.settlementIban.trim() || null,
isMerchantOfRecord: form.isMerchantOfRecord,
commissionRate: commission,
- adminUserId: form.adminUserId.trim() === '' ? null : Number(form.adminUserId),
+ adminUserId: displayedAdminUser?.id ?? null,
};
mutation.mutate(input, {
onSuccess: () => {
@@ -235,12 +270,13 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
onChange={(e) => set('commissionRate', e.target.value)}
slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }}
/>
- set('adminUser', u)}
label={t('partner_admin_user')}
- value={form.adminUserId}
- onChange={(e) => set('adminUserId', e.target.value)}
- slotProps={{ htmlInput: { min: 1, step: 1 } }}
+ placeholder={t('user_picker_search_ph')}
+ noOptionsText={t('user_picker_no_options')}
+ loadingText={t('user_picker_loading')}
/>
diff --git a/client/src/app/[locale]/(private-routes)/admin/payouts/[batchId]/page.tsx b/client/src/app/[locale]/(private-routes)/admin/payouts/[batchId]/page.tsx
index dd10ffe..1d72005 100644
--- a/client/src/app/[locale]/(private-routes)/admin/payouts/[batchId]/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/admin/payouts/[batchId]/page.tsx
@@ -1,18 +1,22 @@
'use client';
-import { FunctionComponent, ReactNode, useState } from 'react';
-import { useParams, useRouter } from 'next/navigation';
+import { FunctionComponent, ReactNode, Suspense, useState } from 'react';
+import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Chip, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
-import { AppButton, StatusChip } from '@/components';
+import { AppButton, AppLoading, PageHeader, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPager, ConfirmDialog } from '@/components/admin';
-import { useAdminCapabilities } from '@/hooks';
+import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { ROUTES } from '@/constants';
import { formatIrrToToman, formatShamsiDate } from '@/utils';
import { usePayoutBatchDetail, useRecordTransferReference, useRetryPayout } from '@/services/payouts';
import type { AdminPayoutRow, PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
+/** This detail page has no filters — only a page number worth mirroring into the URL. */
+type BatchRowsFilters = Record;
+const EMPTY_FILTERS: BatchRowsFilters = {};
+
const BATCH_STATUS_KIND: Record = {
draft: 'neutral',
processing: 'info',
@@ -35,33 +39,37 @@ const PAYOUT_STATUS_KIND: Record = {
* `canPayout`. Money is display-only Toman; the client never recomputes amounts, eligibility, or dates.
*/
export default function AdminPayoutBatchDetailPage() {
+ return (
+ }>
+
+
+ );
+}
+
+/** Wrapped in `` above — `useAdminListState` calls `useSearchParams()`, which requires it. */
+function AdminPayoutBatchDetailScreen() {
const t = useTranslations('admin');
const locale = useLocale();
- const router = useRouter();
const caps = useAdminCapabilities();
const params = useParams<{ batchId: string }>();
const batchId = Number(params?.batchId);
- const [page, setPage] = useState(1);
+ const { page, goToPage } = useAdminListState({
+ parse: () => EMPTY_FILTERS,
+ serialize: () => ({}),
+ empty: EMPTY_FILTERS,
+ });
const detail = usePayoutBatchDetail(Number.isFinite(batchId) ? batchId : null, page);
const data = detail.data;
const pageCount = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
return (
-
- router.push(`/${locale}${ROUTES.ADMIN_PAYOUTS}`)}
- sx={{ alignSelf: 'flex-start' }}
- >
- {t('back')}
-
-
- {t('payout_batch_title', { id: batchId })}
-
-
+
{detail.isLoading ? (
@@ -116,11 +124,11 @@ export default function AdminPayoutBatchDetailPage() {
setPage((p) => Math.max(1, p - 1))}
- onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
+ onPrev={() => goToPage(Math.max(1, page - 1))}
+ onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
- indicator={t('page_indicator', { page })}
+ indicator={t('page_indicator', { page, total: pageCount })}
/>
>
)}
diff --git a/client/src/app/[locale]/(private-routes)/admin/payouts/page.tsx b/client/src/app/[locale]/(private-routes)/admin/payouts/page.tsx
index b40812e..5391cc5 100644
--- a/client/src/app/[locale]/(private-routes)/admin/payouts/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/admin/payouts/page.tsx
@@ -17,7 +17,7 @@ import {
TextField,
Typography,
} from '@mui/material';
-import { AppButton, Money, StatusChip } from '@/components';
+import { AppButton, JalaliDateField, Money, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
@@ -30,7 +30,7 @@ import {
import type { AdminTableColumn } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks';
import { adminPayoutBatchPath } from '@/constants';
-import { formatShamsiDate } from '@/utils';
+import { formatNumber, formatShamsiDate } from '@/utils';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { usePayoutBatches, usePreviewPayoutBatch, useRunPayoutBatch } from '@/services/payouts';
import type { PayoutBatchStatus, PayoutBatchSummary } from '@/services/payouts/types';
@@ -52,8 +52,17 @@ const BATCH_STATUSES: readonly PayoutBatchStatus[] = [
'failed',
];
-/** UTC ISO date (`YYYY-MM-DD`) — the wire shape for the batch window. */
-const isoDate = (d: Date): string => d.toISOString().slice(0, 10);
+/**
+ * ISO date (`YYYY-MM-DD`) — the wire shape for the batch window. Formats using the browser's **local**
+ * date fields, never `toISOString()` (which converts to UTC first): near Tehran local midnight that would
+ * silently roll the date back/forward a day from the admin's actual wall-clock date.
+ */
+const isoDate = (d: Date): string => {
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ return `${y}-${m}-${day}`;
+};
/**
* Admin payout-batch dashboard (f15) — the reconciliation list of weekly `nurse_payout_batches` and the
@@ -173,7 +182,7 @@ export default function AdminPayoutsPage() {
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
- indicator={t('page_indicator', { page })}
+ indicator={t('page_indicator', { page, total: pageCount })}
/>
{previewOpen ? setPreviewOpen(false)} /> : null}
@@ -230,22 +239,13 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
{t('payout_preview_title')}
- setPeriodStart(e.target.value)}
- slotProps={{ inputLabel: { shrink: true } }}
- />
- setPeriodEnd(e.target.value)}
- slotProps={{ inputLabel: { shrink: true } }}
+ onChange={setPeriodStart}
/>
+
+
+ {t('payout_run_summary_intro')}
+
+
+
+
+
+ {t('payout_run_count_label')}
+
+
+ {formatNumber(result.eligible.length, locale)}
+
+
+
+
+ {t('payout_col_processing')}
+
+
+ {formatShamsiDate(result.processingDate, locale)}
+
+
+
+
+ ) : null
+ }
confirmLabel={t('payout_run')}
cancelLabel={t('cancel')}
onConfirm={onRunConfirm}
onClose={() => setRunConfirmOpen(false)}
loading={run.isPending}
+ requireTypedConfirmation={result ? ['تایید', result.totalNetIrr] : []}
+ typedConfirmationLabel={t('payout_run_type_to_confirm')}
/>
);
diff --git a/client/src/app/[locale]/(private-routes)/admin/reviews/page.tsx b/client/src/app/[locale]/(private-routes)/admin/reviews/page.tsx
index e86537a..e10b2b7 100644
--- a/client/src/app/[locale]/(private-routes)/admin/reviews/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/admin/reviews/page.tsx
@@ -1,11 +1,11 @@
'use client';
-import { useState } from 'react';
+import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
-import { AppButton, RatingInput } from '@/components';
+import { AppButton, AppLoading, RatingInput } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog } from '@/components/admin';
-import { useAdminCapabilities } from '@/hooks';
+import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { REVIEWS_PAGE_SIZE } from '@/services/reviews/constants';
import type { ModerationAction, ModerationQueueItem, ModerationStatus } from '@/services/reviews/types';
import { useModerationQueue, useModerateReview } from '@/services/reviews';
@@ -29,13 +29,33 @@ const STATUS_CHIP_COLOR: Record}>
+
+
+ );
+}
+
+function AdminReviewsPageInner() {
const t = useTranslations('admin');
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
- const [status, setStatus] = useState('pending_moderation');
- const [page, setPage] = useState(1);
+ const listState = useAdminListState<{ status: ModerationStatus }>({
+ parse: parseFilters,
+ serialize: (f): Record => (f.status !== DEFAULT_STATUS ? { status: f.status } : {}),
+ empty: { status: DEFAULT_STATUS },
+ });
+ const status = listState.applied.status;
+ const page = listState.page;
const [pending, setPending] = useState<{ item: ModerationQueueItem; action: ModerationAction } | null>(null);
const queue = useModerationQueue({ status }, page);
@@ -44,6 +64,8 @@ export default function AdminReviewsPage() {
const items = queue.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / REVIEWS_PAGE_SIZE));
+ const setStatus = (next: ModerationStatus) => listState.applyFilters({ status: next });
+
const requireReason = pending?.action === 'hide' || pending?.action === 'reject';
const onConfirm = (reason?: string) => {
@@ -70,10 +92,7 @@ export default function AdminReviewsPage() {
size="small"
label={t('filter_label')}
value={status}
- onChange={(e) => {
- setStatus(e.target.value as ModerationStatus);
- setPage(1);
- }}
+ onChange={(e) => setStatus(e.target.value as ModerationStatus)}
sx={{ minWidth: 180 }}
>
{MODERATION_STATUSES.map((s) => (
@@ -107,11 +126,11 @@ export default function AdminReviewsPage() {
setPage((p) => Math.max(1, p - 1))}
- onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
+ onPrev={() => listState.goToPage(Math.max(1, page - 1))}
+ onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
- indicator={t('page_indicator', { page })}
+ indicator={t('page_indicator', { page, total: pageCount })}
/>
g.revokedAt == null);
+ // Batch id→name resolve (3.2) — one request for every grant row, never one per row.
+ const userIds = useMemo(() => [...new Set(active.map((g) => g.userId))].sort((a, b) => a - b), [active]);
+ const userLookup = useUserLookup(userIds);
+ const nameFor = (userId: number): string => userLookup.data?.get(userId)?.displayName ?? `#${userId}`;
+
const columns: AdminTableColumn[] = [
- { key: 'user', header: t('role_col_user'), render: (g) => `#${g.userId}` },
+ { key: 'user', header: t('role_col_user'), render: (g) => nameFor(g.userId) },
{
key: 'role',
header: t('role_col_role'),
@@ -131,7 +137,7 @@ export default function AdminRolesPage() {
void }) {
const { enqueueSnackbar } = useSnackbar();
const grant = useGrantRole();
- const [userId, setUserId] = useState('');
+ const [user, setUser] = useState(null);
const [role, setRole] = useState('support');
- const parsedId = Number(userId);
- const valid = /^\d+$/.test(userId.trim()) && parsedId > 0;
+ const valid = user != null;
const onGrant = () => {
- if (!valid) return;
+ if (!user) return;
grant.mutate(
- { userId: parsedId, role },
+ { userId: user.id, role },
{
onSuccess: () => {
enqueueSnackbar(t('role_updated'), { variant: 'success' });
@@ -173,12 +178,13 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
{t('role_grant')}
- setUserId(e.target.value)}
- slotProps={{ htmlInput: { min: 1 } }}
+ placeholder={t('user_picker_search_ph')}
+ noOptionsText={t('user_picker_no_options')}
+ loadingText={t('user_picker_loading')}
/>
setRole(e.target.value as AdminRole)}>
{ROLES.map((r) => (
@@ -188,7 +194,7 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
))}
{valid ? (
- {t('role_grant_confirm', { role: t(`role_${role}`), id: parsedId })}
+ {t('role_grant_confirm', { role: t(`role_${role}`), name: user.displayName })}
) : null}
diff --git a/client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx
index a81bc17..97392b9 100644
--- a/client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
-import { useParams, useRouter } from 'next/navigation';
+import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
@@ -13,14 +13,17 @@ import {
TextField,
ToggleButton,
ToggleButtonGroup,
- Typography,
+ Tooltip,
} from '@mui/material';
-import { AppButton, StatusChip } from '@/components';
+import { AppButton, PageHeader, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
-import { AdminEmptyState, AdminErrorState, AdminMessageBubble, RefundPanel } from '@/components/admin';
-import { useAdminCapabilities } from '@/hooks';
+import { useThreadScroll } from '@/components/messaging';
+import { AdminEmptyState, AdminErrorState, AdminMessageBubble, ConfirmDialog, RefundPanel } from '@/components/admin';
+import { useAdminBackToList, useAdminCapabilities } from '@/hooks';
+import { useAuth } from '@/context/auth';
import { ROUTES } from '@/constants';
-import { useAdminTicket, usePostAdminMessage } from '@/services/tickets';
+import { useAdminTicket, useAssignTicket, useCloseTicket, usePostAdminMessage, useReopenTicket } from '@/services/tickets';
+import { TICKET_LIFECYCLE_ENABLED } from '@/services/tickets/constants';
import type { TicketAuthorRole, TicketStatus } from '@/services/tickets/types';
/** Status → chip color: an open ticket is pending work, a closed one is neutral (mirrors the queue). */
@@ -40,18 +43,22 @@ function makeClientMessageId(): string {
* The admin ticket thread (f15) — the full conversation **including internal notes** (the admin lens carries
* `isInternal`; the user app never does). Staff read the whole thread and reply as **either** a participant-
* visible reply **or** a staff-only internal note (the composer toggles `isInternal`; `AdminMessageBubble`
- * renders internal notes distinctly). Sends are optimistic (`usePostAdminMessage`) — the draft clears only on
- * confirm. When the ticket is a **refund** case linked to a booking, the admin opens the `RefundPanel` inline
- * (it always initiates from a ticket, never a standalone form). Composer + refund are gated on the principal's
- * capabilities; the server is the real authority.
+ * renders internal notes distinctly, and ui-phase-11 makes the composer itself amber-tinted in internal mode
+ * so a staff member can never post an internal note publicly by mistake). Sends are optimistic
+ * (`usePostAdminMessage`) — the draft clears only on confirm; the thread opens scrolled to the newest message
+ * (`useThreadScroll`). When the ticket is a **refund** case linked to a booking, the admin opens the
+ * `RefundPanel` inline. Close/reopen/assign-to-me (REQ-063) are gated behind `TICKET_LIFECYCLE_ENABLED` — no
+ * live route yet — AND the principal's `canManageTickets` capability; the server is the real authority.
*/
export default function AdminTicketThreadPage() {
const t = useTranslations('admin');
const tickets = useTranslations('tickets');
const locale = useLocale();
- const router = useRouter();
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
+ const [authState] = useAuth();
+ const meId = authState.currentUser?.id;
+ const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_TICKETS}`);
const params = useParams<{ id: string }>();
const parsed = Number(params?.id);
@@ -59,12 +66,20 @@ export default function AdminTicketThreadPage() {
const { data: detail, isLoading, isError, refetch } = useAdminTicket(ticketId || null);
const post = usePostAdminMessage(ticketId);
+ const closeTicket = useCloseTicket(ticketId);
+ const reopenTicket = useReopenTicket(ticketId);
+ const assignTicket = useAssignTicket(ticketId);
const [mode, setMode] = useState<'reply' | 'internal'>('reply');
const [body, setBody] = useState('');
const [refundShown, setRefundShown] = useState(false);
+ const [closeDialogOpen, setCloseDialogOpen] = useState(false);
+ const [reopenDialogOpen, setReopenDialogOpen] = useState(false);
const isInternal = mode === 'internal';
+ const messageCount = detail?.messages.length ?? 0;
+ const lastMessage = detail?.messages[messageCount - 1];
+ const { bottomRef } = useThreadScroll(messageCount, lastMessage?.isMine ?? false);
const send = () => {
const trimmed = body.trim();
@@ -80,16 +95,62 @@ export default function AdminTicketThreadPage() {
);
};
+ const onCloseConfirm = () => {
+ closeTicket.mutate(undefined, {
+ onSuccess: () => {
+ setCloseDialogOpen(false);
+ enqueueSnackbar(t('ticket_closed_ok'), { variant: 'success' });
+ },
+ });
+ };
+
+ const onReopenConfirm = () => {
+ reopenTicket.mutate(undefined, {
+ onSuccess: () => {
+ setReopenDialogOpen(false);
+ enqueueSnackbar(t('ticket_reopened_ok'), { variant: 'success' });
+ },
+ });
+ };
+
+ const onAssignMe = () => {
+ if (meId == null || assignTicket.isPending) return;
+ assignTicket.mutate({ ownerUserId: meId }, { onSuccess: () => enqueueSnackbar(t('ticket_assigned_me'), { variant: 'success' }) });
+ };
+
const showRefund = !!detail && detail.category === 'refund' && detail.bookingId != null && caps.canRefund;
+ const showLifecycle = TICKET_LIFECYCLE_ENABLED && caps.canManageTickets;
+
+ const lifecycleActions = showLifecycle && detail ? (
+ <>
+ {detail.status === 'open' ? (
+ setCloseDialogOpen(true)}>
+ {t('ticket_close')}
+
+ ) : (
+ setReopenDialogOpen(true)}>
+ {t('ticket_reopen')}
+
+ )}
+
+
+
+ {t('ticket_assign_me')}
+
+
+
+ >
+ ) : null;
return (
- router.push(`/${locale}${ROUTES.ADMIN_TICKETS}`)}
- sx={{ alignSelf: 'flex-start' }}
- >
+
{t('back')}
@@ -106,44 +167,41 @@ export default function AdminTicketThreadPage() {
) : (
<>
-
-
- {t('ticket_thread_title', { ref: detail.referenceCode })}
-
- {detail.subject ? (
-
- {detail.subject}
-
- ) : null}
-
-
-
- {detail.bookingId != null ? (
-
- ) : null}
- {detail.refundId != null ? (
-
- ) : null}
-
+
+
+
+ {detail.bookingId != null ? (
+
+ ) : null}
+ {detail.refundId != null ? (
+
+ ) : null}
+
+ }
+ />
- {showRefund ? (
-
- setRefundShown((v) => !v)}
- >
- {t('refund_open')}
-
-
-
-
-
-
-
- ) : null}
-
+ {showRefund ? (
+
+ setRefundShown((v) => !v)}
+ >
+ {t('refund_open')}
+
+
+
+
+
+
+
+ ) : null}
@@ -154,10 +212,20 @@ export default function AdminTicketThreadPage() {
authorLabel={tickets(authorLabelKey(m.authorRole))}
/>
))}
+
{caps.canManageTickets ? (
-
+
- {t('ticket_send')}
+ {isInternal ? t('ticket_send_internal') : t('ticket_send')}
) : null}
+
+ setCloseDialogOpen(false)}
+ loading={closeTicket.isPending}
+ confirmColor="primary"
+ />
+ setReopenDialogOpen(false)}
+ loading={reopenTicket.isPending}
+ confirmColor="primary"
+ />
>
)}
diff --git a/client/src/app/[locale]/(private-routes)/admin/tickets/page.tsx b/client/src/app/[locale]/(private-routes)/admin/tickets/page.tsx
index cb8ca44..a08d47d 100644
--- a/client/src/app/[locale]/(private-routes)/admin/tickets/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/admin/tickets/page.tsx
@@ -1,9 +1,9 @@
'use client';
-import { useState } from 'react';
+import { Suspense } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
-import { AppButton, StatusChip } from '@/components';
+import { AppButton, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
@@ -14,9 +14,11 @@ import {
type AdminTableColumn,
} from '@/components/admin';
import { adminTicketThreadPath } from '@/constants';
+import { useAdminListState } from '@/hooks';
import { useAdminTickets } from '@/services/tickets';
import { TICKETS_PAGE_SIZE } from '@/services/tickets/constants';
import type { AdminTicketFilters, AdminTicketSummary, TicketCategory, TicketStatus } from '@/services/tickets/types';
+import { formatShamsiDate } from '@/utils';
const STATUSES: readonly TicketStatus[] = ['open', 'closed'];
const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refund', 'emergency'];
@@ -24,35 +26,55 @@ const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refun
const STATUS_KIND: Record = { open: 'pending', closed: 'neutral' };
const EMPTY: AdminTicketFilters = {};
+function parseFilters(params: URLSearchParams): AdminTicketFilters {
+ return {
+ status: (params.get('status') as TicketStatus | null) ?? undefined,
+ category: (params.get('category') as TicketCategory | null) ?? undefined,
+ referenceCode: params.get('referenceCode') ?? undefined,
+ };
+}
+
+function serializeFilters(filters: AdminTicketFilters): Record {
+ const record: Record = {};
+ if (filters.status) record.status = filters.status;
+ if (filters.category) record.category = filters.category;
+ if (filters.referenceCode) record.referenceCode = filters.referenceCode;
+ return record;
+}
+
/**
* The admin global ticket queue (f15) — EVERY ticket across the platform (not one viewer's), the entry point
* into a case. Filter by status/category/reference; a row opens the admin thread where internal notes and the
* refund panel live. The filter **draft** commits to the query only on Apply, so typing a reference never
- * refetches; the applied filters + page are the cache key (`useAdminTickets`), so revisiting a filter/page
- * serves from cache. This surface is staff-only — the server enforces the scope; the UI just routes here.
+ * refetches; the applied filters + page are mirrored into the URL (`useAdminListState`, ui-phase-11) so
+ * browser back/refresh/a pasted link all reproduce the exact same queue view. This surface is staff-only —
+ * the server enforces the scope; the UI just routes here.
*/
export default function AdminTicketsPage() {
+ return (
+ }>
+
+
+ );
+}
+
+function AdminTicketsQueue() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
- const [draft, setDraft] = useState(EMPTY);
- const [applied, setApplied] = useState(EMPTY);
- const [page, setPage] = useState(1);
+ const { draft, setDraft, applied, page, apply, clear, goToPage } = useAdminListState({
+ parse: parseFilters,
+ serialize: serializeFilters,
+ empty: EMPTY,
+ });
const tickets = useAdminTickets(applied, page);
const items = tickets.data?.items ?? [];
- const pageCount = Math.max(1, Math.ceil((tickets.data?.total ?? 0) / TICKETS_PAGE_SIZE));
-
- const apply = () => {
- setApplied(draft);
- setPage(1);
- };
- const clear = () => {
- setDraft(EMPTY);
- setApplied(EMPTY);
- setPage(1);
- };
+ const total = tickets.data?.total ?? 0;
+ const pageCount = Math.max(1, Math.ceil(total / TICKETS_PAGE_SIZE));
+ const from = items.length === 0 ? 0 : (page - 1) * TICKETS_PAGE_SIZE + 1;
+ const to = items.length === 0 ? 0 : from + items.length - 1;
const columns: AdminTableColumn[] = [
{
@@ -76,6 +98,12 @@ export default function AdminTicketsPage() {
render: (row) => ,
},
{ key: 'booking', header: t('ticket_col_booking'), render: (row) => row.bookingId ?? '—' },
+ {
+ key: 'activity',
+ header: t('ticket_activity_col'),
+ minWidth: 140,
+ render: (row) => formatShamsiDate(row.createdAt, locale),
+ },
];
return (
@@ -149,17 +177,18 @@ export default function AdminTicketsPage() {
getRowKey={(row) => row.id}
ariaLabel={t('ticket_title')}
onRowClick={(row) => router.push(`/${locale}${adminTicketThreadPath(row.id)}`)}
+ footer={t('showing_range', { from, to, total })}
/>
)}
setPage((p) => Math.max(1, p - 1))}
- onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
+ onPrev={() => goToPage(Math.max(1, page - 1))}
+ onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
- indicator={t('page_indicator', { page })}
+ indicator={t('page_indicator', { page, total: pageCount })}
/>
);
diff --git a/client/src/app/[locale]/(private-routes)/admin/users/page.tsx b/client/src/app/[locale]/(private-routes)/admin/users/page.tsx
index 09d9861..3198641 100644
--- a/client/src/app/[locale]/(private-routes)/admin/users/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/admin/users/page.tsx
@@ -1,8 +1,101 @@
-import { getTranslations } from 'next-intl/server';
-import { PlaceholderScreen } from '@/components';
+'use client';
+import { useState } from 'react';
+import { useLocale, useTranslations } from 'next-intl';
+import { Box, Chip, Stack, TextField, Typography } from '@mui/material';
+import { AdminDataTable, AdminEmptyState, AdminPageHeader, type AdminTableColumn } from '@/components/admin';
+import { AppLink } from '@/components';
+import { ROUTES } from '@/constants';
+import { useUserSearch } from '@/services/admin';
+import { useDebouncedValue } from '@/services/search';
+import type { AdminUserSummary, DirectoryUserRole } from '@/services/admin/types';
-export default async function AdminUsersPage() {
- const t = await getTranslations('nav');
- const tShell = await getTranslations('shell');
- return ;
+const SEARCH_DEBOUNCE_MS = 300;
+const MIN_QUERY_LENGTH = 2;
+
+/** Directory role → chip label key. */
+const ROLE_LABEL_KEY: Record = {
+ customer: 'user_role_customer',
+ nurse: 'user_role_nurse',
+ admin: 'user_role_admin',
+ partner: 'user_role_partner',
+};
+
+/**
+ * Read-first admin user directory (phase §3.8) — search by name/phone over the same admin user-directory
+ * seam `UserPicker`/`NursePicker` use (REQ-061, gap — mock-backed until delivered). Built because the REQ
+ * this console depends on is filed and mocked-behind-the-seam this same phase; it becomes real the moment
+ * `services/admin`'s seam flips. Each row links into the audit log filtered to that user (the one console
+ * that already supports an entity filter) — a ticket-queue deep link isn't offered because the admin
+ * ticket queue has no actor/user filter to land on (would be a dishonest link).
+ */
+export default function AdminUsersPage() {
+ const t = useTranslations('admin');
+ const locale = useLocale();
+ const [query, setQuery] = useState('');
+ const debounced = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
+ const search = useUserSearch(debounced);
+ const items = search.data ?? [];
+ const hasQuery = debounced.trim().length >= MIN_QUERY_LENGTH;
+
+ const columns: AdminTableColumn[] = [
+ { key: 'name', header: t('user_col_name'), render: (u) => u.displayName, minWidth: 180 },
+ {
+ key: 'phone',
+ header: t('user_col_phone'),
+ render: (u) => (
+
+ {u.maskedPhone}
+
+ ),
+ },
+ { key: 'id', header: t('user_col_id'), render: (u) => `#${u.id}` },
+ {
+ key: 'roles',
+ header: t('user_col_roles'),
+ render: (u) => (
+
+ {u.roles.map((r) => (
+
+ ))}
+
+ ),
+ },
+ {
+ key: 'actions',
+ header: '',
+ align: 'right' as const,
+ render: (u) => (
+ {t('user_view_audit')}
+ ),
+ },
+ ];
+
+ return (
+
+
+
+ setQuery(e.target.value)}
+ sx={{ maxWidth: 360 }}
+ />
+
+ {!hasQuery ? (
+
+ {t('user_search_hint')}
+
+ ) : search.isFetching ? (
+
+ {t('user_picker_loading')}
+
+ ) : items.length === 0 ? (
+
+ ) : (
+ u.id} ariaLabel={t('user_title')} />
+ )}
+
+ );
}
diff --git a/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx b/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx
index 7944e77..7a38571 100644
--- a/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState } from 'react';
-import { useParams, useRouter } from 'next/navigation';
+import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
+import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import {
@@ -15,25 +15,21 @@ import {
TextField,
Typography,
} from '@mui/material';
-import { AppButton, StatusChip } from '@/components';
+import { AppButton, AppLoading, JalaliDateField, PageHeader, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
-import {
- AdminEmptyState,
- AdminErrorState,
- AdminPageHeader,
- ConfirmDialog,
- DocumentViewer,
-} from '@/components/admin';
-import { ROUTES } from '@/constants';
+import { AdminEmptyState, AdminErrorState, ConfirmDialog, DocumentViewer } from '@/components/admin';
+import { ROUTES, adminVerificationCasePath } from '@/constants';
import { formatShamsiDate } from '@/utils';
-import { useAdminCapabilities } from '@/hooks';
+import { useAdminBackToList, useAdminCapabilities } from '@/hooks';
import {
useApproveVerification,
useDecideStep,
useRejectVerification,
useVerificationCase,
+ useVerificationQueue,
} from '@/services/verification';
import type { AdminVerificationStepDetail, VerificationStepStatus } from '@/services/verification/types';
+import { parseQueueFilters, queueCaseHref } from '../queueFilters';
/** The three credential-bearing step types — a Pass here opens the structured credential form. */
const CREDENTIAL_STEP_CODES: readonly string[] = ['moh_competency_license', 'ino_membership', 'criminal_record'];
@@ -48,6 +44,14 @@ const STEP_STATUS_KIND: Record = {
expired: 'rejected',
};
+export default function AdminVerificationCasePage() {
+ return (
+ }>
+
+
+ );
+}
+
/**
* Verification case (b6 `AdminVerificationsController`) — the trust desk works one nurse: the identity on
* file for cross-check, every step with its status + documents (each `DocumentViewer` re-signs its own
@@ -55,12 +59,18 @@ const STEP_STATUS_KIND: Record = {
* (encrypted) credential via a structured form; recorded credentials are listed by **type** only — the
* number never crosses the wire. The whole verification is approvable only when every required step has
* passed; a decision re-aggregates server-side (flipping `is_verified`) and removes the case from the queue.
+ *
+ * «پرونده بعدی»/«پرونده قبلی» + arrow keys move through the **current queue page's order** without
+ * returning to the list: the case URL carries the queue's `status`/`search`/`page` (`queueCaseHref`), so
+ * re-deriving the same filters here hits `useVerificationQueue`'s cache the list already primed (no extra
+ * fetch in the common flow). Fetching adjacent pages is out of scope — prev/next disable at the page's ends.
*/
-export default function AdminVerificationCasePage() {
+function AdminVerificationCaseScreen() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ nurseId: string }>();
+ const searchParams = useSearchParams();
const nurseVerificationId = Number(params?.nurseId);
const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar();
@@ -74,7 +84,36 @@ export default function AdminVerificationCasePage() {
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
- const backToQueue = () => router.push(`/${locale}${ROUTES.ADMIN_VERIFICATION}`);
+ const backToQueue = useAdminBackToList(`/${locale}${ROUTES.ADMIN_VERIFICATION}`);
+
+ // Re-derive the SAME filters/page the queue list would have parsed from its own URL, so this query
+ // reuses the list's cached page (React Query keys structurally) instead of an unfiltered refetch.
+ const queueFilters = useMemo(() => parseQueueFilters(searchParams), [searchParams]);
+ const queuePage = useMemo(() => {
+ const raw = Number(searchParams.get('page'));
+ return Number.isInteger(raw) && raw > 0 ? raw : 1;
+ }, [searchParams]);
+ const queueQuery = useVerificationQueue(queueFilters, queuePage);
+ const queueItems = queueQuery.data?.items ?? [];
+ const caseIndex = queueItems.findIndex((item) => item.nurseVerificationId === nurseVerificationId);
+ const prevCase = caseIndex > 0 ? queueItems[caseIndex - 1] : null;
+ const nextCase = caseIndex >= 0 && caseIndex < queueItems.length - 1 ? queueItems[caseIndex + 1] : null;
+
+ const goToCase = useCallback(
+ (id: number) => router.push(queueCaseHref(locale, adminVerificationCasePath(id), queueFilters, queuePage)),
+ [router, locale, queueFilters, queuePage],
+ );
+
+ useEffect(() => {
+ function onKeyDown(event: KeyboardEvent) {
+ const tag = (document.activeElement as HTMLElement | null)?.tagName;
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
+ if (event.key === 'ArrowRight' && prevCase) goToCase(prevCase.nurseVerificationId);
+ else if (event.key === 'ArrowLeft' && nextCase) goToCase(nextCase.nurseVerificationId);
+ }
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [prevCase, nextCase, goToCase]);
const allPassed = !!data && data.steps.length > 0 && data.steps.every((step) => step.status === 'passed');
@@ -103,12 +142,33 @@ export default function AdminVerificationCasePage() {
return (
-
-
- {t('back')}
-
-
-
+
+ prevCase && goToCase(prevCase.nurseVerificationId)}
+ disabled={!prevCase}
+ >
+ {t('ver_prev_case')}
+
+ nextCase && goToCase(nextCase.nurseVerificationId)}
+ disabled={!nextCase}
+ >
+ {t('ver_next_case')}
+
+
+ }
+ />
{isLoading ? (
@@ -408,24 +468,15 @@ function CredentialDialog({
value={issuingAuthority}
onChange={(e) => setIssuingAuthority(e.target.value)}
/>
-
+ setIssuedAt(e.target.value)}
- slotProps={{ inputLabel: { shrink: true } }}
- />
- setExpiresAt(e.target.value)}
+ value={expiresAt || null}
+ onChange={setExpiresAt}
required={expiryRequired}
error={expiryMissing}
helperText={expiryMissing ? t('ver_expiry_required') : undefined}
- slotProps={{ inputLabel: { shrink: true } }}
/>
diff --git a/client/src/app/[locale]/(private-routes)/admin/verification/page.tsx b/client/src/app/[locale]/(private-routes)/admin/verification/page.tsx
index cf1d9d6..d96def9 100644
--- a/client/src/app/[locale]/(private-routes)/admin/verification/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/admin/verification/page.tsx
@@ -1,9 +1,9 @@
'use client';
-import { useState } from 'react';
+import { Suspense, useEffect, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
-import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
-import { AppIcon, StatusChip } from '@/components';
+import { Box, Chip, Skeleton, Stack, Tab, Tabs, TextField } from '@mui/material';
+import { AppButton, AppIcon, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import {
AdminDataTable,
@@ -14,13 +14,21 @@ import {
} from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
import { adminVerificationCasePath } from '@/constants';
-import { formatShamsiDate } from '@/utils';
+import { formatNumber, formatRelativeTime, formatShamsiDate } from '@/utils';
+import { useAdminListState } from '@/hooks';
import { useVerificationQueue } from '@/services/verification';
import { ADMIN_QUEUE_PAGE_SIZE } from '@/services/verification/constants';
-import type { AdminVerificationQueueItem, VerificationAggregateStatus } from '@/services/verification/types';
+import type {
+ AdminVerificationQueueFilters,
+ AdminVerificationQueueItem,
+ VerificationAggregateStatus,
+} from '@/services/verification/types';
+import { EMPTY_QUEUE_FILTERS, parseQueueFilters, queueCaseHref, serializeQueueFilters } from './queueFilters';
-/** The queue status filter — a subset of the aggregate statuses the desk works (default all). */
-type QueueStatusFilter = '' | 'pending' | 'in_review';
+/** SLA thresholds for the waiting-time column's color (display-only client signal — never a server rule). */
+const WAITING_TIME_WARNING_HOURS = 48;
+const WAITING_TIME_ALARM_HOURS = 96;
+const MS_PER_HOUR = 60 * 60 * 1000;
/** Aggregate status → chip kind. `in_review` reads as informational; a rejected/suspended case shows red. */
const AGG_STATUS_KIND: Record = {
@@ -32,24 +40,66 @@ const AGG_STATUS_KIND: Record = {
suspended: 'rejected',
};
+export default function AdminVerificationQueuePage() {
+ return (
+ }>
+
+
+ );
+}
+
/**
* Verification review queue (b6 `AdminVerificationsController`) — the trust desk's worklist, one row per
- * nurse folded from the per-step endpoint. Filter by status (all / pending / in_review); each row surfaces
- * the step progress, the next pending step, when it was submitted, and a warning when a credential is
- * expiring. A row opens its case. The filter + page are the query key, so switching them reuses cached
- * pages; a decision on a case invalidates the queue so the desk re-renders without a manual refresh.
+ * nurse folded from the per-step endpoint. Status tabs (all / pending / in_review, badge-counted when the
+ * server serves `counts` — REQ-062) replace the old lone select; a name/phone search follows the same
+ * draft-vs-applied Apply/Clear pattern as `admin/tickets`/`admin/audit`. Filters + page are URL-synced via
+ * `useAdminListState`, so a queue row carries them forward into the case URL (`queueCaseHref`) — the case
+ * page re-derives the same query key to reuse this cache for next/prev case navigation.
*/
-export default function AdminVerificationQueuePage() {
+function AdminVerificationQueueScreen() {
const t = useTranslations('admin');
const locale = useLocale();
const router = useRouter();
- const [status, setStatus] = useState('');
- const [page, setPage] = useState(1);
+ const state = useAdminListState({
+ parse: parseQueueFilters,
+ serialize: serializeQueueFilters,
+ empty: EMPTY_QUEUE_FILTERS,
+ });
- const queue = useVerificationQueue({ status: status || undefined }, page);
+ // Tabs commit immediately (they're discrete, not free text) — `state.apply()` closes over the CURRENT
+ // render's `draft`, so calling it synchronously right after `setDraft` would still see the stale value.
+ // Deferring the commit to the render that follows the draft update reads the fresh `draft` correctly.
+ const applyPendingRef = useRef(false);
+ useEffect(() => {
+ if (applyPendingRef.current) {
+ applyPendingRef.current = false;
+ state.apply();
+ }
+ });
+
+ const selectStatus = (next: '' | 'pending' | 'in_review') => {
+ state.setDraft((d) => ({ ...d, status: next || undefined }));
+ applyPendingRef.current = true;
+ };
+
+ const queue = useVerificationQueue(state.applied, state.page);
const items = queue.data?.items ?? [];
- const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / ADMIN_QUEUE_PAGE_SIZE));
+ const total = queue.data?.total ?? 0;
+ const counts = queue.data?.counts;
+ const pageCount = Math.max(1, Math.ceil(total / ADMIN_QUEUE_PAGE_SIZE));
+
+ const tabLabel = (base: string, count: number | undefined): string =>
+ count === undefined ? base : `${base} (${formatNumber(count, locale)})`;
+
+ const footerText =
+ total > 0
+ ? t('showing_range', {
+ from: (state.page - 1) * ADMIN_QUEUE_PAGE_SIZE + 1,
+ to: Math.min(state.page * ADMIN_QUEUE_PAGE_SIZE, total),
+ total,
+ })
+ : undefined;
const columns: AdminTableColumn[] = [
{
@@ -91,31 +141,56 @@ export default function AdminVerificationQueuePage() {
header: t('ver_col_submitted'),
render: (item) => (item.submittedAt ? formatShamsiDate(item.submittedAt, locale) : '—'),
},
+ {
+ key: 'waiting',
+ header: t('ver_col_waiting'),
+ render: (item) => {
+ if (!item.submittedAt) return '—';
+ const hours = (Date.now() - new Date(item.submittedAt).getTime()) / MS_PER_HOUR;
+ const color =
+ hours >= WAITING_TIME_ALARM_HOURS
+ ? 'var(--bal-error)'
+ : hours >= WAITING_TIME_WARNING_HOURS
+ ? 'var(--bal-warning)'
+ : undefined;
+ return (
+
+ {formatRelativeTime(item.submittedAt, locale, formatShamsiDate)}
+
+ );
+ },
+ },
];
return (
- {
- setStatus(e.target.value as QueueStatusFilter);
- setPage(1);
- }}
- sx={{ minWidth: 160 }}
- >
-
-
-
-
- }
- />
+
+
+ selectStatus(value)}>
+
+
+
+
+
+
+ state.setDraft((d) => ({ ...d, search: e.target.value || undefined }))}
+ sx={{ minWidth: 240 }}
+ />
+
+ {t('apply')}
+
+
+ {t('clear')}
+
+
{queue.isLoading ? (
{[0, 1, 2, 3].map((k) => )}
@@ -129,18 +204,23 @@ export default function AdminVerificationQueuePage() {
rows={items}
getRowKey={(item) => item.nurseVerificationId}
ariaLabel={t('ver_title')}
- onRowClick={(item) => router.push(`/${locale}${adminVerificationCasePath(item.nurseVerificationId)}`)}
+ footer={footerText}
+ onRowClick={(item) =>
+ router.push(
+ queueCaseHref(locale, adminVerificationCasePath(item.nurseVerificationId), state.applied, state.page),
+ )
+ }
/>
)}
setPage((p) => Math.max(1, p - 1))}
- onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
+ onPrev={() => state.goToPage(Math.max(1, state.page - 1))}
+ onNext={() => state.goToPage(Math.min(pageCount, state.page + 1))}
prevLabel={t('prev_page')}
nextLabel={t('next_page')}
- indicator={t('page_indicator', { page })}
+ indicator={t('page_indicator', { page: state.page, total: pageCount })}
/>
);
diff --git a/client/src/app/[locale]/(private-routes)/admin/verification/queueFilters.ts b/client/src/app/[locale]/(private-routes)/admin/verification/queueFilters.ts
new file mode 100644
index 0000000..42775f1
--- /dev/null
+++ b/client/src/app/[locale]/(private-routes)/admin/verification/queueFilters.ts
@@ -0,0 +1,41 @@
+import type { AdminVerificationQueueFilters } from '@/services/verification/types';
+
+/**
+ * The queue's URL-synced filter shape — shared between the list (`page.tsx`, via `useAdminListState`)
+ * and the case detail (`[nurseId]/page.tsx`) so a case page can **re-derive the same query key** the
+ * list primed (`status`/`search`/`page` → `useVerificationQueue`) and reuse its cached page for
+ * next/prev case navigation, with no extra URL param and no duplicate fetch in the common flow.
+ */
+export const EMPTY_QUEUE_FILTERS: AdminVerificationQueueFilters = {};
+
+/** The URL query-param name for the name/phone search (REQ-062's proposed `q`). */
+const SEARCH_PARAM = 'q';
+
+export function parseQueueFilters(params: URLSearchParams): AdminVerificationQueueFilters {
+ const status = params.get('status');
+ return {
+ status: status === 'pending' || status === 'in_review' ? status : undefined,
+ search: params.get(SEARCH_PARAM) ?? undefined,
+ };
+}
+
+export function serializeQueueFilters(filters: AdminVerificationQueueFilters): Record {
+ const record: Record = {};
+ if (filters.status) record.status = filters.status;
+ if (filters.search) record[SEARCH_PARAM] = filters.search;
+ return record;
+}
+
+/** A case URL carrying the queue's current filters + page, so next/prev navigation (and a browser
+ * back/refresh) keeps resolving against the same cached queue page instead of an unfiltered default. */
+export function queueCaseHref(
+ locale: string,
+ casePath: string,
+ filters: AdminVerificationQueueFilters,
+ page: number,
+): string {
+ const params = new URLSearchParams(serializeQueueFilters(filters));
+ if (page > 1) params.set('page', String(page));
+ const qs = params.toString();
+ return `/${locale}${casePath}${qs ? `?${qs}` : ''}`;
+}
diff --git a/client/src/app/[locale]/(private-routes)/partner/bookings/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/partner/bookings/[id]/page.tsx
new file mode 100644
index 0000000..ff95c25
--- /dev/null
+++ b/client/src/app/[locale]/(private-routes)/partner/bookings/[id]/page.tsx
@@ -0,0 +1,115 @@
+'use client';
+import type { ReactNode } from 'react';
+import { useParams } from 'next/navigation';
+import { useLocale, useTranslations } from 'next-intl';
+import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
+import { PageHeader, StatusChip, StatusTimeline } from '@/components';
+import type { StatusKind, TimelineNode, TimelineNodeState } from '@/components';
+import { AdminEmptyState, AdminErrorState } from '@/components/admin';
+import { CONTENT_MAX_WIDTH } from '@/components/config';
+import { ROUTES } from '@/constants';
+import { useAdminBackToList } from '@/hooks';
+import { formatShamsiDate, formatShamsiDateTime } from '@/utils';
+import { useMySponsoredBookingDetail } from '@/services/partnerCenter';
+
+/** Same tone map as the bookings list — one status vocabulary across the portal. */
+const PARTNER_BOOKING_STATUS_KIND: Record = {
+ pending_payment: 'pending',
+ confirmed: 'info',
+ in_progress: 'active',
+ completed: 'verified',
+ disputed: 'rejected',
+ closed: 'neutral',
+ cancelled: 'rejected',
+};
+
+/** A booking's lifecycle is done once it reaches one of these — the timeline's last node reads as "current" otherwise. */
+const TERMINAL_STATUSES = new Set(['completed', 'closed', 'cancelled']);
+
+/**
+ * Partner portal — scoped sponsored-booking detail (f15, REQ-064). Strictly read-only and bounded to what
+ * the portal is allowed to see: the patient's display name, the scheduled date, the current status, and a
+ * server-truth status timeline — **no clinical content, no address, no money**. A bookings-list row deep-
+ * links here; tenancy (only bookings the signed-in center legally covers) is server-enforced.
+ */
+export default function PartnerBookingDetailPage() {
+ const t = useTranslations('partner');
+ const ta = useTranslations('admin');
+ const locale = useLocale();
+ const params = useParams<{ id: string }>();
+ const bookingId = Number(params?.id);
+
+ const goBack = useAdminBackToList(`/${locale}${ROUTES.PARTNER_BOOKINGS}`);
+ const detail = useMySponsoredBookingDetail(Number.isFinite(bookingId) ? bookingId : undefined);
+
+ const timelineNodes: TimelineNode[] =
+ detail.data?.timeline.map((entry, index, all) => {
+ const isLast = index === all.length - 1;
+ const state: TimelineNodeState = isLast && !TERMINAL_STATUSES.has(detail.data!.status) ? 'current' : 'completed';
+ return {
+ key: `${entry.status}-${index}`,
+ label: t(`bstatus_${entry.status}`),
+ timestamp: formatShamsiDateTime(entry.occurredAt, locale),
+ state,
+ };
+ }) ?? [];
+
+ return (
+
+
+
+ {detail.isLoading ? (
+
+
+
+
+ ) : detail.isError ? (
+ detail.refetch()} />
+ ) : !detail.data ? (
+
+ ) : (
+ <>
+
+
+
+
+ {t('bookings_col_id')} #{detail.data.bookingId}
+
+
+
+ {detail.data.patientName}
+
+ {formatShamsiDate(detail.data.scheduledDate, locale)}
+
+
+
+
+
+
+ {t('booking_detail_timeline_title')}
+
+
+
+
+
+ >
+ )}
+
+ );
+}
+
+function MetaLine({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+
+ {label}
+
+
+ {children}
+
+
+ );
+}
diff --git a/client/src/app/[locale]/(private-routes)/partner/bookings/page.tsx b/client/src/app/[locale]/(private-routes)/partner/bookings/page.tsx
index 0e369e8..8dd5345 100644
--- a/client/src/app/[locale]/(private-routes)/partner/bookings/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/partner/bookings/page.tsx
@@ -1,39 +1,77 @@
'use client';
-import { useState } from 'react';
+import { Suspense } from 'react';
+import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
-import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
+import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
+import { AppButton, AppLoading, StatusChip } from '@/components';
+import type { StatusKind } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager } from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin';
+import { partnerBookingDetailPath } from '@/constants';
+import { useAdminListState } from '@/hooks';
import { formatShamsiDate } from '@/utils';
-import type { SponsoredBooking } from '@/services/partnerCenter/types';
+import type { SponsoredBooking, SponsoredBookingFilters } from '@/services/partnerCenter/types';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import { useMySponsoredBookings } from '@/services/partnerCenter';
-/**
- * Booking lifecycle codes the center may legally cover — the read-only filter options. Stable string codes
- * (the wire enum); labels are the codes themselves since `status` is a free-form summary field, not a
- * localized enum in the portal contract.
- */
+/** Booking lifecycle codes the center may legally cover — the read-only filter options (stable wire codes). */
const BOOKING_STATUS_OPTIONS = ['pending_payment', 'confirmed', 'in_progress', 'completed', 'disputed', 'closed', 'cancelled'] as const;
+/** Booking status → chip tone. Progression reads left-to-right calmer→done; disputed/cancelled are the alarm tier. */
+const PARTNER_BOOKING_STATUS_KIND: Record = {
+ pending_payment: 'pending',
+ confirmed: 'info',
+ in_progress: 'active',
+ completed: 'verified',
+ disputed: 'rejected',
+ closed: 'neutral',
+ cancelled: 'rejected',
+};
+
+const EMPTY_FILTERS: SponsoredBookingFilters = {};
+
+function parseFilters(params: URLSearchParams): SponsoredBookingFilters {
+ return { status: params.get('status') ?? undefined };
+}
+
+function serializeFilters(filters: SponsoredBookingFilters): Record {
+ return filters.status ? { status: filters.status } : {};
+}
+
/**
* Partner portal — sponsored bookings (f15). The read-only list of bookings the signed-in center legally
- * covers (portal scope; server-enforced tenancy). An optional status filter (filter+page keyed cache) and a
- * dense table of id/patient/date/status. No PII beyond the summary; the center never sees clinical detail.
+ * covers (portal scope; server-enforced tenancy). A status filter (applied+page mirrored into the URL via
+ * `useAdminListState`, ui-phase-11) and a dense table of id/patient/date/status — every status label is
+ * translated (`bstatus_*`), never the raw wire code. Rows link to a scoped read-only detail (REQ-064).
+ * `useSearchParams` (inside `useAdminListState`) needs a `` boundary.
*/
export default function PartnerBookingsPage() {
+ return (
+ }>
+
+
+ );
+}
+
+function PartnerBookingsScreen() {
const t = useTranslations('partner');
const ta = useTranslations('admin');
const locale = useLocale();
+ const router = useRouter();
- const [status, setStatus] = useState('');
- const [page, setPage] = useState(1);
+ const { draft, setDraft, applied, page, apply, clear, goToPage } = useAdminListState({
+ parse: parseFilters,
+ serialize: serializeFilters,
+ empty: EMPTY_FILTERS,
+ });
- const filters = { status: status || undefined };
- const bookings = useMySponsoredBookings(filters, page);
+ const bookings = useMySponsoredBookings(applied, page);
const items = bookings.data?.items ?? [];
- const pageCount = Math.max(1, Math.ceil((bookings.data?.total ?? 0) / PARTNER_PAGE_SIZE));
+ const total = bookings.data?.total ?? 0;
+ const pageCount = Math.max(1, Math.ceil(total / PARTNER_PAGE_SIZE));
+ const from = items.length === 0 ? 0 : (page - 1) * PARTNER_PAGE_SIZE + 1;
+ const to = items.length === 0 ? 0 : from + items.length - 1;
const columns: AdminTableColumn[] = [
{ key: 'id', header: t('bookings_col_id'), render: (b) => `#${b.bookingId}` },
@@ -42,35 +80,42 @@ export default function PartnerBookingsPage() {
{
key: 'status',
header: t('bookings_col_status'),
- render: (b) => ,
+ render: (b) => (
+
+ ),
},
];
return (
- {
- setStatus(e.target.value);
- setPage(1);
- }}
- sx={{ minWidth: 180 }}
- >
-
- {BOOKING_STATUS_OPTIONS.map((s) => (
-
- ))}
-
- }
- />
+
+
+
+ setDraft({ status: e.target.value || undefined })}
+ sx={{ minWidth: 180 }}
+ >
+
+ {BOOKING_STATUS_OPTIONS.map((s) => (
+
+ ))}
+
+
+ {ta('apply')}
+
+
+ {ta('clear')}
+
+
{bookings.isLoading ? (
@@ -88,17 +133,19 @@ export default function PartnerBookingsPage() {
rows={items}
getRowKey={(b) => b.bookingId}
ariaLabel={t('bookings_title')}
+ onRowClick={(b) => router.push(`/${locale}${partnerBookingDetailPath(b.bookingId)}`)}
+ footer={ta('showing_range', { from, to, total })}
/>
)}
setPage((p) => Math.max(1, p - 1))}
- onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
+ onPrev={() => goToPage(Math.max(1, page - 1))}
+ onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={ta('prev_page')}
nextLabel={ta('next_page')}
- indicator={ta('page_indicator', { page })}
+ indicator={ta('page_indicator', { page, total: pageCount })}
/>
);
diff --git a/client/src/app/[locale]/(private-routes)/partner/settlement/page.tsx b/client/src/app/[locale]/(private-routes)/partner/settlement/page.tsx
index 741d9da..cc4ba51 100644
--- a/client/src/app/[locale]/(private-routes)/partner/settlement/page.tsx
+++ b/client/src/app/[locale]/(private-routes)/partner/settlement/page.tsx
@@ -1,7 +1,8 @@
'use client';
-import { useState } from 'react';
-import { useTranslations } from 'next-intl';
+import { Suspense } from 'react';
+import { useLocale, useTranslations } from 'next-intl';
import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
+import { AppButton, AppLoading } from '@/components';
import {
AdminEmptyState,
AdminErrorState,
@@ -9,21 +10,86 @@ import {
AdminPager,
PartnerSettlementRow,
} from '@/components/admin';
+import { useAdminListState } from '@/hooks';
+import { formatIrrToToman, formatShamsiDate, toCsv } from '@/utils';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
+import type { CenterInvoice } from '@/services/partnerCenter/types';
import { useMyPartnerCenter, useMySettlement } from '@/services/partnerCenter';
+const NO_FILTERS: Record = {};
+
/**
* Partner portal — settlement & invoices (f15). **Merchant-of-record drives the whole view**: a non-MoR
* center settles through Balinyaar and issues no commission invoices, so it sees only the
* `settlement_not_mor` state (no table). A MoR center sees its per-booking commission invoices
* (`PartnerSettlementRow`, VAT-on-commission-only breakdown) with the masked settlement IBAN and a
- * signed-URL PDF that opens in a new tab. Read-only.
+ * signed-URL PDF that opens in a new tab, plus a client-side CSV export of the currently loaded page. Read-
+ * only. `useSearchParams` (inside `useAdminListState`) needs a `` boundary.
*/
export default function PartnerSettlementPage() {
+ return (
+ }>
+
+
+ );
+}
+
+/** Already-translated CSV column headers, keyed to the row shape `exportInvoicesCsv` builds below. */
+interface InvoiceCsvHeaders {
+ invoiceNumber: string;
+ bookingId: string;
+ gross: string;
+ commission: string;
+ vat: string;
+ total: string;
+ issuedAt: string;
+}
+
+/** Builds + triggers the download of a CSV of the currently loaded invoices page (no new fetch). */
+function exportInvoicesCsv(invoices: CenterInvoice[], locale: string, headers: InvoiceCsvHeaders): void {
+ const csv = toCsv(
+ invoices.map((inv) => ({
+ invoiceNumber: inv.invoiceNumber,
+ bookingId: inv.bookingId,
+ gross: formatIrrToToman(inv.grossIrr, locale),
+ commission: formatIrrToToman(inv.platformCommissionIrr, locale),
+ vat: formatIrrToToman(inv.vatIrr, locale),
+ total: formatIrrToToman(inv.totalIrr, locale),
+ issuedAt: formatShamsiDate(inv.issuedAt, locale),
+ })),
+ [
+ { key: 'invoiceNumber', label: headers.invoiceNumber },
+ { key: 'bookingId', label: headers.bookingId },
+ { key: 'gross', label: headers.gross },
+ { key: 'commission', label: headers.commission },
+ { key: 'vat', label: headers.vat },
+ { key: 'total', label: headers.total },
+ { key: 'issuedAt', label: headers.issuedAt },
+ ],
+ );
+ // UTF-8 BOM so Excel renders Persian text correctly.
+ const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = 'settlement.csv';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+}
+
+function PartnerSettlementScreen() {
const t = useTranslations('partner');
const ta = useTranslations('admin');
+ const locale = useLocale();
const center = useMyPartnerCenter();
- const [page, setPage] = useState(1);
+ // Page-only URL sync (no filters on this list) — survives refresh/back like the other portal worklists.
+ const { page, goToPage } = useAdminListState>({
+ parse: () => NO_FILTERS,
+ serialize: () => ({}),
+ empty: NO_FILTERS,
+ });
// Called unconditionally (rules of hooks); only rendered for a merchant-of-record center.
const settlement = useMySettlement(page);
@@ -58,7 +124,30 @@ export default function PartnerSettlementPage() {
return (
-
+
+ exportInvoicesCsv(invoices, locale, {
+ invoiceNumber: t('csv_col_invoice_number'),
+ bookingId: t('csv_col_booking_id'),
+ gross: t('csv_col_gross'),
+ commission: t('csv_col_commission'),
+ vat: t('csv_col_vat'),
+ total: t('csv_col_total'),
+ issuedAt: t('csv_col_issued_at'),
+ })
+ }
+ >
+ {t('settlement_export_csv')}
+
+ }
+ />
@@ -98,11 +187,11 @@ export default function PartnerSettlementPage() {
setPage((p) => Math.max(1, p - 1))}
- onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
+ onPrev={() => goToPage(Math.max(1, page - 1))}
+ onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={ta('prev_page')}
nextLabel={ta('next_page')}
- indicator={ta('page_indicator', { page })}
+ indicator={ta('page_indicator', { page, total: pageCount })}
/>
);
diff --git a/client/src/components/admin/AdminDataTable.test.tsx b/client/src/components/admin/AdminDataTable.test.tsx
index f4f880b..5d396ae 100644
--- a/client/src/components/admin/AdminDataTable.test.tsx
+++ b/client/src/components/admin/AdminDataTable.test.tsx
@@ -38,4 +38,34 @@ describe('', () => {
fireEvent.click(screen.getByText('Beta'));
expect(onRowClick).toHaveBeenCalledWith(ROWS[1]);
});
+
+ it('renders the footer line only when passed', () => {
+ const { rerender, container } = wrap( r.id} />);
+ expect(container.querySelector('p')).not.toBeInTheDocument();
+ rerender(
+
+ r.id} footer="Showing 1–2 of 2" />
+ ,
+ );
+ expect(screen.getByText('Showing 1–2 of 2')).toBeInTheDocument();
+ });
+
+ it('fires onSortChange with the column key when a sortable header is clicked', () => {
+ const onSortChange = jest.fn();
+ const sortableColumns: AdminTableColumn[] = [
+ { key: 'id', header: 'ID', render: (r) => r.id, sortable: true },
+ { key: 'name', header: 'Name', render: (r) => r.name },
+ ];
+ wrap(
+ r.id}
+ sort={{ key: 'id', direction: 'asc' }}
+ onSortChange={onSortChange}
+ />,
+ );
+ fireEvent.click(screen.getByRole('button', { name: /ID/ }));
+ expect(onSortChange).toHaveBeenCalledWith('id');
+ });
});
diff --git a/client/src/components/admin/AdminDataTable.tsx b/client/src/components/admin/AdminDataTable.tsx
index f73c032..b3826c8 100644
--- a/client/src/components/admin/AdminDataTable.tsx
+++ b/client/src/components/admin/AdminDataTable.tsx
@@ -7,8 +7,12 @@ import {
TableContainer,
TableHead,
TableRow,
+ TableSortLabel,
+ Typography,
} from '@mui/material';
+export type AdminSortDirection = 'asc' | 'desc';
+
export interface AdminTableColumn {
/** Stable column key (also `data-col` for tests). */
key: string;
@@ -19,6 +23,15 @@ export interface AdminTableColumn {
/** Optional cell alignment (defaults to `inherit`, which follows text direction — RTL-safe). */
align?: 'inherit' | 'left' | 'center' | 'right';
width?: number | string;
+ /** Floor width so a narrow value (a status chip, a short date) doesn't collapse the column on scroll. */
+ minWidth?: number | string;
+ /** Marks the column as a server-param sort target — renders a `TableSortLabel`; requires `onSortChange`. */
+ sortable?: boolean;
+}
+
+export interface AdminSortState {
+ key: string;
+ direction: AdminSortDirection;
}
export interface AdminDataTableProps {
@@ -29,6 +42,18 @@ export interface AdminDataTableProps {
dense?: boolean;
/** Accessible table name (already translated). */
ariaLabel?: string;
+ /** Bounds the table to a scrollable viewport with a header that stays visible while scrolling — opt in
+ * on long worklists (the filter object stays the query key; sort/page are just more of it, per 3.1). */
+ stickyHeader?: boolean;
+ /** `stickyHeader`'s scroll viewport height. */
+ stickyMaxHeight?: number | string;
+ /** The currently active server-param sort, or `null`/absent when unsorted. */
+ sort?: AdminSortState | null;
+ /** Fired with a sortable column's `key` when its header is clicked — the caller owns the 3-state cycle. */
+ onSortChange?: (key: string) => void;
+ /** Already-translated footer line («نمایش ۱–۲۰ از ۱۲۴») — rendered only when passed; every caller already
+ * holds `total` from its paginated query, so this mirrors `AdminPager`'s pre-translated-string convention. */
+ footer?: string;
}
/**
@@ -36,39 +61,85 @@ export interface AdminDataTableProps {
* whole table scrolls horizontally inside its own container so a wide worklist never breaks the page layout
* (a hard responsive rule). Rows are optionally clickable (a queue row → its case). Header/cell alignment
* defaults to `inherit` so it follows the active text direction (RTL-safe). Colors come from the palette.
+ * v2 (ui-phase-11) adds optional per-column server-param sort, a sticky-header scroll viewport for long
+ * pages, per-column `minWidth`, and a results footer line.
* @component AdminDataTable
*/
-function AdminDataTable({ columns, rows, getRowKey, onRowClick, dense = true, ariaLabel }: AdminDataTableProps) {
+function AdminDataTable({
+ columns,
+ rows,
+ getRowKey,
+ onRowClick,
+ dense = true,
+ ariaLabel,
+ stickyHeader = false,
+ stickyMaxHeight = 640,
+ sort,
+ onSortChange,
+ footer,
+}: AdminDataTableProps) {
return (
-
-
-
-
- {columns.map((col) => (
-
- {col.header}
-
- ))}
-
-
-
- {rows.map((row) => (
- onRowClick(row) : undefined}
- sx={{ cursor: onRowClick ? 'pointer' : 'default', '&:last-child td': { border: 0 } }}
- >
+ <>
+
+
+
+
{columns.map((col) => (
-
- {col.render(row)}
+
+ {col.sortable && onSortChange ? (
+ onSortChange(col.key)}
+ >
+ {col.header}
+
+ ) : (
+ col.header
+ )}
))}
- ))}
-
-
-
+
+
+ {rows.map((row) => (
+ onRowClick(row) : undefined}
+ sx={{ cursor: onRowClick ? 'pointer' : 'default', '&:last-child td': { border: 0 } }}
+ >
+ {columns.map((col) => (
+
+ {col.render(row)}
+
+ ))}
+
+ ))}
+
+
+
+ {footer ? (
+
+ {footer}
+
+ ) : null}
+ >
);
}
diff --git a/client/src/components/admin/AuditLogRow.test.tsx b/client/src/components/admin/AuditLogRow.test.tsx
index 57326e1..7bdfe13 100644
--- a/client/src/components/admin/AuditLogRow.test.tsx
+++ b/client/src/components/admin/AuditLogRow.test.tsx
@@ -33,4 +33,28 @@ describe('', () => {
expect(screen.getByText('0.09')).toBeInTheDocument();
expect(screen.getByText('0.10')).toBeInTheDocument();
});
+
+ it('toggles aria-expanded on the row header', () => {
+ wrap();
+ const header = screen.getByRole('button');
+ expect(header).toHaveAttribute('aria-expanded', 'false');
+ fireEvent.click(header);
+ expect(header).toHaveAttribute('aria-expanded', 'true');
+ });
+
+ it('falls back to #id when no actorLabel is resolved, else shows the name', () => {
+ const { rerender } = wrap();
+ expect(screen.getByText('#3')).toBeInTheDocument();
+ rerender(
+
+
+ ,
+ );
+ expect(screen.getByText('سارا کریمی')).toBeInTheDocument();
+ });
+
+ it('renders no button role for a row with no diff to expand', () => {
+ wrap();
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
+ });
});
diff --git a/client/src/components/admin/AuditLogRow.tsx b/client/src/components/admin/AuditLogRow.tsx
index 840d3d7..bcc9f9c 100644
--- a/client/src/components/admin/AuditLogRow.tsx
+++ b/client/src/components/admin/AuditLogRow.tsx
@@ -1,13 +1,15 @@
'use client';
-import { FunctionComponent, useState } from 'react';
+import { FunctionComponent, KeyboardEvent, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, Collapse, Paper, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
import { formatShamsiDateTime } from '@/utils';
-import type { AuditLogEntry } from '@/services/admin/types';
+import type { AdminUserSummary, AuditLogEntry } from '@/services/admin/types';
import AppIcon from '../common/AppIcon';
export interface AuditLogRowProps {
entry: AuditLogEntry;
+ /** Resolved actor name (3.2's batch id→label lookup) — falls back to `#id` until the REQ-061 lookup resolves. */
+ actorLabel?: string;
}
/** Stringify a diff value (JSON for objects, `—` for null). */
@@ -17,17 +19,40 @@ function displayValue(v: unknown): string {
return String(v);
}
+/** The actor cell's display text — the resolved name when known, else the honest `#id` fallback. */
+export function actorDisplay(actorUserId: number | null, actorLabel?: string): string {
+ if (actorUserId == null) return '—';
+ return actorLabel ?? `#${actorUserId}`;
+}
+
+/** Resolve a batch `lookupUsers` result into the label `AuditLogRow`/`actorDisplay` expects. */
+export function actorLabelFrom(userMap: Map | undefined, userId: number | null): string | undefined {
+ if (userId == null) return undefined;
+ return userMap?.get(userId)?.displayName;
+}
+
/**
* One row of the append-only audit viewer, with an expandable `changedFields` diff (old → new per field;
* PII is server-redacted as ``). Read-only by design — there is **no** edit/delete affordance
- * (phase §5). Presentational; the caller passes the paged entries.
+ * (phase §5). Presentational; the caller passes the paged entries + (once resolved) the actor's name. The
+ * expand chevron rotates and the header carries `aria-expanded` + button semantics so open/closed state is
+ * visible and keyboard-toggleable (ui-phase-11).
* @component AuditLogRow
*/
-const AuditLogRow: FunctionComponent = ({ entry }) => {
+const AuditLogRow: FunctionComponent = ({ entry, actorLabel }) => {
const t = useTranslations('admin');
const locale = useLocale();
const [open, setOpen] = useState(false);
const fields = entry.changedFields ? Object.entries(entry.changedFields) : [];
+ const hasDetail = fields.length > 0;
+
+ const toggle = () => setOpen((v) => !v);
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ toggle();
+ }
+ };
return (
= ({ entry }) => {
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}
>
setOpen((v) => !v) : undefined}
+ sx={{ gap: 2, alignItems: 'center', p: 1.5, cursor: hasDetail ? 'pointer' : 'default', flexWrap: 'wrap' }}
+ onClick={hasDetail ? toggle : undefined}
+ onKeyDown={hasDetail ? onKeyDown : undefined}
>
{entry.action}
- {entry.actorUserId != null ? `#${entry.actorUserId}` : '—'}
+ {actorDisplay(entry.actorUserId, actorLabel)}
{formatShamsiDateTime(entry.occurredAt, locale)}
- {fields.length ? : null}
+ {hasDetail ? (
+
+ ) : null}
- 0}>
+
{t('audit_diff_title')}
diff --git a/client/src/components/admin/ConfirmDialog.test.tsx b/client/src/components/admin/ConfirmDialog.test.tsx
index 81b2c9d..09966e6 100644
--- a/client/src/components/admin/ConfirmDialog.test.tsx
+++ b/client/src/components/admin/ConfirmDialog.test.tsx
@@ -41,4 +41,58 @@ describe('', () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
+
+ it('renders the typed-confirmation field when requireTypedConfirmation is set', () => {
+ wrap(
+ ,
+ );
+ expect(screen.getByLabelText('Type to confirm')).toBeInTheDocument();
+ });
+
+ it('keeps confirm disabled until the typed value matches, case/whitespace-insensitively', () => {
+ const onConfirm = jest.fn();
+ wrap(
+ ,
+ );
+ const confirmBtn = screen.getByRole('button', { name: 'Run' });
+ const field = screen.getByLabelText('Type to confirm');
+
+ expect(confirmBtn).toBeDisabled();
+
+ fireEvent.change(field, { target: { value: 'nope' } });
+ expect(confirmBtn).toBeDisabled();
+
+ fireEvent.change(field, { target: { value: ' confirm ' } });
+ expect(confirmBtn).not.toBeDisabled();
+
+ fireEvent.click(confirmBtn);
+ expect(onConfirm).toHaveBeenCalledWith(undefined);
+ });
+
+ it('enables confirm on an exact match against a different allowed value (e.g. an amount)', () => {
+ wrap(
+ ,
+ );
+ const confirmBtn = screen.getByRole('button', { name: 'Run' });
+ fireEvent.change(screen.getByLabelText('Type to confirm'), { target: { value: '150000' } });
+ expect(confirmBtn).not.toBeDisabled();
+ });
});
diff --git a/client/src/components/admin/NursePicker/NursePicker.test.tsx b/client/src/components/admin/NursePicker/NursePicker.test.tsx
new file mode 100644
index 0000000..5c69643
--- /dev/null
+++ b/client/src/components/admin/NursePicker/NursePicker.test.tsx
@@ -0,0 +1,43 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { ThemeProvider } from '../../../theme';
+
+const useUserSearchMock = jest.fn();
+jest.mock('@/services/admin', () => ({ useUserSearch: (...args: unknown[]) => useUserSearchMock(...args) }));
+jest.mock('@/services/search', () => ({ useDebouncedValue: (value: unknown) => value }));
+
+import NursePicker from './NursePicker';
+import type { AdminUserSummary } from '@/services/admin/types';
+
+const ZAHRA: AdminUserSummary = {
+ id: 101,
+ displayName: 'زهرا رضایی',
+ maskedPhone: '0912•••0001',
+ roles: ['nurse'],
+ nurseProfileId: 15,
+};
+
+describe('', () => {
+ const wrap = (ui: React.ReactNode) => render({ui});
+
+ beforeEach(() => {
+ useUserSearchMock.mockReset();
+ useUserSearchMock.mockReturnValue({ data: [ZAHRA], isFetching: false });
+ });
+
+ it('always searches with roleFilter="nurse"', async () => {
+ const user = userEvent.setup();
+ wrap();
+ await user.type(screen.getByLabelText('پرستار'), 'زهرا');
+ expect(useUserSearchMock).toHaveBeenLastCalledWith('زهرا', 'nurse');
+ });
+
+ it('returns the picked option, carrying nurseProfileId', async () => {
+ const onChange = jest.fn();
+ const user = userEvent.setup();
+ wrap();
+ await user.click(screen.getByLabelText('پرستار'));
+ await user.click(await screen.findByText('زهرا رضایی'));
+ expect(onChange).toHaveBeenCalledWith(ZAHRA);
+ });
+});
diff --git a/client/src/components/admin/NursePicker/NursePicker.tsx b/client/src/components/admin/NursePicker/NursePicker.tsx
new file mode 100644
index 0000000..59a213c
--- /dev/null
+++ b/client/src/components/admin/NursePicker/NursePicker.tsx
@@ -0,0 +1,14 @@
+import { FunctionComponent } from 'react';
+import UserPicker, { type UserPickerProps } from '../UserPicker';
+
+export type NursePickerProps = Omit;
+
+/**
+ * Thin `UserPicker` variant fixed to `roleFilter="nurse"` — the sponsored-nurse assignment picker
+ * (`admin/partners/[id]`). The selected `AdminUserSummary.nurseProfileId` is the id the caller actually
+ * needs (a different id space than `.id`, the user id) — see `services/admin/types.ts`.
+ * @component NursePicker
+ */
+const NursePicker: FunctionComponent = (props) => ;
+
+export default NursePicker;
diff --git a/client/src/components/admin/NursePicker/index.tsx b/client/src/components/admin/NursePicker/index.tsx
new file mode 100644
index 0000000..c3e4631
--- /dev/null
+++ b/client/src/components/admin/NursePicker/index.tsx
@@ -0,0 +1,2 @@
+export { default } from './NursePicker';
+export type { NursePickerProps } from './NursePicker';
diff --git a/client/src/components/admin/SupportAlertCard.tsx b/client/src/components/admin/SupportAlertCard.tsx
index 1291469..2235104 100644
--- a/client/src/components/admin/SupportAlertCard.tsx
+++ b/client/src/components/admin/SupportAlertCard.tsx
@@ -13,6 +13,11 @@ export interface SupportAlertCardProps {
canAct?: boolean;
onAssignSelf?: (alert: SupportAlert) => void;
onResolve?: (alert: SupportAlert) => void;
+ /** True while the caller's own id hasn't hydrated yet — disables "assign to me" rather than falling back
+ * to a guessed id (phase §3.2: never default assign-to-self to user #1). */
+ assignSelfDisabled?: boolean;
+ /** Already-translated tooltip shown on the disabled "assign to me" button while hydrating. */
+ assignSelfDisabledTitle?: string;
}
/** Alert status → semantic chip kind. */
@@ -37,7 +42,14 @@ const SEVERITY_ACCENT: Record = {
* namespace keyed off the stable code.
* @component SupportAlertCard
*/
-const SupportAlertCard: FunctionComponent = ({ alert, canAct = false, onAssignSelf, onResolve }) => {
+const SupportAlertCard: FunctionComponent = ({
+ alert,
+ canAct = false,
+ onAssignSelf,
+ onResolve,
+ assignSelfDisabled = false,
+ assignSelfDisabledTitle,
+}) => {
const t = useTranslations('admin');
const locale = useLocale();
@@ -85,7 +97,14 @@ const SupportAlertCard: FunctionComponent = ({ alert, can
{canAct && alert.status !== 'resolved' ? (
{alert.status === 'open' ? (
- onAssignSelf?.(alert)}>
+ onAssignSelf?.(alert)}
+ disabled={assignSelfDisabled}
+ title={assignSelfDisabled ? assignSelfDisabledTitle : undefined}
+ >
{t('alert_assign_me')}
) : null}
diff --git a/client/src/components/admin/UserPicker/UserPicker.test.tsx b/client/src/components/admin/UserPicker/UserPicker.test.tsx
new file mode 100644
index 0000000..05010e8
--- /dev/null
+++ b/client/src/components/admin/UserPicker/UserPicker.test.tsx
@@ -0,0 +1,46 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { ThemeProvider } from '../../../theme';
+
+const useUserSearchMock = jest.fn();
+jest.mock('@/services/admin', () => ({ useUserSearch: (...args: unknown[]) => useUserSearchMock(...args) }));
+jest.mock('@/services/search', () => ({ useDebouncedValue: (value: unknown) => value }));
+
+import UserPicker from './UserPicker';
+import type { AdminUserSummary } from '@/services/admin/types';
+
+const MARYAM: AdminUserSummary = { id: 2, displayName: 'مریم احمدی', maskedPhone: '0912•••0002', roles: ['admin'] };
+
+describe('', () => {
+ const wrap = (ui: React.ReactNode) => render({ui});
+
+ beforeEach(() => {
+ useUserSearchMock.mockReset();
+ useUserSearchMock.mockReturnValue({ data: [MARYAM], isFetching: false });
+ });
+
+ it('renders the label and, once opened, an option with name + masked phone + id', async () => {
+ const user = userEvent.setup();
+ wrap();
+ const input = screen.getByLabelText('کاربر');
+ await user.click(input);
+ expect(await screen.findByText('مریم احمدی')).toBeInTheDocument();
+ expect(screen.getByText('0912•••0002 · #2')).toBeInTheDocument();
+ });
+
+ it('calls onChange with the selected user on pick', async () => {
+ const onChange = jest.fn();
+ const user = userEvent.setup();
+ wrap();
+ await user.click(screen.getByLabelText('کاربر'));
+ await user.click(await screen.findByText('مریم احمدی'));
+ expect(onChange).toHaveBeenCalledWith(MARYAM);
+ });
+
+ it('passes the typed query + roleFilter through to useUserSearch', async () => {
+ const user = userEvent.setup();
+ wrap();
+ await user.type(screen.getByLabelText('پرستار'), 'زهرا');
+ await waitFor(() => expect(useUserSearchMock).toHaveBeenLastCalledWith('زهرا', 'nurse'));
+ });
+});
diff --git a/client/src/components/admin/UserPicker/UserPicker.tsx b/client/src/components/admin/UserPicker/UserPicker.tsx
new file mode 100644
index 0000000..2e788d8
--- /dev/null
+++ b/client/src/components/admin/UserPicker/UserPicker.tsx
@@ -0,0 +1,110 @@
+'use client';
+import { FunctionComponent, useState } from 'react';
+import Autocomplete from '@mui/material/Autocomplete';
+import Box from '@mui/material/Box';
+import CircularProgress from '@mui/material/CircularProgress';
+import Stack from '@mui/material/Stack';
+import TextField from '@mui/material/TextField';
+import Typography from '@mui/material/Typography';
+import { useUserSearch } from '@/services/admin';
+import { useDebouncedValue } from '@/services/search';
+import type { AdminUserSummary, DirectoryUserRole } from '@/services/admin/types';
+
+export interface UserPickerProps {
+ value: AdminUserSummary | null;
+ onChange: (user: AdminUserSummary | null) => void;
+ /** Already-translated field label. */
+ label: string;
+ placeholder?: string;
+ helperText?: string;
+ noOptionsText?: string;
+ loadingText?: string;
+ /** Narrow the search to one coarse role (`NursePicker` passes `'nurse'`). */
+ roleFilter?: DirectoryUserRole;
+ disabled?: boolean;
+ error?: boolean;
+}
+
+const SEARCH_DEBOUNCE_MS = 300;
+
+/**
+ * Async name/phone search over the admin user directory (REQ-061, gap — mock-backed until delivered),
+ * replacing every raw numeric-id `TextField` on an audited action (role grants, partner-center admin
+ * assignment, sponsored-nurse assignment, alert assignment — phase §3.2). Each option renders
+ * **name + masked phone + `#id`**, never a bare id, so the caller's confirm dialog can echo a resolved
+ * **person** instead of `#42`. `NursePicker` is a thin `roleFilter="nurse"` wrapper over this component.
+ * @component UserPicker
+ */
+const UserPicker: FunctionComponent = ({
+ value,
+ onChange,
+ label,
+ placeholder,
+ helperText,
+ noOptionsText,
+ loadingText,
+ roleFilter,
+ disabled,
+ error,
+}) => {
+ const [inputValue, setInputValue] = useState('');
+ const debouncedQuery = useDebouncedValue(inputValue, SEARCH_DEBOUNCE_MS);
+ const search = useUserSearch(debouncedQuery, roleFilter);
+ const options = search.data ?? [];
+
+ return (
+ onChange(next)}
+ inputValue={inputValue}
+ onInputChange={(_event, next) => setInputValue(next)}
+ options={options}
+ loading={search.isFetching}
+ disabled={disabled}
+ // The directory is already narrowed server-side (mock or real) — never re-filter client-side.
+ filterOptions={(opts) => opts}
+ getOptionLabel={(option) => option.displayName}
+ isOptionEqualToValue={(option, selected) => option.id === selected.id}
+ noOptionsText={noOptionsText}
+ loadingText={loadingText}
+ renderOption={(optionProps, option) => {
+ const { key, ...rest } = optionProps;
+ return (
+
+
+
+ {option.displayName}
+
+
+ {option.maskedPhone} · #{option.id}
+
+
+
+ );
+ }}
+ renderInput={({ slotProps: autocompleteSlotProps, ...rest }) => (
+
+ {search.isFetching ? : null}
+ {autocompleteSlotProps.input.endAdornment}
+ >
+ ),
+ },
+ }}
+ />
+ )}
+ />
+ );
+};
+
+export default UserPicker;
diff --git a/client/src/components/admin/UserPicker/index.tsx b/client/src/components/admin/UserPicker/index.tsx
new file mode 100644
index 0000000..b6dd851
--- /dev/null
+++ b/client/src/components/admin/UserPicker/index.tsx
@@ -0,0 +1,2 @@
+export { default } from './UserPicker';
+export type { UserPickerProps } from './UserPicker';
diff --git a/client/src/components/admin/index.ts b/client/src/components/admin/index.ts
index fbcc797..dc9ac14 100644
--- a/client/src/components/admin/index.ts
+++ b/client/src/components/admin/index.ts
@@ -30,3 +30,7 @@ export { default as RefundPanel } from './RefundPanel';
export type { RefundPanelProps } from './RefundPanel';
export { default as AdminMessageBubble } from './AdminMessageBubble';
export type { AdminMessageBubbleProps } from './AdminMessageBubble';
+export { default as UserPicker } from './UserPicker';
+export type { UserPickerProps } from './UserPicker';
+export { default as NursePicker } from './NursePicker';
+export type { NursePickerProps } from './NursePicker';
diff --git a/client/src/components/common/ConfirmDialog/ConfirmDialog.test.tsx b/client/src/components/common/ConfirmDialog/ConfirmDialog.test.tsx
index 4149497..c2f08bd 100644
--- a/client/src/components/common/ConfirmDialog/ConfirmDialog.test.tsx
+++ b/client/src/components/common/ConfirmDialog/ConfirmDialog.test.tsx
@@ -41,4 +41,58 @@ describe('', () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledTimes(1);
});
+
+ it('renders the typed-confirmation field when requireTypedConfirmation is set', () => {
+ wrap(
+ ,
+ );
+ expect(screen.getByLabelText('Type to confirm')).toBeInTheDocument();
+ });
+
+ it('keeps confirm disabled until the typed value matches, case/whitespace-insensitively', () => {
+ const onConfirm = jest.fn();
+ wrap(
+ ,
+ );
+ const confirmBtn = screen.getByRole('button', { name: 'Run' });
+ const field = screen.getByLabelText('Type to confirm');
+
+ expect(confirmBtn).toBeDisabled();
+
+ fireEvent.change(field, { target: { value: 'nope' } });
+ expect(confirmBtn).toBeDisabled();
+
+ fireEvent.change(field, { target: { value: ' confirm ' } });
+ expect(confirmBtn).not.toBeDisabled();
+
+ fireEvent.click(confirmBtn);
+ expect(onConfirm).toHaveBeenCalledWith(undefined);
+ });
+
+ it('enables confirm on an exact match against a different allowed value (e.g. an amount)', () => {
+ wrap(
+ ,
+ );
+ const confirmBtn = screen.getByRole('button', { name: 'Run' });
+ fireEvent.change(screen.getByLabelText('Type to confirm'), { target: { value: '150000' } });
+ expect(confirmBtn).not.toBeDisabled();
+ });
});
diff --git a/client/src/components/common/ConfirmDialog/ConfirmDialog.tsx b/client/src/components/common/ConfirmDialog/ConfirmDialog.tsx
index 1df1b64..6246bda 100644
--- a/client/src/components/common/ConfirmDialog/ConfirmDialog.tsx
+++ b/client/src/components/common/ConfirmDialog/ConfirmDialog.tsx
@@ -27,6 +27,15 @@ export interface ConfirmDialogProps {
requireReason?: boolean;
reasonLabel?: string;
reasonPlaceholder?: string;
+ /**
+ * When set, confirm stays disabled until the typed value case-insensitively matches ONE of these
+ * (e.g. the literal word «تایید» OR the exact amount digit-string) — the guard for an irreversible
+ * action like running a payout batch. A gate, not a payload: `onConfirm`'s signature is unchanged, the
+ * typed value itself is never passed to the caller.
+ */
+ requireTypedConfirmation?: string[];
+ typedConfirmationLabel?: string;
+ typedConfirmationPlaceholder?: string;
/** MUI color for the confirm button — `error` for a destructive action. */
confirmColor?: 'primary' | 'error' | 'secondary';
}
@@ -52,21 +61,31 @@ const ConfirmDialog: FunctionComponent = ({
requireReason = false,
reasonLabel,
reasonPlaceholder,
+ requireTypedConfirmation,
+ typedConfirmationLabel,
+ typedConfirmationPlaceholder,
confirmColor = 'primary',
}) => {
const [reason, setReason] = useState('');
+ const [typedValue, setTypedValue] = useState('');
const close = () => {
setReason('');
+ setTypedValue('');
onClose();
};
const confirm = () => {
onConfirm(requireReason ? reason.trim() : undefined);
setReason('');
+ setTypedValue('');
};
- const confirmDisabled = loading || (requireReason && reason.trim().length === 0);
+ const confirmDisabled =
+ loading ||
+ (requireReason && reason.trim().length === 0) ||
+ (requireTypedConfirmation != null &&
+ !requireTypedConfirmation.some((v) => v.trim().toLowerCase() === typedValue.trim().toLowerCase()));
return (