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