ui phase 11
This commit is contained in:
+22
-21
@@ -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 + 0–1 rate validation + audited-save dialog + change-history drawer
|
│ │ │ ├── config/page.tsx # /admin/config — f15 config editor: typed input by data_type + 0–1 rate validation + audited-save dialog + change-history drawer; ui-phase-11 gives both the list and the history drawer real page state (was hard-wired to page 1) and drops the dead `dataType==='int'||'decimal'?'text':'text'` ternary
|
||||||
│ │ │ ├── holidays/page.tsx # /admin/holidays — f15 Iranian-holiday manager (is_bank_closed toggle; client never computes the payout shift)
|
│ │ │ ├── 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
@@ -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 can’t pay a booking twice.",
|
"payout_run_confirm_body": "Money moves to nurses. This is protected by an idempotency key — a double-click can’t pay a booking twice.",
|
||||||
|
"payout_run_summary_intro": "This batch will run and the amount below will be transferred:",
|
||||||
|
"payout_run_count_label": "Nurse count",
|
||||||
|
"payout_run_type_to_confirm": "Type “CONFIRM” or the amount to proceed",
|
||||||
"payout_running": "Submitting to the bank rail…",
|
"payout_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": "Couldn’t open the invoice PDF.",
|
"invoice_pdf_error": "Couldn’t 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
@@ -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 1–2 of 2" />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Showing 1–2 of 2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onSortChange with the column key when a sortable header is clicked', () => {
|
||||||
|
const onSortChange = jest.fn();
|
||||||
|
const sortableColumns: AdminTableColumn<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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 id→label 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 0–1 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 id→label 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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 1–5, `sortOrder` 0–4). 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 1–5, `sortOrder` 0–4). 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 0–1 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 0–1 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 2–4-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.
|
||||||
Reference in New Issue
Block a user