ui phase 11

This commit is contained in:
hamid
2026-07-19 19:19:44 +03:30
parent b4b8c9ea79
commit 87fa4cd497
74 changed files with 3115 additions and 506 deletions
+22 -21
View File
@@ -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) │ │ │ ├── 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 │ │ │ ├── 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 │ │ │ └── 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 `<Suspense>` wrapper.
│ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=admin) → AdminLayout (capability-gated nav) │ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=admin) → AdminLayout (capability-gated nav)
│ │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton │ │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
│ │ │ ├── page.tsx # Thin RSC — generateMetadata (admin.overview_title) + renders AdminOverviewScreen │ │ │ ├── page.tsx # Thin RSC — generateMetadata (admin.overview_title) + renders AdminOverviewScreen
│ │ │ ├── AdminOverviewScreen.tsx # 'use client' — f15 overview landing: a capability-gated grid of console cards │ │ │ ├── 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) │ │ │ ├── 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 — 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) │ │ │ ├── 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 — 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 │ │ │ ├── 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 │ │ │ ├── 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 + 01 rate validation + audited-save dialog + change-history drawer │ │ │ ├── config/page.tsx # /admin/config — f15 config editor: typed input by data_type + 01 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) │ │ │ ├── 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 │ │ │ ├── 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) │ │ │ ├── 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) │ │ │ ├── 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) │ │ │ ├── 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 │ │ │ ├── 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 │ │ │ └── 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). │ │ └── 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) │ │ ├── layout.tsx # 'use client' — RoleGuard (no expected role — hydration-only) → PartnerLayout (own partner nav; self-gates via useMyPartnerCenter)
│ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton │ │ ├── loading.tsx # → ../_chrome/SidebarShellSkeleton
│ │ ├── page.tsx # Thin RSC — generateMetadata (partner.home_title) + renders PartnerHomeScreen │ │ ├── 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 │ │ ├── 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) │ │ ├── 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) │ │ ├── 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 │ │ └── 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) │ ├── (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) │ │ ├── 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 │ │ └── 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) │ │ ├── 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) │ │ ├── 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 │ │ ├── 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) │ │ ├── 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 │ │ ├── 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' │ │ ├── SurfaceCard/ # flat Paper wrapper, padding: 'sm'|'md'|'lg'
│ │ ├── AccentCard/ # SurfaceCard + tone → 4px borderInlineStart accent (primary/secondary/success/error/warning/info/trust/neutral) │ │ ├── AccentCard/ # SurfaceCard + tone → 4px borderInlineStart accent (primary/secondary/success/error/warning/info/trust/neutral)
│ │ ├── Money/ # <Money amountIrr size tone deduction hideUnit strikethrough> — 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) │ │ ├── Money/ # <Money amountIrr size tone deduction hideUnit strikethrough> — 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) │ │ ├── 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) │ │ ├── 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 │ │ └── 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 │ ├── PlaceholderScreen/ # Empty-state scaffold for not-yet-built screens
│ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe) │ ├── OtpInput/ # OTP code input (auto-advance, paste, RTL-safe)
│ ├── PhoneNumberField/ # Iranian mobile field (digit-normalizing, LTR-in-RTL, maskIranMobile) │ ├── 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) │ ├── 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 │ ├── 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) │ ├── 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 │ ├── 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 │ ├── 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 │ ├── 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 │ ├── 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 │ ├── 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 │ ├── 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) │ ├── 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): │ │ # 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) │ │ # 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 │ ├── 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_*) │ └── index.ts # Public re-exports (ThemeProvider, getDirection, APP_THEME_*)
├── constants/ # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts) ├── constants/ # App-wide constants (routes.ts w/ actor paths, roles.ts, headers.ts)
├── hooks/ # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome) ├── hooks/ # incl. auth.ts → useIsAuthenticated / useActorRole (role-aware chrome); ui-phase-11 added `useAdminListState.ts` — URL-synced worklist state (`useSearchParams`-based; callers need a `<Suspense>` 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 ├── 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 └── config.ts
``` ```
+61 -4
View File
@@ -1491,7 +1491,7 @@
"actor": "Actor", "actor": "Actor",
"timestamp": "Time", "timestamp": "Time",
"showing_range": "Showing {from}{to} of {total}", "showing_range": "Showing {from}{to} of {total}",
"page_indicator": "Page {page}", "page_indicator": "Page {page} of {total}",
"prev_page": "Previous", "prev_page": "Previous",
"next_page": "Next", "next_page": "Next",
"search_placeholder": "Search…", "search_placeholder": "Search…",
@@ -1506,10 +1506,15 @@
"ver_col_step": "Step", "ver_col_step": "Step",
"ver_col_status": "Status", "ver_col_status": "Status",
"ver_col_submitted": "Submitted", "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_progress": "{done} of {total}",
"ver_next_step": "Next: {step}", "ver_next_step": "Next: {step}",
"ver_open_case": "Review", "ver_open_case": "Review",
"ver_case_title": "Verification case", "ver_case_title": "Verification case",
"ver_next_case": "Next case",
"ver_prev_case": "Previous case",
"ver_identity_name": "Identity on file", "ver_identity_name": "Identity on file",
"ver_steps_title": "Steps", "ver_steps_title": "Steps",
"ver_documents_title": "Documents", "ver_documents_title": "Documents",
@@ -1564,6 +1569,9 @@
"payout_run": "Run batch", "payout_run": "Run batch",
"payout_run_confirm_title": "Run this payout 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 cant pay a booking twice.", "payout_run_confirm_body": "Money moves to nurses. This is protected by an idempotency key — a double-click cant 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_running": "Submitting to the bank rail…",
"payout_skipped": "Skipped nurses", "payout_skipped": "Skipped nurses",
"payout_batch_title": "Batch #{id}", "payout_batch_title": "Batch #{id}",
@@ -1710,6 +1718,16 @@
"ticket_sent": "Message sent.", "ticket_sent": "Message sent.",
"ticket_participants": "Participants", "ticket_participants": "Participants",
"ticket_linked_refund": "Refund #{id}", "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_title": "Partner centers",
"partner_subtitle": "Licensed centers that sponsor nurses and may be merchant-of-record.", "partner_subtitle": "Licensed centers that sponsor nurses and may be merchant-of-record.",
"partner_empty": "No partner centers yet.", "partner_empty": "No partner centers yet.",
@@ -1744,6 +1762,20 @@
"partner_assign_nurse_ph": "Nurse profile id", "partner_assign_nurse_ph": "Nurse profile id",
"partner_unlink_nurse": "Remove", "partner_unlink_nurse": "Remove",
"partner_nurse_assigned": "Nurse assignment updated.", "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_title": "Roles & access",
"role_subtitle": "Grant or revoke admin roles. (Awaiting backend role endpoints.)", "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.", "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_col_granted": "Granted",
"role_grant": "Grant role", "role_grant": "Grant role",
"role_revoke": "Revoke", "role_revoke": "Revoke",
"role_grant_confirm": "Grant {role} to user #{id}?", "role_grant_confirm": "Grant {role} to {name}?",
"role_revoke_confirm": "Revoke {role} from user #{id}?", "role_revoke_confirm": "Revoke {role} from {name}?",
"role_updated": "Role updated.", "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_not_started": "Not started",
"agg_pending": "Pending", "agg_pending": "Pending",
"agg_in_review": "In review", "agg_in_review": "In review",
@@ -1860,6 +1896,19 @@
"bookings_col_patient": "Patient", "bookings_col_patient": "Patient",
"bookings_col_date": "Date", "bookings_col_date": "Date",
"bookings_col_status": "Status", "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_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_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.", "settlement_empty": "No invoices yet.",
@@ -1877,7 +1926,15 @@
"invoice_download": "Download PDF", "invoice_download": "Download PDF",
"invoice_pdf_error": "Couldnt open the invoice PDF.", "invoice_pdf_error": "Couldnt open the invoice PDF.",
"invoice_number": "Invoice #{number}", "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": { "routeChrome": {
"error_title": "Something went wrong", "error_title": "Something went wrong",
+60 -3
View File
@@ -1491,7 +1491,7 @@
"actor": "عامل", "actor": "عامل",
"timestamp": "زمان", "timestamp": "زمان",
"showing_range": "نمایش {from}{to} از {total}", "showing_range": "نمایش {from}{to} از {total}",
"page_indicator": "صفحه {page}", "page_indicator": "صفحه {page} از {total}",
"prev_page": "قبلی", "prev_page": "قبلی",
"next_page": "بعدی", "next_page": "بعدی",
"search_placeholder": "جستجو…", "search_placeholder": "جستجو…",
@@ -1506,10 +1506,15 @@
"ver_col_step": "مرحله", "ver_col_step": "مرحله",
"ver_col_status": "وضعیت", "ver_col_status": "وضعیت",
"ver_col_submitted": "زمان ارسال", "ver_col_submitted": "زمان ارسال",
"ver_col_waiting": "زمان انتظار",
"ver_search_label": "جستجوی پرستار",
"ver_search_ph": "نام یا شماره تماس",
"ver_progress": "{done} از {total}", "ver_progress": "{done} از {total}",
"ver_next_step": "بعدی: {step}", "ver_next_step": "بعدی: {step}",
"ver_open_case": "بررسی", "ver_open_case": "بررسی",
"ver_case_title": "پرونده احراز هویت", "ver_case_title": "پرونده احراز هویت",
"ver_next_case": "پروندهٔ بعدی",
"ver_prev_case": "پروندهٔ قبلی",
"ver_identity_name": "نام ثبت‌شده", "ver_identity_name": "نام ثبت‌شده",
"ver_steps_title": "مراحل", "ver_steps_title": "مراحل",
"ver_documents_title": "مدارک", "ver_documents_title": "مدارک",
@@ -1564,6 +1569,9 @@
"payout_run": "اجرای دسته", "payout_run": "اجرای دسته",
"payout_run_confirm_title": "این دستهٔ تسویه اجرا شود؟", "payout_run_confirm_title": "این دستهٔ تسویه اجرا شود؟",
"payout_run_confirm_body": "مبلغ به پرستاران منتقل می‌شود. این اقدام با کلید یکتا محافظت می‌شود — کلیک دوباره باعث پرداخت مضاعف نمی‌شود.", "payout_run_confirm_body": "مبلغ به پرستاران منتقل می‌شود. این اقدام با کلید یکتا محافظت می‌شود — کلیک دوباره باعث پرداخت مضاعف نمی‌شود.",
"payout_run_summary_intro": "این دسته اجرا و مبلغ زیر واریز می‌شود:",
"payout_run_count_label": "تعداد پرستاران",
"payout_run_type_to_confirm": "برای تایید، «تایید» یا مبلغ را تایپ کنید",
"payout_running": "در حال ارسال به سامانهٔ بانکی…", "payout_running": "در حال ارسال به سامانهٔ بانکی…",
"payout_skipped": "پرستاران نادیده‌گرفته‌شده", "payout_skipped": "پرستاران نادیده‌گرفته‌شده",
"payout_batch_title": "دسته #{id}", "payout_batch_title": "دسته #{id}",
@@ -1710,6 +1718,16 @@
"ticket_sent": "پیام ارسال شد.", "ticket_sent": "پیام ارسال شد.",
"ticket_participants": "شرکت‌کنندگان", "ticket_participants": "شرکت‌کنندگان",
"ticket_linked_refund": "بازپرداخت #{id}", "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_title": "مراکز همکار",
"partner_subtitle": "مراکز دارای مجوز که پرستاران را پشتیبانی می‌کنند و می‌توانند فروشندهٔ رسمی باشند.", "partner_subtitle": "مراکز دارای مجوز که پرستاران را پشتیبانی می‌کنند و می‌توانند فروشندهٔ رسمی باشند.",
"partner_empty": "هنوز مرکزی ثبت نشده.", "partner_empty": "هنوز مرکزی ثبت نشده.",
@@ -1744,6 +1762,20 @@
"partner_assign_nurse_ph": "شناسهٔ پروفایل پرستار", "partner_assign_nurse_ph": "شناسهٔ پروفایل پرستار",
"partner_unlink_nurse": "حذف", "partner_unlink_nurse": "حذف",
"partner_nurse_assigned": "وابستگی پرستار به‌روزرسانی شد.", "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_title": "نقش‌ها و دسترسی",
"role_subtitle": "اعطا یا لغو نقش‌های مدیریتی. (در انتظار سرویس‌های نقش سمت سرور.)", "role_subtitle": "اعطا یا لغو نقش‌های مدیریتی. (در انتظار سرویس‌های نقش سمت سرور.)",
"role_deferred": "این بخش تا آماده‌شدن سرویس‌های نقش، با داده‌های موقتِ سمت‌کاربر نمایش داده می‌شود.", "role_deferred": "این بخش تا آماده‌شدن سرویس‌های نقش، با داده‌های موقتِ سمت‌کاربر نمایش داده می‌شود.",
@@ -1752,9 +1784,13 @@
"role_col_granted": "اعطا شده", "role_col_granted": "اعطا شده",
"role_grant": "اعطای نقش", "role_grant": "اعطای نقش",
"role_revoke": "لغو", "role_revoke": "لغو",
"role_grant_confirm": "نقش {role} به کاربر #{id} اعطا شود؟", "role_grant_confirm": "نقش {role} به {name} اعطا شود؟",
"role_revoke_confirm": "نقش {role} از کاربر #{id} لغو شود؟", "role_revoke_confirm": "نقش {role} از {name} لغو شود؟",
"role_updated": "نقش به‌روزرسانی شد.", "role_updated": "نقش به‌روزرسانی شد.",
"user_picker_search_ph": "جستجو با نام یا شماره موبایل…",
"user_picker_no_options": "کاربری یافت نشد",
"user_picker_loading": "در حال جستجو…",
"assign_me_loading": "در حال بارگذاری حساب شما…",
"agg_not_started": "شروع‌نشده", "agg_not_started": "شروع‌نشده",
"agg_pending": "در انتظار", "agg_pending": "در انتظار",
"agg_in_review": "در حال بررسی", "agg_in_review": "در حال بررسی",
@@ -1860,6 +1896,19 @@
"bookings_col_patient": "بیمار", "bookings_col_patient": "بیمار",
"bookings_col_date": "تاریخ", "bookings_col_date": "تاریخ",
"bookings_col_status": "وضعیت", "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_title": "تسویه و فاکتورها",
"settlement_not_mor": "این مرکز فروشندهٔ رسمی نیست — تسویه از طریق بالین‌یار انجام می‌شود و فاکتور کارمزدی اینجا صادر نمی‌شود.", "settlement_not_mor": "این مرکز فروشندهٔ رسمی نیست — تسویه از طریق بالین‌یار انجام می‌شود و فاکتور کارمزدی اینجا صادر نمی‌شود.",
"settlement_empty": "هنوز فاکتوری نیست.", "settlement_empty": "هنوز فاکتوری نیست.",
@@ -1877,6 +1926,14 @@
"invoice_download": "دانلود PDF", "invoice_download": "دانلود PDF",
"invoice_pdf_error": "باز کردن فایل فاکتور ناموفق بود.", "invoice_pdf_error": "باز کردن فایل فاکتور ناموفق بود.",
"invoice_number": "فاکتور #{number}", "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": "شبای تسویه" "settlement_iban": "شبای تسویه"
}, },
"routeChrome": { "routeChrome": {
@@ -1,15 +1,21 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material'; import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
import { AppLoading } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog, SupportAlertCard } from '@/components/admin'; 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 { useAuth } from '@/context/auth';
import { ADMIN_PAGE_SIZE } from '@/services/admin/constants'; import { ADMIN_PAGE_SIZE } from '@/services/admin/constants';
import type { SupportAlert, SupportAlertStatus, SupportAlertType } from '@/services/admin/types'; import type { SupportAlert, SupportAlertStatus, SupportAlertType } from '@/services/admin/types';
import { useSupportAlerts, useAssignSupportAlert, useResolveSupportAlert } from '@/services/admin'; import { useSupportAlerts, useAssignSupportAlert, useResolveSupportAlert } from '@/services/admin';
interface AlertFilters {
status?: SupportAlertStatus;
type?: SupportAlertType;
}
const STATUSES: readonly SupportAlertStatus[] = ['open', 'assigned', 'resolved']; const STATUSES: readonly SupportAlertStatus[] = ['open', 'assigned', 'resolved'];
const TYPES: readonly SupportAlertType[] = [ const TYPES: readonly SupportAlertType[] = [
'low_rating', 'low_rating',
@@ -23,32 +29,60 @@ const TYPES: readonly SupportAlertType[] = [
'emergency', '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<string, string> {
const r: Record<string, string> = {};
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 * 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 * 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. * surface (phase §5). Server enforces the role scope; `canManageAlerts` only hides the controls.
*/ */
export default function AdminAlertsPage() { export default function AdminAlertsPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminAlertsPageInner />
</Suspense>
);
}
function AdminAlertsPageInner() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const [authState] = useAuth(); const [authState] = useAuth();
const meId = authState.currentUser?.id ?? 1; const meId = authState.currentUser?.id;
const [status, setStatus] = useState<SupportAlertStatus | ''>('open'); const listState = useAdminListState<AlertFilters>({ parse: parseFilters, serialize: serializeFilters, empty: EMPTY });
const [type, setType] = useState<SupportAlertType | ''>(''); const { applied: filters, page } = listState;
const [page, setPage] = useState(1);
const [resolving, setResolving] = useState<SupportAlert | null>(null); const [resolving, setResolving] = useState<SupportAlert | null>(null);
const filters = { status: status || undefined, type: type || undefined };
const alerts = useSupportAlerts(filters, page); const alerts = useSupportAlerts(filters, page);
const assign = useAssignSupportAlert(); const assign = useAssignSupportAlert();
const resolve = useResolveSupportAlert(); const resolve = useResolveSupportAlert();
const items = alerts.data?.items ?? []; 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) => { const onAssignSelf = (alert: SupportAlert) => {
if (meId == null) return;
assign.mutate( assign.mutate(
{ alertId: alert.id, ownerUserId: meId }, { alertId: alert.id, ownerUserId: meId },
{ onSuccess: () => enqueueSnackbar(t('alert_assigned'), { variant: 'success' }) }, { 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 ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader <AdminPageHeader
@@ -79,11 +118,8 @@ export default function AdminAlertsPage() {
select select
size="small" size="small"
label={t('alert_col_status')} label={t('alert_col_status')}
value={status} value={filters.status ?? ''}
onChange={(e) => { onChange={(e) => setStatusFilter(e.target.value as SupportAlertStatus | '')}
setStatus(e.target.value as SupportAlertStatus | '');
setPage(1);
}}
sx={{ minWidth: 140 }} sx={{ minWidth: 140 }}
> >
<MenuItem value="">{t('filter_all')}</MenuItem> <MenuItem value="">{t('filter_all')}</MenuItem>
@@ -97,11 +133,8 @@ export default function AdminAlertsPage() {
select select
size="small" size="small"
label={t('alert_col_type')} label={t('alert_col_type')}
value={type} value={filters.type ?? ''}
onChange={(e) => { onChange={(e) => setTypeFilter(e.target.value as SupportAlertType | '')}
setType(e.target.value as SupportAlertType | '');
setPage(1);
}}
sx={{ minWidth: 180 }} sx={{ minWidth: 180 }}
> >
<MenuItem value="">{t('filter_all')}</MenuItem> <MenuItem value="">{t('filter_all')}</MenuItem>
@@ -130,6 +163,8 @@ export default function AdminAlertsPage() {
canAct={caps.canManageAlerts} canAct={caps.canManageAlerts}
onAssignSelf={onAssignSelf} onAssignSelf={onAssignSelf}
onResolve={setResolving} onResolve={setResolving}
assignSelfDisabled={meId == null}
assignSelfDisabledTitle={t('assign_me_loading')}
/> />
))} ))}
</Stack> </Stack>
@@ -138,11 +173,11 @@ export default function AdminAlertsPage() {
<AdminPager <AdminPager
page={page} page={page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page, total: pageCount })}
/> />
<ConfirmDialog <ConfirmDialog
@@ -1,15 +1,35 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useMemo } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { Box, Skeleton, Stack, TextField } from '@mui/material'; import { Box, Skeleton, Stack, TextField } from '@mui/material';
import { AppButton } from '@/components'; import { AppButton, AppLoading, JalaliDateField } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, AuditLogRow } from '@/components/admin'; import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, AuditLogRow } from '@/components/admin';
import { actorLabelFrom } from '@/components/admin/AuditLogRow';
import { useAdminListState } from '@/hooks';
import { AUDIT_PAGE_SIZE } from '@/services/admin/constants'; import { AUDIT_PAGE_SIZE } from '@/services/admin/constants';
import type { AuditFilters } from '@/services/admin/types'; import type { AuditFilters } from '@/services/admin/types';
import { useAuditLogs } from '@/services/admin'; import { useAuditLogs, useUserLookup } from '@/services/admin';
const EMPTY: AuditFilters = {}; const EMPTY: AuditFilters = {};
function parseFilters(params: URLSearchParams): AuditFilters {
return {
entityType: params.get('entityType') ?? undefined,
entityId: params.get('entityId') ?? undefined,
from: params.get('from') ?? undefined,
to: params.get('to') ?? undefined,
};
}
function serializeFilters(filters: AuditFilters): Record<string, string> {
const r: Record<string, string> = {};
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, * 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 * 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. * page are the cache key, so switching filters/pages never refetches data already held.
*/ */
export default function AdminAuditPage() { export default function AdminAuditPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminAuditPageInner />
</Suspense>
);
}
function AdminAuditPageInner() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const [draft, setDraft] = useState<AuditFilters>(EMPTY); const listState = useAdminListState<AuditFilters>({ parse: parseFilters, serialize: serializeFilters, empty: EMPTY });
const [applied, setApplied] = useState<AuditFilters>(EMPTY); const { draft, setDraft, applied, page } = listState;
const [page, setPage] = useState(1);
const audit = useAuditLogs(applied, page); const audit = useAuditLogs(applied, page);
const items = audit.data?.items ?? []; 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 = () => { // Batch id→name resolve (3.2/3.6) — one request for every actor rendered on this page, never one per row.
setApplied(draft); const actorIds = useMemo(
setPage(1); () => [...new Set((audit.data?.items ?? []).map((e) => e.actorUserId).filter((id): id is number => id != null))].sort(
}; (a, b) => a - b,
const clear = () => { ),
setDraft(EMPTY); [audit.data?.items],
setApplied(EMPTY); );
setPage(1); const userLookup = useUserLookup(actorIds);
};
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
@@ -60,26 +89,24 @@ export default function AdminAuditPage() {
onChange={(e) => setDraft((d) => ({ ...d, entityId: e.target.value || undefined }))} onChange={(e) => setDraft((d) => ({ ...d, entityId: e.target.value || undefined }))}
sx={{ minWidth: 120 }} sx={{ minWidth: 120 }}
/> />
<TextField <JalaliDateField
size="small"
type="date"
label={t('audit_from')} label={t('audit_from')}
value={draft.from ?? ''} value={draft.from ?? null}
onChange={(e) => setDraft((d) => ({ ...d, from: e.target.value || undefined }))} onChange={(iso) => setDraft((d) => ({ ...d, from: iso }))}
slotProps={{ inputLabel: { shrink: true } }} max={draft.to}
sx={{ minWidth: 160 }}
/> />
<TextField <JalaliDateField
size="small"
type="date"
label={t('audit_to')} label={t('audit_to')}
value={draft.to ?? ''} value={draft.to ?? null}
onChange={(e) => setDraft((d) => ({ ...d, to: e.target.value || undefined }))} onChange={(iso) => setDraft((d) => ({ ...d, to: iso }))}
slotProps={{ inputLabel: { shrink: true } }} min={draft.from}
sx={{ minWidth: 160 }}
/> />
<AppButton variant="contained" color="primary" onClick={apply}> <AppButton variant="contained" color="primary" onClick={listState.apply}>
{t('apply')} {t('apply')}
</AppButton> </AppButton>
<AppButton variant="text" color="inherit" onClick={clear}> <AppButton variant="text" color="inherit" onClick={listState.clear}>
{t('clear')} {t('clear')}
</AppButton> </AppButton>
</Stack> </Stack>
@@ -93,19 +120,22 @@ export default function AdminAuditPage() {
) : ( ) : (
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 1 }}>
{items.map((entry) => ( {items.map((entry) => (
<AuditLogRow key={entry.id} entry={entry} /> <AuditLogRow key={entry.id} entry={entry} actorLabel={actorLabelFrom(userLookup.data, entry.actorUserId)} />
))} ))}
</Stack> </Stack>
)} )}
{items.length > 0 ? (
<Box sx={{ typography: 'caption', color: 'text.secondary' }}>{t('showing_range', { from, to, total })}</Box>
) : null}
<AdminPager <AdminPager
page={page} page={page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page, total: pageCount })}
/> />
</Box> </Box>
); );
@@ -1,5 +1,5 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { Suspense, useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { import {
@@ -17,10 +17,10 @@ import {
TextField, TextField,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { AppButton, AppIcon } from '@/components'; import { AppButton, AppIcon, AppLoading } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPageHeader, ConfigRow } from '@/components/admin'; import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfigRow } from '@/components/admin';
import { formatShamsiDateTime } from '@/utils'; import { formatShamsiDateTime } from '@/utils';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { CONFIG_GROUPS, RATE_CONFIG_KEYS } from '@/services/admin/constants'; import { CONFIG_GROUPS, RATE_CONFIG_KEYS } from '@/services/admin/constants';
import type { PlatformConfig } from '@/services/admin/types'; import type { PlatformConfig } from '@/services/admin/types';
import { usePlatformConfigs, useUpdatePlatformConfig, useConfigChangeHistory } from '@/services/admin'; import { usePlatformConfigs, useUpdatePlatformConfig, useConfigChangeHistory } from '@/services/admin';
@@ -58,9 +58,18 @@ function validate(config: PlatformConfig, value: string): string | null {
* `data_type` (phase §5). * `data_type` (phase §5).
*/ */
export default function AdminConfigPage() { export default function AdminConfigPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminConfigPageInner />
</Suspense>
);
}
function AdminConfigPageInner() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const configs = usePlatformConfigs(1); const listState = useAdminListState<Record<string, never>>({ parse: () => ({}), serialize: () => ({}), empty: {} });
const configs = usePlatformConfigs(listState.page);
const [editing, setEditing] = useState<PlatformConfig | null>(null); const [editing, setEditing] = useState<PlatformConfig | null>(null);
const [historyKey, setHistoryKey] = useState<string | null>(null); const [historyKey, setHistoryKey] = useState<string | null>(null);
@@ -101,6 +110,19 @@ export default function AdminConfigPage() {
)) ))
)} )}
<AdminPager
page={listState.page}
pageCount={Math.max(1, Math.ceil((configs.data?.total ?? 0) / (configs.data?.pageSize ?? 1)))}
onPrev={() => 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 ? <ConfigEditDialog config={editing} onClose={() => setEditing(null)} /> : null} {editing ? <ConfigEditDialog config={editing} onClose={() => setEditing(null)} /> : null}
<ConfigHistoryDrawer configKey={historyKey} onClose={() => setHistoryKey(null)} /> <ConfigHistoryDrawer configKey={historyKey} onClose={() => setHistoryKey(null)} />
</Box> </Box>
@@ -149,7 +171,6 @@ function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose
autoFocus autoFocus
multiline={config.dataType === 'json'} multiline={config.dataType === 'json'}
minRows={config.dataType === 'json' ? 4 : 1} minRows={config.dataType === 'json' ? 4 : 1}
type={config.dataType === 'int' || config.dataType === 'decimal' ? 'text' : 'text'}
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
label={t('cfg_col_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 }) { function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null; onClose: () => void }) {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); 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). // RTL-aware: the drawer slides from the reading-end (left on fa/RTL, right on en/LTR).
const anchor = locale === 'fa' ? 'left' : 'right'; 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 ( return (
<Drawer anchor={anchor} open={configKey != null} onClose={onClose} slotProps={{ paper: { sx: { width: { xs: '100%', sm: 420 }, p: 3 } } }}> <Drawer anchor={anchor} open={configKey != null} onClose={close} slotProps={{ paper: { sx: { width: { xs: '100%', sm: 420 }, p: 3 } } }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">{configKey ? t('cfg_history_title', { key: configKey }) : ''}</Typography> <Typography variant="h6">{configKey ? t('cfg_history_title', { key: configKey }) : ''}</Typography>
<AppButton variant="text" color="inherit" onClick={onClose} sx={{ minWidth: 0 }}> <AppButton variant="text" color="inherit" onClick={close} sx={{ minWidth: 0 }}>
<AppIcon icon="close" /> <AppIcon icon="close" />
</AppButton> </AppButton>
</Stack> </Stack>
@@ -213,6 +241,16 @@ function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null;
))} ))}
</Stack> </Stack>
)} )}
<AdminPager
page={page}
pageCount={pageCount}
onPrev={() => 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 })}
/>
</Drawer> </Drawer>
); );
} }
@@ -1,5 +1,5 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { import {
@@ -16,13 +16,24 @@ import {
Switch, Switch,
TextField, TextField,
} from '@mui/material'; } from '@mui/material';
import { AppButton } from '@/components'; import { AppButton, AppLoading, JalaliDateField } from '@/components';
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, type AdminTableColumn } from '@/components/admin'; import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, type AdminTableColumn } from '@/components/admin';
import { formatShamsiDate } from '@/utils'; 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 type { Holiday, HolidayInput, HolidayType } from '@/services/admin/types';
import { useHolidays, useUpsertHoliday } from '@/services/admin'; 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']; 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). * computes the shift itself (phase §5).
*/ */
export default function AdminHolidaysPage() { export default function AdminHolidaysPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminHolidaysPageInner />
</Suspense>
);
}
function AdminHolidaysPageInner() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const holidays = useHolidays({}, 1); const listState = useAdminListState<Record<string, never>>({ parse: () => ({}), serialize: () => ({}), empty: {} });
const holidays = useHolidays({}, listState.page);
const [editing, setEditing] = useState<Holiday | 'new' | null>(null); const [editing, setEditing] = useState<Holiday | 'new' | null>(null);
const pageCount = Math.max(1, Math.ceil((holidays.data?.total ?? 0) / ADMIN_PAGE_SIZE));
const columns: AdminTableColumn<Holiday>[] = [ const columns: AdminTableColumn<Holiday>[] = [
{ key: 'date', header: t('hol_col_date'), render: (h) => formatShamsiDate(h.holidayDate, locale) }, { key: 'date', header: t('hol_col_date'), render: (h) => formatShamsiDate(h.holidayDate, locale) },
@@ -96,6 +117,16 @@ export default function AdminHolidaysPage() {
<AdminDataTable columns={columns} rows={holidays.data?.items ?? []} getRowKey={(h) => h.id} ariaLabel={t('hol_title')} /> <AdminDataTable columns={columns} rows={holidays.data?.items ?? []} getRowKey={(h) => h.id} ariaLabel={t('hol_title')} />
)} )}
<AdminPager
page={listState.page}
pageCount={pageCount}
onPrev={() => 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 ? ( {editing ? (
<HolidayDialog holiday={editing === 'new' ? null : editing} onClose={() => setEditing(null)} /> <HolidayDialog holiday={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />
) : 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 }) { function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose: () => void }) {
const t = useTranslations('admin'); const t = useTranslations('admin');
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertHoliday(); const upsert = useUpsertHoliday();
const [form, setForm] = useState<HolidayInput>({ const [form, setForm] = useState<HolidayInput>(() => ({
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? TODAY_ISO, holidayDate: holiday?.holidayDate?.slice(0, 10) ?? todayLocalIso(),
nameFa: holiday?.nameFa ?? '', nameFa: holiday?.nameFa ?? '',
type: holiday?.type ?? 'official', type: holiday?.type ?? 'official',
isBankClosed: holiday?.isBankClosed ?? true, isBankClosed: holiday?.isBankClosed ?? true,
}); }));
const valid = form.holidayDate.length > 0 && form.nameFa.trim().length > 0; const valid = form.holidayDate.length > 0 && form.nameFa.trim().length > 0;
@@ -136,13 +165,11 @@ function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose:
<DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle> <DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle>
<DialogContent> <DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}> <Stack sx={{ gap: 2, mt: 1 }}>
<TextField <JalaliDateField
type="date"
label={t('hol_col_date')} label={t('hol_col_date')}
value={form.holidayDate} value={form.holidayDate || null}
onChange={(e) => setForm((f) => ({ ...f, holidayDate: e.target.value }))} onChange={(iso) => setForm((f) => ({ ...f, holidayDate: iso }))}
disabled={!!holiday} disabled={!!holiday}
slotProps={{ inputLabel: { shrink: true } }}
/> />
<TextField <TextField
label={t('hol_name_fa')} label={t('hol_name_fa')}
@@ -1,13 +1,14 @@
'use client'; 'use client';
import { ReactNode, useState } from 'react'; import { ReactNode, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, Divider, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, StatusChip, TrustBadge } from '@/components'; import { AppButton, PageHeader, StatusChip, TrustBadge } from '@/components';
import { AdminEmptyState, AdminErrorState, ConfirmDialog } from '@/components/admin'; import { AdminEmptyState, AdminErrorState, ConfirmDialog, NursePicker } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities, useAdminBackToList } from '@/hooks';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import type { AdminUserSummary } from '@/services/admin/types';
import { import {
usePartnerCenter, usePartnerCenter,
useCenterSponsoredNurses, useCenterSponsoredNurses,
@@ -27,9 +28,9 @@ import { CENTER_STATE_KIND, PartnerCenterFormDialog } from '../page';
export default function AdminPartnerCenterDetailPage() { export default function AdminPartnerCenterDetailPage() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_PARTNERS}`);
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const parsed = Number(params?.id); const parsed = Number(params?.id);
@@ -43,7 +44,7 @@ export default function AdminPartnerCenterDetailPage() {
const [confirmVerify, setConfirmVerify] = useState(false); const [confirmVerify, setConfirmVerify] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [assignId, setAssignId] = useState(''); const [assignNurseUser, setAssignNurseUser] = useState<AdminUserSummary | null>(null);
const data = center.data; const data = center.data;
@@ -57,14 +58,14 @@ export default function AdminPartnerCenterDetailPage() {
}; };
const onAssign = () => { const onAssign = () => {
const nurseProfileId = Number(assignId); const nurseProfileId = assignNurseUser?.nurseProfileId;
if (!Number.isFinite(nurseProfileId) || nurseProfileId <= 0) return; if (nurseProfileId == null) return;
assignNurse.mutate( assignNurse.mutate(
{ nurseProfileId, unlink: false }, { nurseProfileId, unlink: false },
{ {
onSuccess: () => { onSuccess: () => {
enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' }); enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' });
setAssignId(''); setAssignNurseUser(null);
}, },
}, },
); );
@@ -77,17 +78,7 @@ export default function AdminPartnerCenterDetailPage() {
); );
}; };
const back = ( const back = <PageHeader title={t('partner_detail_title')} onBack={goBack} backLabel={t('back')} />;
<AppButton
variant="text"
color="primary"
startIcon="partners"
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PARTNERS}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('back')}
</AppButton>
);
if (center.isLoading) { if (center.isLoading) {
return ( return (
@@ -124,18 +115,13 @@ export default function AdminPartnerCenterDetailPage() {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 0.75 }}> <PageHeader
{back} title={t('partner_detail_title')}
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}> subtitle={data.name}
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}> onBack={goBack}
{t('partner_detail_title')} backLabel={t('back')}
</Typography> meta={<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} />}
<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} /> />
</Stack>
<Typography variant="subtitle1" sx={{ color: 'text.secondary' }}>
{data.name}
</Typography>
</Stack>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}> <Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack divider={<Divider flexItem />} sx={{ gap: 1.25 }}> <Stack divider={<Divider flexItem />} sx={{ gap: 1.25 }}>
@@ -221,21 +207,22 @@ export default function AdminPartnerCenterDetailPage() {
{caps.canManagePartners ? ( {caps.canManagePartners ? (
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start', flexWrap: 'wrap' }}> <Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<TextField <Box sx={{ minWidth: 260 }}>
size="small" <NursePicker
type="number" value={assignNurseUser}
label={t('partner_assign_nurse_ph')} onChange={setAssignNurseUser}
value={assignId} label={t('partner_assign_nurse_ph')}
onChange={(e) => setAssignId(e.target.value)} placeholder={t('user_picker_search_ph')}
slotProps={{ htmlInput: { min: 1, step: 1 } }} noOptionsText={t('user_picker_no_options')}
sx={{ minWidth: 200 }} loadingText={t('user_picker_loading')}
/> />
</Box>
<AppButton <AppButton
variant="contained" variant="contained"
color="primary" color="primary"
startIcon="assign" startIcon="assign"
onClick={onAssign} onClick={onAssign}
disabled={assignNurse.isPending || Number(assignId) <= 0} disabled={assignNurse.isPending || assignNurseUser?.nurseProfileId == null}
sx={{ mt: 0.25 }} sx={{ mt: 0.25 }}
> >
{t('partner_assign_nurse')} {t('partner_assign_nurse')}
@@ -1,5 +1,5 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
@@ -16,7 +16,7 @@ import {
TextField, TextField,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { AppButton, StatusChip } from '@/components'; import { AppButton, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components'; import type { StatusKind } from '@/components';
import { import {
AdminDataTable, AdminDataTable,
@@ -24,14 +24,21 @@ import {
AdminErrorState, AdminErrorState,
AdminPageHeader, AdminPageHeader,
AdminPager, AdminPager,
UserPicker,
type AdminTableColumn, type AdminTableColumn,
} from '@/components/admin'; } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { adminPartnerCenterPath } from '@/constants'; import { adminPartnerCenterPath } from '@/constants';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants'; import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import type { CenterOnboardingState, PartnerCenter, PartnerCenterInput } from '@/services/partnerCenter/types'; 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'; 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<string, never>;
const EMPTY_FILTERS: PartnersListFilters = {};
/** State → semantic chip color. verified = green, pending = amber, suspended = red, draft = neutral. */ /** State → semantic chip color. verified = green, pending = amber, suspended = red, draft = neutral. */
export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = { export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
verified: 'verified', verified: 'verified',
@@ -48,16 +55,33 @@ export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
* it is only ever entered here, never displayed (the list carries no IBAN at all). * it is only ever entered here, never displayed (the list carries no IBAN at all).
*/ */
export default function AdminPartnersPage() { export default function AdminPartnersPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminPartnersPageInner />
</Suspense>
);
}
function AdminPartnersPageInner() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const [page, setPage] = useState(1);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const listState = useAdminListState<PartnersListFilters>({
parse: () => EMPTY_FILTERS,
serialize: () => ({}),
empty: EMPTY_FILTERS,
});
const page = listState.page;
const centers = usePartnerCenters({}, page); const centers = usePartnerCenters({}, page);
const items = centers.data?.items ?? []; 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<PartnerCenter>[] = [ const columns: AdminTableColumn<PartnerCenter>[] = [
{ key: 'name', header: t('partner_col_name'), render: (c) => c.name }, { key: 'name', header: t('partner_col_name'), render: (c) => c.name },
@@ -97,17 +121,18 @@ export default function AdminPartnersPage() {
getRowKey={(c) => c.id} getRowKey={(c) => c.id}
ariaLabel={t('partner_title')} ariaLabel={t('partner_title')}
onRowClick={(c) => router.push(`/${locale}${adminPartnerCenterPath(c.id)}`)} onRowClick={(c) => router.push(`/${locale}${adminPartnerCenterPath(c.id)}`)}
footer={total > 0 ? t('showing_range', { from, to, total }) : undefined}
/> />
)} )}
<AdminPager <AdminPager
page={page} page={page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page, total: pageCount })}
/> />
{creating ? <PartnerCenterFormDialog center={null} onClose={() => setCreating(false)} /> : null} {creating ? <PartnerCenterFormDialog center={null} onClose={() => 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 { interface CenterFormState {
name: string; name: string;
legalEntityType: string; legalEntityType: string;
@@ -125,7 +152,7 @@ interface CenterFormState {
settlementIban: string; settlementIban: string;
isMerchantOfRecord: boolean; isMerchantOfRecord: boolean;
commissionRate: string; commissionRate: string;
adminUserId: string; adminUser: AdminUserSummary | null | undefined;
} }
function initialForm(center: PartnerCenter | null): CenterFormState { function initialForm(center: PartnerCenter | null): CenterFormState {
@@ -139,7 +166,7 @@ function initialForm(center: PartnerCenter | null): CenterFormState {
settlementIban: '', settlementIban: '',
isMerchantOfRecord: center?.isMerchantOfRecord ?? false, isMerchantOfRecord: center?.isMerchantOfRecord ?? false,
commissionRate: center != null ? String(center.commissionRate) : '', 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 mutation = isEdit ? update : create;
const [form, setForm] = useState<CenterFormState>(() => initialForm(center)); const [form, setForm] = useState<CenterFormState>(() => 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 = <K extends keyof CenterFormState>(key: K, value: CenterFormState[K]) => const set = <K extends keyof CenterFormState>(key: K, value: CenterFormState[K]) =>
setForm((f) => ({ ...f, [key]: value })); setForm((f) => ({ ...f, [key]: value }));
@@ -179,7 +214,7 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
settlementIban: form.settlementIban.trim() || null, settlementIban: form.settlementIban.trim() || null,
isMerchantOfRecord: form.isMerchantOfRecord, isMerchantOfRecord: form.isMerchantOfRecord,
commissionRate: commission, commissionRate: commission,
adminUserId: form.adminUserId.trim() === '' ? null : Number(form.adminUserId), adminUserId: displayedAdminUser?.id ?? null,
}; };
mutation.mutate(input, { mutation.mutate(input, {
onSuccess: () => { onSuccess: () => {
@@ -235,12 +270,13 @@ export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCe
onChange={(e) => set('commissionRate', e.target.value)} onChange={(e) => set('commissionRate', e.target.value)}
slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }} slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }}
/> />
<TextField <UserPicker
type="number" value={displayedAdminUser}
onChange={(u) => set('adminUser', u)}
label={t('partner_admin_user')} label={t('partner_admin_user')}
value={form.adminUserId} placeholder={t('user_picker_search_ph')}
onChange={(e) => set('adminUserId', e.target.value)} noOptionsText={t('user_picker_no_options')}
slotProps={{ htmlInput: { min: 1, step: 1 } }} loadingText={t('user_picker_loading')}
/> />
</Stack> </Stack>
</DialogContent> </DialogContent>
@@ -1,18 +1,22 @@
'use client'; 'use client';
import { FunctionComponent, ReactNode, useState } from 'react'; import { FunctionComponent, ReactNode, Suspense, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, Chip, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; 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 type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminPager, ConfirmDialog } from '@/components/admin'; import { AdminEmptyState, AdminErrorState, AdminPager, ConfirmDialog } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities, useAdminListState } from '@/hooks';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { formatIrrToToman, formatShamsiDate } from '@/utils'; import { formatIrrToToman, formatShamsiDate } from '@/utils';
import { usePayoutBatchDetail, useRecordTransferReference, useRetryPayout } from '@/services/payouts'; import { usePayoutBatchDetail, useRecordTransferReference, useRetryPayout } from '@/services/payouts';
import type { AdminPayoutRow, PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types'; 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<string, never>;
const EMPTY_FILTERS: BatchRowsFilters = {};
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = { const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
draft: 'neutral', draft: 'neutral',
processing: 'info', processing: 'info',
@@ -35,33 +39,37 @@ const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
* `canPayout`. Money is display-only Toman; the client never recomputes amounts, eligibility, or dates. * `canPayout`. Money is display-only Toman; the client never recomputes amounts, eligibility, or dates.
*/ */
export default function AdminPayoutBatchDetailPage() { export default function AdminPayoutBatchDetailPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminPayoutBatchDetailScreen />
</Suspense>
);
}
/** Wrapped in `<Suspense>` above — `useAdminListState` calls `useSearchParams()`, which requires it. */
function AdminPayoutBatchDetailScreen() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const params = useParams<{ batchId: string }>(); const params = useParams<{ batchId: string }>();
const batchId = Number(params?.batchId); const batchId = Number(params?.batchId);
const [page, setPage] = useState(1); const { page, goToPage } = useAdminListState<BatchRowsFilters>({
parse: () => EMPTY_FILTERS,
serialize: () => ({}),
empty: EMPTY_FILTERS,
});
const detail = usePayoutBatchDetail(Number.isFinite(batchId) ? batchId : null, page); const detail = usePayoutBatchDetail(Number.isFinite(batchId) ? batchId : null, page);
const data = detail.data; const data = detail.data;
const pageCount = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1; const pageCount = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 0.5 }}> <PageHeader
<AppButton title={t('payout_batch_title', { id: batchId })}
variant="text" backTo={`/${locale}${ROUTES.ADMIN_PAYOUTS}`}
color="primary" backLabel={t('back')}
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PAYOUTS}`)} />
sx={{ alignSelf: 'flex-start' }}
>
{t('back')}
</AppButton>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('payout_batch_title', { id: batchId })}
</Typography>
</Stack>
{detail.isLoading ? ( {detail.isLoading ? (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
@@ -116,11 +124,11 @@ export default function AdminPayoutBatchDetailPage() {
<AdminPager <AdminPager
page={page} page={page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => goToPage(Math.max(1, page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page, total: pageCount })}
/> />
</> </>
)} )}
@@ -17,7 +17,7 @@ import {
TextField, TextField,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { AppButton, Money, StatusChip } from '@/components'; import { AppButton, JalaliDateField, Money, StatusChip } from '@/components';
import type { StatusKind } from '@/components'; import type { StatusKind } from '@/components';
import { import {
AdminDataTable, AdminDataTable,
@@ -30,7 +30,7 @@ import {
import type { AdminTableColumn } from '@/components/admin'; import type { AdminTableColumn } from '@/components/admin';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities } from '@/hooks';
import { adminPayoutBatchPath } from '@/constants'; import { adminPayoutBatchPath } from '@/constants';
import { formatShamsiDate } from '@/utils'; import { formatNumber, formatShamsiDate } from '@/utils';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants'; import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { usePayoutBatches, usePreviewPayoutBatch, useRunPayoutBatch } from '@/services/payouts'; import { usePayoutBatches, usePreviewPayoutBatch, useRunPayoutBatch } from '@/services/payouts';
import type { PayoutBatchStatus, PayoutBatchSummary } from '@/services/payouts/types'; import type { PayoutBatchStatus, PayoutBatchSummary } from '@/services/payouts/types';
@@ -52,8 +52,17 @@ const BATCH_STATUSES: readonly PayoutBatchStatus[] = [
'failed', '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 * 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))} onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page, total: pageCount })}
/> />
{previewOpen ? <PreviewBatchDialog canPayout={caps.canPayout} onClose={() => setPreviewOpen(false)} /> : null} {previewOpen ? <PreviewBatchDialog canPayout={caps.canPayout} onClose={() => setPreviewOpen(false)} /> : null}
@@ -230,22 +239,13 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
<DialogTitle sx={{ fontWeight: 800 }}>{t('payout_preview_title')}</DialogTitle> <DialogTitle sx={{ fontWeight: 800 }}>{t('payout_preview_title')}</DialogTitle>
<DialogContent> <DialogContent>
<Stack direction="row" sx={{ gap: 1.5, mt: 1, flexWrap: 'wrap', alignItems: 'center' }}> <Stack direction="row" sx={{ gap: 1.5, mt: 1, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField <JalaliDateField
type="date"
size="small" size="small"
label={t('payout_period_start')} label={t('payout_period_start')}
value={periodStart} value={periodStart}
onChange={(e) => setPeriodStart(e.target.value)} onChange={setPeriodStart}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
type="date"
size="small"
label={t('payout_period_end')}
value={periodEnd}
onChange={(e) => setPeriodEnd(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/> />
<JalaliDateField size="small" label={t('payout_period_end')} value={periodEnd} onChange={setPeriodEnd} />
<AppButton <AppButton
variant="outlined" variant="outlined"
color="primary" color="primary"
@@ -361,12 +361,41 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
<ConfirmDialog <ConfirmDialog
open={runConfirmOpen} open={runConfirmOpen}
title={t('payout_run_confirm_title')} title={t('payout_run_confirm_title')}
body={t('payout_run_confirm_body')} body={
result ? (
<Stack sx={{ gap: 1.5, mt: 0.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_run_summary_intro')}
</Typography>
<Money amountIrr={result.totalNetIrr} size="lg" tone="emphasis" />
<Stack sx={{ gap: 0.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_run_count_label')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatNumber(result.eligible.length, locale)}
</Typography>
</Stack>
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('payout_col_processing')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatShamsiDate(result.processingDate, locale)}
</Typography>
</Stack>
</Stack>
</Stack>
) : null
}
confirmLabel={t('payout_run')} confirmLabel={t('payout_run')}
cancelLabel={t('cancel')} cancelLabel={t('cancel')}
onConfirm={onRunConfirm} onConfirm={onRunConfirm}
onClose={() => setRunConfirmOpen(false)} onClose={() => setRunConfirmOpen(false)}
loading={run.isPending} loading={run.isPending}
requireTypedConfirmation={result ? ['تایید', result.totalNetIrr] : []}
typedConfirmationLabel={t('payout_run_type_to_confirm')}
/> />
</Dialog> </Dialog>
); );
@@ -1,11 +1,11 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { Box, Chip, MenuItem, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material'; 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 { 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 { REVIEWS_PAGE_SIZE } from '@/services/reviews/constants';
import type { ModerationAction, ModerationQueueItem, ModerationStatus } from '@/services/reviews/types'; import type { ModerationAction, ModerationQueueItem, ModerationStatus } from '@/services/reviews/types';
import { useModerationQueue, useModerateReview } from '@/services/reviews'; import { useModerationQueue, useModerateReview } from '@/services/reviews';
@@ -29,13 +29,33 @@ const STATUS_CHIP_COLOR: Record<ModerationStatus, 'default' | 'success' | 'warni
* Publishing recomputes the nurse aggregate **server-side**; the mutation invalidates the queue so the row * Publishing recomputes the nurse aggregate **server-side**; the mutation invalidates the queue so the row
* leaves on success. `canModerate` only hides the controls — the server enforces the role scope. * leaves on success. `canModerate` only hides the controls — the server enforces the role scope.
*/ */
const DEFAULT_STATUS: ModerationStatus = 'pending_moderation';
function parseFilters(params: URLSearchParams): { status: ModerationStatus } {
const status = params.get('status') as ModerationStatus | null;
return { status: status && MODERATION_STATUSES.includes(status) ? status : DEFAULT_STATUS };
}
export default function AdminReviewsPage() { export default function AdminReviewsPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminReviewsPageInner />
</Suspense>
);
}
function AdminReviewsPageInner() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const [status, setStatus] = useState<ModerationStatus>('pending_moderation'); const listState = useAdminListState<{ status: ModerationStatus }>({
const [page, setPage] = useState(1); parse: parseFilters,
serialize: (f): Record<string, string> => (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 [pending, setPending] = useState<{ item: ModerationQueueItem; action: ModerationAction } | null>(null);
const queue = useModerationQueue({ status }, page); const queue = useModerationQueue({ status }, page);
@@ -44,6 +64,8 @@ export default function AdminReviewsPage() {
const items = queue.data?.items ?? []; const items = queue.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / REVIEWS_PAGE_SIZE)); 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 requireReason = pending?.action === 'hide' || pending?.action === 'reject';
const onConfirm = (reason?: string) => { const onConfirm = (reason?: string) => {
@@ -70,10 +92,7 @@ export default function AdminReviewsPage() {
size="small" size="small"
label={t('filter_label')} label={t('filter_label')}
value={status} value={status}
onChange={(e) => { onChange={(e) => setStatus(e.target.value as ModerationStatus)}
setStatus(e.target.value as ModerationStatus);
setPage(1);
}}
sx={{ minWidth: 180 }} sx={{ minWidth: 180 }}
> >
{MODERATION_STATUSES.map((s) => ( {MODERATION_STATUSES.map((s) => (
@@ -107,11 +126,11 @@ export default function AdminReviewsPage() {
<AdminPager <AdminPager
page={page} page={page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => listState.goToPage(Math.max(1, page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => listState.goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page, total: pageCount })}
/> />
<ConfirmDialog <ConfirmDialog
@@ -8,7 +8,7 @@
* grid lists **active** grants (revoked rows are filtered out); an audited confirm dialog fronts every * grid lists **active** grants (revoked rows are filtered out); an audited confirm dialog fronts every
* revoke and grant. * revoke and grant.
*/ */
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { import {
@@ -31,12 +31,13 @@ import {
AdminErrorState, AdminErrorState,
AdminPageHeader, AdminPageHeader,
ConfirmDialog, ConfirmDialog,
UserPicker,
type AdminTableColumn, type AdminTableColumn,
} from '@/components/admin'; } from '@/components/admin';
import { formatShamsiDate } from '@/utils'; import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks'; import { useAdminCapabilities } from '@/hooks';
import type { AdminRole, RoleGrant } from '@/services/admin/types'; import type { AdminRole, AdminUserSummary, RoleGrant } from '@/services/admin/types';
import { useAdminRoles, useGrantRole, useRevokeRole } from '@/services/admin'; import { useAdminRoles, useGrantRole, useRevokeRole, useUserLookup } from '@/services/admin';
/** The fine-grained admin roles the grid grants (aligned with the b2 `AdminRole` enum). */ /** The fine-grained admin roles the grid grants (aligned with the b2 `AdminRole` enum). */
const ROLES: readonly AdminRole[] = ['super_admin', 'admin', 'support', 'finance', 'moderation']; const ROLES: readonly AdminRole[] = ['super_admin', 'admin', 'support', 'finance', 'moderation'];
@@ -56,8 +57,13 @@ export default function AdminRolesPage() {
// Only active grants — a revoked grant leaves the grid. // Only active grants — a revoked grant leaves the grid.
const active = (roles.data ?? []).filter((g) => g.revokedAt == null); const active = (roles.data ?? []).filter((g) => 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<RoleGrant>[] = [ const columns: AdminTableColumn<RoleGrant>[] = [
{ 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', key: 'role',
header: t('role_col_role'), header: t('role_col_role'),
@@ -131,7 +137,7 @@ export default function AdminRolesPage() {
<ConfirmDialog <ConfirmDialog
open={revoking != null} open={revoking != null}
title={t('role_revoke')} title={t('role_revoke')}
body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), id: revoking.userId }) : undefined} body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), name: nameFor(revoking.userId) }) : undefined}
confirmLabel={t('role_revoke')} confirmLabel={t('role_revoke')}
cancelLabel={t('cancel')} cancelLabel={t('cancel')}
confirmColor="error" confirmColor="error"
@@ -149,16 +155,15 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const grant = useGrantRole(); const grant = useGrantRole();
const [userId, setUserId] = useState(''); const [user, setUser] = useState<AdminUserSummary | null>(null);
const [role, setRole] = useState<AdminRole>('support'); const [role, setRole] = useState<AdminRole>('support');
const parsedId = Number(userId); const valid = user != null;
const valid = /^\d+$/.test(userId.trim()) && parsedId > 0;
const onGrant = () => { const onGrant = () => {
if (!valid) return; if (!user) return;
grant.mutate( grant.mutate(
{ userId: parsedId, role }, { userId: user.id, role },
{ {
onSuccess: () => { onSuccess: () => {
enqueueSnackbar(t('role_updated'), { variant: 'success' }); enqueueSnackbar(t('role_updated'), { variant: 'success' });
@@ -173,12 +178,13 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
<DialogTitle sx={{ fontWeight: 800 }}>{t('role_grant')}</DialogTitle> <DialogTitle sx={{ fontWeight: 800 }}>{t('role_grant')}</DialogTitle>
<DialogContent> <DialogContent>
<Stack sx={{ gap: 2, mt: 1 }}> <Stack sx={{ gap: 2, mt: 1 }}>
<TextField <UserPicker
value={user}
onChange={setUser}
label={t('role_col_user')} label={t('role_col_user')}
type="number" placeholder={t('user_picker_search_ph')}
value={userId} noOptionsText={t('user_picker_no_options')}
onChange={(e) => setUserId(e.target.value)} loadingText={t('user_picker_loading')}
slotProps={{ htmlInput: { min: 1 } }}
/> />
<TextField select label={t('role_col_role')} value={role} onChange={(e) => setRole(e.target.value as AdminRole)}> <TextField select label={t('role_col_role')} value={role} onChange={(e) => setRole(e.target.value as AdminRole)}>
{ROLES.map((r) => ( {ROLES.map((r) => (
@@ -188,7 +194,7 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
))} ))}
</TextField> </TextField>
{valid ? ( {valid ? (
<DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), id: parsedId })}</DialogContentText> <DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), name: user.displayName })}</DialogContentText>
) : null} ) : null}
</Stack> </Stack>
</DialogContent> </DialogContent>
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { import {
@@ -13,14 +13,17 @@ import {
TextField, TextField,
ToggleButton, ToggleButton,
ToggleButtonGroup, ToggleButtonGroup,
Typography, Tooltip,
} from '@mui/material'; } from '@mui/material';
import { AppButton, StatusChip } from '@/components'; import { AppButton, PageHeader, StatusChip } from '@/components';
import type { StatusKind } from '@/components'; import type { StatusKind } from '@/components';
import { AdminEmptyState, AdminErrorState, AdminMessageBubble, RefundPanel } from '@/components/admin'; import { useThreadScroll } from '@/components/messaging';
import { useAdminCapabilities } from '@/hooks'; import { AdminEmptyState, AdminErrorState, AdminMessageBubble, ConfirmDialog, RefundPanel } from '@/components/admin';
import { useAdminBackToList, useAdminCapabilities } from '@/hooks';
import { useAuth } from '@/context/auth';
import { ROUTES } from '@/constants'; 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'; 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). */ /** 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 * 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- * `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` * 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 * renders internal notes distinctly, and ui-phase-11 makes the composer itself amber-tinted in internal mode
* confirm. When the ticket is a **refund** case linked to a booking, the admin opens the `RefundPanel` inline * so a staff member can never post an internal note publicly by mistake). Sends are optimistic
* (it always initiates from a ticket, never a standalone form). Composer + refund are gated on the principal's * (`usePostAdminMessage`) — the draft clears only on confirm; the thread opens scrolled to the newest message
* capabilities; the server is the real authority. * (`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() { export default function AdminTicketThreadPage() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const tickets = useTranslations('tickets'); const tickets = useTranslations('tickets');
const locale = useLocale(); const locale = useLocale();
const router = useRouter();
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const [authState] = useAuth();
const meId = authState.currentUser?.id;
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_TICKETS}`);
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const parsed = Number(params?.id); const parsed = Number(params?.id);
@@ -59,12 +66,20 @@ export default function AdminTicketThreadPage() {
const { data: detail, isLoading, isError, refetch } = useAdminTicket(ticketId || null); const { data: detail, isLoading, isError, refetch } = useAdminTicket(ticketId || null);
const post = usePostAdminMessage(ticketId); const post = usePostAdminMessage(ticketId);
const closeTicket = useCloseTicket(ticketId);
const reopenTicket = useReopenTicket(ticketId);
const assignTicket = useAssignTicket(ticketId);
const [mode, setMode] = useState<'reply' | 'internal'>('reply'); const [mode, setMode] = useState<'reply' | 'internal'>('reply');
const [body, setBody] = useState(''); const [body, setBody] = useState('');
const [refundShown, setRefundShown] = useState(false); const [refundShown, setRefundShown] = useState(false);
const [closeDialogOpen, setCloseDialogOpen] = useState(false);
const [reopenDialogOpen, setReopenDialogOpen] = useState(false);
const isInternal = mode === 'internal'; 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 send = () => {
const trimmed = body.trim(); 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 showRefund = !!detail && detail.category === 'refund' && detail.bookingId != null && caps.canRefund;
const showLifecycle = TICKET_LIFECYCLE_ENABLED && caps.canManageTickets;
const lifecycleActions = showLifecycle && detail ? (
<>
{detail.status === 'open' ? (
<AppButton variant="outlined" color="primary" startIcon="close" onClick={() => setCloseDialogOpen(true)}>
{t('ticket_close')}
</AppButton>
) : (
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => setReopenDialogOpen(true)}>
{t('ticket_reopen')}
</AppButton>
)}
<Tooltip title={meId == null ? t('assign_me_loading') : ''}>
<span>
<AppButton
variant="outlined"
color="primary"
startIcon="assign"
onClick={onAssignMe}
disabled={meId == null || assignTicket.isPending}
>
{t('ticket_assign_me')}
</AppButton>
</span>
</Tooltip>
</>
) : null;
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 820, mx: 'auto', width: '100%' }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 820, mx: 'auto', width: '100%' }}>
<AppButton <AppButton variant="text" color="primary" onClick={goBack} sx={{ alignSelf: 'flex-start' }}>
variant="text"
color="primary"
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_TICKETS}`)}
sx={{ alignSelf: 'flex-start' }}
>
{t('back')} {t('back')}
</AppButton> </AppButton>
@@ -106,44 +167,41 @@ export default function AdminTicketThreadPage() {
) : ( ) : (
<> <>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}> <Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}> <PageHeader
<Typography variant="h6" component="h1" sx={{ fontWeight: 800 }} dir="ltr"> title={t('ticket_thread_title', { ref: detail.referenceCode })}
{t('ticket_thread_title', { ref: detail.referenceCode })} subtitle={detail.subject ?? undefined}
</Typography> actions={lifecycleActions}
{detail.subject ? ( meta={
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
{detail.subject} <Chip size="small" label={t(`tcat_${detail.category}`)} />
</Typography> <StatusChip status={STATUS_KIND[detail.status]} label={t(`tstatus_${detail.status}`)} />
) : null} {detail.bookingId != null ? (
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}> <Chip size="small" variant="outlined" label={t('refund_linked_booking', { id: detail.bookingId })} />
<Chip size="small" label={t(`tcat_${detail.category}`)} /> ) : null}
<StatusChip status={STATUS_KIND[detail.status]} label={t(`tstatus_${detail.status}`)} /> {detail.refundId != null ? (
{detail.bookingId != null ? ( <Chip size="small" variant="outlined" label={t('ticket_linked_refund', { id: detail.refundId })} />
<Chip size="small" variant="outlined" label={t('refund_linked_booking', { id: detail.bookingId })} /> ) : null}
) : null} </Stack>
{detail.refundId != null ? ( }
<Chip size="small" variant="outlined" label={t('ticket_linked_refund', { id: detail.refundId })} /> />
) : null}
</Stack>
{showRefund ? ( {showRefund ? (
<Box> <Box sx={{ mt: 1.5 }}>
<AppButton <AppButton
variant="outlined" variant="outlined"
color="primary" color="primary"
startIcon="refunds" startIcon="refunds"
onClick={() => setRefundShown((v) => !v)} onClick={() => setRefundShown((v) => !v)}
> >
{t('refund_open')} {t('refund_open')}
</AppButton> </AppButton>
<Collapse in={refundShown} unmountOnExit> <Collapse in={refundShown} unmountOnExit>
<Paper elevation={0} sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}> <Paper elevation={0} sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} /> <RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
</Paper> </Paper>
</Collapse> </Collapse>
</Box> </Box>
) : null} ) : null}
</Stack>
</Paper> </Paper>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, maxHeight: 520, overflowY: 'auto', p: 0.5 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, maxHeight: 520, overflowY: 'auto', p: 0.5 }}>
@@ -154,10 +212,20 @@ export default function AdminTicketThreadPage() {
authorLabel={tickets(authorLabelKey(m.authorRole))} authorLabel={tickets(authorLabelKey(m.authorRole))}
/> />
))} ))}
<div ref={bottomRef} />
</Box> </Box>
{caps.canManageTickets ? ( {caps.canManageTickets ? (
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}> <Paper
elevation={0}
sx={{
p: 2,
border: '1px solid',
borderColor: isInternal ? 'var(--bal-warning)' : 'divider',
borderRadius: 2,
bgcolor: isInternal ? 'var(--bal-warning-soft)' : 'background.paper',
}}
>
<Stack sx={{ gap: 1.5 }}> <Stack sx={{ gap: 1.5 }}>
<ToggleButtonGroup <ToggleButtonGroup
size="small" size="small"
@@ -185,11 +253,34 @@ export default function AdminTicketThreadPage() {
disabled={post.isPending || body.trim().length === 0} disabled={post.isPending || body.trim().length === 0}
sx={{ alignSelf: 'flex-start' }} sx={{ alignSelf: 'flex-start' }}
> >
{t('ticket_send')} {isInternal ? t('ticket_send_internal') : t('ticket_send')}
</AppButton> </AppButton>
</Stack> </Stack>
</Paper> </Paper>
) : null} ) : null}
<ConfirmDialog
open={closeDialogOpen}
title={t('ticket_close')}
body={t('ticket_close_confirm')}
confirmLabel={t('ticket_close')}
cancelLabel={t('cancel')}
onConfirm={onCloseConfirm}
onClose={() => setCloseDialogOpen(false)}
loading={closeTicket.isPending}
confirmColor="primary"
/>
<ConfirmDialog
open={reopenDialogOpen}
title={t('ticket_reopen')}
body={t('ticket_reopen_confirm')}
confirmLabel={t('ticket_reopen')}
cancelLabel={t('cancel')}
onConfirm={onReopenConfirm}
onClose={() => setReopenDialogOpen(false)}
loading={reopenTicket.isPending}
confirmColor="primary"
/>
</> </>
)} )}
</Box> </Box>
@@ -1,9 +1,9 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material'; 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 type { StatusKind } from '@/components';
import { import {
AdminDataTable, AdminDataTable,
@@ -14,9 +14,11 @@ import {
type AdminTableColumn, type AdminTableColumn,
} from '@/components/admin'; } from '@/components/admin';
import { adminTicketThreadPath } from '@/constants'; import { adminTicketThreadPath } from '@/constants';
import { useAdminListState } from '@/hooks';
import { useAdminTickets } from '@/services/tickets'; import { useAdminTickets } from '@/services/tickets';
import { TICKETS_PAGE_SIZE } from '@/services/tickets/constants'; import { TICKETS_PAGE_SIZE } from '@/services/tickets/constants';
import type { AdminTicketFilters, AdminTicketSummary, TicketCategory, TicketStatus } from '@/services/tickets/types'; import type { AdminTicketFilters, AdminTicketSummary, TicketCategory, TicketStatus } from '@/services/tickets/types';
import { formatShamsiDate } from '@/utils';
const STATUSES: readonly TicketStatus[] = ['open', 'closed']; const STATUSES: readonly TicketStatus[] = ['open', 'closed'];
const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refund', 'emergency']; const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refund', 'emergency'];
@@ -24,35 +26,55 @@ const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refun
const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' }; const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' };
const EMPTY: AdminTicketFilters = {}; 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<string, string> {
const record: Record<string, string> = {};
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 * 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 * 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 * 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 * refetches; the applied filters + page are mirrored into the URL (`useAdminListState`, ui-phase-11) so
* serves from cache. This surface is staff-only — the server enforces the scope; the UI just routes here. * 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() { export default function AdminTicketsPage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminTicketsQueue />
</Suspense>
);
}
function AdminTicketsQueue() {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const [draft, setDraft] = useState<AdminTicketFilters>(EMPTY); const { draft, setDraft, applied, page, apply, clear, goToPage } = useAdminListState<AdminTicketFilters>({
const [applied, setApplied] = useState<AdminTicketFilters>(EMPTY); parse: parseFilters,
const [page, setPage] = useState(1); serialize: serializeFilters,
empty: EMPTY,
});
const tickets = useAdminTickets(applied, page); const tickets = useAdminTickets(applied, page);
const items = tickets.data?.items ?? []; const items = tickets.data?.items ?? [];
const pageCount = Math.max(1, Math.ceil((tickets.data?.total ?? 0) / TICKETS_PAGE_SIZE)); const total = tickets.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / TICKETS_PAGE_SIZE));
const apply = () => { const from = items.length === 0 ? 0 : (page - 1) * TICKETS_PAGE_SIZE + 1;
setApplied(draft); const to = items.length === 0 ? 0 : from + items.length - 1;
setPage(1);
};
const clear = () => {
setDraft(EMPTY);
setApplied(EMPTY);
setPage(1);
};
const columns: AdminTableColumn<AdminTicketSummary>[] = [ const columns: AdminTableColumn<AdminTicketSummary>[] = [
{ {
@@ -76,6 +98,12 @@ export default function AdminTicketsPage() {
render: (row) => <StatusChip status={STATUS_KIND[row.status]} label={t(`tstatus_${row.status}`)} />, render: (row) => <StatusChip status={STATUS_KIND[row.status]} label={t(`tstatus_${row.status}`)} />,
}, },
{ key: 'booking', header: t('ticket_col_booking'), render: (row) => row.bookingId ?? '—' }, { 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 ( return (
@@ -149,17 +177,18 @@ export default function AdminTicketsPage() {
getRowKey={(row) => row.id} getRowKey={(row) => row.id}
ariaLabel={t('ticket_title')} ariaLabel={t('ticket_title')}
onRowClick={(row) => router.push(`/${locale}${adminTicketThreadPath(row.id)}`)} onRowClick={(row) => router.push(`/${locale}${adminTicketThreadPath(row.id)}`)}
footer={t('showing_range', { from, to, total })}
/> />
)} )}
<AdminPager <AdminPager
page={page} page={page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => goToPage(Math.max(1, page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page, total: pageCount })}
/> />
</Box> </Box>
); );
@@ -1,8 +1,101 @@
import { getTranslations } from 'next-intl/server'; 'use client';
import { PlaceholderScreen } from '@/components'; 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 SEARCH_DEBOUNCE_MS = 300;
const t = await getTranslations('nav'); const MIN_QUERY_LENGTH = 2;
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="users" title={t('users')} description={tShell('placeholder_body')} />; /** Directory role → chip label key. */
const ROLE_LABEL_KEY: Record<DirectoryUserRole, string> = {
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<AdminUserSummary>[] = [
{ key: 'name', header: t('user_col_name'), render: (u) => u.displayName, minWidth: 180 },
{
key: 'phone',
header: t('user_col_phone'),
render: (u) => (
<Box component="span" dir="ltr">
{u.maskedPhone}
</Box>
),
},
{ key: 'id', header: t('user_col_id'), render: (u) => `#${u.id}` },
{
key: 'roles',
header: t('user_col_roles'),
render: (u) => (
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
{u.roles.map((r) => (
<Chip key={r} size="small" variant="outlined" label={t(ROLE_LABEL_KEY[r])} />
))}
</Stack>
),
},
{
key: 'actions',
header: '',
align: 'right' as const,
render: (u) => (
<AppLink to={`/${locale}${ROUTES.ADMIN_AUDIT}?entityType=User&entityId=${u.id}`}>{t('user_view_audit')}</AppLink>
),
},
];
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('user_title')} subtitle={t('user_subtitle')} />
<TextField
size="small"
label={t('user_search_label')}
placeholder={t('user_picker_search_ph')}
value={query}
onChange={(e) => setQuery(e.target.value)}
sx={{ maxWidth: 360 }}
/>
{!hasQuery ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('user_search_hint')}
</Typography>
) : search.isFetching ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('user_picker_loading')}
</Typography>
) : items.length === 0 ? (
<AdminEmptyState icon="users" title={t('user_empty')} />
) : (
<AdminDataTable columns={columns} rows={items} getRowKey={(u) => u.id} ariaLabel={t('user_title')} />
)}
</Box>
);
} }
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter, useSearchParams } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack'; import { useSnackbar } from 'notistack';
import { import {
@@ -15,25 +15,21 @@ import {
TextField, TextField,
Typography, Typography,
} from '@mui/material'; } from '@mui/material';
import { AppButton, StatusChip } from '@/components'; import { AppButton, AppLoading, JalaliDateField, PageHeader, StatusChip } from '@/components';
import type { StatusKind } from '@/components'; import type { StatusKind } from '@/components';
import { import { AdminEmptyState, AdminErrorState, ConfirmDialog, DocumentViewer } from '@/components/admin';
AdminEmptyState, import { ROUTES, adminVerificationCasePath } from '@/constants';
AdminErrorState,
AdminPageHeader,
ConfirmDialog,
DocumentViewer,
} from '@/components/admin';
import { ROUTES } from '@/constants';
import { formatShamsiDate } from '@/utils'; import { formatShamsiDate } from '@/utils';
import { useAdminCapabilities } from '@/hooks'; import { useAdminBackToList, useAdminCapabilities } from '@/hooks';
import { import {
useApproveVerification, useApproveVerification,
useDecideStep, useDecideStep,
useRejectVerification, useRejectVerification,
useVerificationCase, useVerificationCase,
useVerificationQueue,
} from '@/services/verification'; } from '@/services/verification';
import type { AdminVerificationStepDetail, VerificationStepStatus } from '@/services/verification/types'; 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. */ /** 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']; const CREDENTIAL_STEP_CODES: readonly string[] = ['moh_competency_license', 'ino_membership', 'criminal_record'];
@@ -48,6 +44,14 @@ const STEP_STATUS_KIND: Record<VerificationStepStatus, StatusKind> = {
expired: 'rejected', expired: 'rejected',
}; };
export default function AdminVerificationCasePage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminVerificationCaseScreen />
</Suspense>
);
}
/** /**
* Verification case (b6 `AdminVerificationsController`) the trust desk works one nurse: the identity on * 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 * 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<VerificationStepStatus, StatusKind> = {
* (encrypted) credential via a structured form; recorded credentials are listed by **type** only the * (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 * 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. * 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 t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const params = useParams<{ nurseId: string }>(); const params = useParams<{ nurseId: string }>();
const searchParams = useSearchParams();
const nurseVerificationId = Number(params?.nurseId); const nurseVerificationId = Number(params?.nurseId);
const caps = useAdminCapabilities(); const caps = useAdminCapabilities();
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
@@ -74,7 +84,36 @@ export default function AdminVerificationCasePage() {
const [approveOpen, setApproveOpen] = useState(false); const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = 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'); const allPassed = !!data && data.steps.length > 0 && data.steps.every((step) => step.status === 'passed');
@@ -103,12 +142,33 @@ export default function AdminVerificationCasePage() {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 1 }}> <PageHeader
<AppButton variant="text" color="primary" onClick={backToQueue} sx={{ alignSelf: 'flex-start' }}> title={t('ver_case_title')}
{t('back')} onBack={backToQueue}
</AppButton> backLabel={t('back')}
<AdminPageHeader title={t('ver_case_title')} /> actions={
</Stack> <Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
variant="outlined"
color="inherit"
startIcon="back"
onClick={() => prevCase && goToCase(prevCase.nurseVerificationId)}
disabled={!prevCase}
>
{t('ver_prev_case')}
</AppButton>
<AppButton
variant="outlined"
color="inherit"
endIcon="forward"
onClick={() => nextCase && goToCase(nextCase.nurseVerificationId)}
disabled={!nextCase}
>
{t('ver_next_case')}
</AppButton>
</Stack>
}
/>
{isLoading ? ( {isLoading ? (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
@@ -408,24 +468,15 @@ function CredentialDialog({
value={issuingAuthority} value={issuingAuthority}
onChange={(e) => setIssuingAuthority(e.target.value)} onChange={(e) => setIssuingAuthority(e.target.value)}
/> />
<TextField <JalaliDateField fullWidth label={t('ver_issued_at')} value={issuedAt || null} onChange={setIssuedAt} />
<JalaliDateField
fullWidth fullWidth
type="date"
label={t('ver_issued_at')}
value={issuedAt}
onChange={(e) => setIssuedAt(e.target.value)}
slotProps={{ inputLabel: { shrink: true } }}
/>
<TextField
fullWidth
type="date"
label={t('ver_expires_at')} label={t('ver_expires_at')}
value={expiresAt} value={expiresAt || null}
onChange={(e) => setExpiresAt(e.target.value)} onChange={setExpiresAt}
required={expiryRequired} required={expiryRequired}
error={expiryMissing} error={expiryMissing}
helperText={expiryMissing ? t('ver_expiry_required') : undefined} helperText={expiryMissing ? t('ver_expiry_required') : undefined}
slotProps={{ inputLabel: { shrink: true } }}
/> />
</Stack> </Stack>
</DialogContent> </DialogContent>
@@ -1,9 +1,9 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense, useEffect, useRef } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material'; import { Box, Chip, Skeleton, Stack, Tab, Tabs, TextField } from '@mui/material';
import { AppIcon, StatusChip } from '@/components'; import { AppButton, AppIcon, AppLoading, StatusChip } from '@/components';
import type { StatusKind } from '@/components'; import type { StatusKind } from '@/components';
import { import {
AdminDataTable, AdminDataTable,
@@ -14,13 +14,21 @@ import {
} from '@/components/admin'; } from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin'; import type { AdminTableColumn } from '@/components/admin';
import { adminVerificationCasePath } from '@/constants'; import { adminVerificationCasePath } from '@/constants';
import { formatShamsiDate } from '@/utils'; import { formatNumber, formatRelativeTime, formatShamsiDate } from '@/utils';
import { useAdminListState } from '@/hooks';
import { useVerificationQueue } from '@/services/verification'; import { useVerificationQueue } from '@/services/verification';
import { ADMIN_QUEUE_PAGE_SIZE } from '@/services/verification/constants'; 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). */ /** SLA thresholds for the waiting-time column's color (display-only client signal — never a server rule). */
type QueueStatusFilter = '' | 'pending' | 'in_review'; 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. */ /** Aggregate status → chip kind. `in_review` reads as informational; a rejected/suspended case shows red. */
const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = { const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
@@ -32,24 +40,66 @@ const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
suspended: 'rejected', suspended: 'rejected',
}; };
export default function AdminVerificationQueuePage() {
return (
<Suspense fallback={<AppLoading />}>
<AdminVerificationQueueScreen />
</Suspense>
);
}
/** /**
* Verification review queue (b6 `AdminVerificationsController`) the trust desk's worklist, one row per * 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 * nurse folded from the per-step endpoint. Status tabs (all / pending / in_review, badge-counted when the
* the step progress, the next pending step, when it was submitted, and a warning when a credential is * server serves `counts` REQ-062) replace the old lone select; a name/phone search follows the same
* expiring. A row opens its case. The filter + page are the query key, so switching them reuses cached * draft-vs-applied Apply/Clear pattern as `admin/tickets`/`admin/audit`. Filters + page are URL-synced via
* pages; a decision on a case invalidates the queue so the desk re-renders without a manual refresh. * `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 t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const [status, setStatus] = useState<QueueStatusFilter>(''); const state = useAdminListState<AdminVerificationQueueFilters>({
const [page, setPage] = useState(1); 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 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<AdminVerificationQueueItem>[] = [ const columns: AdminTableColumn<AdminVerificationQueueItem>[] = [
{ {
@@ -91,31 +141,56 @@ export default function AdminVerificationQueuePage() {
header: t('ver_col_submitted'), header: t('ver_col_submitted'),
render: (item) => (item.submittedAt ? formatShamsiDate(item.submittedAt, locale) : '—'), 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 (
<Box component="span" sx={color ? { color } : undefined}>
{formatRelativeTime(item.submittedAt, locale, formatShamsiDate)}
</Box>
);
},
},
]; ];
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader <AdminPageHeader title={t('ver_title')} subtitle={t('ver_subtitle')} />
title={t('ver_title')}
subtitle={t('ver_subtitle')} <Tabs value={state.draft.status ?? ''} onChange={(_event, value: '' | 'pending' | 'in_review') => selectStatus(value)}>
actions={ <Tab value="" data-tab="all" label={t('filter_all')} />
<TextField <Tab value="pending" data-tab="pending" label={tabLabel(t('agg_pending'), counts?.pending)} />
select <Tab value="in_review" data-tab="in_review" label={tabLabel(t('agg_in_review'), counts?.in_review)} />
size="small" </Tabs>
label={t('ver_col_status')}
value={status} <Stack
onChange={(e) => { direction="row"
setStatus(e.target.value as QueueStatusFilter); sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
setPage(1); >
}} <TextField
sx={{ minWidth: 160 }} size="small"
> label={t('ver_search_label')}
<MenuItem value="">{t('filter_all')}</MenuItem> placeholder={t('ver_search_ph')}
<MenuItem value="pending">{t('agg_pending')}</MenuItem> value={state.draft.search ?? ''}
<MenuItem value="in_review">{t('agg_in_review')}</MenuItem> onChange={(e) => state.setDraft((d) => ({ ...d, search: e.target.value || undefined }))}
</TextField> sx={{ minWidth: 240 }}
} />
/> <AppButton variant="contained" color="primary" onClick={state.apply}>
{t('apply')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={state.clear}>
{t('clear')}
</AppButton>
</Stack>
{queue.isLoading ? ( {queue.isLoading ? (
<Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack> <Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack>
@@ -129,18 +204,23 @@ export default function AdminVerificationQueuePage() {
rows={items} rows={items}
getRowKey={(item) => item.nurseVerificationId} getRowKey={(item) => item.nurseVerificationId}
ariaLabel={t('ver_title')} 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),
)
}
/> />
)} )}
<AdminPager <AdminPager
page={page} page={state.page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => state.goToPage(Math.max(1, state.page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => state.goToPage(Math.min(pageCount, state.page + 1))}
prevLabel={t('prev_page')} prevLabel={t('prev_page')}
nextLabel={t('next_page')} nextLabel={t('next_page')}
indicator={t('page_indicator', { page })} indicator={t('page_indicator', { page: state.page, total: pageCount })}
/> />
</Box> </Box>
); );
@@ -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<string, string> {
const record: Record<string, string> = {};
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}` : ''}`;
}
@@ -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<string, StatusKind> = {
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 (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
<PageHeader title={t('booking_detail_title')} onBack={goBack} backLabel={t('booking_detail_back')} />
{detail.isLoading ? (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={200} />
</Stack>
) : detail.isError ? (
<AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => detail.refetch()} />
) : !detail.data ? (
<AdminEmptyState icon="bookings" title={t('booking_detail_not_found')} />
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('bookings_col_id')} #{detail.data.bookingId}
</Typography>
<StatusChip
status={PARTNER_BOOKING_STATUS_KIND[detail.data.status] ?? 'neutral'}
label={t(`bstatus_${detail.data.status}`)}
/>
</Stack>
<MetaLine label={t('booking_detail_patient')}>{detail.data.patientName}</MetaLine>
<MetaLine label={t('booking_detail_scheduled_date')}>
{formatShamsiDate(detail.data.scheduledDate, locale)}
</MetaLine>
</Stack>
</Paper>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('booking_detail_timeline_title')}
</Typography>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<StatusTimeline nodes={timelineNodes} />
</Paper>
</Stack>
</>
)}
</Box>
);
}
function MetaLine({ label, children }: { label: string; children: ReactNode }) {
return (
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{children}
</Typography>
</Stack>
);
}
@@ -1,39 +1,77 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl'; 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 { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager } from '@/components/admin';
import type { AdminTableColumn } from '@/components/admin'; import type { AdminTableColumn } from '@/components/admin';
import { partnerBookingDetailPath } from '@/constants';
import { useAdminListState } from '@/hooks';
import { formatShamsiDate } from '@/utils'; 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 { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import { useMySponsoredBookings } from '@/services/partnerCenter'; import { useMySponsoredBookings } from '@/services/partnerCenter';
/** /** Booking lifecycle codes the center may legally cover — the read-only filter options (stable wire codes). */
* 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.
*/
const BOOKING_STATUS_OPTIONS = ['pending_payment', 'confirmed', 'in_progress', 'completed', 'disputed', 'closed', 'cancelled'] as const; 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<string, StatusKind> = {
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<string, string> {
return filters.status ? { status: filters.status } : {};
}
/** /**
* Partner portal sponsored bookings (f15). The read-only list of bookings the signed-in center legally * 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 * covers (portal scope; server-enforced tenancy). A status filter (applied+page mirrored into the URL via
* dense table of id/patient/date/status. No PII beyond the summary; the center never sees clinical detail. * `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 `<Suspense>` boundary.
*/ */
export default function PartnerBookingsPage() { export default function PartnerBookingsPage() {
return (
<Suspense fallback={<AppLoading />}>
<PartnerBookingsScreen />
</Suspense>
);
}
function PartnerBookingsScreen() {
const t = useTranslations('partner'); const t = useTranslations('partner');
const ta = useTranslations('admin'); const ta = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const router = useRouter();
const [status, setStatus] = useState<string>(''); const { draft, setDraft, applied, page, apply, clear, goToPage } = useAdminListState<SponsoredBookingFilters>({
const [page, setPage] = useState(1); parse: parseFilters,
serialize: serializeFilters,
empty: EMPTY_FILTERS,
});
const filters = { status: status || undefined }; const bookings = useMySponsoredBookings(applied, page);
const bookings = useMySponsoredBookings(filters, page);
const items = bookings.data?.items ?? []; 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<SponsoredBooking>[] = [ const columns: AdminTableColumn<SponsoredBooking>[] = [
{ key: 'id', header: t('bookings_col_id'), render: (b) => `#${b.bookingId}` }, { key: 'id', header: t('bookings_col_id'), render: (b) => `#${b.bookingId}` },
@@ -42,35 +80,42 @@ export default function PartnerBookingsPage() {
{ {
key: 'status', key: 'status',
header: t('bookings_col_status'), header: t('bookings_col_status'),
render: (b) => <Chip size="small" variant="outlined" label={b.status} />, render: (b) => (
<StatusChip status={PARTNER_BOOKING_STATUS_KIND[b.status] ?? 'neutral'} label={t(`bstatus_${b.status}`)} />
),
}, },
]; ];
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader <AdminPageHeader title={t('bookings_title')} />
title={t('bookings_title')}
actions={ <Stack
<TextField direction="row"
select sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
size="small" >
label={t('bookings_col_status')} <TextField
value={status} select
onChange={(e) => { size="small"
setStatus(e.target.value); label={t('bookings_col_status')}
setPage(1); value={draft.status ?? ''}
}} onChange={(e) => setDraft({ status: e.target.value || undefined })}
sx={{ minWidth: 180 }} sx={{ minWidth: 180 }}
> >
<MenuItem value="">{ta('filter_all')}</MenuItem> <MenuItem value="">{ta('filter_all')}</MenuItem>
{BOOKING_STATUS_OPTIONS.map((s) => ( {BOOKING_STATUS_OPTIONS.map((s) => (
<MenuItem key={s} value={s}> <MenuItem key={s} value={s}>
{s} {t(`bstatus_${s}`)}
</MenuItem> </MenuItem>
))} ))}
</TextField> </TextField>
} <AppButton variant="contained" color="primary" onClick={apply}>
/> {ta('apply')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={clear}>
{ta('clear')}
</AppButton>
</Stack>
{bookings.isLoading ? ( {bookings.isLoading ? (
<Stack sx={{ gap: 2 }}> <Stack sx={{ gap: 2 }}>
@@ -88,17 +133,19 @@ export default function PartnerBookingsPage() {
rows={items} rows={items}
getRowKey={(b) => b.bookingId} getRowKey={(b) => b.bookingId}
ariaLabel={t('bookings_title')} ariaLabel={t('bookings_title')}
onRowClick={(b) => router.push(`/${locale}${partnerBookingDetailPath(b.bookingId)}`)}
footer={ta('showing_range', { from, to, total })}
/> />
)} )}
<AdminPager <AdminPager
page={page} page={page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => goToPage(Math.max(1, page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={ta('prev_page')} prevLabel={ta('prev_page')}
nextLabel={ta('next_page')} nextLabel={ta('next_page')}
indicator={ta('page_indicator', { page })} indicator={ta('page_indicator', { page, total: pageCount })}
/> />
</Box> </Box>
); );
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState } from 'react'; import { Suspense } from 'react';
import { useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material'; import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppLoading } from '@/components';
import { import {
AdminEmptyState, AdminEmptyState,
AdminErrorState, AdminErrorState,
@@ -9,21 +10,86 @@ import {
AdminPager, AdminPager,
PartnerSettlementRow, PartnerSettlementRow,
} from '@/components/admin'; } from '@/components/admin';
import { useAdminListState } from '@/hooks';
import { formatIrrToToman, formatShamsiDate, toCsv } from '@/utils';
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants'; import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
import type { CenterInvoice } from '@/services/partnerCenter/types';
import { useMyPartnerCenter, useMySettlement } from '@/services/partnerCenter'; import { useMyPartnerCenter, useMySettlement } from '@/services/partnerCenter';
const NO_FILTERS: Record<string, never> = {};
/** /**
* Partner portal settlement & invoices (f15). **Merchant-of-record drives the whole view**: a non-MoR * 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 * 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 * `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 * (`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 `<Suspense>` boundary.
*/ */
export default function PartnerSettlementPage() { export default function PartnerSettlementPage() {
return (
<Suspense fallback={<AppLoading />}>
<PartnerSettlementScreen />
</Suspense>
);
}
/** 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 t = useTranslations('partner');
const ta = useTranslations('admin'); const ta = useTranslations('admin');
const locale = useLocale();
const center = useMyPartnerCenter(); 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<Record<string, never>>({
parse: () => NO_FILTERS,
serialize: () => ({}),
empty: NO_FILTERS,
});
// Called unconditionally (rules of hooks); only rendered for a merchant-of-record center. // Called unconditionally (rules of hooks); only rendered for a merchant-of-record center.
const settlement = useMySettlement(page); const settlement = useMySettlement(page);
@@ -58,7 +124,30 @@ export default function PartnerSettlementPage() {
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<AdminPageHeader title={t('settlement_title')} /> <AdminPageHeader
title={t('settlement_title')}
actions={
<AppButton
variant="outlined"
color="primary"
startIcon="download"
disabled={settlement.isLoading || invoices.length === 0}
onClick={() =>
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')}
</AppButton>
}
/>
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}> <Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}> <Stack direction="row" sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}>
@@ -98,11 +187,11 @@ export default function PartnerSettlementPage() {
<AdminPager <AdminPager
page={page} page={page}
pageCount={pageCount} pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))} onPrev={() => goToPage(Math.max(1, page - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))} onNext={() => goToPage(Math.min(pageCount, page + 1))}
prevLabel={ta('prev_page')} prevLabel={ta('prev_page')}
nextLabel={ta('next_page')} nextLabel={ta('next_page')}
indicator={ta('page_indicator', { page })} indicator={ta('page_indicator', { page, total: pageCount })}
/> />
</Box> </Box>
); );
@@ -38,4 +38,34 @@ describe('<AdminDataTable/>', () => {
fireEvent.click(screen.getByText('Beta')); fireEvent.click(screen.getByText('Beta'));
expect(onRowClick).toHaveBeenCalledWith(ROWS[1]); expect(onRowClick).toHaveBeenCalledWith(ROWS[1]);
}); });
it('renders the footer line only when passed', () => {
const { rerender, container } = wrap(<AdminDataTable columns={COLUMNS} rows={ROWS} getRowKey={(r) => r.id} />);
expect(container.querySelector('p')).not.toBeInTheDocument();
rerender(
<ThemeProvider>
<AdminDataTable columns={COLUMNS} rows={ROWS} getRowKey={(r) => r.id} footer="Showing 12 of 2" />
</ThemeProvider>,
);
expect(screen.getByText('Showing 12 of 2')).toBeInTheDocument();
});
it('fires onSortChange with the column key when a sortable header is clicked', () => {
const onSortChange = jest.fn();
const sortableColumns: AdminTableColumn<Row>[] = [
{ key: 'id', header: 'ID', render: (r) => r.id, sortable: true },
{ key: 'name', header: 'Name', render: (r) => r.name },
];
wrap(
<AdminDataTable
columns={sortableColumns}
rows={ROWS}
getRowKey={(r) => r.id}
sort={{ key: 'id', direction: 'asc' }}
onSortChange={onSortChange}
/>,
);
fireEvent.click(screen.getByRole('button', { name: /ID/ }));
expect(onSortChange).toHaveBeenCalledWith('id');
});
}); });
+97 -26
View File
@@ -7,8 +7,12 @@ import {
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
TableSortLabel,
Typography,
} from '@mui/material'; } from '@mui/material';
export type AdminSortDirection = 'asc' | 'desc';
export interface AdminTableColumn<T> { export interface AdminTableColumn<T> {
/** Stable column key (also `data-col` for tests). */ /** Stable column key (also `data-col` for tests). */
key: string; key: string;
@@ -19,6 +23,15 @@ export interface AdminTableColumn<T> {
/** Optional cell alignment (defaults to `inherit`, which follows text direction — RTL-safe). */ /** Optional cell alignment (defaults to `inherit`, which follows text direction — RTL-safe). */
align?: 'inherit' | 'left' | 'center' | 'right'; align?: 'inherit' | 'left' | 'center' | 'right';
width?: number | string; 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<T> { export interface AdminDataTableProps<T> {
@@ -29,6 +42,18 @@ export interface AdminDataTableProps<T> {
dense?: boolean; dense?: boolean;
/** Accessible table name (already translated). */ /** Accessible table name (already translated). */
ariaLabel?: string; 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<T> {
* whole table scrolls horizontally inside its own container so a wide worklist never breaks the page layout * 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 * (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. * 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 * @component AdminDataTable
*/ */
function AdminDataTable<T>({ columns, rows, getRowKey, onRowClick, dense = true, ariaLabel }: AdminDataTableProps<T>) { function AdminDataTable<T>({
columns,
rows,
getRowKey,
onRowClick,
dense = true,
ariaLabel,
stickyHeader = false,
stickyMaxHeight = 640,
sort,
onSortChange,
footer,
}: AdminDataTableProps<T>) {
return ( return (
<TableContainer component={Paper} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflowX: 'auto' }}> <>
<Table size={dense ? 'small' : 'medium'} aria-label={ariaLabel} sx={{ minWidth: 640 }}> <TableContainer
<TableHead> component={Paper}
<TableRow sx={{ '& th': { fontWeight: 700, color: 'text.secondary', bgcolor: 'action.hover' } }}> elevation={0}
{columns.map((col) => ( sx={{
<TableCell key={col.key} align={col.align ?? 'inherit'} sx={{ width: col.width }}> border: '1px solid',
{col.header} borderColor: 'divider',
</TableCell> borderRadius: 2,
))} overflowX: 'auto',
</TableRow> ...(stickyHeader ? { maxHeight: stickyMaxHeight, overflowY: 'auto' } : {}),
</TableHead> }}
<TableBody> >
{rows.map((row) => ( <Table stickyHeader={stickyHeader} size={dense ? 'small' : 'medium'} aria-label={ariaLabel} sx={{ minWidth: 640 }}>
<TableRow <TableHead>
key={getRowKey(row)} <TableRow sx={{ '& th': { fontWeight: 700, color: 'text.secondary', bgcolor: 'action.hover' } }}>
hover={!!onRowClick}
onClick={onRowClick ? () => onRowClick(row) : undefined}
sx={{ cursor: onRowClick ? 'pointer' : 'default', '&:last-child td': { border: 0 } }}
>
{columns.map((col) => ( {columns.map((col) => (
<TableCell key={col.key} data-col={col.key} align={col.align ?? 'inherit'}> <TableCell
{col.render(row)} key={col.key}
align={col.align ?? 'inherit'}
sx={{ width: col.width, minWidth: col.minWidth }}
sortDirection={col.sortable && sort?.key === col.key ? sort.direction : false}
>
{col.sortable && onSortChange ? (
<TableSortLabel
active={sort?.key === col.key}
direction={sort?.key === col.key ? sort.direction : 'asc'}
onClick={() => onSortChange(col.key)}
>
{col.header}
</TableSortLabel>
) : (
col.header
)}
</TableCell> </TableCell>
))} ))}
</TableRow> </TableRow>
))} </TableHead>
</TableBody> <TableBody>
</Table> {rows.map((row) => (
</TableContainer> <TableRow
key={getRowKey(row)}
hover={!!onRowClick}
onClick={onRowClick ? () => onRowClick(row) : undefined}
sx={{ cursor: onRowClick ? 'pointer' : 'default', '&:last-child td': { border: 0 } }}
>
{columns.map((col) => (
<TableCell key={col.key} data-col={col.key} align={col.align ?? 'inherit'}>
{col.render(row)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{footer ? (
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary', mt: 1 }}>
{footer}
</Typography>
) : null}
</>
); );
} }
@@ -33,4 +33,28 @@ describe('<AuditLogRow/>', () => {
expect(screen.getByText('0.09')).toBeInTheDocument(); expect(screen.getByText('0.09')).toBeInTheDocument();
expect(screen.getByText('0.10')).toBeInTheDocument(); expect(screen.getByText('0.10')).toBeInTheDocument();
}); });
it('toggles aria-expanded on the row header', () => {
wrap(<AuditLogRow entry={ENTRY} />);
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(<AuditLogRow entry={ENTRY} />);
expect(screen.getByText('#3')).toBeInTheDocument();
rerender(
<ThemeProvider>
<AuditLogRow entry={ENTRY} actorLabel="سارا کریمی" />
</ThemeProvider>,
);
expect(screen.getByText('سارا کریمی')).toBeInTheDocument();
});
it('renders no button role for a row with no diff to expand', () => {
wrap(<AuditLogRow entry={{ ...ENTRY, changedFields: null }} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
}); });
+45 -9
View File
@@ -1,13 +1,15 @@
'use client'; 'use client';
import { FunctionComponent, useState } from 'react'; import { FunctionComponent, KeyboardEvent, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { Box, Chip, Collapse, Paper, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'; import { Box, Chip, Collapse, Paper, Stack, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
import { formatShamsiDateTime } from '@/utils'; import { formatShamsiDateTime } from '@/utils';
import type { AuditLogEntry } from '@/services/admin/types'; import type { AdminUserSummary, AuditLogEntry } from '@/services/admin/types';
import AppIcon from '../common/AppIcon'; import AppIcon from '../common/AppIcon';
export interface AuditLogRowProps { export interface AuditLogRowProps {
entry: AuditLogEntry; 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). */ /** Stringify a diff value (JSON for objects, `—` for null). */
@@ -17,17 +19,40 @@ function displayValue(v: unknown): string {
return String(v); 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<number, AdminUserSummary> | 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; * One row of the append-only audit viewer, with an expandable `changedFields` diff (old new per field;
* PII is server-redacted as `<redacted>`). Read-only by design there is **no** edit/delete affordance * PII is server-redacted as `<redacted>`). 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 * @component AuditLogRow
*/ */
const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry }) => { const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry, actorLabel }) => {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const fields = entry.changedFields ? Object.entries(entry.changedFields) : []; const fields = entry.changedFields ? Object.entries(entry.changedFields) : [];
const hasDetail = fields.length > 0;
const toggle = () => setOpen((v) => !v);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
};
return ( return (
<Paper <Paper
@@ -36,24 +61,35 @@ const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry }) => {
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}
> >
<Stack <Stack
role={hasDetail ? 'button' : undefined}
tabIndex={hasDetail ? 0 : undefined}
aria-expanded={hasDetail ? open : undefined}
direction="row" direction="row"
sx={{ gap: 2, alignItems: 'center', p: 1.5, cursor: fields.length ? 'pointer' : 'default', flexWrap: 'wrap' }} sx={{ gap: 2, alignItems: 'center', p: 1.5, cursor: hasDetail ? 'pointer' : 'default', flexWrap: 'wrap' }}
onClick={fields.length ? () => setOpen((v) => !v) : undefined} onClick={hasDetail ? toggle : undefined}
onKeyDown={hasDetail ? onKeyDown : undefined}
> >
<Chip size="small" variant="outlined" label={`${entry.entityType} #${entry.entityId}`} /> <Chip size="small" variant="outlined" label={`${entry.entityType} #${entry.entityId}`} />
<Typography variant="body2" sx={{ fontWeight: 700 }}> <Typography variant="body2" sx={{ fontWeight: 700 }}>
{entry.action} {entry.action}
</Typography> </Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', flexGrow: 1 }}> <Typography variant="body2" sx={{ color: 'text.secondary', flexGrow: 1 }}>
{entry.actorUserId != null ? `#${entry.actorUserId}` : '—'} {actorDisplay(entry.actorUserId, actorLabel)}
</Typography> </Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}> <Typography variant="body2" sx={{ color: 'text.secondary' }}>
{formatShamsiDateTime(entry.occurredAt, locale)} {formatShamsiDateTime(entry.occurredAt, locale)}
</Typography> </Typography>
{fields.length ? <AppIcon icon="expand" size={18} color="var(--bal-text-secondary)" /> : null} {hasDetail ? (
<AppIcon
icon="expand"
size={18}
color="var(--bal-text-secondary)"
style={{ transition: 'transform var(--bal-motion-fast) var(--bal-easing-standard)', transform: open ? 'rotate(180deg)' : 'none' }}
/>
) : null}
</Stack> </Stack>
<Collapse in={open && fields.length > 0}> <Collapse in={open && hasDetail}>
<Box sx={{ px: 1.5, pb: 1.5 }}> <Box sx={{ px: 1.5, pb: 1.5 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}> <Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
{t('audit_diff_title')} {t('audit_diff_title')}
@@ -41,4 +41,58 @@ describe('<ConfirmDialog/>', () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1);
}); });
it('renders the typed-confirmation field when requireTypedConfirmation is set', () => {
wrap(
<ConfirmDialog
open
onConfirm={jest.fn()}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
expect(screen.getByLabelText('Type to confirm')).toBeInTheDocument();
});
it('keeps confirm disabled until the typed value matches, case/whitespace-insensitively', () => {
const onConfirm = jest.fn();
wrap(
<ConfirmDialog
open
onConfirm={onConfirm}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
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(
<ConfirmDialog
open
onConfirm={jest.fn()}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
const confirmBtn = screen.getByRole('button', { name: 'Run' });
fireEvent.change(screen.getByLabelText('Type to confirm'), { target: { value: '150000' } });
expect(confirmBtn).not.toBeDisabled();
});
}); });
@@ -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('<NursePicker/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
beforeEach(() => {
useUserSearchMock.mockReset();
useUserSearchMock.mockReturnValue({ data: [ZAHRA], isFetching: false });
});
it('always searches with roleFilter="nurse"', async () => {
const user = userEvent.setup();
wrap(<NursePicker value={null} onChange={jest.fn()} label="پرستار" />);
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(<NursePicker value={null} onChange={onChange} label="پرستار" />);
await user.click(screen.getByLabelText('پرستار'));
await user.click(await screen.findByText('زهرا رضایی'));
expect(onChange).toHaveBeenCalledWith(ZAHRA);
});
});
@@ -0,0 +1,14 @@
import { FunctionComponent } from 'react';
import UserPicker, { type UserPickerProps } from '../UserPicker';
export type NursePickerProps = Omit<UserPickerProps, 'roleFilter'>;
/**
* 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<NursePickerProps> = (props) => <UserPicker {...props} roleFilter="nurse" />;
export default NursePicker;
@@ -0,0 +1,2 @@
export { default } from './NursePicker';
export type { NursePickerProps } from './NursePicker';
@@ -13,6 +13,11 @@ export interface SupportAlertCardProps {
canAct?: boolean; canAct?: boolean;
onAssignSelf?: (alert: SupportAlert) => void; onAssignSelf?: (alert: SupportAlert) => void;
onResolve?: (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. */ /** Alert status → semantic chip kind. */
@@ -37,7 +42,14 @@ const SEVERITY_ACCENT: Record<SupportAlertSeverity, string> = {
* namespace keyed off the stable code. * namespace keyed off the stable code.
* @component SupportAlertCard * @component SupportAlertCard
*/ */
const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({ alert, canAct = false, onAssignSelf, onResolve }) => { const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({
alert,
canAct = false,
onAssignSelf,
onResolve,
assignSelfDisabled = false,
assignSelfDisabledTitle,
}) => {
const t = useTranslations('admin'); const t = useTranslations('admin');
const locale = useLocale(); const locale = useLocale();
@@ -85,7 +97,14 @@ const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({ alert, can
{canAct && alert.status !== 'resolved' ? ( {canAct && alert.status !== 'resolved' ? (
<Stack direction="row" sx={{ gap: 1, mt: 1.5, flexWrap: 'wrap' }}> <Stack direction="row" sx={{ gap: 1, mt: 1.5, flexWrap: 'wrap' }}>
{alert.status === 'open' ? ( {alert.status === 'open' ? (
<AppButton variant="outlined" color="primary" startIcon="assign" onClick={() => onAssignSelf?.(alert)}> <AppButton
variant="outlined"
color="primary"
startIcon="assign"
onClick={() => onAssignSelf?.(alert)}
disabled={assignSelfDisabled}
title={assignSelfDisabled ? assignSelfDisabledTitle : undefined}
>
{t('alert_assign_me')} {t('alert_assign_me')}
</AppButton> </AppButton>
) : null} ) : null}
@@ -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('<UserPicker/>', () => {
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
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(<UserPicker value={null} onChange={jest.fn()} label="کاربر" />);
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(<UserPicker value={null} onChange={onChange} label="کاربر" />);
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(<UserPicker value={null} onChange={jest.fn()} label="پرستار" roleFilter="nurse" />);
await user.type(screen.getByLabelText('پرستار'), 'زهرا');
await waitFor(() => expect(useUserSearchMock).toHaveBeenLastCalledWith('زهرا', 'nurse'));
});
});
@@ -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<UserPickerProps> = ({
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 (
<Autocomplete
value={value}
onChange={(_event, next) => 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 (
<Box component="li" key={key} {...rest}>
<Stack sx={{ gap: 0.125, py: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{option.displayName}
</Typography>
<Typography component="span" variant="caption" dir="ltr" sx={{ color: 'text.secondary' }}>
{option.maskedPhone} · #{option.id}
</Typography>
</Stack>
</Box>
);
}}
renderInput={({ slotProps: autocompleteSlotProps, ...rest }) => (
<TextField
{...rest}
label={label}
placeholder={placeholder}
helperText={helperText}
error={error}
slotProps={{
...autocompleteSlotProps,
input: {
...autocompleteSlotProps.input,
endAdornment: (
<>
{search.isFetching ? <CircularProgress color="inherit" size={16} /> : null}
{autocompleteSlotProps.input.endAdornment}
</>
),
},
}}
/>
)}
/>
);
};
export default UserPicker;
@@ -0,0 +1,2 @@
export { default } from './UserPicker';
export type { UserPickerProps } from './UserPicker';
+4
View File
@@ -30,3 +30,7 @@ export { default as RefundPanel } from './RefundPanel';
export type { RefundPanelProps } from './RefundPanel'; export type { RefundPanelProps } from './RefundPanel';
export { default as AdminMessageBubble } from './AdminMessageBubble'; export { default as AdminMessageBubble } from './AdminMessageBubble';
export type { AdminMessageBubbleProps } 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';
@@ -41,4 +41,58 @@ describe('<ConfirmDialog/>', () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onClose).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1);
}); });
it('renders the typed-confirmation field when requireTypedConfirmation is set', () => {
wrap(
<ConfirmDialog
open
onConfirm={jest.fn()}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
expect(screen.getByLabelText('Type to confirm')).toBeInTheDocument();
});
it('keeps confirm disabled until the typed value matches, case/whitespace-insensitively', () => {
const onConfirm = jest.fn();
wrap(
<ConfirmDialog
open
onConfirm={onConfirm}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
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(
<ConfirmDialog
open
onConfirm={jest.fn()}
{...base}
requireTypedConfirmation={['CONFIRM', '150000']}
typedConfirmationLabel="Type to confirm"
/>,
);
const confirmBtn = screen.getByRole('button', { name: 'Run' });
fireEvent.change(screen.getByLabelText('Type to confirm'), { target: { value: '150000' } });
expect(confirmBtn).not.toBeDisabled();
});
}); });
@@ -27,6 +27,15 @@ export interface ConfirmDialogProps {
requireReason?: boolean; requireReason?: boolean;
reasonLabel?: string; reasonLabel?: string;
reasonPlaceholder?: 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. */ /** MUI color for the confirm button — `error` for a destructive action. */
confirmColor?: 'primary' | 'error' | 'secondary'; confirmColor?: 'primary' | 'error' | 'secondary';
} }
@@ -52,21 +61,31 @@ const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
requireReason = false, requireReason = false,
reasonLabel, reasonLabel,
reasonPlaceholder, reasonPlaceholder,
requireTypedConfirmation,
typedConfirmationLabel,
typedConfirmationPlaceholder,
confirmColor = 'primary', confirmColor = 'primary',
}) => { }) => {
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
const [typedValue, setTypedValue] = useState('');
const close = () => { const close = () => {
setReason(''); setReason('');
setTypedValue('');
onClose(); onClose();
}; };
const confirm = () => { const confirm = () => {
onConfirm(requireReason ? reason.trim() : undefined); onConfirm(requireReason ? reason.trim() : undefined);
setReason(''); 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 ( return (
<Dialog open={open} onClose={loading ? undefined : close} fullWidth maxWidth="xs"> <Dialog open={open} onClose={loading ? undefined : close} fullWidth maxWidth="xs">
@@ -92,6 +111,17 @@ const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
sx={{ mt: 2 }} sx={{ mt: 2 }}
/> />
) : null} ) : null}
{requireTypedConfirmation ? (
<TextField
autoFocus
fullWidth
value={typedValue}
onChange={(e) => setTypedValue(e.target.value)}
label={typedConfirmationLabel}
placeholder={typedConfirmationPlaceholder}
sx={{ mt: 2 }}
/>
) : null}
</DialogContent> </DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}> <DialogActions sx={{ px: 3, pb: 2 }}>
<AppButton variant="text" color="inherit" onClick={close} disabled={loading}> <AppButton variant="text" color="inherit" onClick={close} disabled={loading}>
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'; import { fireEvent, render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../../theme'; import { ThemeProvider } from '../../../theme';
import PageHeader from './PageHeader'; import PageHeader from './PageHeader';
@@ -16,6 +16,11 @@ describe('<PageHeader/>', () => {
expect(screen.getByRole('button', { name: 'Do' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Do' })).toBeInTheDocument();
}); });
it('renders the meta slot below the title when provided', () => {
wrap(<PageHeader title="T" meta={<span>Open</span>} />);
expect(screen.getByText('Open')).toBeInTheDocument();
});
it('omits the back button when backTo is not given', () => { it('omits the back button when backTo is not given', () => {
wrap(<PageHeader title="T" />); wrap(<PageHeader title="T" />);
expect(screen.queryByRole('link')).not.toBeInTheDocument(); expect(screen.queryByRole('link')).not.toBeInTheDocument();
@@ -25,4 +30,12 @@ describe('<PageHeader/>', () => {
wrap(<PageHeader title="T" backTo="/patients" backLabel="Back" />); wrap(<PageHeader title="T" backTo="/patients" backLabel="Back" />);
expect(screen.getByRole('link')).toHaveAttribute('href', '/patients'); expect(screen.getByRole('link')).toHaveAttribute('href', '/patients');
}); });
it('renders a back button that calls onBack, not a link, when onBack is given', () => {
const onBack = jest.fn();
wrap(<PageHeader title="T" onBack={onBack} backLabel="Back" />);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
expect(onBack).toHaveBeenCalledTimes(1);
});
}); });
@@ -11,24 +11,53 @@ export interface PageHeaderProps {
subtitle?: string; subtitle?: string;
/** Optional action node (a button / filter) rendered end-aligned on desktop, wrapping on mobile. */ /** Optional action node (a button / filter) rendered end-aligned on desktop, wrapping on mobile. */
actions?: ReactNode; actions?: ReactNode;
/**
* Optional node rendered below the title/subtitle a chip row (status/category/linked-record chips)
* or any other short meta line that isn't a button (ui-phase-11: the admin ticket thread's category/
* status/linked-booking/linked-refund chips). Kept distinct from `actions`, which is end-aligned and
* reserved for buttons.
*/
meta?: ReactNode;
/** Optional back-navigation target — renders an RTL-flippable chevron before the title. */ /** Optional back-navigation target — renders an RTL-flippable chevron before the title. */
backTo?: string; backTo?: string;
/** Already-translated accessible label for the back button (required when `backTo` is set). */ /** Already-translated accessible label for the back button (required when `backTo` or `onBack` is set). */
backLabel?: string; backLabel?: string;
/**
* Optional back-navigation handler an alternative to `backTo` for a caller that needs `router.back()`
* semantics (e.g. `useAdminBackToList`) rather than a fixed link target. Takes precedence over `backTo`
* when both are given (they shouldn't be).
*/
onBack?: () => void;
} }
/** /**
* The standard page header title + optional subtitle, an end-aligned actions slot, and an optional * The standard page header title + optional subtitle + optional meta row, an end-aligned actions slot,
* back affordance. The generalized, promoted form of `AdminPageHeader` (kept as a thin alias); every area * and an optional back affordance. The generalized, promoted form of `AdminPageHeader` (kept as a thin
* page hand-rolling its own `h5`/`h1` + subtitle block should adopt this instead. Presentational, * alias); every area page hand-rolling its own `h5`/`h1` + subtitle block should adopt this instead.
* caller-owned i18n, RTL-safe (logical flex, the `back` icon mirrors automatically under `dir="rtl"`). * Presentational, caller-owned i18n, RTL-safe (logical flex, the `back` icon mirrors automatically under
* `dir="rtl"`).
* @component PageHeader * @component PageHeader
*/ */
const PageHeader: FunctionComponent<PageHeaderProps> = ({ title, subtitle, actions, backTo, backLabel }) => ( const PageHeader: FunctionComponent<PageHeaderProps> = ({
title,
subtitle,
actions,
meta,
backTo,
backLabel,
onBack,
}) => (
<Stack direction="row" sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}> <Stack direction="row" sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}> <Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
{backTo ? ( {backTo || onBack ? (
<AppIconButton icon="back" to={backTo} title={backLabel} sx={{ mt: 0.25 }} iconProps={{ size: 20 }} /> <AppIconButton
icon="back"
to={onBack ? undefined : backTo}
onClick={onBack}
title={backLabel}
sx={{ mt: 0.25 }}
iconProps={{ size: 20 }}
/>
) : null} ) : null}
<Box> <Box>
<Typography variant="h5" component="h1" sx={{ fontWeight: 700 }}> <Typography variant="h5" component="h1" sx={{ fontWeight: 700 }}>
@@ -39,6 +68,7 @@ const PageHeader: FunctionComponent<PageHeaderProps> = ({ title, subtitle, actio
{subtitle} {subtitle}
</Typography> </Typography>
) : null} ) : null}
{meta ? <Box sx={{ mt: 1 }}>{meta}</Box> : null}
</Box> </Box>
</Stack> </Stack>
{actions ? <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>{actions}</Box> : null} {actions ? <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>{actions}</Box> : null}
+4
View File
@@ -121,6 +121,10 @@ export const adminPayoutBatchPath = (batchId: number | string): string =>
export const adminPartnerCenterPath = (centerId: number | string): string => export const adminPartnerCenterPath = (centerId: number | string): string =>
`${ROUTES.ADMIN_PARTNERS}/${centerId}`; `${ROUTES.ADMIN_PARTNERS}/${centerId}`;
/** The partner-portal sponsored-booking detail (f15) — a bookings-list row deep-links here (read-only). */
export const partnerBookingDetailPath = (bookingId: number | string): string =>
`${ROUTES.PARTNER_BOOKINGS}/${bookingId}`;
/** The customer booking-detail view (f8) — the E2 care-record history's «مشاهده رزرو» link lands here. */ /** The customer booking-detail view (f8) — the E2 care-record history's «مشاهده رزرو» link lands here. */
export const bookingDetailPath = (bookingId: number | string): string => `${ROUTES.BOOKINGS}/${bookingId}`; export const bookingDetailPath = (bookingId: number | string): string => `${ROUTES.BOOKINGS}/${bookingId}`;
+1
View File
@@ -2,3 +2,4 @@ export * from './auth';
export * from './capabilities'; export * from './capabilities';
export * from './event'; export * from './event';
export * from './layout'; export * from './layout';
export * from './useAdminListState';
+115
View File
@@ -0,0 +1,115 @@
import { renderHook, act } from '@testing-library/react';
const mockReplace = jest.fn();
const mockBack = jest.fn();
const mockPush = jest.fn();
let mockSearchParams = new URLSearchParams();
jest.mock('next/navigation', () => ({
useRouter: () => ({ replace: mockReplace, back: mockBack, push: mockPush }),
usePathname: () => '/fa/admin/tickets',
useSearchParams: () => mockSearchParams,
}));
import { useAdminListState, useAdminBackToList } from './useAdminListState';
interface Filters {
status?: string;
}
const CONFIG = {
parse: (params: URLSearchParams): Filters => ({ status: params.get('status') ?? undefined }),
serialize: (filters: Filters): Record<string, string> => (filters.status ? { status: filters.status } : {}),
empty: {} as Filters,
};
describe('useAdminListState', () => {
beforeEach(() => {
mockReplace.mockReset();
mockBack.mockReset();
mockPush.mockReset();
mockSearchParams = new URLSearchParams();
});
it('reads the initial applied filters + page from the URL', () => {
mockSearchParams = new URLSearchParams('status=open&page=3');
const { result } = renderHook(() => useAdminListState(CONFIG));
expect(result.current.applied).toEqual({ status: 'open' });
expect(result.current.draft).toEqual({ status: 'open' });
expect(result.current.page).toBe(3);
});
it('defaults to page 1 with empty filters when the URL carries none', () => {
const { result } = renderHook(() => useAdminListState(CONFIG));
expect(result.current.applied).toEqual({ status: undefined });
expect(result.current.page).toBe(1);
});
it('editing draft never touches applied or the URL', () => {
const { result } = renderHook(() => useAdminListState(CONFIG));
act(() => result.current.setDraft({ status: 'closed' }));
expect(result.current.draft).toEqual({ status: 'closed' });
expect(result.current.applied).toEqual({ status: undefined });
expect(mockReplace).not.toHaveBeenCalled();
});
it('apply commits draft into applied, resets to page 1, and writes the URL', () => {
const { result } = renderHook(() => useAdminListState(CONFIG));
act(() => result.current.setDraft({ status: 'closed' }));
act(() => result.current.apply());
expect(result.current.applied).toEqual({ status: 'closed' });
expect(result.current.page).toBe(1);
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets?status=closed', { scroll: false });
});
it('clear resets draft + applied and writes a bare URL', () => {
mockSearchParams = new URLSearchParams('status=open');
const { result } = renderHook(() => useAdminListState(CONFIG));
act(() => result.current.clear());
expect(result.current.draft).toEqual({});
expect(result.current.applied).toEqual({});
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets', { scroll: false });
});
it('applyFilters commits an explicit value atomically (never a stale draft)', () => {
const { result } = renderHook(() => useAdminListState(CONFIG));
// No setDraft call first — applyFilters must not depend on draft having been set beforehand.
act(() => result.current.applyFilters({ status: 'closed' }));
expect(result.current.draft).toEqual({ status: 'closed' });
expect(result.current.applied).toEqual({ status: 'closed' });
expect(result.current.page).toBe(1);
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets?status=closed', { scroll: false });
});
it('goToPage keeps applied filters and appends page to the URL', () => {
mockSearchParams = new URLSearchParams('status=open');
const { result } = renderHook(() => useAdminListState(CONFIG));
act(() => result.current.goToPage(2));
expect(result.current.page).toBe(2);
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets?status=open&page=2', { scroll: false });
});
});
describe('useAdminBackToList', () => {
beforeEach(() => {
mockReplace.mockReset();
mockBack.mockReset();
mockPush.mockReset();
});
it('calls router.back() when there is browser history', () => {
Object.defineProperty(window, 'history', { value: { length: 3 }, configurable: true });
const { result } = renderHook(() => useAdminBackToList('/fa/admin/tickets'));
act(() => result.current());
expect(mockBack).toHaveBeenCalledTimes(1);
expect(mockPush).not.toHaveBeenCalled();
});
it('falls back to pushing the list href when there is no history', () => {
Object.defineProperty(window, 'history', { value: { length: 1 }, configurable: true });
const { result } = renderHook(() => useAdminBackToList('/fa/admin/tickets'));
act(() => result.current());
expect(mockPush).toHaveBeenCalledWith('/fa/admin/tickets');
expect(mockBack).not.toHaveBeenCalled();
});
});
+135
View File
@@ -0,0 +1,135 @@
'use client';
import { useCallback, useState, type Dispatch, type SetStateAction } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
/** The URL query-param name every admin/partner worklist uses for its page number. */
const PAGE_PARAM = 'page';
export interface AdminListStateConfig<F> {
/** Rebuild a filters object from the URL's current search params (only defined keys are read). */
parse: (params: URLSearchParams) => F;
/** Serialize a filters object to a plain string record, omitting empty/absent filters. */
serialize: (filters: F) => Record<string, string>;
/** The "clear"/default filters value. */
empty: F;
}
export interface AdminListState<F> {
/** Local, uncommitted filter edits — typing here never refetches or touches the URL. */
draft: F;
setDraft: Dispatch<SetStateAction<F>>;
/** The filters actually driving the query (and the URL) — set only by `apply`/`clear`. */
applied: F;
page: number;
/** Commits `draft` into `applied`, resets to page 1, and writes both into the URL. */
apply: () => void;
/**
* Commits an EXPLICIT filters value (not `draft`) into `applied` and the URL, resetting to page 1 for a
* discrete control (a status tab/select) that should commit the instant it changes. Prefer this over
* `setDraft(next); apply()` in the same handler: `apply()` closes over the `draft` from the render it was
* created in, so calling it synchronously right after `setDraft` would commit the OLD value, not `next`.
* Also updates `draft` to the same value, so the two states never drift apart.
*/
applyFilters: (filters: F) => void;
/** Resets both draft and applied to `empty` and writes that (page 1) into the URL. */
clear: () => void;
/** Moves to a page, keeping `applied` filters, and writes it into the URL. */
goToPage: (page: number) => void;
}
/**
* URL-synced worklist state for the admin/partner consoles mirrors **applied** filters + the current
* page into `searchParams` via `router.replace` (never a full navigation, `scroll: false`), so browser
* back/refresh/a pasted link all reproduce the exact same queue view. Draft filter state stays local per
* the established draft-vs-applied pattern (tickets/audit): typing in `draft` never refetches and never
* touches the URL only `apply`/`clear`/`goToPage` do, mirroring `services/search/filterParams.ts`'s
* "the filter object is the query key" model onto every other worklist.
*
* The initial `applied`/`page` are read from the URL **once, on mount** after that the URL only ever
* follows local state, so a user typing in `draft` can never have their input clobbered by a stale
* `searchParams` re-read. Callers using this hook must be rendered inside a `<Suspense>` boundary
* (`useSearchParams` requirement) wrap the page body the way `SearchScreen`/`search/SearchScreen.tsx`
* does.
*/
export function useAdminListState<F>({
parse,
serialize,
empty,
}: AdminListStateConfig<F>): AdminListState<F> {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [initial] = useState<{ filters: F; page: number }>(() => {
const rawPage = Number(searchParams.get(PAGE_PARAM));
return {
filters: parse(searchParams),
page: Number.isInteger(rawPage) && rawPage > 0 ? rawPage : 1,
};
});
const [draft, setDraft] = useState<F>(initial.filters);
const [applied, setApplied] = useState<F>(initial.filters);
const [page, setPage] = useState<number>(initial.page);
const writeUrl = useCallback(
(filters: F, nextPage: number) => {
const params = new URLSearchParams(serialize(filters));
if (nextPage > 1) params.set(PAGE_PARAM, String(nextPage));
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
},
[pathname, router, serialize],
);
const apply = useCallback(() => {
setApplied(draft);
setPage(1);
writeUrl(draft, 1);
}, [draft, writeUrl]);
const applyFilters = useCallback(
(filters: F) => {
setDraft(filters);
setApplied(filters);
setPage(1);
writeUrl(filters, 1);
},
[writeUrl],
);
const clear = useCallback(() => {
setDraft(empty);
setApplied(empty);
setPage(1);
writeUrl(empty, 1);
}, [empty, writeUrl]);
const goToPage = useCallback(
(next: number) => {
setPage(next);
writeUrl(applied, next);
},
[applied, writeUrl],
);
return { draft, setDraft, applied, page, apply, applyFilters, clear, goToPage };
}
/**
* A detail page's "back" affordance: a real `router.back()` when there is browser history to return to
* (the common case a queue row was clicked to get here, so back restores its filter/page/scroll state),
* falling back to pushing `listHref` when there isn't (a pasted/bookmarked detail link). `listHref` must
* already be locale-prefixed (`next/navigation`'s router does not add it) pass
* `` `/${locale}${ROUTES.ADMIN_TICKETS}` ``.
*/
export function useAdminBackToList(listHref: string): () => void {
const router = useRouter();
return useCallback(() => {
if (typeof window !== 'undefined' && window.history.length > 1) {
router.back();
} else {
router.push(listHref);
}
}, [router, listHref]);
}
+18 -3
View File
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { FunctionComponent, PropsWithChildren, useMemo } from 'react'; import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { ProfileSummary } from '@/components'; import { Stack } from '@mui/material';
import { ProfileSummary, StatusChip } from '@/components';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { LinkToPage } from '@/utils'; import { LinkToPage } from '@/utils';
import { useMyPartnerCenter } from '@/services/partnerCenter'; import { useMyPartnerCenter } from '@/services/partnerCenter';
@@ -11,11 +12,14 @@ import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
* Partner-center portal shell (f15) a **separate authz scope** from the Balinyaar admin console. A * Partner-center portal shell (f15) a **separate authz scope** from the Balinyaar admin console. A
* center admin is not a Balinyaar admin; they see only their own center's data (server-enforced * center admin is not a Balinyaar admin; they see only their own center's data (server-enforced
* tenancy). Same engine as the admin shell; the TopBar identity slot shows the center's own name * tenancy). Same engine as the admin shell; the TopBar identity slot shows the center's own name
* (skeleton while resolving) the page-level access-denied state (403/404) stays where it is. * (skeleton while resolving) **plus a compact merchant-of-record indicator** (ui-phase-11 previously
* only the home page's own header showed this, so it disappeared once a center admin navigated away)
* the page-level access-denied state (403/404) stays where it is.
* @layout PartnerLayout * @layout PartnerLayout
*/ */
const PartnerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => { const PartnerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const t = useTranslations('nav'); const t = useTranslations('nav');
const tPartner = useTranslations('partner');
const { data: center, isLoading } = useMyPartnerCenter(); const { data: center, isLoading } = useMyPartnerCenter();
const sidebarItems: Array<LinkToPage> = useMemo( const sidebarItems: Array<LinkToPage> = useMemo(
@@ -31,7 +35,18 @@ const PartnerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
return ( return (
<TopBarAndSideBarLayout <TopBarAndSideBarLayout
sidebarItems={sidebarItems} sidebarItems={sidebarItems}
identity={<ProfileSummary compact displayName={center?.name ?? ''} loading={isLoading} />} identity={
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', minWidth: 0 }}>
<ProfileSummary compact displayName={center?.name ?? ''} loading={isLoading} />
{!isLoading && center ? (
<StatusChip
status={center.isMerchantOfRecord ? 'active' : 'neutral'}
label={center.isMerchantOfRecord ? tPartner('is_mor_yes') : tPartner('is_mor_no')}
sx={{ height: 20, fontSize: '0.6875rem', display: { xs: 'none', sm: 'inline-flex' } }}
/>
) : null}
</Stack>
}
> >
{children} {children}
</TopBarAndSideBarLayout> </TopBarAndSideBarLayout>
@@ -4,9 +4,11 @@ import { ADMIN_PAGE_SIZE } from '../constants';
import type { import type {
AdminApi, AdminApi,
AdminRole, AdminRole,
AdminUserSummary,
AuditFilters, AuditFilters,
AuditLogEntry, AuditLogEntry,
ConfigChange, ConfigChange,
DirectoryUserRole,
Holiday, Holiday,
HolidayFilters, HolidayFilters,
HolidayInput, HolidayInput,
@@ -175,4 +177,18 @@ export const adminClientApi: AdminApi = {
body: JSON.stringify({ userId, role }), body: JSON.stringify({ userId, role }),
}); });
}, },
// User directory (REQ-061 — routes proposed; not live). Kept real-shaped so the swap is one line.
searchUsers: async (query: string, roleFilter?: DirectoryUserRole) => {
const q = new URLSearchParams({ q: query });
if (roleFilter) q.set('role', roleFilter);
return unwrap(await clientFetch<ApiEnvelope<AdminUserSummary[]>>(`${API}/admin_users/search?${q.toString()}`));
},
lookupUsers: async (userIds: number[]) =>
unwrap(
await clientFetch<ApiEnvelope<AdminUserSummary[]>>(`${API}/admin_users/lookup`, {
method: 'POST',
body: JSON.stringify({ userIds }),
}),
),
}; };
+43
View File
@@ -2,9 +2,11 @@ import type { PageParams, Paginated } from '@/lib/api/types';
import type { import type {
AdminApi, AdminApi,
AdminRole, AdminRole,
AdminUserSummary,
AuditFilters, AuditFilters,
AuditLogEntry, AuditLogEntry,
ConfigChange, ConfigChange,
DirectoryUserRole,
Holiday, Holiday,
HolidayFilters, HolidayFilters,
HolidayInput, HolidayInput,
@@ -103,6 +105,34 @@ const ROLES: RoleGrant[] = [
{ userId: 6, role: 'moderation', grantedBy: 2, grantedAt: isoDaysAgo(60), revokedAt: null }, { userId: 6, role: 'moderation', grantedBy: 2, grantedAt: isoDaysAgo(60), revokedAt: null },
]; ];
// ── User directory (REQ-061 gap) — backs UserPicker/NursePicker + AuditLogRow's actor-name resolve.
// `phone` is the mock's own full-number field, kept out of `AdminUserSummary` — only the masked form
// ever leaves `toDirectorySummary` (the same write-then-masked PII discipline as everywhere else).
interface DirectoryEntry extends AdminUserSummary {
phone: string;
}
const DIRECTORY: DirectoryEntry[] = [
{ id: 1, displayName: 'مدیر ارشد بالین‌یار', phone: '09120000001', maskedPhone: '0912•••0001', roles: ['admin'] },
{ id: 2, displayName: 'مریم احمدی', phone: '09120000002', maskedPhone: '0912•••0002', roles: ['admin'] },
{ id: 3, displayName: 'سارا کریمی', phone: '09120000003', maskedPhone: '0912•••0003', roles: ['admin'] },
{ id: 4, displayName: 'رضا نوری', phone: '09120000004', maskedPhone: '0912•••0004', roles: ['admin'] },
{ id: 5, displayName: 'نگار صادقی', phone: '09120000005', maskedPhone: '0912•••0005', roles: ['admin'] },
{ id: 6, displayName: 'امیر حسینی', phone: '09120000006', maskedPhone: '0912•••0006', roles: ['admin'] },
{ id: 101, displayName: 'زهرا رضایی', phone: '09121110001', maskedPhone: '0912•••0001', roles: ['nurse'], nurseProfileId: 15 },
{ id: 102, displayName: 'فاطمه محمدی', phone: '09121110002', maskedPhone: '0912•••0002', roles: ['nurse'], nurseProfileId: 16 },
{ id: 103, displayName: 'لیلا صادقی', phone: '09121110003', maskedPhone: '0912•••0003', roles: ['nurse'], nurseProfileId: 17 },
{ id: 104, displayName: 'مینا رستمی', phone: '09121110004', maskedPhone: '0912•••0004', roles: ['nurse'], nurseProfileId: 18 },
{ id: 201, displayName: 'حسین یزدانی', phone: '09122220001', maskedPhone: '0912•••0001', roles: ['customer'] },
{ id: 202, displayName: 'مینا اکبری', phone: '09122220002', maskedPhone: '0912•••0002', roles: ['customer'] },
{ id: 301, displayName: 'شرکت پرستاری آرامش', phone: '09123330001', maskedPhone: '0912•••0001', roles: ['partner'] },
];
function toDirectorySummary(e: DirectoryEntry): AdminUserSummary {
const { phone: _phone, ...summary } = e;
return summary;
}
export const adminMockApi: AdminApi = { export const adminMockApi: AdminApi = {
listConfigs: async (params) => delay(paginate([...CONFIGS], params)), listConfigs: async (params) => delay(paginate([...CONFIGS], params)),
@@ -193,4 +223,17 @@ export const adminMockApi: AdminApi = {
if (r) r.revokedAt = new Date().toISOString(); if (r) r.revokedAt = new Date().toISOString();
return delay(undefined); return delay(undefined);
}, },
searchUsers: async (query: string, roleFilter?: DirectoryUserRole) => {
let items = DIRECTORY;
if (roleFilter) items = items.filter((e) => e.roles.includes(roleFilter));
const q = query.trim().toLowerCase();
if (q.length > 0) items = items.filter((e) => e.displayName.toLowerCase().includes(q) || e.phone.includes(q));
return delay(items.slice(0, 10).map(toDirectorySummary));
},
lookupUsers: async (userIds: number[]) => {
const ids = new Set(userIds);
return delay(DIRECTORY.filter((e) => ids.has(e.id)).map(toDirectorySummary));
},
}; };
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
import { ADMIN_GC_TIME } from '../constants';
import type { AdminUserSummary } from '../types';
/**
* Batch idlabel resolve (REQ-061, gap) one request for every actor/owner id a page renders (`AuditLogRow`,
* role grants, ), never one request per row. Callers should memoize `userIds` (a stable array reference)
* so the query key doesn't churn every render; pass a deduped list.
*/
export function useUserLookup(userIds: number[]) {
return useQuery({
queryKey: adminKeys.userLookup(userIds),
queryFn: () => adminApi.lookupUsers(userIds),
enabled: userIds.length > 0,
staleTime: 5 * 60 * 1000,
gcTime: ADMIN_GC_TIME,
select: (data): Map<number, AdminUserSummary> => new Map(data.map((u) => [u.id, u])),
});
}
@@ -0,0 +1,24 @@
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../apis';
import { adminKeys } from '../keys';
import { ADMIN_GC_TIME } from '../constants';
import type { DirectoryUserRole } from '../types';
/** Minimum query length before searching — avoids a fan-out request per keystroke on 01 chars. */
const MIN_QUERY_LENGTH = 2;
/**
* Name/phone search over the admin user directory (REQ-061, gap) backs `UserPicker`/`NursePicker`.
* The caller debounces `query`; this hook only gates on length so an empty/near-empty query renders no
* options instead of the whole directory.
*/
export function useUserSearch(query: string, roleFilter?: DirectoryUserRole) {
const trimmed = query.trim();
return useQuery({
queryKey: adminKeys.userSearch(trimmed, roleFilter),
queryFn: () => adminApi.searchUsers(trimmed, roleFilter),
enabled: trimmed.length >= MIN_QUERY_LENGTH,
staleTime: 30 * 1000,
gcTime: ADMIN_GC_TIME,
});
}
+2
View File
@@ -15,3 +15,5 @@ export { useResolveSupportAlert } from './hooks/useResolveSupportAlert';
export { useAdminRoles } from './hooks/useAdminRoles'; export { useAdminRoles } from './hooks/useAdminRoles';
export { useGrantRole } from './hooks/useGrantRole'; export { useGrantRole } from './hooks/useGrantRole';
export { useRevokeRole } from './hooks/useRevokeRole'; export { useRevokeRole } from './hooks/useRevokeRole';
export { useUserSearch } from './hooks/useUserSearch';
export { useUserLookup } from './hooks/useUserLookup';
+6 -1
View File
@@ -1,5 +1,5 @@
import type { PageParams } from '@/lib/api/types'; import type { PageParams } from '@/lib/api/types';
import type { AuditFilters, HolidayFilters, SupportAlertFilters } from './types'; import type { AuditFilters, DirectoryUserRole, HolidayFilters, SupportAlertFilters } from './types';
/** /**
* React Query key factory for the admin domain (hierarchical, per the `services/{domain}` pattern). The * React Query key factory for the admin domain (hierarchical, per the `services/{domain}` pattern). The
@@ -27,4 +27,9 @@ export const adminKeys = {
roles: () => [...adminKeys.all, 'roles'] as const, roles: () => [...adminKeys.all, 'roles'] as const,
roleList: (userId?: number) => [...adminKeys.roles(), 'list', userId ?? null] as const, roleList: (userId?: number) => [...adminKeys.roles(), 'list', userId ?? null] as const,
users: () => [...adminKeys.all, 'users'] as const,
userSearch: (query: string, roleFilter?: DirectoryUserRole) =>
[...adminKeys.users(), 'search', query, roleFilter ?? null] as const,
userLookup: (userIds: number[]) => [...adminKeys.users(), 'lookup', [...userIds].sort((a, b) => a - b)] as const,
}; };
+25
View File
@@ -143,6 +143,26 @@ export interface SupportAlertFilters {
ownerUserId?: number; ownerUserId?: number;
} }
// ── User directory (admin lookup; gap — REQ-061) ─────────────────────────────────────────────────────
/** The coarse app roles a directory entry may hold — enough to label a picker option. */
export type DirectoryUserRole = 'customer' | 'nurse' | 'admin' | 'partner';
/**
* `AdminUserSummaryDto` (REQ-061, gap no user-search endpoint exists yet). Backs `UserPicker`/
* `NursePicker`: search-by-name/phone + a batch idlabel lookup, so every audited action can target a
* resolved **person** instead of a hand-typed numeric id. `maskedPhone` follows the same PII discipline
* as everywhere else (never the full number). `nurseProfileId` is set only when `roles` includes `nurse`
* the id `NursePicker` actually needs for sponsorship/roster assignment (a different id space than
* `id`, the user id).
*/
export interface AdminUserSummary {
id: number;
displayName: string;
maskedPhone: string;
roles: DirectoryUserRole[];
nurseProfileId?: number | null;
}
// ── RBAC (b15 — role endpoints not yet in the contract; mock-primary, REQ-031) ───────────────────────── // ── RBAC (b15 — role endpoints not yet in the contract; mock-primary, REQ-031) ─────────────────────────
/** The fine-grained admin roles the RBAC grid grants/revokes (aligned with the b2 `AdminRole` enum). */ /** The fine-grained admin roles the RBAC grid grants/revokes (aligned with the b2 `AdminRole` enum). */
export type AdminRole = 'super_admin' | 'admin' | 'support' | 'finance' | 'moderation'; export type AdminRole = 'super_admin' | 'admin' | 'support' | 'finance' | 'moderation';
@@ -180,4 +200,9 @@ export interface AdminApi {
listRoles(userId?: number): Promise<RoleGrant[]>; listRoles(userId?: number): Promise<RoleGrant[]>;
grantRole(userId: number, role: AdminRole): Promise<void>; grantRole(userId: number, role: AdminRole): Promise<void>;
revokeRole(userId: number, role: AdminRole): Promise<void>; revokeRole(userId: number, role: AdminRole): Promise<void>;
// user directory (deferred-if-missing — REQ-061)
/** Search by name/phone (min 2 chars); `roleFilter` narrows to one coarse role (e.g. `NursePicker`). */
searchUsers(query: string, roleFilter?: DirectoryUserRole): Promise<AdminUserSummary[]>;
/** Batch id→label resolve — powers `AuditLogRow`'s actor names without one request per row. */
lookupUsers(userIds: number[]): Promise<AdminUserSummary[]>;
} }
@@ -8,6 +8,7 @@ import type {
PartnerCenterFilters, PartnerCenterFilters,
PartnerCenterInput, PartnerCenterInput,
SponsoredBooking, SponsoredBooking,
SponsoredBookingDetail,
SponsoredBookingFilters, SponsoredBookingFilters,
SponsoredNurse, SponsoredNurse,
} from '../types'; } from '../types';
@@ -111,6 +112,9 @@ export const partnerCenterClientApi: PartnerCenterApi = {
if (filters.status) q.set('status', filters.status); if (filters.status) q.set('status', filters.status);
return unwrap(await clientFetch<ApiEnvelope<Paginated<SponsoredBooking>>>(`${API}/centers/me/bookings?${q}`)); return unwrap(await clientFetch<ApiEnvelope<Paginated<SponsoredBooking>>>(`${API}/centers/me/bookings?${q}`));
}, },
// REQ-064 — no single-booking read in the b15 contract yet; proposed shape (sibling of the list route).
getMySponsoredBookingDetail: async (bookingId: number) =>
unwrap(await clientFetch<ApiEnvelope<SponsoredBookingDetail>>(`${API}/centers/me/bookings/${bookingId}`)),
listMySettlement: async (params) => { listMySettlement: async (params) => {
const q = new URLSearchParams(); const q = new URLSearchParams();
q.set('page', String(params.page ?? 1)); q.set('page', String(params.page ?? 1));
@@ -7,6 +7,7 @@ import type {
PartnerCenterFilters, PartnerCenterFilters,
PartnerCenterInput, PartnerCenterInput,
SponsoredBooking, SponsoredBooking,
SponsoredBookingDetail,
SponsoredBookingFilters, SponsoredBookingFilters,
SponsoredNurse, SponsoredNurse,
} from '../types'; } from '../types';
@@ -119,6 +120,44 @@ const BOOKINGS: Record<number, SponsoredBooking[]> = {
3: [], 3: [],
}; };
/** The normal-path lifecycle ladder a booking climbs before any dispute/cancellation branch. */
const BOOKING_STATUS_LADDER = ['confirmed', 'in_progress', 'completed', 'closed'] as const;
/**
* A synthetic 2-4-step status timeline consistent with the booking's current `status` (REQ-064 the wire
* has no timeline endpoint yet). Earlier steps get earlier (larger days-ago) timestamps than later ones.
*/
function buildBookingTimeline(status: string): { status: string; occurredAt: string }[] {
if (status === 'pending_payment') return [{ status: 'pending_payment', occurredAt: isoDaysAgo(1) }];
if (status === 'cancelled') {
return [
{ status: 'confirmed', occurredAt: isoDaysAgo(4) },
{ status: 'cancelled', occurredAt: isoDaysAgo(3) },
];
}
if (status === 'disputed') {
return [
{ status: 'confirmed', occurredAt: isoDaysAgo(6) },
{ status: 'in_progress', occurredAt: isoDaysAgo(4) },
{ status: 'completed', occurredAt: isoDaysAgo(3) },
{ status: 'disputed', occurredAt: isoDaysAgo(1) },
];
}
const stepIndex = BOOKING_STATUS_LADDER.indexOf(status as (typeof BOOKING_STATUS_LADDER)[number]);
if (stepIndex === -1) return [{ status, occurredAt: isoDaysAgo(1) }];
return BOOKING_STATUS_LADDER.slice(0, stepIndex + 1).map((s, i) => ({
status: s,
occurredAt: isoDaysAgo(stepIndex - i + 1),
}));
}
function findSponsoredBooking(bookingId: number): SponsoredBooking {
const all = Object.values(BOOKINGS).flat();
const booking = all.find((b) => b.bookingId === bookingId);
if (!booking) throw new Error(`Mock sponsored booking ${bookingId} not found`);
return booking;
}
/** Build a reconciling commission invoice (VAT on the commission line only; total = comm + bnpl + vat). */ /** Build a reconciling commission invoice (VAT on the commission line only; total = comm + bnpl + vat). */
function makeInvoice(id: number, bookingId: number, grossIrr: bigint, commissionIrr: bigint, bnplIrr: bigint, vatRate: number, days: number): CenterInvoice { function makeInvoice(id: number, bookingId: number, grossIrr: bigint, commissionIrr: bigint, bnplIrr: bigint, vatRate: number, days: number): CenterInvoice {
const vatIrr = (commissionIrr * BigInt(Math.round(vatRate * 100))) / BigInt(100); const vatIrr = (commissionIrr * BigInt(Math.round(vatRate * 100))) / BigInt(100);
@@ -241,6 +280,10 @@ export const partnerCenterMockApi: PartnerCenterApi = {
if (filters.status) items = items.filter((b) => b.status === filters.status); if (filters.status) items = items.filter((b) => b.status === filters.status);
return delay(paginate(items, params)); return delay(paginate(items, params));
}, },
getMySponsoredBookingDetail: async (bookingId: number): Promise<SponsoredBookingDetail> => {
const booking = findSponsoredBooking(bookingId);
return delay({ ...booking, timeline: buildBookingTimeline(booking.status) });
},
listMySettlement: async (params) => { listMySettlement: async (params) => {
const center = centerById(MOCK_MY_CENTER_ID); const center = centerById(MOCK_MY_CENTER_ID);
// Non-MoR centers issue no commission invoices here — the portal renders the "via Balinyaar" state. // Non-MoR centers issue no commission invoices here — the portal renders the "via Balinyaar" state.
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { partnerCenterApi } from '../apis';
import { centerKeys } from '../keys';
import { PARTNER_DETAIL_STALE_TIME, PARTNER_GC_TIME } from '../constants';
/**
* The scoped, read-only booking detail behind a sponsored-bookings-list row (REQ-064) dates + a status
* timeline only, no clinical content. `bookingId` is `undefined` while the route param hasn't resolved yet
* (mirrors the disabled-until-ready pattern used by the nurse payout detail hook).
*/
export function useMySponsoredBookingDetail(bookingId: number | undefined) {
return useQuery({
queryKey: centerKeys.mySponsoredBookingDetail(bookingId ?? -1),
queryFn: () => partnerCenterApi.getMySponsoredBookingDetail(bookingId as number),
enabled: bookingId != null && Number.isFinite(bookingId),
staleTime: PARTNER_DETAIL_STALE_TIME,
gcTime: PARTNER_GC_TIME,
});
}
@@ -13,4 +13,5 @@ export { useAssignNurseToPartnerCenter } from './hooks/useAssignNurseToPartnerCe
export { useMyPartnerCenter } from './hooks/useMyPartnerCenter'; export { useMyPartnerCenter } from './hooks/useMyPartnerCenter';
export { useMySponsoredNurses } from './hooks/useMySponsoredNurses'; export { useMySponsoredNurses } from './hooks/useMySponsoredNurses';
export { useMySponsoredBookings } from './hooks/useMySponsoredBookings'; export { useMySponsoredBookings } from './hooks/useMySponsoredBookings';
export { useMySponsoredBookingDetail } from './hooks/useMySponsoredBookingDetail';
export { useMySettlement } from './hooks/useMySettlement'; export { useMySettlement } from './hooks/useMySettlement';
@@ -21,5 +21,6 @@ export const centerKeys = {
mySponsoredNurses: () => [...centerKeys.myCenter(), 'nurses'] as const, mySponsoredNurses: () => [...centerKeys.myCenter(), 'nurses'] as const,
mySponsoredBookings: (filters: SponsoredBookingFilters, params: PageParams) => mySponsoredBookings: (filters: SponsoredBookingFilters, params: PageParams) =>
[...centerKeys.myCenter(), 'bookings', filters, params] as const, [...centerKeys.myCenter(), 'bookings', filters, params] as const,
mySponsoredBookingDetail: (bookingId: number) => [...centerKeys.myCenter(), 'bookings', bookingId] as const,
mySettlement: (params: PageParams) => [...centerKeys.myCenter(), 'settlement', params] as const, mySettlement: (params: PageParams) => [...centerKeys.myCenter(), 'settlement', params] as const,
}; };
+19 -3
View File
@@ -72,6 +72,16 @@ export interface SponsoredBooking {
status: string; status: string;
} }
/**
* The portal's scoped, read-only booking detail (REQ-064) `SponsoredBooking` plus a server-truth status
* timeline. Deliberately bounded to dates/status/patient display name: no clinical content, no address, no
* money the portal never sees more than a center legally needs to confirm a booking happened.
*/
export interface SponsoredBookingDetail extends SponsoredBooking {
/** Server-truth status timeline for this booking — dates only, no clinical content (portal scope). */
timeline: { status: string; occurredAt: string }[];
}
/** `invoices.moadian_status`. */ /** `invoices.moadian_status`. */
export type MoadianStatus = 'pending' | 'submitted' | 'registered' | 'failed'; export type MoadianStatus = 'pending' | 'submitted' | 'registered' | 'failed';
@@ -104,10 +114,14 @@ export interface PartnerCenterFilters {
isActive?: boolean; isActive?: boolean;
} }
/** Bookings list filter (portal). */ /**
export interface SponsoredBookingFilters { * Bookings list filter (portal). A `type` (not `interface`) so it satisfies `useAdminListState`'s
* `Record<string, unknown>` generic constraint a plain interface has no implicit index signature and
* TS rejects it as a type argument there, even though it's structurally identical.
*/
export type SponsoredBookingFilters = {
status?: string; status?: string;
} };
/** /**
* The partner-center API seam admin-side management + the center-scoped portal reads. The real client * The partner-center API seam admin-side management + the center-scoped portal reads. The real client
@@ -127,6 +141,8 @@ export interface PartnerCenterApi {
getMyCenter(): Promise<PartnerCenter>; getMyCenter(): Promise<PartnerCenter>;
listMySponsoredNurses(): Promise<SponsoredNurse[]>; listMySponsoredNurses(): Promise<SponsoredNurse[]>;
listMySponsoredBookings(filters: SponsoredBookingFilters, params: PageParams): Promise<Paginated<SponsoredBooking>>; listMySponsoredBookings(filters: SponsoredBookingFilters, params: PageParams): Promise<Paginated<SponsoredBooking>>;
/** REQ-064 — the scoped read-only detail behind a bookings-list row (dates + status timeline only). */
getMySponsoredBookingDetail(bookingId: number): Promise<SponsoredBookingDetail>;
listMySettlement(params: PageParams): Promise<Paginated<CenterInvoice>>; listMySettlement(params: PageParams): Promise<Paginated<CenterInvoice>>;
} }
@@ -262,4 +262,18 @@ export const ticketsClientApi: TicketsApi = {
}), }),
), ),
// Lifecycle (REQ-063 — routes proposed; not live). Kept real-shaped so the swap is one line once they ship;
// gated behind `TICKET_LIFECYCLE_ENABLED` on the caller side until then.
closeTicket: async (ticketId: number): Promise<void> => {
await clientFetch<ApiEnvelope<null>>(`${API}/tickets/${ticketId}/close`, { method: 'POST' });
},
reopenTicket: async (ticketId: number): Promise<void> => {
await clientFetch<ApiEnvelope<null>>(`${API}/tickets/${ticketId}/reopen`, { method: 'POST' });
},
assignTicket: async (ticketId: number, ownerUserId: number): Promise<void> => {
await clientFetch<ApiEnvelope<null>>(`${API}/tickets/${ticketId}/assign`, {
method: 'POST',
body: JSON.stringify({ ownerUserId }),
});
},
}; };
@@ -67,6 +67,8 @@ interface StoredTicket {
messages: StoredMessage[]; messages: StoredMessage[];
/** Unread-for-the-viewer count the inbox renders; cleared when the thread is opened. */ /** Unread-for-the-viewer count the inbox renders; cleared when the thread is opened. */
unread: number; unread: number;
/** The staff member the ticket is assigned to (REQ-063, mock-only — no wire field yet). */
assigneeUserId: number | null;
} }
const CUSTOMER = MOCK_VIEWER_USER_ID.customer; const CUSTOMER = MOCK_VIEWER_USER_ID.customer;
@@ -117,6 +119,7 @@ const tickets: StoredTicket[] = [
closedAt: null, closedAt: null,
participants: [CUSTOMER_PARTICIPANT, NURSE_PARTICIPANT, ADMIN_PARTICIPANT], participants: [CUSTOMER_PARTICIPANT, NURSE_PARTICIPANT, ADMIN_PARTICIPANT],
unread: 1, unread: 1,
assigneeUserId: null,
messages: [ messages: [
{ id: 40_001, senderId: ADMIN, body: 'این گفتگو برای هماهنگی ویزیت شما ایجاد شد. در صورت نیاز اینجا پیام بگذارید.', internal: false, sentAt: isoMinsAgo(600) }, { id: 40_001, senderId: ADMIN, body: 'این گفتگو برای هماهنگی ویزیت شما ایجاد شد. در صورت نیاز اینجا پیام بگذارید.', internal: false, sentAt: isoMinsAgo(600) },
{ id: 40_002, senderId: CUSTOMER, body: 'سلام، لطفاً ساعت ویزیت را به عصر منتقل کنید.', internal: false, sentAt: isoMinsAgo(540) }, { id: 40_002, senderId: CUSTOMER, body: 'سلام، لطفاً ساعت ویزیت را به عصر منتقل کنید.', internal: false, sentAt: isoMinsAgo(540) },
@@ -137,6 +140,7 @@ const tickets: StoredTicket[] = [
closedAt: null, closedAt: null,
participants: [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT], participants: [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT],
unread: 0, unread: 0,
assigneeUserId: null,
messages: [ messages: [
{ id: 40_010, senderId: CUSTOMER, body: 'آیا امکان انتخاب پرستار خانم برای ویزیت بعدی هست؟', internal: false, sentAt: isoMinsAgo(2_880) }, { id: 40_010, senderId: CUSTOMER, body: 'آیا امکان انتخاب پرستار خانم برای ویزیت بعدی هست؟', internal: false, sentAt: isoMinsAgo(2_880) },
{ id: 40_011, senderId: ADMIN, body: 'بله، هنگام جست‌وجو می‌توانید جنسیت مراقب را انتخاب کنید.', internal: false, sentAt: isoMinsAgo(2_820) }, { id: 40_011, senderId: ADMIN, body: 'بله، هنگام جست‌وجو می‌توانید جنسیت مراقب را انتخاب کنید.', internal: false, sentAt: isoMinsAgo(2_820) },
@@ -154,6 +158,7 @@ const tickets: StoredTicket[] = [
closedAt: isoMinsAgo(4_000), closedAt: isoMinsAgo(4_000),
participants: [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT], participants: [CUSTOMER_PARTICIPANT, ADMIN_PARTICIPANT],
unread: 0, unread: 0,
assigneeUserId: ADMIN,
messages: [ messages: [
{ id: 40_020, senderId: CUSTOMER, body: 'بازپرداخت من چه زمانی انجام می‌شود؟', internal: false, sentAt: isoMinsAgo(5_760) }, { id: 40_020, senderId: CUSTOMER, body: 'بازپرداخت من چه زمانی انجام می‌شود؟', internal: false, sentAt: isoMinsAgo(5_760) },
{ id: 40_021, senderId: ADMIN, body: 'بازپرداخت شما ثبت و به کارت شما واریز شد. این گفتگو بسته می‌شود.', internal: false, sentAt: isoMinsAgo(4_010) }, { id: 40_021, senderId: ADMIN, body: 'بازپرداخت شما ثبت و به کارت شما واریز شد. این گفتگو بسته می‌شود.', internal: false, sentAt: isoMinsAgo(4_010) },
@@ -280,6 +285,7 @@ function toAdminDetail(t: StoredTicket, viewerUserId: number): AdminTicketDetail
closedAt: t.closedAt, closedAt: t.closedAt,
participants: t.participants, participants: t.participants,
messages, messages,
assigneeUserId: t.assigneeUserId,
}; };
} }
@@ -343,6 +349,7 @@ export const ticketsMockApi: TicketsApi = {
refundId: body.refundId ?? null, refundId: body.refundId ?? null,
openedById: opener, openedById: opener,
closedAt: null, closedAt: null,
assigneeUserId: null,
participants, participants,
unread: 0, unread: 0,
messages: [{ id: nextMessageId++, senderId: opener, body: body.body, internal: false, sentAt: now }], messages: [{ id: nextMessageId++, senderId: opener, body: body.body, internal: false, sentAt: now }],
@@ -424,4 +431,25 @@ export const ticketsMockApi: TicketsApi = {
t.messages.push({ id, senderId: lastAdminViewerUserId, body: body.body, internal: body.isInternal, sentAt }); t.messages.push({ id, senderId: lastAdminViewerUserId, body: body.body, internal: body.isInternal, sentAt });
return { messageId: id, ticketId, sentAt }; return { messageId: id, ticketId, sentAt };
}, },
// Lifecycle (REQ-063). Mock is the source of truth here — no wire route exists yet.
closeTicket: async (ticketId: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
const t = findTicket(ticketId);
t.status = 'closed';
t.closedAt = new Date().toISOString();
},
reopenTicket: async (ticketId: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
const t = findTicket(ticketId);
t.status = 'open';
t.closedAt = null;
},
assignTicket: async (ticketId: number, ownerUserId: number): Promise<void> => {
await sleep(MOCK_LATENCY_MS);
const t = findTicket(ticketId);
t.assigneeUserId = ownerUserId;
},
}; };
+8
View File
@@ -43,6 +43,14 @@ export const TICKETS_ATTACHMENTS_ENABLED = false;
/** The admin global queue is a live worklist — a short stale window keeps it fresh without hammering. */ /** The admin global queue is a live worklist — a short stale window keeps it fresh without hammering. */
export const ADMIN_TICKETS_LIST_STALE_TIME = 20 * 1000; export const ADMIN_TICKETS_LIST_STALE_TIME = 20 * 1000;
/**
* Ticket lifecycle controls (close/reopen/assign) capability gate mirrors the `TICKETS_ATTACHMENTS_ENABLED`
* pattern above. Default **off**: the backend has no close/reopen/assign routes yet (REQ-063), so the
* real-path controls stay hidden rather than pointing at a route that would 404. Flip once the endpoints
* land no component change beyond this flag.
*/
export const TICKET_LIFECYCLE_ENABLED = false;
/** /**
* DEV-ONLY trigger for the optimistic-send **failure** path (phase §7 step 2): posting this exact message * DEV-ONLY trigger for the optimistic-send **failure** path (phase §7 step 2): posting this exact message
* body makes the mock throw a `500` so a human can watch the bubble roll back, the draft stay in the * body makes the mock throw a `500` so a human can watch the bubble roll back, the draft stay in the
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
/**
* Assign a ticket to a staff owner (REQ-063 gated behind `TICKET_LIFECYCLE_ENABLED`, the caller's job;
* today the only caller is "assign to me"). Invalidates the admin thread + queue on success.
*/
export function useAssignTicket(ticketId: number) {
const queryClient = useQueryClient();
return useMutation<void, unknown, { ownerUserId: number }>({
mutationFn: ({ ownerUserId }) => ticketsApi.assignTicket(ticketId, ownerUserId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ticketKeys.adminDetail(ticketId) });
queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() });
},
});
}
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
/**
* Close an open ticket (REQ-063 gated behind `TICKET_LIFECYCLE_ENABLED`, the caller's job). Invalidates
* the admin thread + every admin queue page so the ticket leaves the open worklist immediately.
*/
export function useCloseTicket(ticketId: number) {
const queryClient = useQueryClient();
return useMutation<void, unknown, void>({
mutationFn: () => ticketsApi.closeTicket(ticketId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ticketKeys.adminDetail(ticketId) });
queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() });
},
});
}
@@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketsApi } from '../apis';
import { ticketKeys } from '../keys';
/**
* Reopen a closed ticket (REQ-063 gated behind `TICKET_LIFECYCLE_ENABLED`, the caller's job). Invalidates
* the admin thread + every admin queue page so the ticket reappears in the open worklist immediately.
*/
export function useReopenTicket(ticketId: number) {
const queryClient = useQueryClient();
return useMutation<void, unknown, void>({
mutationFn: () => ticketsApi.reopenTicket(ticketId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ticketKeys.adminDetail(ticketId) });
queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() });
},
});
}
+5
View File
@@ -15,3 +15,8 @@ export { useAdminTickets } from './hooks/useAdminTickets';
export { useAdminTicket } from './hooks/useAdminTicket'; export { useAdminTicket } from './hooks/useAdminTicket';
export { useAdminTicketThread } from './hooks/useAdminTicketThread'; export { useAdminTicketThread } from './hooks/useAdminTicketThread';
export { usePostAdminMessage } from './hooks/usePostAdminMessage'; export { usePostAdminMessage } from './hooks/usePostAdminMessage';
// Ticket lifecycle (ui-phase-11, REQ-063) — close/reopen/assign, gated behind TICKET_LIFECYCLE_ENABLED.
export { useCloseTicket } from './hooks/useCloseTicket';
export { useReopenTicket } from './hooks/useReopenTicket';
export { useAssignTicket } from './hooks/useAssignTicket';
+15
View File
@@ -176,6 +176,12 @@ export interface AdminTicketDetail {
closedAt: string | null; closedAt: string | null;
participants: TicketParticipant[]; participants: TicketParticipant[];
messages: AdminTicketMessage[]; messages: AdminTicketMessage[];
/**
* The staff member the ticket is assigned to, or `null` when unassigned. **Not on the wire yet** the
* b15 admin DTOs carry no assignment field (REQ-063). Mock-only until delivered; the real mapper always
* yields `null` (never fabricates an owner).
*/
assigneeUserId?: number | null;
} }
export interface AdminTicketSummary { export interface AdminTicketSummary {
id: number; id: number;
@@ -229,4 +235,13 @@ export interface TicketsApi {
listAdminTickets(filters: AdminTicketFilters, params: PageParams): Promise<Paginated<AdminTicketSummary>>; listAdminTickets(filters: AdminTicketFilters, params: PageParams): Promise<Paginated<AdminTicketSummary>>;
getAdminTicket(ticketId: number, viewerUserId?: number): Promise<AdminTicketDetail>; getAdminTicket(ticketId: number, viewerUserId?: number): Promise<AdminTicketDetail>;
postAdminMessage(ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult>; postAdminMessage(ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult>;
/**
* Ticket lifecycle mutations (REQ-063 no live route yet, gated behind `TICKET_LIFECYCLE_ENABLED`). A
* resolved ticket has no way to leave the admin queue today; these three close the loop. `assignTicket`
* sets the (currently mock-only) `assigneeUserId`.
*/
closeTicket(ticketId: number): Promise<void>;
reopenTicket(ticketId: number): Promise<void>;
assignTicket(ticketId: number, ownerUserId: number): Promise<void>;
} }
@@ -6,6 +6,7 @@ import type {
AdminVerificationCase, AdminVerificationCase,
AdminVerificationQueueFilters, AdminVerificationQueueFilters,
AdminVerificationQueueItem, AdminVerificationQueueItem,
AdminVerificationQueuePage,
AdminVerificationStepDetail, AdminVerificationStepDetail,
CredentialDetailsInput, CredentialDetailsInput,
DecideStepInput, DecideStepInput,
@@ -216,9 +217,12 @@ export const verificationClientApi: VerificationApi = {
listVerificationQueue: async ( listVerificationQueue: async (
filters: AdminVerificationQueueFilters, filters: AdminVerificationQueueFilters,
params: PageParams, params: PageParams,
): Promise<Paginated<AdminVerificationQueueItem>> => { ): Promise<AdminVerificationQueuePage> => {
const query = new URLSearchParams(); const query = new URLSearchParams();
if (filters.status) query.set('status', filters.status); if (filters.status) query.set('status', filters.status);
// REQ-062: proposed `q` search param — the server ignores it today (no-op, never a 400) until the
// endpoint gains the filter; the client sends it so the swap is a no-op once it lands.
if (filters.search) query.set('q', filters.search);
query.set('page', String(params.page ?? 1)); query.set('page', String(params.page ?? 1));
query.set('page_size', String(params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE)); query.set('page_size', String(params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE));
const page = unwrap( const page = unwrap(
@@ -226,6 +230,8 @@ export const verificationClientApi: VerificationApi = {
); );
// REQ-034: `total`/`page`/`pageSize` stay the wire (per-step) values until a nurse-level queue endpoint // REQ-034: `total`/`page`/`pageSize` stay the wire (per-step) values until a nurse-level queue endpoint
// exists — folding to one item per nurse (see foldQueueRows) makes the count nominal, not exact. // exists — folding to one item per nurse (see foldQueueRows) makes the count nominal, not exact.
// REQ-062: `counts` stays undefined on the real path — the queue screen renders the status tabs
// without badge counts until the endpoint serves the whole-desk totals.
return { items: foldQueueRows(page.items), total: page.total, page: page.page, pageSize: page.pageSize }; return { items: foldQueueRows(page.items), total: page.total, page: page.page, pageSize: page.pageSize };
}, },
@@ -388,13 +388,23 @@ export const verificationMockApi: VerificationApi = {
listVerificationQueue: async (filters, params) => { listVerificationQueue: async (filters, params) => {
await sleep(MOCK_LATENCY_MS); await sleep(MOCK_LATENCY_MS);
// REQ-062: whole-desk counts, computed over the ENTIRE unfiltered set — never the current
// status/search/page slice — so the status-tab badges reflect the true queue at all times.
const counts = {
pending: adminCases.filter((record) => record.status === 'pending').length,
in_review: adminCases.filter((record) => record.status === 'in_review').length,
};
// Default (no status filter) shows the whole desk — both `pending` and `in_review`. // Default (no status filter) shows the whole desk — both `pending` and `in_review`.
const wanted: ReadonlyArray<AdminCaseRecord['status']> = filters.status ? [filters.status] : ['pending', 'in_review']; const wanted: ReadonlyArray<AdminCaseRecord['status']> = filters.status ? [filters.status] : ['pending', 'in_review'];
const matched = adminCases.filter((record) => wanted.includes(record.status)).map(toQueueItem); const search = filters.search?.trim().toLowerCase();
const matched = adminCases
.filter((record) => wanted.includes(record.status))
.filter((record) => !search || record.nurseName.toLowerCase().includes(search))
.map(toQueueItem);
const page = params.page ?? 1; const page = params.page ?? 1;
const pageSize = params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE; const pageSize = params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE;
const start = (page - 1) * pageSize; const start = (page - 1) * pageSize;
return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize }; return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize, counts };
}, },
getVerificationCase: async (nurseVerificationId) => { getVerificationCase: async (nurseVerificationId) => {
+18 -2
View File
@@ -197,6 +197,22 @@ export interface AdminVerificationQueueItem {
/** Queue filter — `status` defaults to `in_review` server-side when omitted. */ /** Queue filter — `status` defaults to `in_review` server-side when omitted. */
export interface AdminVerificationQueueFilters { export interface AdminVerificationQueueFilters {
status?: 'pending' | 'in_review'; status?: 'pending' | 'in_review';
/**
* Case-insensitive name/phone search (REQ-062, filed by ui-phase-11 not yet on the wire). The mock
* matches against the seeded `nurseName`; the real client maps it to a proposed `q` query param and the
* server currently ignores it (no-ops, never throws) until the endpoint gains the filter.
*/
search?: string;
}
/**
* The queue list response the standard paginated envelope plus optional whole-desk `counts` for the
* status tabs (REQ-062). `counts` reflects the **entire unfiltered queue** (not the current page/status/
* search), so the tab badges never drift from the true `pending`/`in_review` totals. Mock-tolerant:
* `undefined` on the real path until the endpoint serves it callers render the tabs without counts.
*/
export interface AdminVerificationQueuePage extends Paginated<AdminVerificationQueueItem> {
counts?: { pending: number; in_review: number };
} }
/** `AdminStepDetailDto` — one step of the admin case view, carrying its documents (signed GET URLs). */ /** `AdminStepDetailDto` — one step of the admin case view, carrying its documents (signed GET URLs). */
@@ -276,8 +292,8 @@ export interface VerificationApi {
getTrustBadge(nurseId: number): Promise<TrustBadge>; getTrustBadge(nurseId: number): Promise<TrustBadge>;
// --- Admin review queue (b6 AdminVerificationsController) --- // --- Admin review queue (b6 AdminVerificationsController) ---
/** The review queue, folded to one item per nurse. `status` filters (default `in_review`); paginated. */ /** The review queue, folded to one item per nurse. `status`/`search` filter (status default `in_review`); paginated. */
listVerificationQueue(filters: AdminVerificationQueueFilters, params: PageParams): Promise<Paginated<AdminVerificationQueueItem>>; listVerificationQueue(filters: AdminVerificationQueueFilters, params: PageParams): Promise<AdminVerificationQueuePage>;
/** The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check. */ /** The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check. */
getVerificationCase(nurseVerificationId: number): Promise<AdminVerificationCase>; getVerificationCase(nurseVerificationId: number): Promise<AdminVerificationCase>;
/** A freshly-signed, short-lived GET URL for a document — fetched on demand (URLs expire; never long-cached). */ /** A freshly-signed, short-lived GET URL for a document — fetched on demand (URLs expire; never long-cached). */
+1
View File
@@ -6,5 +6,6 @@ export * from './navigation';
export * from './number'; export * from './number';
export * from './sessionStorage'; export * from './sessionStorage';
export * from './sleep'; export * from './sleep';
export * from './toCsv';
export * from './type'; export * from './type';
export * from './text'; export * from './text';
+45
View File
@@ -0,0 +1,45 @@
import { toCsv } from './toCsv';
describe('toCsv', () => {
const headers = [
{ key: 'id', label: 'ID' },
{ key: 'name', label: 'Name' },
];
it('serializes a header row + one row per item', () => {
const csv = toCsv(
[
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
],
headers,
);
expect(csv).toBe('ID,Name\r\n1,Alice\r\n2,Bob');
});
it('quotes a field containing a comma', () => {
const csv = toCsv([{ id: 1, name: 'Doe, Jane' }], headers);
expect(csv).toBe('ID,Name\r\n1,"Doe, Jane"');
});
it('quotes a field containing an embedded quote, doubling it', () => {
const csv = toCsv([{ id: 1, name: 'Say "hi"' }], headers);
expect(csv).toBe('ID,Name\r\n1,"Say ""hi"""');
});
it('uses CRLF line endings throughout', () => {
const csv = toCsv(
[
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
],
headers,
);
expect(csv.split('\r\n')).toHaveLength(3);
expect(csv).not.toMatch(/(?<!\r)\n/);
});
it('renders an empty rows list as just the header row', () => {
expect(toCsv([], headers)).toBe('ID,Name');
});
});
+32
View File
@@ -0,0 +1,32 @@
/**
* A small, dependency-free CSV serializer (RFC 4180-ish). Used for client-side "export the currently loaded
* page" affordances (e.g. the partner settlement invoices table) no server round-trip, no library.
*/
export interface CsvColumn {
/** The row-object key this column reads. */
key: string;
/** Already-translated column header. */
label: string;
}
/** Wraps a field in double quotes (doubling any internal quote) when it contains a comma, quote, or newline. */
function quoteField(value: string): string {
if (/[",\r\n]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}
/**
* Builds a CSV string (header row + one row per item, `\r\n` line endings Excel expects CRLF) from a list
* of plain row objects and a column spec. Values are stringified as-is (callers pre-format money/dates);
* any field containing a comma, double quote, or newline is quoted per RFC 4180.
*/
export function toCsv(rows: Record<string, string | number>[], headers: CsvColumn[]): string {
const lines = [
headers.map((h) => quoteField(h.label)).join(','),
...rows.map((row) => headers.map((h) => quoteField(String(row[h.key] ?? ''))).join(',')),
];
return lines.join('\r\n');
}
@@ -978,3 +978,80 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
URL), and `TicketMessageDto` gains `attachments: { id: string, url: string, contentType: string }[]`. URL), and `TicketMessageDto` gains `attachments: { id: string, url: string, contentType: string }[]`.
- **Status:** deferred — the affordance is designed and gated off; nothing renders until this lands and the - **Status:** deferred — the affordance is designed and gated off; nothing renders until this lands and the
capability flag flips. capability flag flips.
## REQ-062 — Verification queue enrichment: name/phone search + per-status counts — filed by ui-phase-11 — 2026-07-19
- **Need:** Two additions to `GET admin_verifications` (the b6 admin review-queue list, folded client-side to
one row per nurse — see REQ-034):
1. A **name/phone search** query param (proposed `q`) that filters the queue to nurses whose name or phone
matches (case-insensitive substring), combinable with the existing `status` filter.
2. **Whole-desk per-status counts** in the response envelope — `pending`/`in_review` totals across the
**entire** queue (not the current page or the applied filters), so the status-tab badges never drift
from the true queue size. Optionally also a `submittedAt` sort param (`sort=submittedAt&dir=asc|desc`)
for the new waiting-time column.
- **Why:** The review queue (phase §3.3, the flagship trust-desk screen) previously offered only a 3-value
status filter and no way to find a specific nurse or see how much work is queued per status — the audit's
headline finding. The client's `AdminVerificationQueueFilters` now carries an optional `search` field and
`AdminVerificationQueuePage` an optional `counts: { pending, in_review }` (both in
`client/src/services/verification/types.ts`); the mock (`apis/mockApi.ts`) computes real counts over its
whole unfiltered fixture set and matches `search` against the seeded `nurseName`. The real
`verificationClientApi.listVerificationQueue` (`apis/clientApi.ts`) already sends `search` as `q` on the
query string (a no-op today — the server currently ignores unknown query params rather than 400ing) and
leaves `counts` `undefined`; the queue screen renders the status tabs **without** badge counts until this
lands (mock-tolerant, never a fake count).
- **Proposed shape:** `GET admin_verifications?status=&q=&page=&pageSize=&sort=&dir=`
`{ items: AdminPendingStepDto[], total, page, pageSize, counts: { pending: number, in_review: number } }`
(`counts` computed over the whole queue, ignoring `status`/`q`/`page`).
- **Status:** open
## REQ-063 — Ticket lifecycle mutations (close/reopen/assign) — filed by ui-phase-11 — 2026-07-19
- **Need:** Close/reopen/assign endpoints for a ticket, plus an `assigneeUserId` field on the admin ticket
DTOs (`AdminTicketSummary`/`AdminTicketDetail`) so the queue and thread can show and change who owns a
case.
- **Why:** Today a ticket has no lifecycle command at all — a resolved ticket can never leave the admin
queue, and there is no way to hand a case to a specific staff member. The ops desk (f15 admin console)
needs both for a working worklist. The client-side controls (`services/tickets` `useCloseTicket`/
`useReopenTicket`/`useAssignTicket`, and the admin thread's close/reopen/"assign to me" affordances) are
built against the mock and gated behind `TICKET_LIFECYCLE_ENABLED` (`services/tickets/constants.ts`,
default `false`) so nothing points at a 404'ing route in production.
- **Proposed shape:** `POST tickets/{id}/close` (no body) → ticket `status: 'closed'`, sets `closedAt`.
`POST tickets/{id}/reopen` (no body) → `status: 'open'`, clears `closedAt`. `POST tickets/{id}/assign`
with `{ ownerUserId: number }` → sets the ticket's assignee. `AdminTicketSummary`/`AdminTicketDetail` gain
`assigneeUserId: number | null`.
- **Status:** open
## REQ-064 — Partner scoped booking detail (read-only summary) — filed by ui-phase-11 — 2026-07-19
- **Need:** A single-sponsored-booking read for the partner portal (`GET centers/me/bookings/{bookingId}`,
sibling of the existing `GET centers/me/bookings` list route), returning everything the list row already
has plus a status timeline for that booking.
- **Why:** §3.7 — the partner portal's sponsored-bookings list (`/partner/bookings`) needs to link each row
to a detail view so a center admin can confirm what happened on a booking without leaving the portal. The
portal is explicitly scoped to read-only, non-clinical data (portal scope boundary, product/business) —
patient display name + dates + status only, never clinical content, address, or money.
- **Proposed shape:** `GET centers/me/bookings/{bookingId}``SponsoredBookingDetailDto` = the existing
`SponsoredBookingDto` (`bookingId`, `patientName`, `scheduledDate`, `status`) plus
`timeline: { status: string, occurredAt: string }[]` (server-truth status history, dates only — no
clinical content). The client-side seam (`services/partnerCenter`) is built against this shape (mock
primary, `USE_PARTNER_MOCK`); the real client call is written but unreachable until this route exists.
- **Status:** open
## REQ-061 — Admin user lookup: name/phone search + batch id→label resolve — filed by ui-phase-11 — 2026-07-19
- **Need:** Two endpoints backing a directory of platform users for admin use:
1. `GET admin_users/search?q=&role=` — search by name/phone (min 2 chars), optionally narrowed to one
coarse role (`customer`/`nurse`/`admin`/`partner`), returning up to ~10 matches: `{ id, displayName,
maskedPhone, roles: string[], nurseProfileId?: number }` (`nurseProfileId` set only when `roles`
includes `nurse` — the id `NursePicker` needs, a different id space than the user id).
2. `POST admin_users/lookup` with `{ userIds: number[] }` — batch id→label resolve, returning the same
shape for each found id, so a page with N actor ids (an audit log, a role grid) makes one request
instead of N.
- **Why:** The single scariest wrong-target failure mode in the backoffice — every audited action (role
grants, partner-center admin assignment, sponsored-nurse assignment) previously targeted a hand-typed
raw numeric id with no lookup or name echo-back (phase §3.2, the audit's flagship "high" finding), and
every actor/owner id across the console (audit log, role grid) rendered as a bare `#42` with no way to
find out who that is (§3.6, `AuditLogRow`). `UserPicker`/`NursePicker` (`client/src/components/admin/`)
and the new read-first `/admin/users` directory console (§3.8) are both built against this shape,
mock-primary behind `services/admin`'s existing `USE_ADMIN_MOCK` seam (`searchUsers`/`lookupUsers` on
`AdminApi`) — no page points at a 404'ing route; the mock seeds a small but representative directory
(admin/support/finance staff, nurses, customers, one partner-center contact) across both endpoints.
- **Proposed shape:** see **Need** above; masked phone follows the existing `maskIranMobile` convention
(`"0912•••1234"`, first-4/last-4) — never the full number.
- **Status:** open
@@ -118,7 +118,7 @@ the frontend can build before the backend phase merges, and swap to the real HTT
| `ServiceAreasApi` | `client/src/services/serviceAreas/apis/mockApi.ts` | Nurse coverage areas (list whole-city-first / add / remove). Enforces `UNIQUE(cityId, districtId)` exactly as the server — a duplicate (incl. a second whole-city row) throws the same **`409`** (`area_duplicate`) so the coverage editor's inline dup handling is demonstrable | `USE_SERVICE_AREAS_MOCK` (`services/serviceAreas/constants.ts`, default `true`) | b4 `nurse_service_areas/*` are live; set flag `false``serviceAreasClientApi` is wired (maps the server 409 to the same inline message). No hook/component change | 🟢 (real, refinement-phase-4) | | `ServiceAreasApi` | `client/src/services/serviceAreas/apis/mockApi.ts` | Nurse coverage areas (list whole-city-first / add / remove). Enforces `UNIQUE(cityId, districtId)` exactly as the server — a duplicate (incl. a second whole-city row) throws the same **`409`** (`area_duplicate`) so the coverage editor's inline dup handling is demonstrable | `USE_SERVICE_AREAS_MOCK` (`services/serviceAreas/constants.ts`, default `true`) | b4 `nurse_service_areas/*` are live; set flag `false``serviceAreasClientApi` is wired (maps the server 409 to the same inline message). No hook/component change | 🟢 (real, refinement-phase-4) |
| `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange``AddressForm` and every caller stay unchanged | 🟡 | | `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange``AddressForm` and every caller stay unchanged | 🟡 |
| `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 15, `sortOrder` 04). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false``catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟢 (real, refinement-phase-4) | | `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 15, `sortOrder` 04). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false``catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟢 (real, refinement-phase-4) |
| `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true. **ui-phase-8:** `VerificationStatus` gained two mock-tolerant fields the nurse-facing wire doesn't serve yet — `submittedAt` (stamped once when `start()` first seeds the steps, REQ-055) and `credentialSubmission` (stamped by `submitCredentialDetails``inoNumberSubmitted` boolean + specialties/registry fields, **never** the raw INO number, REQ-056) — so B6's timestamp and B5's hydrate-on-return both demo pre-REQ | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false``verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveats:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet — the document uploads it accompanies are contract-backed); `submittedAt` (REQ-055) and `credentialSubmission` (REQ-056) are both undefined until served, degrading gracefully (B6 omits the timestamp line; B5 falls back to blank fields). No hook/component change | 🟡 | | `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true. **ui-phase-8:** `VerificationStatus` gained two mock-tolerant fields the nurse-facing wire doesn't serve yet — `submittedAt` (stamped once when `start()` first seeds the steps, REQ-055) and `credentialSubmission` (stamped by `submitCredentialDetails``inoNumberSubmitted` boolean + specialties/registry fields, **never** the raw INO number, REQ-056) — so B6's timestamp and B5's hydrate-on-return both demo pre-REQ. **ui-phase-11 addition:** `listVerificationQueue` gained `search` (name/phone substring match against the mock's own fixture set) + a `counts: {pending, in_review}` computed over the **whole unfiltered** queue (REQ-062) — backs the admin verification desk's search field + status-tab badge counts | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false``verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveats:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet — the document uploads it accompanies are contract-backed); `submittedAt` (REQ-055) and `credentialSubmission` (REQ-056) are both undefined until served, degrading gracefully (B6 omits the timestamp line; B5 falls back to blank fields); `search`/`counts` (REQ-062) — the real client already sends `search` as `q` (currently a no-op, ignored by the server) and `counts` stays `undefined`, so the queue's status tabs render without badge counts until delivered. No hook/component change | 🟡 |
| `BookingsApi` | `client/src/services/bookings/apis/mockApi.ts` | The post-payment engagement (b9). Seeds **2 confirmed bookings** (one 3-session multi-day, one single-visit) + `booking_care_instructions` + a per-session **EVV state machine**`checkInVisit` flips the session→`in_progress`/`checked_in` (booking→`in_progress`) and computes the **advisory** `checkInAddressMatch` (haversine vs the seeded address ± `MOCK_EVV_TOLERANCE_METERS`, `null` when GPS was absent); `checkOutVisit` requires an open check-in (**`400 no_open_check_in`** otherwise), completes the session (stamps `payoutEligibleAt`), and completes the booking + opens the dispute window once **all** sessions settle. `getCareInstructions` **404s any viewer but the assigned nurse** (the two-stage-disclosure boundary; the UI `enabled` gate means the customer never even calls it). Money stays IRR digit-strings with `gross = commission + payout` and `Σ visitPayout = payout`. **ui-phase-7:** `forViewer` now unmasks `addressSnapshotJson` for the nurse once the booking is `confirmed`+ (simulating REQ-051, still delivered) instead of unconditionally nulling it — `addr5001` gained `latitude`/`longitude` matching the EVV reference point so the new address-card map link is demoable; `listTodaySessions` stamps a `variantLabel` off the booking's frozen variant snapshot (REQ-052) | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `false`) | b9 `bookings/*` + `booking_sessions/*` are live, but a booking only exists after `bookings/convert` runs on a **paid** request — both upstreams (`bookingRequests` mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag `false``bookingsClientApi` maps the routes 1:1 (+ `bookingsServerApi` for the RSC prefetch). No hook/component change. Deliver **REQ-051** (nurse-view address post-confirmation) + **REQ-052** (today-feed `variantLabel`) | 🟢 (real, refinement-phase-4) | | `BookingsApi` | `client/src/services/bookings/apis/mockApi.ts` | The post-payment engagement (b9). Seeds **2 confirmed bookings** (one 3-session multi-day, one single-visit) + `booking_care_instructions` + a per-session **EVV state machine**`checkInVisit` flips the session→`in_progress`/`checked_in` (booking→`in_progress`) and computes the **advisory** `checkInAddressMatch` (haversine vs the seeded address ± `MOCK_EVV_TOLERANCE_METERS`, `null` when GPS was absent); `checkOutVisit` requires an open check-in (**`400 no_open_check_in`** otherwise), completes the session (stamps `payoutEligibleAt`), and completes the booking + opens the dispute window once **all** sessions settle. `getCareInstructions` **404s any viewer but the assigned nurse** (the two-stage-disclosure boundary; the UI `enabled` gate means the customer never even calls it). Money stays IRR digit-strings with `gross = commission + payout` and `Σ visitPayout = payout`. **ui-phase-7:** `forViewer` now unmasks `addressSnapshotJson` for the nurse once the booking is `confirmed`+ (simulating REQ-051, still delivered) instead of unconditionally nulling it — `addr5001` gained `latitude`/`longitude` matching the EVV reference point so the new address-card map link is demoable; `listTodaySessions` stamps a `variantLabel` off the booking's frozen variant snapshot (REQ-052) | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `false`) | b9 `bookings/*` + `booking_sessions/*` are live, but a booking only exists after `bookings/convert` runs on a **paid** request — both upstreams (`bookingRequests` mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag `false``bookingsClientApi` maps the routes 1:1 (+ `bookingsServerApi` for the RSC prefetch). No hook/component change. Deliver **REQ-051** (nurse-view address post-confirmation) + **REQ-052** (today-feed `variantLabel`) | 🟢 (real, refinement-phase-4) |
| `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟢 (real, refinement-phase-4) | | `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟢 (real, refinement-phase-4) |
| `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued. **ui-phase-6:** `initiatePayment`'s `redirectUrl` is now `null` (was a stale pointer to the deleted card-gateway harness page — a latent bug, since the harness itself was already removed in refinement-phase-4; the checkout page's `!redirectUrl` branch already reads the outcome directly, no behavior change); `getCheckoutSummary` adds mock `nurseAvatarUrl: null`/`nurseVerified: true` (REQ-046, the C6 identity moment); the capture path stamps `capturedAt`/`createdAt` on the transaction so `PaymentOutcomeDto` serves `trackingCode`/`paidAt` (REQ-046, the confirmation receipt) and the new `getPaymentHistory` reads the same transaction list (REQ-047, wallet «پرداخت‌ها»); invoice creation adds mock `paymentMethod: 'card'`/`transactionReference`/`sellerFiscalIdentity` (REQ-049, fiscal-grade invoice) | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture) + **REQ-046** (nurse identity + tracking code/paid-at) + **REQ-047** (payment history) + **REQ-049** (invoice fiscal fields), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟢 (real, refinement-phase-4) | | `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued. **ui-phase-6:** `initiatePayment`'s `redirectUrl` is now `null` (was a stale pointer to the deleted card-gateway harness page — a latent bug, since the harness itself was already removed in refinement-phase-4; the checkout page's `!redirectUrl` branch already reads the outcome directly, no behavior change); `getCheckoutSummary` adds mock `nurseAvatarUrl: null`/`nurseVerified: true` (REQ-046, the C6 identity moment); the capture path stamps `capturedAt`/`createdAt` on the transaction so `PaymentOutcomeDto` serves `trackingCode`/`paidAt` (REQ-046, the confirmation receipt) and the new `getPaymentHistory` reads the same transaction list (REQ-047, wallet «پرداخت‌ها»); invoice creation adds mock `paymentMethod: 'card'`/`transactionReference`/`sellerFiscalIdentity` (REQ-049, fiscal-grade invoice) | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture) + **REQ-046** (nurse identity + tracking code/paid-at) + **REQ-047** (payment history) + **REQ-049** (invoice fiscal fields), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟢 (real, refinement-phase-4) |
@@ -130,8 +130,8 @@ the frontend can build before the backend phase merges, and swap to the real HTT
| `ReviewsApi` | `client/src/services/reviews/apis/mockApi.ts` | **The f13 moderated-review trust loop.** b14 serves the review **submit** (`POST bookings/{id}/review`), the public **nurse reviews** page (`GET nurses/{id}/reviews`), and the tag rollup — those are mapped 1:1 in `reviewsClientApi`. But there is **no review-eligibility read** and **no my-review-for-booking read** (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is **admin-only (f15)**. The mock reads a booking from the shared **f8 bookings store** (`mockGetBookingForReview`) to gate eligibility on a **completed/closed** booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as `pending_moderation` so eligibility flips `already_reviewed` + `getMyReviewForBooking` returns the persistent "under review" state, and seeds a **published list per nurse** (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is **recomputed from the published list** (never a stored sum). A submitted review **never** enters any public list. Dev-only `__mockPublishSubmittedReview(bookingId)` stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free | `USE_REVIEWS_MOCK` (`services/reviews/constants.ts`, default `true`) | Deliver **REQ-026** (`review_eligibility` + `my_review` reads; confirm masked-author omission), then set flag `false``reviewsClientApi.getNurseReviews`/`createReview` already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is **f15** (admin). No hook/component change | 🟢 (real, refinement-phase-4) | | `ReviewsApi` | `client/src/services/reviews/apis/mockApi.ts` | **The f13 moderated-review trust loop.** b14 serves the review **submit** (`POST bookings/{id}/review`), the public **nurse reviews** page (`GET nurses/{id}/reviews`), and the tag rollup — those are mapped 1:1 in `reviewsClientApi`. But there is **no review-eligibility read** and **no my-review-for-booking read** (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is **admin-only (f15)**. The mock reads a booking from the shared **f8 bookings store** (`mockGetBookingForReview`) to gate eligibility on a **completed/closed** booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as `pending_moderation` so eligibility flips `already_reviewed` + `getMyReviewForBooking` returns the persistent "under review" state, and seeds a **published list per nurse** (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is **recomputed from the published list** (never a stored sum). A submitted review **never** enters any public list. Dev-only `__mockPublishSubmittedReview(bookingId)` stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free | `USE_REVIEWS_MOCK` (`services/reviews/constants.ts`, default `true`) | Deliver **REQ-026** (`review_eligibility` + `my_review` reads; confirm masked-author omission), then set flag `false``reviewsClientApi.getNurseReviews`/`createReview` already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is **f15** (admin). No hook/component change | 🟢 (real, refinement-phase-4) |
| `PatientRecordsApi` | `client/src/services/patientRecords/apis/mockApi.ts` | **The f13 continuity-of-care surface.** Two very different things: (1) the **nurse-authored visit-note history** (`getPatientHistory`/`createVisitNote`) is **REAL b14** (`GET`/`POST patients/{id}/care_records`), mapped 1:1 in `patientRecordsClientApi` (the append composes the ticked task checklist into the note `body` since the wire has no structured task field); (2) the **family-owned editable record** (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the **access check** have **NO backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md` model them → **REQ-027**). The mock is **patient-scoped** and lazily seeds a coherent default per patient: a default family record (customer edits it), a **multi-nurse continuity history** (two prior notes from *different* nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID = 8888``canView:false` + a `403` on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) | `USE_PATIENT_RECORDS_MOCK` (`services/patientRecords/constants.ts`, default `true`) | Deliver **REQ-027** (family-owned `care_record` GET/PUT + `record_access` + structured `taskResults`), then set flag `false` — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity | 🟡 | | `PatientRecordsApi` | `client/src/services/patientRecords/apis/mockApi.ts` | **The f13 continuity-of-care surface.** Two very different things: (1) the **nurse-authored visit-note history** (`getPatientHistory`/`createVisitNote`) is **REAL b14** (`GET`/`POST patients/{id}/care_records`), mapped 1:1 in `patientRecordsClientApi` (the append composes the ticked task checklist into the note `body` since the wire has no structured task field); (2) the **family-owned editable record** (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the **access check** have **NO backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md` model them → **REQ-027**). The mock is **patient-scoped** and lazily seeds a coherent default per patient: a default family record (customer edits it), a **multi-nurse continuity history** (two prior notes from *different* nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID = 8888``canView:false` + a `403` on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) | `USE_PATIENT_RECORDS_MOCK` (`services/patientRecords/constants.ts`, default `true`) | Deliver **REQ-027** (family-owned `care_record` GET/PUT + `record_access` + structured `taskResults`), then set flag `false` — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity | 🟡 |
| f8 bookings mock — completed-booking seed 5005 + f13 cross-mock reads | `client/src/services/bookings/apis/mockApi.ts` | **Non-seam additions (mirrors the f10 refunds precedent).** The f8 seeds had **no `completed` booking** (only `confirmed`/`in_progress`/`cancelled`), so f13's review flow needs one: added **booking 5005** (`status: 'completed'`, nurse 1, patient 905, one completed EVV session) so the customer can open a completed booking and leave a review. Also added a **cross-mock read helper**`mockGetBookingForReview(id)` (single booking, clone) — imported by the reviews mock to gate eligibility and read the patient/nurse snapshot for a submission (the `listBookings` seam row omits `patientId`/`nurseId`). One-way edge INTO bookings (the bookings mock never imports f13), so no cycle | — (part of `USE_BOOKINGS_MOCK`) | When the bookings flow goes real (b9/b10 conversion live), 5005 stops being a static seed and the cross-mock helpers retire with the reviews/records mocks | 🟢 (real, refinement-phase-4) | | f8 bookings mock — completed-booking seed 5005 + f13 cross-mock reads | `client/src/services/bookings/apis/mockApi.ts` | **Non-seam additions (mirrors the f10 refunds precedent).** The f8 seeds had **no `completed` booking** (only `confirmed`/`in_progress`/`cancelled`), so f13's review flow needs one: added **booking 5005** (`status: 'completed'`, nurse 1, patient 905, one completed EVV session) so the customer can open a completed booking and leave a review. Also added a **cross-mock read helper**`mockGetBookingForReview(id)` (single booking, clone) — imported by the reviews mock to gate eligibility and read the patient/nurse snapshot for a submission (the `listBookings` seam row omits `patientId`/`nurseId`). One-way edge INTO bookings (the bookings mock never imports f13), so no cycle | — (part of `USE_BOOKINGS_MOCK`) | When the bookings flow goes real (b9/b10 conversion live), 5005 stops being a static seed and the cross-mock helpers retire with the reviews/records mocks | 🟢 (real, refinement-phase-4) |
| `TicketsApi` | `client/src/services/tickets/apis/mockApi.ts` | **The f14 ticket channel (b15).** b15 serves open/list/thread/message and `ticketsClientApi` maps them 1:1; **REQ-028 is now delivered** (`unreadCount`/`lastMessageAt` + `clientMessageId` idempotency are real on the wire), so `USE_TICKETS_MOCK = false` by default — the mock stays available for offline/demo use. It seeds 3 tickets (a booking-5001 **coordination** ticket with a **stored internal admin note the user view NEVER returns** — the no-leak demo — plus a support + a closed refund ticket), returns them newest-activity first with a per-ticket unread count that **clears on open**; `openTicket` is **idempotent for `coordination + bookingId`**; `postMessage` appends as the current viewer, throws `403` on a **closed** ticket, and throws `500` on the dev sentinel body `'/fail'` (the optimistic failure→retry path — **ui-phase-10:** a failed send now flips the bubble to `sendStatus:'failed'` in place instead of rolling it back, and a retry re-mutates the same `clientMessageId`). `MOCK_VIEWER_USER_ID` (per-role "me") drives `isMine`; **`isInternal` is never modelled in the user-app types**. **ui-phase-10 additions:** `toSummary()` now also computes `lastMessagePreview` (first ~80 chars of the last non-internal message) + `lastAuthorRole` (REQ-059 gap — real path maps both `null`, card degrades gracefully); a new `getUnreadTotal()` sums `unread` across every seeded ticket for the chrome support-badge (REQ-059 — real path returns `null`, badge doesn't render) | `USE_TICKETS_MOCK` (`services/tickets/constants.ts`, default **false** — REQ-028 delivered) | Deliver **REQ-059** (`lastMessagePreview`/`lastAuthorRole` on the summary + a cheap unread-total read) to fully retire the mock's enrichment role; **REQ-060** (message photo attachments) gates the composer's designed-but-off attachment affordance (`TICKETS_ATTACHMENTS_ENABLED`, default `false`). `ticketsClientApi` already maps the live b15 routes 1:1 (drops any leaked internal message defensively) | 🟢 real by default (REQ-028); 🟡 REQ-059/060 gaps remain mock-only | | `TicketsApi` | `client/src/services/tickets/apis/mockApi.ts` | **The f14 ticket channel (b15).** b15 serves open/list/thread/message and `ticketsClientApi` maps them 1:1; **REQ-028 is now delivered** (`unreadCount`/`lastMessageAt` + `clientMessageId` idempotency are real on the wire), so `USE_TICKETS_MOCK = false` by default — the mock stays available for offline/demo use. It seeds 3 tickets (a booking-5001 **coordination** ticket with a **stored internal admin note the user view NEVER returns** — the no-leak demo — plus a support + a closed refund ticket), returns them newest-activity first with a per-ticket unread count that **clears on open**; `openTicket` is **idempotent for `coordination + bookingId`**; `postMessage` appends as the current viewer, throws `403` on a **closed** ticket, and throws `500` on the dev sentinel body `'/fail'` (the optimistic failure→retry path — **ui-phase-10:** a failed send now flips the bubble to `sendStatus:'failed'` in place instead of rolling it back, and a retry re-mutates the same `clientMessageId`). `MOCK_VIEWER_USER_ID` (per-role "me") drives `isMine`; **`isInternal` is never modelled in the user-app types**. **ui-phase-10 additions:** `toSummary()` now also computes `lastMessagePreview` (first ~80 chars of the last non-internal message) + `lastAuthorRole` (REQ-059 gap — real path maps both `null`, card degrades gracefully); a new `getUnreadTotal()` sums `unread` across every seeded ticket for the chrome support-badge (REQ-059 — real path returns `null`, badge doesn't render). **ui-phase-11 additions:** `closeTicket`/`reopenTicket`/`assignTicket` (REQ-063) — the admin thread's close/reopen/"assign to me" controls, gated client-side behind `TICKET_LIFECYCLE_ENABLED` (default `false`, `services/tickets/constants.ts`) so nothing points at the still-404ing proposed routes in production; `StoredTicket` gained `assigneeUserId` | `USE_TICKETS_MOCK` (`services/tickets/constants.ts`, default **false** — REQ-028 delivered) | Deliver **REQ-059** (`lastMessagePreview`/`lastAuthorRole` on the summary + a cheap unread-total read) to fully retire the mock's enrichment role; **REQ-060** (message photo attachments) gates the composer's designed-but-off attachment affordance (`TICKETS_ATTACHMENTS_ENABLED`, default `false`); **REQ-063** (close/reopen/assign routes + `assigneeUserId` on the admin DTOs) — once delivered, flip `TICKET_LIFECYCLE_ENABLED` to `true` (the hooks/UI are already built against the mock). `ticketsClientApi` already maps the live b15 routes 1:1 (drops any leaked internal message defensively) | 🟢 real by default (REQ-028); 🟡 REQ-059/060/063 gaps remain mock-only |
| `NotificationsApi` | `client/src/services/notifications/apis/mockApi.ts` | **The f14 notification center + polled bell (b1).** The b1 endpoints are live and `notificationsClientApi` maps them 1:1, but a notification only exists once some other backend domain **dispatches** one (`INotificationDispatcher`) — none run client-side while the upstream flows are mock-primary — so there'd be nothing to show. The mock seeds a realistic **unread-first** feed spanning **every deep-link class** (ticket_message/booking_confirmed/refund_processed/payment_captured/payout_paid/review_published + one unknown-type/no-payload row that degrades to no deep-link), each with a snake_case `dataJson` string the list maps through the **real** `parseNotificationData`; `getUnreadCount`/`markRead`/`markAllRead` mutate the in-memory feed. **Dev-only `__mockPushNotification(type,title,dataJson?,body?)`** prepends a fresh **unread** row so a human can watch the bell badge increment within the poll interval (phase §7 step 4). Ids align with the f8 bookings + tickets mocks so a deep-link lands on a real screen | `USE_NOTIFICATIONS_MOCK` (`services/notifications/constants.ts`, default `true`) | When the upstream domains dispatch real notifications, set flag `false``notificationsClientApi` already maps the live b1 `notifications/*` routes 1:1 (`page`/`pageSize`, `{count}`, `{notificationId}`). No hook/component change | 🟢 (real, refinement-phase-4) | | `NotificationsApi` | `client/src/services/notifications/apis/mockApi.ts` | **The f14 notification center + polled bell (b1).** The b1 endpoints are live and `notificationsClientApi` maps them 1:1, but a notification only exists once some other backend domain **dispatches** one (`INotificationDispatcher`) — none run client-side while the upstream flows are mock-primary — so there'd be nothing to show. The mock seeds a realistic **unread-first** feed spanning **every deep-link class** (ticket_message/booking_confirmed/refund_processed/payment_captured/payout_paid/review_published + one unknown-type/no-payload row that degrades to no deep-link), each with a snake_case `dataJson` string the list maps through the **real** `parseNotificationData`; `getUnreadCount`/`markRead`/`markAllRead` mutate the in-memory feed. **Dev-only `__mockPushNotification(type,title,dataJson?,body?)`** prepends a fresh **unread** row so a human can watch the bell badge increment within the poll interval (phase §7 step 4). Ids align with the f8 bookings + tickets mocks so a deep-link lands on a real screen | `USE_NOTIFICATIONS_MOCK` (`services/notifications/constants.ts`, default `true`) | When the upstream domains dispatch real notifications, set flag `false``notificationsClientApi` already maps the live b1 `notifications/*` routes 1:1 (`page`/`pageSize`, `{count}`, `{notificationId}`). No hook/component change | 🟢 (real, refinement-phase-4) |
| `AdminApi` | `client/src/services/admin/apis/mockApi.ts` | **The f15 backoffice-owned data (b1 + b15).** Fixtures engineered to exercise every console state: **one config per `data_type`** (decimal/int/bool/json/string — so the typed inputs + the 01 rate validation are all reachable) with a **change-history** trail; **holidays** with bank-closed days; a **paged audit log** with `changedFields` diffs (one row `<redacted>` for a PII field); a **support-alert** list spanning **every** `type` (`low_rating`/`evv_no_show`/`evv_location_mismatch`/`verification_expired`/`shared_sim`/`payment_anomaly`/`fraud_signal`/`nurse_clawback`/`emergency`) and all three statuses so the worklist filters are testable; and **RBAC** grants. Mutations mutate the in-memory arrays (a config save writes a history row; assign/resolve advance an alert; grant/revoke flip a role). Timestamps relative to `now` | `USE_ADMIN_MOCK` (`services/admin/constants.ts`, default `true`) | b1 config/holiday/audit/support-alert routes are live and `adminClientApi` maps them 1:1 — deliver **REQ-029** (config `updatedAt`/`updatedBy`) + **REQ-030** (audit actor/action/date filters) + **REQ-031** (the RBAC `admin_roles/*` endpoints, which don't exist yet), then set flag `false`. No hook/component change | 🟡 | | `AdminApi` | `client/src/services/admin/apis/mockApi.ts` | **The f15 backoffice-owned data (b1 + b15).** Fixtures engineered to exercise every console state: **one config per `data_type`** (decimal/int/bool/json/string — so the typed inputs + the 01 rate validation are all reachable) with a **change-history** trail; **holidays** with bank-closed days; a **paged audit log** with `changedFields` diffs (one row `<redacted>` for a PII field); a **support-alert** list spanning **every** `type` (`low_rating`/`evv_no_show`/`evv_location_mismatch`/`verification_expired`/`shared_sim`/`payment_anomaly`/`fraud_signal`/`nurse_clawback`/`emergency`) and all three statuses so the worklist filters are testable; and **RBAC** grants. Mutations mutate the in-memory arrays (a config save writes a history row; assign/resolve advance an alert; grant/revoke flip a role). Timestamps relative to `now`. **ui-phase-11 addition:** `searchUsers(query, roleFilter?)`/`lookupUsers(userIds)` (REQ-061) — a seeded ~13-entry user directory (admin/support/finance staff, nurses with `nurseProfileId`, customers, one partner contact) backing `UserPicker`/`NursePicker` (name+masked-phone+id search, replacing every raw numeric-id `TextField` on an audited action) and `AuditLogRow`'s batch actor-name resolve; `AdminUserSummary.maskedPhone` never exposes the mock's internal full-number field | `USE_ADMIN_MOCK` (`services/admin/constants.ts`, default `true`) | b1 config/holiday/audit/support-alert routes are live and `adminClientApi` maps them 1:1 — deliver **REQ-029** (config `updatedAt`/`updatedBy`) + **REQ-030** (audit actor/action/date filters) + **REQ-031** (the RBAC `admin_roles/*` endpoints, which don't exist yet) + **REQ-061** (the user-directory search/lookup endpoints — `client/src/services/admin/apis/clientApi.ts` already maps them to a proposed `admin_users/search`+`admin_users/lookup` route pair), then set flag `false`. No hook/component change | 🟡 |
| `PartnerCenterApi` | `client/src/services/partnerCenter/apis/mockApi.ts` | **The f15 partner centers (b15) — admin management + the center-scoped portal.** Returns **center #1 = merchant-of-record** (the settlement/invoice view renders) **and** #2 = non-MoR (the "settlement runs through Balinyaar" state) **and** a **draft** #3 (unverified banner); sponsored nurses (verified + unverified), sponsored bookings, and commission invoices whose **platform commission + BNPL commission + VAT = total** (VAT on the commission line only) with a fake 22-digit `moadianReferenceNumber` + a stub PDF url. `settlementIbanMasked` is **last-4 only** (write-then-masked: create/edit submit a full IBAN, only last-4 ever returns). Admin CRUD/verify/set-active/assign-nurse + the portal "my center" reads all mutate/read the in-memory world; "my center" resolves to `MOCK_MY_CENTER_ID` (=1, MoR) | `USE_PARTNER_MOCK` (`services/partnerCenter/constants.ts`, default `true`) + `MOCK_MY_CENTER_ID` | b15 admin partner-center CRUD/verify/sponsor are live; deliver **REQ-032** (portal split reads `centers/me[/nurses|/bookings|/settlement]` + the activate/suspend toggle + confirm the write-then-masked IBAN) + **REQ-033** (center-scoped invoice list + invoice `totalIrr`), then set flag `false``partnerCenterClientApi` maps the live admin routes and targets the proposed portal slugs. No hook/component change | 🟡 | | `PartnerCenterApi` | `client/src/services/partnerCenter/apis/mockApi.ts` | **The f15 partner centers (b15) — admin management + the center-scoped portal.** Returns **center #1 = merchant-of-record** (the settlement/invoice view renders) **and** #2 = non-MoR (the "settlement runs through Balinyaar" state) **and** a **draft** #3 (unverified banner); sponsored nurses (verified + unverified), sponsored bookings, and commission invoices whose **platform commission + BNPL commission + VAT = total** (VAT on the commission line only) with a fake 22-digit `moadianReferenceNumber` + a stub PDF url. `settlementIbanMasked` is **last-4 only** (write-then-masked: create/edit submit a full IBAN, only last-4 ever returns). Admin CRUD/verify/set-active/assign-nurse + the portal "my center" reads all mutate/read the in-memory world; "my center" resolves to `MOCK_MY_CENTER_ID` (=1, MoR). **ui-phase-11 addition:** `getMySponsoredBookingDetail(bookingId)` (REQ-064) — a synthetic 24-step status timeline consistent with the booking's current status, backing the portal's new scoped read-only booking-detail page (dates + status timeline + patient display name only, no clinical content) | `USE_PARTNER_MOCK` (`services/partnerCenter/constants.ts`, default `true`) + `MOCK_MY_CENTER_ID` | b15 admin partner-center CRUD/verify/sponsor are live; deliver **REQ-032** (portal split reads `centers/me[/nurses|/bookings|/settlement]` + the activate/suspend toggle + confirm the write-then-masked IBAN) + **REQ-033** (center-scoped invoice list + invoice `totalIrr`) + **REQ-064** (the single-booking read + timeline), then set flag `false``partnerCenterClientApi` maps the live admin routes and targets the proposed portal slugs. No hook/component change | 🟡 |
| Admin-endpoint additions to existing domain mocks (`verification`/`refunds`/`payouts`/`reviews`/`tickets`) | the same `apis/mockApi.ts` files (+ their `clientApi.ts`) | **The f15 staff lens over prior domains** — new admin methods added behind the existing seams (no new seam, no hook/component change on swap). **verification:** a nurse-level review queue (`pending`/`in_review`, one with an expiring credential) + a per-nurse case whose manual credential steps carry a document, and `getDocumentSignedUrl` that returns a **fresh short-lived URL each call** (sentinel `documentId 9999` throws → viewer error/re-request path); `decideStep`/`approve`/`reject` re-aggregate. **refunds:** a `getRefundPreview` with the fee/payout split reconciling to the rial per booking (a normal card, a BNPL w/ ETA, a post-payout w/ clawback notice, and a provider-decline **sentinel that fails then retries succeeds**). **payouts:** batches spanning `completed`/`partially_failed`/`processing` (one holiday-shifted), a preview w/ eligible + skipped(no-IBAN) + clawback line + holiday-shifted date, an **idempotency-keyed** run/retry (same key → same result, never double-pays), a `failed` payout to retry, and record-transfer-reference. **reviews:** a moderation queue incl. a low-rating flagged review; `moderateReview` returns a plausible recomputed aggregate. **tickets:** a global admin queue + a thread that **includes** the seeded internal note (the no-leak *inverse* demo) + `postAdminMessage` w/ `isInternal`; a refund-linked ticket (bookingId+refundId) so the RefundPanel opens from it | the owning domain's flag (`USE_VERIFICATION_MOCK` / `USE_REFUNDS_MOCK` / `USE_PAYOUTS_MOCK` / `USE_REVIEWS_MOCK` / `USE_TICKETS_MOCK`, all default `true`) | Deliver the per-domain admin gaps — **REQ-034** (verification nurse-queue + on-demand doc URL + whole-verification approve/reject), **REQ-035** (refund preview + explicit approve/reject), **REQ-036** (payout single-preview + `holidayShifted` + record-transfer-reference), **REQ-037** (moderation `tagCodes`) — then flip the owning domain's flag. The real `clientApi` methods already map the live admin routes 1:1 and target the proposed slugs for the gaps | 🟢 (real, refinement-phase-4) | | Admin-endpoint additions to existing domain mocks (`verification`/`refunds`/`payouts`/`reviews`/`tickets`) | the same `apis/mockApi.ts` files (+ their `clientApi.ts`) | **The f15 staff lens over prior domains** — new admin methods added behind the existing seams (no new seam, no hook/component change on swap). **verification:** a nurse-level review queue (`pending`/`in_review`, one with an expiring credential) + a per-nurse case whose manual credential steps carry a document, and `getDocumentSignedUrl` that returns a **fresh short-lived URL each call** (sentinel `documentId 9999` throws → viewer error/re-request path); `decideStep`/`approve`/`reject` re-aggregate. **refunds:** a `getRefundPreview` with the fee/payout split reconciling to the rial per booking (a normal card, a BNPL w/ ETA, a post-payout w/ clawback notice, and a provider-decline **sentinel that fails then retries succeeds**). **payouts:** batches spanning `completed`/`partially_failed`/`processing` (one holiday-shifted), a preview w/ eligible + skipped(no-IBAN) + clawback line + holiday-shifted date, an **idempotency-keyed** run/retry (same key → same result, never double-pays), a `failed` payout to retry, and record-transfer-reference. **reviews:** a moderation queue incl. a low-rating flagged review; `moderateReview` returns a plausible recomputed aggregate. **tickets:** a global admin queue + a thread that **includes** the seeded internal note (the no-leak *inverse* demo) + `postAdminMessage` w/ `isInternal`; a refund-linked ticket (bookingId+refundId) so the RefundPanel opens from it | the owning domain's flag (`USE_VERIFICATION_MOCK` / `USE_REFUNDS_MOCK` / `USE_PAYOUTS_MOCK` / `USE_REVIEWS_MOCK` / `USE_TICKETS_MOCK`, all default `true`) | Deliver the per-domain admin gaps — **REQ-034** (verification nurse-queue + on-demand doc URL + whole-verification approve/reject), **REQ-035** (refund preview + explicit approve/reject), **REQ-036** (payout single-preview + `holidayShifted` + record-transfer-reference), **REQ-037** (moderation `tagCodes`) — then flip the owning domain's flag. The real `clientApi` methods already map the live admin routes 1:1 and target the proposed slugs for the gaps | 🟢 (real, refinement-phase-4) |
@@ -0,0 +1,260 @@
# UI Phase 11 — Admin & partner console — Report (2026-07-19)
## What was built
### 3.1 `useAdminListState` — URL-synced worklist state, adopted everywhere
- New `client/src/hooks/useAdminListState.ts` — mirrors **applied** filters + page into `searchParams` via
`router.replace({ scroll: false })`; draft stays local (typing never refetches or touches the URL) until
`apply()`/`applyFilters(explicitValue)`/`clear()`/`goToPage(n)` commit it. Initial `applied`/`page` are
read from the URL **once, on mount**. `applyFilters` exists because a discrete control (a status tab/
select) that should commit the instant it changes cannot safely do `setDraft(next); apply()` in the same
handler — `apply()` closes over the *previous* render's `draft`, so it would commit the stale value; two
pages hit this bug during integration and were fixed to use `applyFilters` instead (a co-located test
covers it). Also exports `useAdminBackToList(listHref)` — a real `router.back()` when there's browser
history, falling back to pushing `listHref` otherwise (the detail-page "back" fix).
- Because it calls `useSearchParams()`, every page using it wraps its body in `<Suspense>` (the existing
`SearchScreen.tsx` pattern) — a default-exported thin wrapper + a `*Inner`/`*Screen` body component.
- Adopted on **every** admin/partner queue page: tickets, audit, verification, reviews, payouts (list +
batch detail), partners (list), alerts, holidays, config (list + history drawer), and the partner
bookings/settlement lists. `roles` was intentionally **not** touched (it's an unpaginated, unfiltered
grid — nothing to URL-sync).
- **Fixed the hard-wired page-1 reads:** `admin/config/page.tsx` (`usePlatformConfigs`) and its
change-history drawer, and `admin/holidays/page.tsx` (`useHolidays`) now carry real page state + an
`AdminPager` — previously any row beyond page 1 was invisible/uneditable.
- Detail pages (tickets thread, payout batch, partner center) now use `useAdminBackToList` (or `PageHeader`'s
new `onBack`) instead of a hand-rolled `router.push` to the bare list.
### 3.2 `UserPicker`/`NursePicker` — killing raw-ID targeting
- New `client/src/components/admin/UserPicker/` — an async MUI `Autocomplete` (name/phone search, 300ms
debounce via the existing `useDebouncedValue`) rendering **name + masked phone + `#id`** per option, never
a bare id. `NursePicker` is a thin `roleFilter="nurse"` wrapper — its selection carries `nurseProfileId`
(a different id space than the user id, the one sponsorship/roster assignment actually needs). Both
co-located-tested (mock the `useUserSearch` hook, no `QueryClientProvider` needed).
- Backed by a new admin user-directory seam (REQ-061, gap): `AdminUserSummary` type, `searchUsers`/
`lookupUsers` on `AdminApi`, a seeded ~13-entry mock directory (admin/support/finance staff, nurses with
`nurseProfileId`, customers, one partner contact), `useUserSearch`/`useUserLookup` hooks. `useUserLookup`
is the **batch** id→label resolve — one request for every actor/owner id a page renders, never one per row.
- Wired into: `admin/roles`'s grant dialog (the confirm copy now names the resolved person —
«نقش {role} به {name} اعطا شود؟» — never `#42`), `admin/partners`'s create/edit dialog (`adminUserId`) and
detail page (sponsored-nurse assignment, via `NursePicker`). `admin/alerts`' "assign to me" doesn't need a
picker (it targets the current admin, not an arbitrary user) — its fix is below.
- **Fixed the alert assign-to-self fallback:** `admin/alerts/page.tsx`'s `meId = authState.currentUser?.id
?? 1` is gone. The button is now `disabled` with a `title`/`Tooltip` («در حال بارگذاری حساب شما…») until
the real id hydrates — it can never silently target user `#1`. `SupportAlertCard` gained
`assignSelfDisabled`/`assignSelfDisabledTitle` props for this. The admin ticket thread's new "assign to
me" control (§3.4) uses the identical pattern.
### 3.3 Verification desk — the flagship trust queue
- **Status tabs with counts:** the lone 3-value select is now MUI `Tabs` (all/pending/in_review), each
showing a `(count)` suffix when the server serves `counts` (REQ-062, gap — mock computes real counts over
the whole unfiltered queue; the real client sends the param but the response field stays `undefined`, so
the tabs render without badges until delivered — never a fake count).
- **Name/phone search** behind the established draft-vs-applied Apply/Clear pattern (mirrors tickets/audit).
- **Waiting-time column:** client-computed, display-only relative age off `submittedAt`, colored past
`WAITING_TIME_WARNING_HOURS=48` (`--bal-warning`) and `WAITING_TIME_ALARM_HOURS=96` (`--bal-error`) —
named constants, never magic numbers.
- **Next/prev case navigation:** `admin/verification/[nurseId]/page.tsx` re-derives the queue's filters/page
from the URL (a new `queueFilters.ts` shared by both pages) and calls `useVerificationQueue` with the same
params — React Query serves it from the list's own cache, no extra fetch — to compute the previous/next
`nurseVerificationId` in the current queue order. «پرونده بعدی»/«پرونده قبلی» buttons + `ArrowLeft`/
`ArrowRight` window keydown bindings (ignored while focus is in a text input). A full split-pane case view
stays explicitly **out of scope** (deferred post-chain) per the phase brief.
- `CredentialDialog`'s two native `type="date"` inputs (issued/expires) are now `JalaliDateField`.
- `DocumentViewer`'s signed-URL flow is untouched (do-not-regress, confirmed).
### 3.4 Ticket console — lifecycle + a safe composer
- **Close/reopen/assign mutations** (`useCloseTicket`/`useReopenTicket`/`useAssignTicket`, REQ-063, gap) —
full mock implementations (`StoredTicket` gained `assigneeUserId`) and `clientApi` methods mapped to
proposed routes, all gated behind `TICKET_LIFECYCLE_ENABLED` (`services/tickets/constants.ts`, default
`false`) so no control points at a 404ing route in production. The thread header gets close/reopen
buttons (via `ConfirmDialog`, no reason required — closing is terminal, not destructive) + "assign to me"
(disabled-with-tooltip until hydrated, never a fallback id), all also gated on `caps.canManageTickets`.
- **Scroll-to-latest:** the thread reuses `useThreadScroll` (built in ui-phase-10, explicitly earmarked in
its own docstring for "phase 11's admin thread scrollbox next") — attaching its `bottomRef` after the
message list is the only change needed; the hook's own initial-scroll effect handles the rest.
- **Internal-note mode made unmistakable:** the composer `Paper` turns amber (`--bal-warning`/
`--bal-warning-soft`, both schemes) and the send button relabels to «ثبت یادداشت داخلی» whenever `mode ===
'internal'` — the safety cue now lives on the action itself, not only on the toggle above it.
- **Queue columns:** an activity column + a results footer (`AdminDataTable`'s new `footer` prop). REQ-028's
`unreadCount`/`lastMessageAt` are real on the **user** ticket list but the admin queue (`AdminTicketSummary`)
is explicitly served `unreadCount = 0` per that REQ's delivery note — this phase does **not** re-file that
admin-side extension (it's ui-phase-10's to own); the queue renders a `createdAt`-based activity column as
the honest fallback.
### 3.5 Money-desk safety
- **Fixed the UTC off-by-one:** `admin/payouts/page.tsx`'s `isoDate` helper now formats via local
`getFullYear()/getMonth()/getDate()` instead of `toISOString().slice()` — near Tehran midnight the
prefilled payout window no longer lands on yesterday. Adopted `JalaliDateField` for the period inputs.
- **Payout run confirm shows the movement summary + a typed confirmation:** the final run-confirm dialog now
shows the batch total (via `<Money>`, sourced from the already-fetched preview — **never** recomputed),
the eligible-nurse count, and the processing date, then requires typing «تایید» or the exact amount before
the confirm button enables. This is a new, generic capability on the **shared** `ConfirmDialog`
(`requireTypedConfirmation: string[]`/`typedConfirmationLabel`/`typedConfirmationPlaceholder`) — additive,
zero behavior change for every other caller, co-located test coverage added.
- **Reconcile/retry polish:** transfer-reference entry stays `dir="ltr"` (confirmed unchanged); failed-payout
rows keep their visible failure reason + `useRetryPayout` retry. `SkippedNurse.reason` was checked against
the real path (`previewPayoutBatch` currently always returns `skipped: []` — a REQ-036 gap; only the mock
invents string reasons) and correctly left `dir="ltr"` free text, per the phase note, rather than inventing
a translation map for something that isn't a documented stable code today.
- The payout-batch detail page is unified onto `PageHeader` and adopts the page-only slice of
`useAdminListState`.
### 3.6 Primitives v2
- **`AdminDataTable`:** optional per-column `sortable` + a `sort`/`onSortChange` pair (renders MUI's native
`TableSortLabel`, the caller owns the 3-state cycle), an opt-in `stickyHeader` (bounded scroll viewport,
`stickyMaxHeight`, MUI's own `stickyHeader` mechanics — a self-contained viewport rather than depending on
window-scroll math or a `layout/` import), per-column `minWidth`, and a `footer?: string` line (callers
pass the existing `t('showing_range', {from,to,total})` i18n key — it already existed in the `admin`
namespace, just unused until now). `align: 'inherit'` and the horizontal-scroll container are unchanged.
- **`AdminPager`:** the `admin.page_indicator` i18n key regained its `{total}` («صفحه {page} از {total}» —
it had regressed to `"صفحه {page}"` while the non-admin `common.page_indicator` kept the full form). The
component's own API is unchanged (it still takes a caller-composed `indicator` string, matching its
established pattern); every caller across the whole admin/partner surface now passes
`t('page_indicator', { page, total: pageCount })`.
- **Detail headers unified onto the shared `PageHeader`:** the four divergent patterns (verification case,
ticket thread, payout batch, partner-center detail) all now render through `PageHeader`. It gained two
small, additive props during integration: `meta?: ReactNode` (a chip-row slot below the title, distinct
from the button-oriented `actions` — the ticket thread's category/status/linked-record chips) and
`onBack?: () => void` (an alternative to `backTo` for `useAdminBackToList`-style back navigation, takes
precedence when both are given). Both are additive/optional; no existing caller changed behavior.
- **Jalali date inputs everywhere:** `JalaliDateField` replaces every native `type="date"` under `/admin`
audit from/to, the payout window, the holiday date, and the credential issued/expires fields. No Gregorian
native input remains anywhere in the backoffice.
- **`AuditLogRow`:** the expand chevron now rotates on open (CSS `transform`, `--bal-motion-fast`), the
header carries `role="button"`/`tabIndex`/`aria-expanded` + `Enter`/`Space` keyboard support (kept as a
`Stack` with ARIA semantics rather than a real `<button>`, avoiding a polymorphic-`component` TS friction
point). A new `actorLabel` prop (+ exported `actorDisplay`/`actorLabelFrom` helpers) renders the resolved
actor name, falling back to `#id` until the REQ-061 lookup lands; the audit page and the roles grid both
batch-resolve every visible actor id in one `useUserLookup` call.
### 3.7 Partner portal — professional, light-touch
- **Localized booking statuses:** the 7 wire codes (`pending_payment``cancelled`) now render through
`StatusChip` + translated labels (`bstatus_*`) in **both** the table and the filter menu — the worst
partner-facing defect the audit found (a raw English code shown to a Persian-speaking center admin) is
gone. `partner/bookings/page.tsx` also adopts `useAdminListState`.
- **Scoped read-only booking detail:** a new `partner/bookings/[id]/page.tsx` — dates, a status timeline
(the shared `StatusTimeline`), and the patient's display name only, **no** clinical content, address, or
money. Backed by a new `getMySponsoredBookingDetail` on `PartnerCenterApi` (REQ-064, gap — mock-backed
with a synthetic timeline consistent with the booking's current status).
- **CSV export on settlement:** a new dependency-free `client/src/utils/toCsv.ts` (CRLF line endings, proper
comma/quote escaping, co-located tests) drives a «خروجی CSV» button on `partner/settlement/page.tsx` — a
client-side, current-result-set export via a UTF-8-BOM-prefixed `Blob` download, so Persian text renders
correctly when opened in Excel.
- **Portal identity in the chrome:** `PartnerLayout`'s TopBar identity slot (already showing the center's
name) gained a compact merchant-of-record `StatusChip` beside it, so the MoR state is now visible on
every portal page, not only the home screen. Additive change to shared layout chrome; `ProfileSummary`
itself is untouched.
### 3.8 Dead ends & honest nav
- **`admin/users`** is now a real read-first directory (was a `PlaceholderScreen`) — search by name/phone
over the same REQ-061 seam `UserPicker` uses, role chips, and a per-row link into the audit log
(`/admin/audit?entityType=User&entityId={id}` — the one console that already supports an entity filter).
A ticket-queue deep link was deliberately **not** offered: the admin ticket queue has no actor/user filter
to land on, and a fake-looking link would be dishonest.
- **`admin/notifications`** stays a placeholder, but it was never in `AdminLayout`'s nav to begin with
(removed in ui-phase-10 — no real admin feed exists yet) — so "no placeholder screen reachable from the
admin nav" is already satisfied without further action this phase.
- Small verified cleanups: the dead `dataType==='int'||'decimal'?'text':'text'` ternary in
`admin/config/page.tsx` is gone (the `TextField` just has no `type` prop now — `text` was always the only
outcome); `admin/holidays/page.tsx`'s lying `TODAY_ISO = ''` constant is replaced by a real
`todayLocalIso()` helper (local date, not UTC — the same class of fix as the payout window).
## How this was built (process note)
Given the scope (8 independent subsystems touching ~50 files), four slices ran as **parallel background
agents** with disjoint file ownership (ticket console, verification desk, money desk, partner portal), each
briefed with the shared primitives' exact APIs up front; the remaining pages (roles, partners, alerts,
config, holidays, audit, reviews, users) and every shared primitive (`useAdminListState`, `UserPicker`/
`NursePicker`, `AdminDataTable`/`AdminPager` v2, `AuditLogRow` v2) were built directly. Two real integration
bugs surfaced only once all slices landed together: `useAdminListState<F extends Record<string, unknown>>`'s
generic constraint rejected every `interface`-declared filter type across five separate pages (TS quirk —
`interface`s don't structurally satisfy a `Record<string, unknown>` constraint the way object-literal types
do); fixed by dropping the constraint entirely (the hook never needed it). And the `setDraft(next); apply()`
same-handler stale-closure bug (§3.1) appeared independently in two pages; fixed once in the hook
(`applyFilters`) and both call sites updated. `PageHeader` was extended twice by two different agents in the
same window (`meta` and `onBack`) without collision — both compose cleanly. Full `npm run check` (0 errors)
and `npm run test:ci` (114 suites / 522 tests, all passing) were run only after every slice landed, to avoid
transient noise from concurrent in-flight edits.
## What is now testable (and exactly how)
1. `/fa/admin/tickets`: apply a status filter + go to page 2 → open a ticket → browser back → filter and
page intact; paste the list URL into a new tab → same view.
2. `/fa/admin/config`: page past page 1 (mock has >20 configs across data types once seeded further, or
verify the pager renders correctly at 1 page today — the mechanism is real either way) → edit a row →
saves; the history drawer pages too (`vat_rate`/`platform_fee_rate` have seeded history).
3. `/fa/admin/roles` → «اعطای نقش»: type a partial name → options show name + masked phone + id → pick one
→ the confirm text names the person, not a number.
4. `/fa/admin/alerts` before `/me` hydrates (throttle the network in devtools): "assign to me" is disabled
with a tooltip.
5. `/fa/admin/verification`: search a seeded nurse by name → row found; waiting-time column shows amber/red
for old cases; open a case → «پرونده بعدی» walks the queue without returning to the list; arrow keys work.
6. `/fa/admin/tickets/[id]`: open a long thread → scrolled to the newest message; toggle internal mode →
composer turns amber, send button reads «ثبت یادداشت داخلی»; close (behind `TICKET_LIFECYCLE_ENABLED`
flip it locally to demo) → leaves the open queue; reopen restores it.
7. `/fa/admin/payouts`: window defaults match today's local date (test near midnight or spoof the clock);
preview → run → the confirm shows total/count/date and stays disabled until «تایید» (or the exact amount)
is typed.
8. `/fa/admin/audit`: pick from/to with the Jalali picker (no native date input anywhere); expand a row →
chevron rotates, `aria-expanded` toggles, the actor shows a resolved name (or `#id` until resolved).
9. `/fa/partner/bookings`: statuses render as Persian `StatusChip`s in the table and the filter menu; click
a row → the scoped detail (dates/timeline/patient name only); `/fa/partner/settlement` → «خروجی CSV» opens
in Excel with correct Persian text.
10. `/fa/admin/users`: search a seeded name/phone (min 2 chars) → results show role chips + a working audit
link.
11. Every pager/footer reads «صفحه ۲ از ۷» / «نمایش ۱–۲۰ از ۱۲۴» with locale digits across all four axes
(`/fa`+`/en` × light+dark); no admin nav item leads to a placeholder.
## What is mocked / waiting on a real service
See `mocks-registry.md`'s updated `AdminApi`/`TicketsApi`/`VerificationApi`/`PartnerCenterApi` rows for the
full detail. Summary: **REQ-061** (admin user directory — `searchUsers`/`lookupUsers`) backs `UserPicker`/
`NursePicker`/`AuditLogRow`'s name resolve; **REQ-062** (verification queue `search` + whole-desk `counts`);
**REQ-063** (ticket close/reopen/assign + `assigneeUserId`, gated behind `TICKET_LIFECYCLE_ENABLED`);
**REQ-064** (partner scoped single-booking read + timeline). All four are built UI-complete against their
domain's mock, with `clientApi` already mapped to the proposed route — flipping the seam is a one-line
change per domain once each lands, no hook/component change.
## Contracts
No backend contract was consumed this phase (frontend-only phase). Requests filed in
`dev/shared-working-context/frontend/requests/for-backend.md`:
- **REQ-061** — Admin user lookup: name/phone search + batch id→label resolve.
- **REQ-062** — Verification queue enrichment: name/phone search + per-status counts.
- **REQ-063** — Ticket lifecycle mutations (close/reopen/assign).
- **REQ-064** — Partner scoped booking detail (read-only summary).
Not re-filed (referenced instead, per the phase's explicit instruction): ui-phase-10's REQ-059 (admin ticket
queue unread/last-activity enrichment).
## Docs updated
- `client/CLAUDE.md` — the `admin/`/`partner/` Project Structure subtrees rewritten for every page's
ui-phase-11 changes; a new `components/admin/` line describing `AdminDataTable` v2/`AdminPager`/
`AuditLogRow` v2/`UserPicker`/`NursePicker`; `PageHeader`/`ConfirmDialog` entries updated for their new
props; `hooks/` and `utils/` entries updated (`useAdminListState`/`useAdminBackToList`, `toCsv.ts`);
`PartnerLayout.tsx`'s line updated for the MoR chip; the `services/admin` domain bullet updated for the
user-directory hooks.
- `dev/shared-working-context/reports/mocks-registry.md``AdminApi`/`TicketsApi`/`VerificationApi`/
`PartnerCenterApi` rows updated with this phase's additions and the new REQ numbers.
- `dev/shared-working-context/frontend/requests/for-backend.md` — REQ-061…064 appended (see Contracts).
## Foundation extensions (minimal, per the ownership rules)
- **`components/common/PageHeader/PageHeader.tsx`** (phase-1-owned): additive `meta?: ReactNode` (chip-row
slot) and `onBack?: () => void` (callback back-nav, takes precedence over `backTo`) — both optional, zero
behavior change for existing callers, tests updated.
- **`components/common/ConfirmDialog/ConfirmDialog.tsx`** (phase-1-owned): additive
`requireTypedConfirmation?: string[]`/`typedConfirmationLabel?`/`typedConfirmationPlaceholder?` — the
typed "type X to proceed" guard for an irreversible action, generalized from the payout-run use case so
any future money-moving confirm can reuse it. Optional, zero behavior change when absent, tests updated.
- **`components/admin/SupportAlertCard.tsx`**: additive `assignSelfDisabled`/`assignSelfDisabledTitle` props.
- **`components/messaging/useThreadScroll.ts`** (ui-phase-10-owned, pre-earmarked for this handoff):
consumed as-is by the admin ticket thread — no changes needed.
## Follow-ups for later phases
- **REQ-061/062/063/064** as filed — each domain's real `clientApi` is already written and route-shaped;
flipping `USE_*_MOCK` (or `TICKET_LIFECYCLE_ENABLED` for REQ-063 specifically) is the only change needed
once the backend lands.
- **ui-phase-12 (copy/motion)** sweeps these surfaces per the standard handoff — the URL-synced list state,
the picker pattern, and the four new REQ numbers are the load-bearing decisions to carry forward (also
saved to persistent memory, see below).
- A full split-pane verification case view (queue rail + case detail) was explicitly deferred — the
next/prev affordance delivers the throughput win this phase asked for at a fraction of the layout risk;
worth revisiting if reviewer throughput is still a bottleneck after this ships.
- Admin ticket-queue `unreadCount`/`lastMessageAt` enrichment (the REQ-028 admin-side gap) stays
ui-phase-10's to file — referenced, not duplicated, here.