ui phase 11
This commit is contained in:
+22
-21
@@ -185,31 +185,31 @@ client/
|
||||
│ │ │ ├── earnings/ # /nurse/earnings — f12 nurse earnings (read-only), ui-phase-7 pass: page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + a «برداشت بعدی» ForecastLine (server-served only) + an accessible ButtonBase ExplainerCard (aria-expanded, registered `expand` chevron) + state-segmented EarningsRow list (deep-links to /nurse/visits/[id], shared Pager) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links); failed-payout reasons now map through `services/payouts/failureReasons.ts` (mapped label headline, raw code demoted to a secondary LTR caption)
|
||||
│ │ │ ├── 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 `<Suspense>` 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/ # <Money amountIrr size tone deduction hideUnit strikethrough> — the one money-rendering primitive (wraps utils/money.ts); size gained `xl` (h4) in ui-phase-6 for the checkout/confirmation prominent-total hero; imports next-intl (see jest.config.ts transformIgnorePatterns note below)
|
||||
@@ -245,6 +245,7 @@ client/
|
||||
│ │ ├── InitialsAvatar/ # ui-phase-9 — warm auto-colored initials for a person with no photo: deterministic name-hash → one of 6 `--bal-avatar-*` token pairs (tokens.css, both scheme blocks); `aria-hidden` (decorative next to a visible name); used by `PatientHeader` and (via `ProfileSummary`'s new `initialsFallback` prop) the customer account hub (tested)
|
||||
│ │ ├── 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 `<Suspense>` boundary) mirroring **applied** filters+page into the URL (draft-vs-applied; `apply`/`applyFilters`/`clear`/`goToPage`) + `useAdminBackToList` (a real `router.back()` with a list-route fallback), adopted by every admin/partner queue page
|
||||
├── utils/ # incl. money.ts (IRR/Toman, integer-safe) + date.ts (Shamsi display) + number.ts (localeTag/formatNumber/formatRelativeTime/formatClock — the one home for locale-ternary formatting) + toEnglishDigits + 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
|
||||
```
|
||||
|
||||
|
||||
+61
-4
@@ -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",
|
||||
|
||||
+60
-3
@@ -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": {
|
||||
|
||||
@@ -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<string, string> {
|
||||
const r: Record<string, string> = {};
|
||||
if (filters.status) r.status = filters.status;
|
||||
if (filters.type) r.type = filters.type;
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Support-alert triage board (f15) — the **internal-only** worklist over `support_alerts`. Filter by
|
||||
* 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 (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminAlertsPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
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<SupportAlertStatus | ''>('open');
|
||||
const [type, setType] = useState<SupportAlertType | ''>('');
|
||||
const [page, setPage] = useState(1);
|
||||
const listState = useAdminListState<AlertFilters>({ parse: parseFilters, serialize: serializeFilters, empty: EMPTY });
|
||||
const { applied: filters, page } = listState;
|
||||
const [resolving, setResolving] = useState<SupportAlert | null>(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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader
|
||||
@@ -79,11 +118,8 @@ export default function AdminAlertsPage() {
|
||||
select
|
||||
size="small"
|
||||
label={t('alert_col_status')}
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value as SupportAlertStatus | '');
|
||||
setPage(1);
|
||||
}}
|
||||
value={filters.status ?? ''}
|
||||
onChange={(e) => setStatusFilter(e.target.value as SupportAlertStatus | '')}
|
||||
sx={{ minWidth: 140 }}
|
||||
>
|
||||
<MenuItem value="">{t('filter_all')}</MenuItem>
|
||||
@@ -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 }}
|
||||
>
|
||||
<MenuItem value="">{t('filter_all')}</MenuItem>
|
||||
@@ -130,6 +163,8 @@ export default function AdminAlertsPage() {
|
||||
canAct={caps.canManageAlerts}
|
||||
onAssignSelf={onAssignSelf}
|
||||
onResolve={setResolving}
|
||||
assignSelfDisabled={meId == null}
|
||||
assignSelfDisabledTitle={t('assign_me_loading')}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -138,11 +173,11 @@ export default function AdminAlertsPage() {
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => 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 })}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Suspense, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Box, Skeleton, Stack, TextField } from '@mui/material';
|
||||
import { AppButton } from '@/components';
|
||||
import { AppButton, AppLoading, JalaliDateField } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, AuditLogRow } from '@/components/admin';
|
||||
import { actorLabelFrom } from '@/components/admin/AuditLogRow';
|
||||
import { useAdminListState } from '@/hooks';
|
||||
import { AUDIT_PAGE_SIZE } from '@/services/admin/constants';
|
||||
import type { AuditFilters } from '@/services/admin/types';
|
||||
import { useAuditLogs } from '@/services/admin';
|
||||
import { useAuditLogs, useUserLookup } from '@/services/admin';
|
||||
|
||||
const EMPTY: AuditFilters = {};
|
||||
|
||||
function parseFilters(params: URLSearchParams): AuditFilters {
|
||||
return {
|
||||
entityType: params.get('entityType') ?? undefined,
|
||||
entityId: params.get('entityId') ?? undefined,
|
||||
from: params.get('from') ?? undefined,
|
||||
to: params.get('to') ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeFilters(filters: AuditFilters): Record<string, string> {
|
||||
const r: Record<string, string> = {};
|
||||
if (filters.entityType) r.entityType = filters.entityType;
|
||||
if (filters.entityId) r.entityId = filters.entityId;
|
||||
if (filters.from) r.from = filters.from;
|
||||
if (filters.to) r.to = filters.to;
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append-only audit-log viewer (f15) — a read-only, filtered, paginated table of every admin state change,
|
||||
* 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 (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminAuditPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminAuditPageInner() {
|
||||
const t = useTranslations('admin');
|
||||
const [draft, setDraft] = useState<AuditFilters>(EMPTY);
|
||||
const [applied, setApplied] = useState<AuditFilters>(EMPTY);
|
||||
const [page, setPage] = useState(1);
|
||||
const listState = useAdminListState<AuditFilters>({ 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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
@@ -60,26 +89,24 @@ export default function AdminAuditPage() {
|
||||
onChange={(e) => setDraft((d) => ({ ...d, entityId: e.target.value || undefined }))}
|
||||
sx={{ minWidth: 120 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
<JalaliDateField
|
||||
label={t('audit_from')}
|
||||
value={draft.from ?? ''}
|
||||
onChange={(e) => 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 }}
|
||||
/>
|
||||
<TextField
|
||||
size="small"
|
||||
type="date"
|
||||
<JalaliDateField
|
||||
label={t('audit_to')}
|
||||
value={draft.to ?? ''}
|
||||
onChange={(e) => 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 }}
|
||||
/>
|
||||
<AppButton variant="contained" color="primary" onClick={apply}>
|
||||
<AppButton variant="contained" color="primary" onClick={listState.apply}>
|
||||
{t('apply')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="inherit" onClick={clear}>
|
||||
<AppButton variant="text" color="inherit" onClick={listState.clear}>
|
||||
{t('clear')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
@@ -93,19 +120,22 @@ export default function AdminAuditPage() {
|
||||
) : (
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{items.map((entry) => (
|
||||
<AuditLogRow key={entry.id} entry={entry} />
|
||||
<AuditLogRow key={entry.id} entry={entry} actorLabel={actorLabelFrom(userLookup.data, entry.actorUserId)} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{items.length > 0 ? (
|
||||
<Box sx={{ typography: 'caption', color: 'text.secondary' }}>{t('showing_range', { from, to, total })}</Box>
|
||||
) : null}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => 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 })}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminConfigPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminConfigPageInner() {
|
||||
const t = useTranslations('admin');
|
||||
const caps = useAdminCapabilities();
|
||||
const configs = usePlatformConfigs(1);
|
||||
const listState = useAdminListState<Record<string, never>>({ parse: () => ({}), serialize: () => ({}), empty: {} });
|
||||
const configs = usePlatformConfigs(listState.page);
|
||||
const [editing, setEditing] = useState<PlatformConfig | null>(null);
|
||||
const [historyKey, setHistoryKey] = useState<string | null>(null);
|
||||
|
||||
@@ -101,6 +110,19 @@ export default function AdminConfigPage() {
|
||||
))
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={listState.page}
|
||||
pageCount={Math.max(1, Math.ceil((configs.data?.total ?? 0) / (configs.data?.pageSize ?? 1)))}
|
||||
onPrev={() => listState.goToPage(Math.max(1, listState.page - 1))}
|
||||
onNext={() => listState.goToPage(listState.page + 1)}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', {
|
||||
page: listState.page,
|
||||
total: Math.max(1, Math.ceil((configs.data?.total ?? 0) / (configs.data?.pageSize ?? 1))),
|
||||
})}
|
||||
/>
|
||||
|
||||
{editing ? <ConfigEditDialog config={editing} onClose={() => setEditing(null)} /> : null}
|
||||
<ConfigHistoryDrawer configKey={historyKey} onClose={() => setHistoryKey(null)} />
|
||||
</Box>
|
||||
@@ -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 (
|
||||
<Drawer anchor={anchor} open={configKey != null} onClose={onClose} slotProps={{ paper: { sx: { width: { xs: '100%', sm: 420 }, p: 3 } } }}>
|
||||
<Drawer anchor={anchor} open={configKey != null} onClose={close} slotProps={{ paper: { sx: { width: { xs: '100%', sm: 420 }, p: 3 } } }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">{configKey ? t('cfg_history_title', { key: configKey }) : ''}</Typography>
|
||||
<AppButton variant="text" color="inherit" onClick={onClose} sx={{ minWidth: 0 }}>
|
||||
<AppButton variant="text" color="inherit" onClick={close} sx={{ minWidth: 0 }}>
|
||||
<AppIcon icon="close" />
|
||||
</AppButton>
|
||||
</Stack>
|
||||
@@ -213,6 +241,16 @@ function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null;
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page, total: pageCount })}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminHolidaysPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminHolidaysPageInner() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const caps = useAdminCapabilities();
|
||||
const holidays = useHolidays({}, 1);
|
||||
const listState = useAdminListState<Record<string, never>>({ parse: () => ({}), serialize: () => ({}), empty: {} });
|
||||
const holidays = useHolidays({}, listState.page);
|
||||
const [editing, setEditing] = useState<Holiday | 'new' | null>(null);
|
||||
const pageCount = Math.max(1, Math.ceil((holidays.data?.total ?? 0) / ADMIN_PAGE_SIZE));
|
||||
|
||||
const columns: AdminTableColumn<Holiday>[] = [
|
||||
{ key: 'date', header: t('hol_col_date'), render: (h) => formatShamsiDate(h.holidayDate, locale) },
|
||||
@@ -96,6 +117,16 @@ export default function AdminHolidaysPage() {
|
||||
<AdminDataTable columns={columns} rows={holidays.data?.items ?? []} getRowKey={(h) => h.id} ariaLabel={t('hol_title')} />
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={listState.page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => listState.goToPage(Math.max(1, listState.page - 1))}
|
||||
onNext={() => listState.goToPage(Math.min(pageCount, listState.page + 1))}
|
||||
prevLabel={t('prev_page')}
|
||||
nextLabel={t('next_page')}
|
||||
indicator={t('page_indicator', { page: listState.page, total: pageCount })}
|
||||
/>
|
||||
|
||||
{editing ? (
|
||||
<HolidayDialog holiday={editing === 'new' ? null : editing} onClose={() => 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<HolidayInput>({
|
||||
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? TODAY_ISO,
|
||||
const [form, setForm] = useState<HolidayInput>(() => ({
|
||||
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:
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack sx={{ gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
type="date"
|
||||
<JalaliDateField
|
||||
label={t('hol_col_date')}
|
||||
value={form.holidayDate}
|
||||
onChange={(e) => setForm((f) => ({ ...f, holidayDate: e.target.value }))}
|
||||
value={form.holidayDate || null}
|
||||
onChange={(iso) => setForm((f) => ({ ...f, holidayDate: iso }))}
|
||||
disabled={!!holiday}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label={t('hol_name_fa')}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
import { ReactNode, 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 { Box, Divider, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, StatusChip, TrustBadge } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState, ConfirmDialog } from '@/components/admin';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, PageHeader, StatusChip, TrustBadge } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState, ConfirmDialog, NursePicker } from '@/components/admin';
|
||||
import { useAdminCapabilities, useAdminBackToList } from '@/hooks';
|
||||
import { ROUTES } from '@/constants';
|
||||
import type { AdminUserSummary } from '@/services/admin/types';
|
||||
import {
|
||||
usePartnerCenter,
|
||||
useCenterSponsoredNurses,
|
||||
@@ -27,9 +28,9 @@ import { CENTER_STATE_KIND, PartnerCenterFormDialog } from '../page';
|
||||
export default function AdminPartnerCenterDetailPage() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const caps = useAdminCapabilities();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const goBack = useAdminBackToList(`/${locale}${ROUTES.ADMIN_PARTNERS}`);
|
||||
|
||||
const params = useParams<{ id: string }>();
|
||||
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<AdminUserSummary | null>(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 = (
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
startIcon="partners"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PARTNERS}`)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('back')}
|
||||
</AppButton>
|
||||
);
|
||||
const back = <PageHeader title={t('partner_detail_title')} onBack={goBack} backLabel={t('back')} />;
|
||||
|
||||
if (center.isLoading) {
|
||||
return (
|
||||
@@ -124,18 +115,13 @@ export default function AdminPartnerCenterDetailPage() {
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
{back}
|
||||
<Stack direction="row" sx={{ gap: 1.5, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
|
||||
{t('partner_detail_title')}
|
||||
</Typography>
|
||||
<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} />
|
||||
</Stack>
|
||||
<Typography variant="subtitle1" sx={{ color: 'text.secondary' }}>
|
||||
{data.name}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<PageHeader
|
||||
title={t('partner_detail_title')}
|
||||
subtitle={data.name}
|
||||
onBack={goBack}
|
||||
backLabel={t('back')}
|
||||
meta={<StatusChip status={CENTER_STATE_KIND[data.onboardingState]} label={t(`center_state_${data.onboardingState}`)} />}
|
||||
/>
|
||||
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack divider={<Divider flexItem />} sx={{ gap: 1.25 }}>
|
||||
@@ -221,21 +207,22 @@ export default function AdminPartnerCenterDetailPage() {
|
||||
|
||||
{caps.canManagePartners ? (
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
label={t('partner_assign_nurse_ph')}
|
||||
value={assignId}
|
||||
onChange={(e) => setAssignId(e.target.value)}
|
||||
slotProps={{ htmlInput: { min: 1, step: 1 } }}
|
||||
sx={{ minWidth: 200 }}
|
||||
/>
|
||||
<Box sx={{ minWidth: 260 }}>
|
||||
<NursePicker
|
||||
value={assignNurseUser}
|
||||
onChange={setAssignNurseUser}
|
||||
label={t('partner_assign_nurse_ph')}
|
||||
placeholder={t('user_picker_search_ph')}
|
||||
noOptionsText={t('user_picker_no_options')}
|
||||
loadingText={t('user_picker_loading')}
|
||||
/>
|
||||
</Box>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon="assign"
|
||||
onClick={onAssign}
|
||||
disabled={assignNurse.isPending || Number(assignId) <= 0}
|
||||
disabled={assignNurse.isPending || assignNurseUser?.nurseProfileId == null}
|
||||
sx={{ mt: 0.25 }}
|
||||
>
|
||||
{t('partner_assign_nurse')}
|
||||
|
||||
@@ -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<string, never>;
|
||||
const EMPTY_FILTERS: PartnersListFilters = {};
|
||||
|
||||
/** State → semantic chip color. verified = green, pending = amber, suspended = red, draft = neutral. */
|
||||
export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
|
||||
verified: 'verified',
|
||||
@@ -48,16 +55,33 @@ export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
|
||||
* it is only ever entered here, never displayed (the list carries no IBAN at all).
|
||||
*/
|
||||
export default function AdminPartnersPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminPartnersPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
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<PartnersListFilters>({
|
||||
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<PartnerCenter>[] = [
|
||||
{ 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => 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 ? <PartnerCenterFormDialog center={null} onClose={() => setCreating(false)} /> : null}
|
||||
@@ -115,7 +140,9 @@ export default function AdminPartnersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs. */
|
||||
/** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs — except
|
||||
* `adminUser`, the resolved picker selection (3.2), never a hand-typed id. `undefined` = untouched (the
|
||||
* edit-mode existing admin, once resolved, still shows); `null` = the admin explicitly cleared the field. */
|
||||
interface CenterFormState {
|
||||
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<CenterFormState>(() => initialForm(center));
|
||||
|
||||
// Edit mode: the center already has an adminUserId (a plain number) — resolve it to a name so the picker
|
||||
// opens pre-filled with a person, never a bare id (3.2). Derived in render (never synced into state via an
|
||||
// effect): once the admin actually picks someone, `form.adminUser` wins over the resolved existing one.
|
||||
const existingAdminId = center?.adminUserId ?? null;
|
||||
const existingAdminLookup = useUserLookup(existingAdminId != null ? [existingAdminId] : []);
|
||||
const resolvedExistingAdmin = existingAdminId != null ? (existingAdminLookup.data?.get(existingAdminId) ?? null) : null;
|
||||
const displayedAdminUser = form.adminUser !== undefined ? form.adminUser : resolvedExistingAdmin;
|
||||
|
||||
const set = <K extends keyof CenterFormState>(key: K, value: CenterFormState[K]) =>
|
||||
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 } }}
|
||||
/>
|
||||
<TextField
|
||||
type="number"
|
||||
<UserPicker
|
||||
value={displayedAdminUser}
|
||||
onChange={(u) => 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')}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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<string, never>;
|
||||
const EMPTY_FILTERS: BatchRowsFilters = {};
|
||||
|
||||
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
|
||||
draft: 'neutral',
|
||||
processing: 'info',
|
||||
@@ -35,33 +39,37 @@ const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
|
||||
* `canPayout`. Money is display-only Toman; the client never recomputes amounts, eligibility, or dates.
|
||||
*/
|
||||
export default function AdminPayoutBatchDetailPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminPayoutBatchDetailScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wrapped in `<Suspense>` above — `useAdminListState` calls `useSearchParams()`, which requires it. */
|
||||
function AdminPayoutBatchDetailScreen() {
|
||||
const t = useTranslations('admin');
|
||||
const 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<BatchRowsFilters>({
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PAYOUTS}`)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('back')}
|
||||
</AppButton>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
|
||||
{t('payout_batch_title', { id: batchId })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<PageHeader
|
||||
title={t('payout_batch_title', { id: batchId })}
|
||||
backTo={`/${locale}${ROUTES.ADMIN_PAYOUTS}`}
|
||||
backLabel={t('back')}
|
||||
/>
|
||||
|
||||
{detail.isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
@@ -116,11 +124,11 @@ export default function AdminPayoutBatchDetailPage() {
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => 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 })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 ? <PreviewBatchDialog canPayout={caps.canPayout} onClose={() => setPreviewOpen(false)} /> : null}
|
||||
@@ -230,22 +239,13 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{t('payout_preview_title')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack direction="row" sx={{ gap: 1.5, mt: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<TextField
|
||||
type="date"
|
||||
<JalaliDateField
|
||||
size="small"
|
||||
label={t('payout_period_start')}
|
||||
value={periodStart}
|
||||
onChange={(e) => setPeriodStart(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
label={t('payout_period_end')}
|
||||
value={periodEnd}
|
||||
onChange={(e) => setPeriodEnd(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
onChange={setPeriodStart}
|
||||
/>
|
||||
<JalaliDateField size="small" label={t('payout_period_end')} value={periodEnd} onChange={setPeriodEnd} />
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
@@ -361,12 +361,41 @@ function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClos
|
||||
<ConfirmDialog
|
||||
open={runConfirmOpen}
|
||||
title={t('payout_run_confirm_title')}
|
||||
body={t('payout_run_confirm_body')}
|
||||
body={
|
||||
result ? (
|
||||
<Stack sx={{ gap: 1.5, mt: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('payout_run_summary_intro')}
|
||||
</Typography>
|
||||
<Money amountIrr={result.totalNetIrr} size="lg" tone="emphasis" />
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('payout_run_count_label')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{formatNumber(result.eligible.length, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('payout_col_processing')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{formatShamsiDate(result.processingDate, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : null
|
||||
}
|
||||
confirmLabel={t('payout_run')}
|
||||
cancelLabel={t('cancel')}
|
||||
onConfirm={onRunConfirm}
|
||||
onClose={() => setRunConfirmOpen(false)}
|
||||
loading={run.isPending}
|
||||
requireTypedConfirmation={result ? ['تایید', result.totalNetIrr] : []}
|
||||
typedConfirmationLabel={t('payout_run_type_to_confirm')}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -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<ModerationStatus, 'default' | 'success' | 'warni
|
||||
* Publishing recomputes the nurse aggregate **server-side**; the mutation invalidates the queue so the row
|
||||
* leaves on success. `canModerate` only hides the controls — the server enforces the role scope.
|
||||
*/
|
||||
const DEFAULT_STATUS: ModerationStatus = 'pending_moderation';
|
||||
|
||||
function parseFilters(params: URLSearchParams): { status: ModerationStatus } {
|
||||
const status = params.get('status') as ModerationStatus | null;
|
||||
return { status: status && MODERATION_STATUSES.includes(status) ? status : DEFAULT_STATUS };
|
||||
}
|
||||
|
||||
export default function AdminReviewsPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminReviewsPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminReviewsPageInner() {
|
||||
const t = useTranslations('admin');
|
||||
const caps = useAdminCapabilities();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const [status, setStatus] = useState<ModerationStatus>('pending_moderation');
|
||||
const [page, setPage] = useState(1);
|
||||
const listState = useAdminListState<{ status: ModerationStatus }>({
|
||||
parse: parseFilters,
|
||||
serialize: (f): Record<string, string> => (f.status !== DEFAULT_STATUS ? { status: f.status } : {}),
|
||||
empty: { status: DEFAULT_STATUS },
|
||||
});
|
||||
const status = listState.applied.status;
|
||||
const page = listState.page;
|
||||
const [pending, setPending] = useState<{ item: ModerationQueueItem; action: ModerationAction } | null>(null);
|
||||
|
||||
const 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() {
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => 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 })}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* grid lists **active** grants (revoked rows are filtered out); an audited confirm dialog fronts every
|
||||
* revoke and grant.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
@@ -31,12 +31,13 @@ import {
|
||||
AdminErrorState,
|
||||
AdminPageHeader,
|
||||
ConfirmDialog,
|
||||
UserPicker,
|
||||
type AdminTableColumn,
|
||||
} from '@/components/admin';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import type { AdminRole, RoleGrant } from '@/services/admin/types';
|
||||
import { useAdminRoles, useGrantRole, useRevokeRole } from '@/services/admin';
|
||||
import type { AdminRole, AdminUserSummary, RoleGrant } from '@/services/admin/types';
|
||||
import { useAdminRoles, useGrantRole, useRevokeRole, useUserLookup } from '@/services/admin';
|
||||
|
||||
/** The fine-grained admin roles the grid grants (aligned with the b2 `AdminRole` enum). */
|
||||
const ROLES: readonly AdminRole[] = ['super_admin', 'admin', 'support', 'finance', 'moderation'];
|
||||
@@ -56,8 +57,13 @@ export default function AdminRolesPage() {
|
||||
// Only active grants — a revoked grant leaves the grid.
|
||||
const active = (roles.data ?? []).filter((g) => g.revokedAt == null);
|
||||
|
||||
// Batch id→name resolve (3.2) — one request for every grant row, never one per row.
|
||||
const userIds = useMemo(() => [...new Set(active.map((g) => g.userId))].sort((a, b) => a - b), [active]);
|
||||
const userLookup = useUserLookup(userIds);
|
||||
const nameFor = (userId: number): string => userLookup.data?.get(userId)?.displayName ?? `#${userId}`;
|
||||
|
||||
const columns: AdminTableColumn<RoleGrant>[] = [
|
||||
{ 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() {
|
||||
<ConfirmDialog
|
||||
open={revoking != null}
|
||||
title={t('role_revoke')}
|
||||
body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), id: revoking.userId }) : undefined}
|
||||
body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), name: nameFor(revoking.userId) }) : undefined}
|
||||
confirmLabel={t('role_revoke')}
|
||||
cancelLabel={t('cancel')}
|
||||
confirmColor="error"
|
||||
@@ -149,16 +155,15 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const grant = useGrantRole();
|
||||
|
||||
const [userId, setUserId] = useState('');
|
||||
const [user, setUser] = useState<AdminUserSummary | null>(null);
|
||||
const [role, setRole] = useState<AdminRole>('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 }) {
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{t('role_grant')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack sx={{ gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
<UserPicker
|
||||
value={user}
|
||||
onChange={setUser}
|
||||
label={t('role_col_user')}
|
||||
type="number"
|
||||
value={userId}
|
||||
onChange={(e) => 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')}
|
||||
/>
|
||||
<TextField select label={t('role_col_role')} value={role} onChange={(e) => setRole(e.target.value as AdminRole)}>
|
||||
{ROLES.map((r) => (
|
||||
@@ -188,7 +194,7 @@ function GrantRoleDialog({ onClose }: { onClose: () => void }) {
|
||||
))}
|
||||
</TextField>
|
||||
{valid ? (
|
||||
<DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), id: parsedId })}</DialogContentText>
|
||||
<DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), name: user.displayName })}</DialogContentText>
|
||||
) : null}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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' ? (
|
||||
<AppButton variant="outlined" color="primary" startIcon="close" onClick={() => setCloseDialogOpen(true)}>
|
||||
{t('ticket_close')}
|
||||
</AppButton>
|
||||
) : (
|
||||
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => setReopenDialogOpen(true)}>
|
||||
{t('ticket_reopen')}
|
||||
</AppButton>
|
||||
)}
|
||||
<Tooltip title={meId == null ? t('assign_me_loading') : ''}>
|
||||
<span>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="assign"
|
||||
onClick={onAssignMe}
|
||||
disabled={meId == null || assignTicket.isPending}
|
||||
>
|
||||
{t('ticket_assign_me')}
|
||||
</AppButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: 820, mx: 'auto', width: '100%' }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_TICKETS}`)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
<AppButton variant="text" color="primary" onClick={goBack} sx={{ alignSelf: 'flex-start' }}>
|
||||
{t('back')}
|
||||
</AppButton>
|
||||
|
||||
@@ -106,44 +167,41 @@ export default function AdminTicketThreadPage() {
|
||||
) : (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Typography variant="h6" component="h1" sx={{ fontWeight: 800 }} dir="ltr">
|
||||
{t('ticket_thread_title', { ref: detail.referenceCode })}
|
||||
</Typography>
|
||||
{detail.subject ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{detail.subject}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Chip size="small" label={t(`tcat_${detail.category}`)} />
|
||||
<StatusChip status={STATUS_KIND[detail.status]} label={t(`tstatus_${detail.status}`)} />
|
||||
{detail.bookingId != null ? (
|
||||
<Chip size="small" variant="outlined" label={t('refund_linked_booking', { id: detail.bookingId })} />
|
||||
) : null}
|
||||
{detail.refundId != null ? (
|
||||
<Chip size="small" variant="outlined" label={t('ticket_linked_refund', { id: detail.refundId })} />
|
||||
) : null}
|
||||
</Stack>
|
||||
<PageHeader
|
||||
title={t('ticket_thread_title', { ref: detail.referenceCode })}
|
||||
subtitle={detail.subject ?? undefined}
|
||||
actions={lifecycleActions}
|
||||
meta={
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Chip size="small" label={t(`tcat_${detail.category}`)} />
|
||||
<StatusChip status={STATUS_KIND[detail.status]} label={t(`tstatus_${detail.status}`)} />
|
||||
{detail.bookingId != null ? (
|
||||
<Chip size="small" variant="outlined" label={t('refund_linked_booking', { id: detail.bookingId })} />
|
||||
) : null}
|
||||
{detail.refundId != null ? (
|
||||
<Chip size="small" variant="outlined" label={t('ticket_linked_refund', { id: detail.refundId })} />
|
||||
) : null}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
{showRefund ? (
|
||||
<Box>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="refunds"
|
||||
onClick={() => setRefundShown((v) => !v)}
|
||||
>
|
||||
{t('refund_open')}
|
||||
</AppButton>
|
||||
<Collapse in={refundShown} unmountOnExit>
|
||||
<Paper elevation={0} sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
{showRefund ? (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="refunds"
|
||||
onClick={() => setRefundShown((v) => !v)}
|
||||
>
|
||||
{t('refund_open')}
|
||||
</AppButton>
|
||||
<Collapse in={refundShown} unmountOnExit>
|
||||
<Paper elevation={0} sx={{ mt: 1.5, p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<RefundPanel bookingId={detail.bookingId as number} ticketId={detail.id} />
|
||||
</Paper>
|
||||
</Collapse>
|
||||
</Box>
|
||||
) : null}
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, maxHeight: 520, overflowY: 'auto', p: 0.5 }}>
|
||||
@@ -154,10 +212,20 @@ export default function AdminTicketThreadPage() {
|
||||
authorLabel={tickets(authorLabelKey(m.authorRole))}
|
||||
/>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</Box>
|
||||
|
||||
{caps.canManageTickets ? (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: isInternal ? 'var(--bal-warning)' : 'divider',
|
||||
borderRadius: 2,
|
||||
bgcolor: isInternal ? 'var(--bal-warning-soft)' : 'background.paper',
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
@@ -185,11 +253,34 @@ export default function AdminTicketThreadPage() {
|
||||
disabled={post.isPending || body.trim().length === 0}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('ticket_send')}
|
||||
{isInternal ? t('ticket_send_internal') : t('ticket_send')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={closeDialogOpen}
|
||||
title={t('ticket_close')}
|
||||
body={t('ticket_close_confirm')}
|
||||
confirmLabel={t('ticket_close')}
|
||||
cancelLabel={t('cancel')}
|
||||
onConfirm={onCloseConfirm}
|
||||
onClose={() => setCloseDialogOpen(false)}
|
||||
loading={closeTicket.isPending}
|
||||
confirmColor="primary"
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={reopenDialogOpen}
|
||||
title={t('ticket_reopen')}
|
||||
body={t('ticket_reopen_confirm')}
|
||||
confirmLabel={t('ticket_reopen')}
|
||||
cancelLabel={t('cancel')}
|
||||
onConfirm={onReopenConfirm}
|
||||
onClose={() => setReopenDialogOpen(false)}
|
||||
loading={reopenTicket.isPending}
|
||||
confirmColor="primary"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -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<TicketStatus, StatusKind> = { 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<string, string> {
|
||||
const record: Record<string, string> = {};
|
||||
if (filters.status) record.status = filters.status;
|
||||
if (filters.category) record.category = filters.category;
|
||||
if (filters.referenceCode) record.referenceCode = filters.referenceCode;
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* The admin global ticket queue (f15) — EVERY ticket across the platform (not one viewer's), the entry point
|
||||
* 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 (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminTicketsQueue />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminTicketsQueue() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const [draft, setDraft] = useState<AdminTicketFilters>(EMPTY);
|
||||
const [applied, setApplied] = useState<AdminTicketFilters>(EMPTY);
|
||||
const [page, setPage] = useState(1);
|
||||
const { draft, setDraft, applied, page, apply, clear, goToPage } = useAdminListState<AdminTicketFilters>({
|
||||
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<AdminTicketSummary>[] = [
|
||||
{
|
||||
@@ -76,6 +98,12 @@ export default function AdminTicketsPage() {
|
||||
render: (row) => <StatusChip status={STATUS_KIND[row.status]} label={t(`tstatus_${row.status}`)} />,
|
||||
},
|
||||
{ key: 'booking', header: t('ticket_col_booking'), render: (row) => row.bookingId ?? '—' },
|
||||
{
|
||||
key: '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 })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => 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 })}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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 <PlaceholderScreen icon="users" title={t('users')} description={tShell('placeholder_body')} />;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
|
||||
/** Directory role → chip label key. */
|
||||
const ROLE_LABEL_KEY: Record<DirectoryUserRole, string> = {
|
||||
customer: 'user_role_customer',
|
||||
nurse: 'user_role_nurse',
|
||||
admin: 'user_role_admin',
|
||||
partner: 'user_role_partner',
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-first admin user directory (phase §3.8) — search by name/phone over the same admin user-directory
|
||||
* seam `UserPicker`/`NursePicker` use (REQ-061, gap — mock-backed until delivered). Built because the REQ
|
||||
* this console depends on is filed and mocked-behind-the-seam this same phase; it becomes real the moment
|
||||
* `services/admin`'s seam flips. Each row links into the audit log filtered to that user (the one console
|
||||
* that already supports an entity filter) — a ticket-queue deep link isn't offered because the admin
|
||||
* ticket queue has no actor/user filter to land on (would be a dishonest link).
|
||||
*/
|
||||
export default function AdminUsersPage() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const [query, setQuery] = useState('');
|
||||
const debounced = useDebouncedValue(query, SEARCH_DEBOUNCE_MS);
|
||||
const search = useUserSearch(debounced);
|
||||
const items = search.data ?? [];
|
||||
const hasQuery = debounced.trim().length >= MIN_QUERY_LENGTH;
|
||||
|
||||
const columns: AdminTableColumn<AdminUserSummary>[] = [
|
||||
{ key: 'name', header: t('user_col_name'), render: (u) => u.displayName, minWidth: 180 },
|
||||
{
|
||||
key: 'phone',
|
||||
header: t('user_col_phone'),
|
||||
render: (u) => (
|
||||
<Box component="span" dir="ltr">
|
||||
{u.maskedPhone}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{ key: 'id', header: t('user_col_id'), render: (u) => `#${u.id}` },
|
||||
{
|
||||
key: 'roles',
|
||||
header: t('user_col_roles'),
|
||||
render: (u) => (
|
||||
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
|
||||
{u.roles.map((r) => (
|
||||
<Chip key={r} size="small" variant="outlined" label={t(ROLE_LABEL_KEY[r])} />
|
||||
))}
|
||||
</Stack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'right' as const,
|
||||
render: (u) => (
|
||||
<AppLink to={`/${locale}${ROUTES.ADMIN_AUDIT}?entityType=User&entityId=${u.id}`}>{t('user_view_audit')}</AppLink>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader title={t('user_title')} subtitle={t('user_subtitle')} />
|
||||
|
||||
<TextField
|
||||
size="small"
|
||||
label={t('user_search_label')}
|
||||
placeholder={t('user_picker_search_ph')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
sx={{ maxWidth: 360 }}
|
||||
/>
|
||||
|
||||
{!hasQuery ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('user_search_hint')}
|
||||
</Typography>
|
||||
) : search.isFetching ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('user_picker_loading')}
|
||||
</Typography>
|
||||
) : items.length === 0 ? (
|
||||
<AdminEmptyState icon="users" title={t('user_empty')} />
|
||||
) : (
|
||||
<AdminDataTable columns={columns} rows={items} getRowKey={(u) => u.id} ariaLabel={t('user_title')} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
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<VerificationStepStatus, StatusKind> = {
|
||||
expired: 'rejected',
|
||||
};
|
||||
|
||||
export default function AdminVerificationCasePage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminVerificationCaseScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification case (b6 `AdminVerificationsController`) — the trust desk works one nurse: the identity on
|
||||
* file for cross-check, every step with its status + documents (each `DocumentViewer` re-signs its own
|
||||
@@ -55,12 +59,18 @@ const STEP_STATUS_KIND: Record<VerificationStepStatus, StatusKind> = {
|
||||
* (encrypted) credential via a structured form; recorded credentials are listed by **type** only — the
|
||||
* 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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<AppButton variant="text" color="primary" onClick={backToQueue} sx={{ alignSelf: 'flex-start' }}>
|
||||
{t('back')}
|
||||
</AppButton>
|
||||
<AdminPageHeader title={t('ver_case_title')} />
|
||||
</Stack>
|
||||
<PageHeader
|
||||
title={t('ver_case_title')}
|
||||
onBack={backToQueue}
|
||||
backLabel={t('back')}
|
||||
actions={
|
||||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon="back"
|
||||
onClick={() => prevCase && goToCase(prevCase.nurseVerificationId)}
|
||||
disabled={!prevCase}
|
||||
>
|
||||
{t('ver_prev_case')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
endIcon="forward"
|
||||
onClick={() => nextCase && goToCase(nextCase.nurseVerificationId)}
|
||||
disabled={!nextCase}
|
||||
>
|
||||
{t('ver_next_case')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
@@ -408,24 +468,15 @@ function CredentialDialog({
|
||||
value={issuingAuthority}
|
||||
onChange={(e) => setIssuingAuthority(e.target.value)}
|
||||
/>
|
||||
<TextField
|
||||
<JalaliDateField fullWidth label={t('ver_issued_at')} value={issuedAt || null} onChange={setIssuedAt} />
|
||||
<JalaliDateField
|
||||
fullWidth
|
||||
type="date"
|
||||
label={t('ver_issued_at')}
|
||||
value={issuedAt}
|
||||
onChange={(e) => setIssuedAt(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="date"
|
||||
label={t('ver_expires_at')}
|
||||
value={expiresAt}
|
||||
onChange={(e) => setExpiresAt(e.target.value)}
|
||||
value={expiresAt || null}
|
||||
onChange={setExpiresAt}
|
||||
required={expiryRequired}
|
||||
error={expiryMissing}
|
||||
helperText={expiryMissing ? t('ver_expiry_required') : undefined}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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<VerificationAggregateStatus, StatusKind> = {
|
||||
@@ -32,24 +40,66 @@ const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
|
||||
suspended: 'rejected',
|
||||
};
|
||||
|
||||
export default function AdminVerificationQueuePage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<AdminVerificationQueueScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verification review queue (b6 `AdminVerificationsController`) — the trust desk's worklist, one row per
|
||||
* 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<QueueStatusFilter>('');
|
||||
const [page, setPage] = useState(1);
|
||||
const state = useAdminListState<AdminVerificationQueueFilters>({
|
||||
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<AdminVerificationQueueItem>[] = [
|
||||
{
|
||||
@@ -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 (
|
||||
<Box component="span" sx={color ? { color } : undefined}>
|
||||
{formatRelativeTime(item.submittedAt, locale, formatShamsiDate)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader
|
||||
title={t('ver_title')}
|
||||
subtitle={t('ver_subtitle')}
|
||||
actions={
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label={t('ver_col_status')}
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value as QueueStatusFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
sx={{ minWidth: 160 }}
|
||||
>
|
||||
<MenuItem value="">{t('filter_all')}</MenuItem>
|
||||
<MenuItem value="pending">{t('agg_pending')}</MenuItem>
|
||||
<MenuItem value="in_review">{t('agg_in_review')}</MenuItem>
|
||||
</TextField>
|
||||
}
|
||||
/>
|
||||
<AdminPageHeader title={t('ver_title')} subtitle={t('ver_subtitle')} />
|
||||
|
||||
<Tabs value={state.draft.status ?? ''} onChange={(_event, value: '' | 'pending' | 'in_review') => selectStatus(value)}>
|
||||
<Tab value="" data-tab="all" label={t('filter_all')} />
|
||||
<Tab value="pending" data-tab="pending" label={tabLabel(t('agg_pending'), counts?.pending)} />
|
||||
<Tab value="in_review" data-tab="in_review" label={tabLabel(t('agg_in_review'), counts?.in_review)} />
|
||||
</Tabs>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
label={t('ver_search_label')}
|
||||
placeholder={t('ver_search_ph')}
|
||||
value={state.draft.search ?? ''}
|
||||
onChange={(e) => state.setDraft((d) => ({ ...d, search: e.target.value || undefined }))}
|
||||
sx={{ minWidth: 240 }}
|
||||
/>
|
||||
<AppButton variant="contained" color="primary" onClick={state.apply}>
|
||||
{t('apply')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="inherit" onClick={state.clear}>
|
||||
{t('clear')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
{queue.isLoading ? (
|
||||
<Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack>
|
||||
@@ -129,18 +204,23 @@ export default function AdminVerificationQueuePage() {
|
||||
rows={items}
|
||||
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),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
page={state.page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => 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 })}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AdminVerificationQueueFilters } from '@/services/verification/types';
|
||||
|
||||
/**
|
||||
* The queue's URL-synced filter shape — shared between the list (`page.tsx`, via `useAdminListState`)
|
||||
* and the case detail (`[nurseId]/page.tsx`) so a case page can **re-derive the same query key** the
|
||||
* list primed (`status`/`search`/`page` → `useVerificationQueue`) and reuse its cached page for
|
||||
* next/prev case navigation, with no extra URL param and no duplicate fetch in the common flow.
|
||||
*/
|
||||
export const EMPTY_QUEUE_FILTERS: AdminVerificationQueueFilters = {};
|
||||
|
||||
/** The URL query-param name for the name/phone search (REQ-062's proposed `q`). */
|
||||
const SEARCH_PARAM = 'q';
|
||||
|
||||
export function parseQueueFilters(params: URLSearchParams): AdminVerificationQueueFilters {
|
||||
const status = params.get('status');
|
||||
return {
|
||||
status: status === 'pending' || status === 'in_review' ? status : undefined,
|
||||
search: params.get(SEARCH_PARAM) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeQueueFilters(filters: AdminVerificationQueueFilters): Record<string, string> {
|
||||
const record: Record<string, string> = {};
|
||||
if (filters.status) record.status = filters.status;
|
||||
if (filters.search) record[SEARCH_PARAM] = filters.search;
|
||||
return record;
|
||||
}
|
||||
|
||||
/** A case URL carrying the queue's current filters + page, so next/prev navigation (and a browser
|
||||
* back/refresh) keeps resolving against the same cached queue page instead of an unfiltered default. */
|
||||
export function queueCaseHref(
|
||||
locale: string,
|
||||
casePath: string,
|
||||
filters: AdminVerificationQueueFilters,
|
||||
page: number,
|
||||
): string {
|
||||
const params = new URLSearchParams(serializeQueueFilters(filters));
|
||||
if (page > 1) params.set('page', String(page));
|
||||
const qs = params.toString();
|
||||
return `/${locale}${casePath}${qs ? `?${qs}` : ''}`;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { PageHeader, StatusChip, StatusTimeline } from '@/components';
|
||||
import type { StatusKind, TimelineNode, TimelineNodeState } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState } from '@/components/admin';
|
||||
import { CONTENT_MAX_WIDTH } from '@/components/config';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAdminBackToList } from '@/hooks';
|
||||
import { formatShamsiDate, formatShamsiDateTime } from '@/utils';
|
||||
import { useMySponsoredBookingDetail } from '@/services/partnerCenter';
|
||||
|
||||
/** Same tone map as the bookings list — one status vocabulary across the portal. */
|
||||
const PARTNER_BOOKING_STATUS_KIND: Record<string, StatusKind> = {
|
||||
pending_payment: 'pending',
|
||||
confirmed: 'info',
|
||||
in_progress: 'active',
|
||||
completed: 'verified',
|
||||
disputed: 'rejected',
|
||||
closed: 'neutral',
|
||||
cancelled: 'rejected',
|
||||
};
|
||||
|
||||
/** A booking's lifecycle is done once it reaches one of these — the timeline's last node reads as "current" otherwise. */
|
||||
const TERMINAL_STATUSES = new Set(['completed', 'closed', 'cancelled']);
|
||||
|
||||
/**
|
||||
* Partner portal — scoped sponsored-booking detail (f15, REQ-064). Strictly read-only and bounded to what
|
||||
* the portal is allowed to see: the patient's display name, the scheduled date, the current status, and a
|
||||
* server-truth status timeline — **no clinical content, no address, no money**. A bookings-list row deep-
|
||||
* links here; tenancy (only bookings the signed-in center legally covers) is server-enforced.
|
||||
*/
|
||||
export default function PartnerBookingDetailPage() {
|
||||
const t = useTranslations('partner');
|
||||
const ta = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const params = useParams<{ id: string }>();
|
||||
const bookingId = Number(params?.id);
|
||||
|
||||
const goBack = useAdminBackToList(`/${locale}${ROUTES.PARTNER_BOOKINGS}`);
|
||||
const detail = useMySponsoredBookingDetail(Number.isFinite(bookingId) ? bookingId : undefined);
|
||||
|
||||
const timelineNodes: TimelineNode[] =
|
||||
detail.data?.timeline.map((entry, index, all) => {
|
||||
const isLast = index === all.length - 1;
|
||||
const state: TimelineNodeState = isLast && !TERMINAL_STATUSES.has(detail.data!.status) ? 'current' : 'completed';
|
||||
return {
|
||||
key: `${entry.status}-${index}`,
|
||||
label: t(`bstatus_${entry.status}`),
|
||||
timestamp: formatShamsiDateTime(entry.occurredAt, locale),
|
||||
state,
|
||||
};
|
||||
}) ?? [];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3, maxWidth: CONTENT_MAX_WIDTH, mx: 'auto', width: '100%' }}>
|
||||
<PageHeader title={t('booking_detail_title')} onBack={goBack} backLabel={t('booking_detail_back')} />
|
||||
|
||||
{detail.isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Skeleton variant="rounded" height={120} />
|
||||
<Skeleton variant="rounded" height={200} />
|
||||
</Stack>
|
||||
) : detail.isError ? (
|
||||
<AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => detail.refetch()} />
|
||||
) : !detail.data ? (
|
||||
<AdminEmptyState icon="bookings" title={t('booking_detail_not_found')} />
|
||||
) : (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('bookings_col_id')} #{detail.data.bookingId}
|
||||
</Typography>
|
||||
<StatusChip
|
||||
status={PARTNER_BOOKING_STATUS_KIND[detail.data.status] ?? 'neutral'}
|
||||
label={t(`bstatus_${detail.data.status}`)}
|
||||
/>
|
||||
</Stack>
|
||||
<MetaLine label={t('booking_detail_patient')}>{detail.data.patientName}</MetaLine>
|
||||
<MetaLine label={t('booking_detail_scheduled_date')}>
|
||||
{formatShamsiDate(detail.data.scheduledDate, locale)}
|
||||
</MetaLine>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('booking_detail_timeline_title')}
|
||||
</Typography>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<StatusTimeline nodes={timelineNodes} />
|
||||
</Paper>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaLine({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,77 @@
|
||||
'use client';
|
||||
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<string, StatusKind> = {
|
||||
pending_payment: 'pending',
|
||||
confirmed: 'info',
|
||||
in_progress: 'active',
|
||||
completed: 'verified',
|
||||
disputed: 'rejected',
|
||||
closed: 'neutral',
|
||||
cancelled: 'rejected',
|
||||
};
|
||||
|
||||
const EMPTY_FILTERS: SponsoredBookingFilters = {};
|
||||
|
||||
function parseFilters(params: URLSearchParams): SponsoredBookingFilters {
|
||||
return { status: params.get('status') ?? undefined };
|
||||
}
|
||||
|
||||
function serializeFilters(filters: SponsoredBookingFilters): Record<string, string> {
|
||||
return filters.status ? { status: filters.status } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Partner portal — sponsored bookings (f15). The read-only list of bookings the signed-in center legally
|
||||
* 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 `<Suspense>` boundary.
|
||||
*/
|
||||
export default function PartnerBookingsPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<PartnerBookingsScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function PartnerBookingsScreen() {
|
||||
const t = useTranslations('partner');
|
||||
const ta = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const [status, setStatus] = useState<string>('');
|
||||
const [page, setPage] = useState(1);
|
||||
const { draft, setDraft, applied, page, apply, clear, goToPage } = useAdminListState<SponsoredBookingFilters>({
|
||||
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<SponsoredBooking>[] = [
|
||||
{ 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) => <Chip size="small" variant="outlined" label={b.status} />,
|
||||
render: (b) => (
|
||||
<StatusChip status={PARTNER_BOOKING_STATUS_KIND[b.status] ?? 'neutral'} label={t(`bstatus_${b.status}`)} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader
|
||||
title={t('bookings_title')}
|
||||
actions={
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label={t('bookings_col_status')}
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
<MenuItem value="">{ta('filter_all')}</MenuItem>
|
||||
{BOOKING_STATUS_OPTIONS.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{s}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
}
|
||||
/>
|
||||
<AdminPageHeader title={t('bookings_title')} />
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1.5, flexWrap: 'wrap', alignItems: 'center', p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<TextField
|
||||
select
|
||||
size="small"
|
||||
label={t('bookings_col_status')}
|
||||
value={draft.status ?? ''}
|
||||
onChange={(e) => setDraft({ status: e.target.value || undefined })}
|
||||
sx={{ minWidth: 180 }}
|
||||
>
|
||||
<MenuItem value="">{ta('filter_all')}</MenuItem>
|
||||
{BOOKING_STATUS_OPTIONS.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{t(`bstatus_${s}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<AppButton variant="contained" color="primary" onClick={apply}>
|
||||
{ta('apply')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="inherit" onClick={clear}>
|
||||
{ta('clear')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
|
||||
{bookings.isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
@@ -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 })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => 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 })}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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<string, never> = {};
|
||||
|
||||
/**
|
||||
* 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 `<Suspense>` boundary.
|
||||
*/
|
||||
export default function PartnerSettlementPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<PartnerSettlementScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/** Already-translated CSV column headers, keyed to the row shape `exportInvoicesCsv` builds below. */
|
||||
interface InvoiceCsvHeaders {
|
||||
invoiceNumber: string;
|
||||
bookingId: string;
|
||||
gross: string;
|
||||
commission: string;
|
||||
vat: string;
|
||||
total: string;
|
||||
issuedAt: string;
|
||||
}
|
||||
|
||||
/** Builds + triggers the download of a CSV of the currently loaded invoices page (no new fetch). */
|
||||
function exportInvoicesCsv(invoices: CenterInvoice[], locale: string, headers: InvoiceCsvHeaders): void {
|
||||
const csv = toCsv(
|
||||
invoices.map((inv) => ({
|
||||
invoiceNumber: inv.invoiceNumber,
|
||||
bookingId: inv.bookingId,
|
||||
gross: formatIrrToToman(inv.grossIrr, locale),
|
||||
commission: formatIrrToToman(inv.platformCommissionIrr, locale),
|
||||
vat: formatIrrToToman(inv.vatIrr, locale),
|
||||
total: formatIrrToToman(inv.totalIrr, locale),
|
||||
issuedAt: formatShamsiDate(inv.issuedAt, locale),
|
||||
})),
|
||||
[
|
||||
{ key: 'invoiceNumber', label: headers.invoiceNumber },
|
||||
{ key: 'bookingId', label: headers.bookingId },
|
||||
{ key: 'gross', label: headers.gross },
|
||||
{ key: 'commission', label: headers.commission },
|
||||
{ key: 'vat', label: headers.vat },
|
||||
{ key: 'total', label: headers.total },
|
||||
{ key: 'issuedAt', label: headers.issuedAt },
|
||||
],
|
||||
);
|
||||
// UTF-8 BOM so Excel renders Persian text correctly.
|
||||
const blob = new Blob(['' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'settlement.csv';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function PartnerSettlementScreen() {
|
||||
const t = useTranslations('partner');
|
||||
const 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<Record<string, never>>({
|
||||
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 (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader title={t('settlement_title')} />
|
||||
<AdminPageHeader
|
||||
title={t('settlement_title')}
|
||||
actions={
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="download"
|
||||
disabled={settlement.isLoading || invoices.length === 0}
|
||||
onClick={() =>
|
||||
exportInvoicesCsv(invoices, locale, {
|
||||
invoiceNumber: t('csv_col_invoice_number'),
|
||||
bookingId: t('csv_col_booking_id'),
|
||||
gross: t('csv_col_gross'),
|
||||
commission: t('csv_col_commission'),
|
||||
vat: t('csv_col_vat'),
|
||||
total: t('csv_col_total'),
|
||||
issuedAt: t('csv_col_issued_at'),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('settlement_export_csv')}
|
||||
</AppButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack direction="row" sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
@@ -98,11 +187,11 @@ export default function PartnerSettlementPage() {
|
||||
<AdminPager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => 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 })}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -38,4 +38,34 @@ describe('<AdminDataTable/>', () => {
|
||||
fireEvent.click(screen.getByText('Beta'));
|
||||
expect(onRowClick).toHaveBeenCalledWith(ROWS[1]);
|
||||
});
|
||||
|
||||
it('renders the footer line only when passed', () => {
|
||||
const { rerender, container } = wrap(<AdminDataTable columns={COLUMNS} rows={ROWS} getRowKey={(r) => r.id} />);
|
||||
expect(container.querySelector('p')).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<ThemeProvider>
|
||||
<AdminDataTable columns={COLUMNS} rows={ROWS} getRowKey={(r) => r.id} footer="Showing 1–2 of 2" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('Showing 1–2 of 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onSortChange with the column key when a sortable header is clicked', () => {
|
||||
const onSortChange = jest.fn();
|
||||
const sortableColumns: AdminTableColumn<Row>[] = [
|
||||
{ key: 'id', header: 'ID', render: (r) => r.id, sortable: true },
|
||||
{ key: 'name', header: 'Name', render: (r) => r.name },
|
||||
];
|
||||
wrap(
|
||||
<AdminDataTable
|
||||
columns={sortableColumns}
|
||||
rows={ROWS}
|
||||
getRowKey={(r) => r.id}
|
||||
sort={{ key: 'id', direction: 'asc' }}
|
||||
onSortChange={onSortChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /ID/ }));
|
||||
expect(onSortChange).toHaveBeenCalledWith('id');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,12 @@ import {
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableSortLabel,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
|
||||
export type AdminSortDirection = 'asc' | 'desc';
|
||||
|
||||
export interface AdminTableColumn<T> {
|
||||
/** Stable column key (also `data-col` for tests). */
|
||||
key: string;
|
||||
@@ -19,6 +23,15 @@ export interface AdminTableColumn<T> {
|
||||
/** 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<T> {
|
||||
@@ -29,6 +42,18 @@ export interface AdminDataTableProps<T> {
|
||||
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<T> {
|
||||
* 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<T>({ columns, rows, getRowKey, onRowClick, dense = true, ariaLabel }: AdminDataTableProps<T>) {
|
||||
function AdminDataTable<T>({
|
||||
columns,
|
||||
rows,
|
||||
getRowKey,
|
||||
onRowClick,
|
||||
dense = true,
|
||||
ariaLabel,
|
||||
stickyHeader = false,
|
||||
stickyMaxHeight = 640,
|
||||
sort,
|
||||
onSortChange,
|
||||
footer,
|
||||
}: AdminDataTableProps<T>) {
|
||||
return (
|
||||
<TableContainer component={Paper} elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflowX: 'auto' }}>
|
||||
<Table size={dense ? 'small' : 'medium'} aria-label={ariaLabel} sx={{ minWidth: 640 }}>
|
||||
<TableHead>
|
||||
<TableRow sx={{ '& th': { fontWeight: 700, color: 'text.secondary', bgcolor: 'action.hover' } }}>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key} align={col.align ?? 'inherit'} sx={{ width: col.width }}>
|
||||
{col.header}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow
|
||||
key={getRowKey(row)}
|
||||
hover={!!onRowClick}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
sx={{ cursor: onRowClick ? 'pointer' : 'default', '&:last-child td': { border: 0 } }}
|
||||
>
|
||||
<>
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
elevation={0}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
overflowX: 'auto',
|
||||
...(stickyHeader ? { maxHeight: stickyMaxHeight, overflowY: 'auto' } : {}),
|
||||
}}
|
||||
>
|
||||
<Table stickyHeader={stickyHeader} size={dense ? 'small' : 'medium'} aria-label={ariaLabel} sx={{ minWidth: 640 }}>
|
||||
<TableHead>
|
||||
<TableRow sx={{ '& th': { fontWeight: 700, color: 'text.secondary', bgcolor: 'action.hover' } }}>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key} data-col={col.key} align={col.align ?? 'inherit'}>
|
||||
{col.render(row)}
|
||||
<TableCell
|
||||
key={col.key}
|
||||
align={col.align ?? 'inherit'}
|
||||
sx={{ width: col.width, minWidth: col.minWidth }}
|
||||
sortDirection={col.sortable && sort?.key === col.key ? sort.direction : false}
|
||||
>
|
||||
{col.sortable && onSortChange ? (
|
||||
<TableSortLabel
|
||||
active={sort?.key === col.key}
|
||||
direction={sort?.key === col.key ? sort.direction : 'asc'}
|
||||
onClick={() => onSortChange(col.key)}
|
||||
>
|
||||
{col.header}
|
||||
</TableSortLabel>
|
||||
) : (
|
||||
col.header
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow
|
||||
key={getRowKey(row)}
|
||||
hover={!!onRowClick}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
sx={{ cursor: onRowClick ? 'pointer' : 'default', '&:last-child td': { border: 0 } }}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key} data-col={col.key} align={col.align ?? 'inherit'}>
|
||||
{col.render(row)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{footer ? (
|
||||
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary', mt: 1 }}>
|
||||
{footer}
|
||||
</Typography>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,4 +33,28 @@ describe('<AuditLogRow/>', () => {
|
||||
expect(screen.getByText('0.09')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.10')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles aria-expanded on the row header', () => {
|
||||
wrap(<AuditLogRow entry={ENTRY} />);
|
||||
const header = screen.getByRole('button');
|
||||
expect(header).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(header);
|
||||
expect(header).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('falls back to #id when no actorLabel is resolved, else shows the name', () => {
|
||||
const { rerender } = wrap(<AuditLogRow entry={ENTRY} />);
|
||||
expect(screen.getByText('#3')).toBeInTheDocument();
|
||||
rerender(
|
||||
<ThemeProvider>
|
||||
<AuditLogRow entry={ENTRY} actorLabel="سارا کریمی" />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByText('سارا کریمی')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no button role for a row with no diff to expand', () => {
|
||||
wrap(<AuditLogRow entry={{ ...ENTRY, changedFields: null }} />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
'use client';
|
||||
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<number, AdminUserSummary> | undefined, userId: number | null): string | undefined {
|
||||
if (userId == null) return undefined;
|
||||
return userMap?.get(userId)?.displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the append-only audit viewer, with an expandable `changedFields` diff (old → new per field;
|
||||
* PII is server-redacted as `<redacted>`). Read-only by design — there is **no** edit/delete affordance
|
||||
* (phase §5). Presentational; the caller passes the paged entries.
|
||||
* (phase §5). Presentational; the caller passes the paged entries + (once resolved) the actor's name. The
|
||||
* expand chevron rotates and the header carries `aria-expanded` + button semantics so open/closed state is
|
||||
* visible and keyboard-toggleable (ui-phase-11).
|
||||
* @component AuditLogRow
|
||||
*/
|
||||
const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry }) => {
|
||||
const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ 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<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
@@ -36,24 +61,35 @@ const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry }) => {
|
||||
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}
|
||||
>
|
||||
<Stack
|
||||
role={hasDetail ? 'button' : undefined}
|
||||
tabIndex={hasDetail ? 0 : undefined}
|
||||
aria-expanded={hasDetail ? open : undefined}
|
||||
direction="row"
|
||||
sx={{ gap: 2, alignItems: 'center', p: 1.5, cursor: fields.length ? 'pointer' : 'default', flexWrap: 'wrap' }}
|
||||
onClick={fields.length ? () => 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}
|
||||
>
|
||||
<Chip size="small" variant="outlined" label={`${entry.entityType} #${entry.entityId}`} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{entry.action}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', flexGrow: 1 }}>
|
||||
{entry.actorUserId != null ? `#${entry.actorUserId}` : '—'}
|
||||
{actorDisplay(entry.actorUserId, actorLabel)}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDateTime(entry.occurredAt, locale)}
|
||||
</Typography>
|
||||
{fields.length ? <AppIcon icon="expand" size={18} color="var(--bal-text-secondary)" /> : null}
|
||||
{hasDetail ? (
|
||||
<AppIcon
|
||||
icon="expand"
|
||||
size={18}
|
||||
color="var(--bal-text-secondary)"
|
||||
style={{ transition: 'transform var(--bal-motion-fast) var(--bal-easing-standard)', transform: open ? 'rotate(180deg)' : 'none' }}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Collapse in={open && fields.length > 0}>
|
||||
<Collapse in={open && hasDetail}>
|
||||
<Box sx={{ px: 1.5, pb: 1.5 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
|
||||
{t('audit_diff_title')}
|
||||
|
||||
@@ -41,4 +41,58 @@ describe('<ConfirmDialog/>', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders the typed-confirmation field when requireTypedConfirmation is set', () => {
|
||||
wrap(
|
||||
<ConfirmDialog
|
||||
open
|
||||
onConfirm={jest.fn()}
|
||||
{...base}
|
||||
requireTypedConfirmation={['CONFIRM', '150000']}
|
||||
typedConfirmationLabel="Type to confirm"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText('Type to confirm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps confirm disabled until the typed value matches, case/whitespace-insensitively', () => {
|
||||
const onConfirm = jest.fn();
|
||||
wrap(
|
||||
<ConfirmDialog
|
||||
open
|
||||
onConfirm={onConfirm}
|
||||
{...base}
|
||||
requireTypedConfirmation={['CONFIRM', '150000']}
|
||||
typedConfirmationLabel="Type to confirm"
|
||||
/>,
|
||||
);
|
||||
const confirmBtn = screen.getByRole('button', { name: 'Run' });
|
||||
const field = screen.getByLabelText('Type to confirm');
|
||||
|
||||
expect(confirmBtn).toBeDisabled();
|
||||
|
||||
fireEvent.change(field, { target: { value: 'nope' } });
|
||||
expect(confirmBtn).toBeDisabled();
|
||||
|
||||
fireEvent.change(field, { target: { value: ' confirm ' } });
|
||||
expect(confirmBtn).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(confirmBtn);
|
||||
expect(onConfirm).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('enables confirm on an exact match against a different allowed value (e.g. an amount)', () => {
|
||||
wrap(
|
||||
<ConfirmDialog
|
||||
open
|
||||
onConfirm={jest.fn()}
|
||||
{...base}
|
||||
requireTypedConfirmation={['CONFIRM', '150000']}
|
||||
typedConfirmationLabel="Type to confirm"
|
||||
/>,
|
||||
);
|
||||
const confirmBtn = screen.getByRole('button', { name: 'Run' });
|
||||
fireEvent.change(screen.getByLabelText('Type to confirm'), { target: { value: '150000' } });
|
||||
expect(confirmBtn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
|
||||
const useUserSearchMock = jest.fn();
|
||||
jest.mock('@/services/admin', () => ({ useUserSearch: (...args: unknown[]) => useUserSearchMock(...args) }));
|
||||
jest.mock('@/services/search', () => ({ useDebouncedValue: (value: unknown) => value }));
|
||||
|
||||
import NursePicker from './NursePicker';
|
||||
import type { AdminUserSummary } from '@/services/admin/types';
|
||||
|
||||
const ZAHRA: AdminUserSummary = {
|
||||
id: 101,
|
||||
displayName: 'زهرا رضایی',
|
||||
maskedPhone: '0912•••0001',
|
||||
roles: ['nurse'],
|
||||
nurseProfileId: 15,
|
||||
};
|
||||
|
||||
describe('<NursePicker/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
beforeEach(() => {
|
||||
useUserSearchMock.mockReset();
|
||||
useUserSearchMock.mockReturnValue({ data: [ZAHRA], isFetching: false });
|
||||
});
|
||||
|
||||
it('always searches with roleFilter="nurse"', async () => {
|
||||
const user = userEvent.setup();
|
||||
wrap(<NursePicker value={null} onChange={jest.fn()} label="پرستار" />);
|
||||
await user.type(screen.getByLabelText('پرستار'), 'زهرا');
|
||||
expect(useUserSearchMock).toHaveBeenLastCalledWith('زهرا', 'nurse');
|
||||
});
|
||||
|
||||
it('returns the picked option, carrying nurseProfileId', async () => {
|
||||
const onChange = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
wrap(<NursePicker value={null} onChange={onChange} label="پرستار" />);
|
||||
await user.click(screen.getByLabelText('پرستار'));
|
||||
await user.click(await screen.findByText('زهرا رضایی'));
|
||||
expect(onChange).toHaveBeenCalledWith(ZAHRA);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import UserPicker, { type UserPickerProps } from '../UserPicker';
|
||||
|
||||
export type NursePickerProps = Omit<UserPickerProps, 'roleFilter'>;
|
||||
|
||||
/**
|
||||
* Thin `UserPicker` variant fixed to `roleFilter="nurse"` — the sponsored-nurse assignment picker
|
||||
* (`admin/partners/[id]`). The selected `AdminUserSummary.nurseProfileId` is the id the caller actually
|
||||
* needs (a different id space than `.id`, the user id) — see `services/admin/types.ts`.
|
||||
* @component NursePicker
|
||||
*/
|
||||
const NursePicker: FunctionComponent<NursePickerProps> = (props) => <UserPicker {...props} roleFilter="nurse" />;
|
||||
|
||||
export default NursePicker;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './NursePicker';
|
||||
export type { NursePickerProps } from './NursePicker';
|
||||
@@ -13,6 +13,11 @@ export interface SupportAlertCardProps {
|
||||
canAct?: boolean;
|
||||
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<SupportAlertSeverity, string> = {
|
||||
* namespace keyed off the stable code.
|
||||
* @component SupportAlertCard
|
||||
*/
|
||||
const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({ alert, canAct = false, onAssignSelf, onResolve }) => {
|
||||
const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({
|
||||
alert,
|
||||
canAct = false,
|
||||
onAssignSelf,
|
||||
onResolve,
|
||||
assignSelfDisabled = false,
|
||||
assignSelfDisabledTitle,
|
||||
}) => {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
|
||||
@@ -85,7 +97,14 @@ const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({ alert, can
|
||||
{canAct && alert.status !== 'resolved' ? (
|
||||
<Stack direction="row" sx={{ gap: 1, mt: 1.5, flexWrap: 'wrap' }}>
|
||||
{alert.status === 'open' ? (
|
||||
<AppButton variant="outlined" color="primary" startIcon="assign" onClick={() => onAssignSelf?.(alert)}>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="assign"
|
||||
onClick={() => onAssignSelf?.(alert)}
|
||||
disabled={assignSelfDisabled}
|
||||
title={assignSelfDisabled ? assignSelfDisabledTitle : undefined}
|
||||
>
|
||||
{t('alert_assign_me')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
|
||||
const useUserSearchMock = jest.fn();
|
||||
jest.mock('@/services/admin', () => ({ useUserSearch: (...args: unknown[]) => useUserSearchMock(...args) }));
|
||||
jest.mock('@/services/search', () => ({ useDebouncedValue: (value: unknown) => value }));
|
||||
|
||||
import UserPicker from './UserPicker';
|
||||
import type { AdminUserSummary } from '@/services/admin/types';
|
||||
|
||||
const MARYAM: AdminUserSummary = { id: 2, displayName: 'مریم احمدی', maskedPhone: '0912•••0002', roles: ['admin'] };
|
||||
|
||||
describe('<UserPicker/>', () => {
|
||||
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
|
||||
beforeEach(() => {
|
||||
useUserSearchMock.mockReset();
|
||||
useUserSearchMock.mockReturnValue({ data: [MARYAM], isFetching: false });
|
||||
});
|
||||
|
||||
it('renders the label and, once opened, an option with name + masked phone + id', async () => {
|
||||
const user = userEvent.setup();
|
||||
wrap(<UserPicker value={null} onChange={jest.fn()} label="کاربر" />);
|
||||
const input = screen.getByLabelText('کاربر');
|
||||
await user.click(input);
|
||||
expect(await screen.findByText('مریم احمدی')).toBeInTheDocument();
|
||||
expect(screen.getByText('0912•••0002 · #2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange with the selected user on pick', async () => {
|
||||
const onChange = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
wrap(<UserPicker value={null} onChange={onChange} label="کاربر" />);
|
||||
await user.click(screen.getByLabelText('کاربر'));
|
||||
await user.click(await screen.findByText('مریم احمدی'));
|
||||
expect(onChange).toHaveBeenCalledWith(MARYAM);
|
||||
});
|
||||
|
||||
it('passes the typed query + roleFilter through to useUserSearch', async () => {
|
||||
const user = userEvent.setup();
|
||||
wrap(<UserPicker value={null} onChange={jest.fn()} label="پرستار" roleFilter="nurse" />);
|
||||
await user.type(screen.getByLabelText('پرستار'), 'زهرا');
|
||||
await waitFor(() => expect(useUserSearchMock).toHaveBeenLastCalledWith('زهرا', 'nurse'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useState } from 'react';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
import Box from '@mui/material/Box';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useUserSearch } from '@/services/admin';
|
||||
import { useDebouncedValue } from '@/services/search';
|
||||
import type { AdminUserSummary, DirectoryUserRole } from '@/services/admin/types';
|
||||
|
||||
export interface UserPickerProps {
|
||||
value: AdminUserSummary | null;
|
||||
onChange: (user: AdminUserSummary | null) => void;
|
||||
/** Already-translated field label. */
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
helperText?: string;
|
||||
noOptionsText?: string;
|
||||
loadingText?: string;
|
||||
/** Narrow the search to one coarse role (`NursePicker` passes `'nurse'`). */
|
||||
roleFilter?: DirectoryUserRole;
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* Async name/phone search over the admin user directory (REQ-061, gap — mock-backed until delivered),
|
||||
* replacing every raw numeric-id `TextField` on an audited action (role grants, partner-center admin
|
||||
* assignment, sponsored-nurse assignment, alert assignment — phase §3.2). Each option renders
|
||||
* **name + masked phone + `#id`**, never a bare id, so the caller's confirm dialog can echo a resolved
|
||||
* **person** instead of `#42`. `NursePicker` is a thin `roleFilter="nurse"` wrapper over this component.
|
||||
* @component UserPicker
|
||||
*/
|
||||
const UserPicker: FunctionComponent<UserPickerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder,
|
||||
helperText,
|
||||
noOptionsText,
|
||||
loadingText,
|
||||
roleFilter,
|
||||
disabled,
|
||||
error,
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const debouncedQuery = useDebouncedValue(inputValue, SEARCH_DEBOUNCE_MS);
|
||||
const search = useUserSearch(debouncedQuery, roleFilter);
|
||||
const options = search.data ?? [];
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
value={value}
|
||||
onChange={(_event, next) => onChange(next)}
|
||||
inputValue={inputValue}
|
||||
onInputChange={(_event, next) => setInputValue(next)}
|
||||
options={options}
|
||||
loading={search.isFetching}
|
||||
disabled={disabled}
|
||||
// The directory is already narrowed server-side (mock or real) — never re-filter client-side.
|
||||
filterOptions={(opts) => opts}
|
||||
getOptionLabel={(option) => option.displayName}
|
||||
isOptionEqualToValue={(option, selected) => option.id === selected.id}
|
||||
noOptionsText={noOptionsText}
|
||||
loadingText={loadingText}
|
||||
renderOption={(optionProps, option) => {
|
||||
const { key, ...rest } = optionProps;
|
||||
return (
|
||||
<Box component="li" key={key} {...rest}>
|
||||
<Stack sx={{ gap: 0.125, py: 0.25 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{option.displayName}
|
||||
</Typography>
|
||||
<Typography component="span" variant="caption" dir="ltr" sx={{ color: 'text.secondary' }}>
|
||||
{option.maskedPhone} · #{option.id}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
renderInput={({ slotProps: autocompleteSlotProps, ...rest }) => (
|
||||
<TextField
|
||||
{...rest}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
helperText={helperText}
|
||||
error={error}
|
||||
slotProps={{
|
||||
...autocompleteSlotProps,
|
||||
input: {
|
||||
...autocompleteSlotProps.input,
|
||||
endAdornment: (
|
||||
<>
|
||||
{search.isFetching ? <CircularProgress color="inherit" size={16} /> : null}
|
||||
{autocompleteSlotProps.input.endAdornment}
|
||||
</>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserPicker;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './UserPicker';
|
||||
export type { UserPickerProps } from './UserPicker';
|
||||
@@ -30,3 +30,7 @@ export { default as RefundPanel } from './RefundPanel';
|
||||
export type { RefundPanelProps } from './RefundPanel';
|
||||
export { 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';
|
||||
|
||||
@@ -41,4 +41,58 @@ describe('<ConfirmDialog/>', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders the typed-confirmation field when requireTypedConfirmation is set', () => {
|
||||
wrap(
|
||||
<ConfirmDialog
|
||||
open
|
||||
onConfirm={jest.fn()}
|
||||
{...base}
|
||||
requireTypedConfirmation={['CONFIRM', '150000']}
|
||||
typedConfirmationLabel="Type to confirm"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText('Type to confirm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps confirm disabled until the typed value matches, case/whitespace-insensitively', () => {
|
||||
const onConfirm = jest.fn();
|
||||
wrap(
|
||||
<ConfirmDialog
|
||||
open
|
||||
onConfirm={onConfirm}
|
||||
{...base}
|
||||
requireTypedConfirmation={['CONFIRM', '150000']}
|
||||
typedConfirmationLabel="Type to confirm"
|
||||
/>,
|
||||
);
|
||||
const confirmBtn = screen.getByRole('button', { name: 'Run' });
|
||||
const field = screen.getByLabelText('Type to confirm');
|
||||
|
||||
expect(confirmBtn).toBeDisabled();
|
||||
|
||||
fireEvent.change(field, { target: { value: 'nope' } });
|
||||
expect(confirmBtn).toBeDisabled();
|
||||
|
||||
fireEvent.change(field, { target: { value: ' confirm ' } });
|
||||
expect(confirmBtn).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(confirmBtn);
|
||||
expect(onConfirm).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('enables confirm on an exact match against a different allowed value (e.g. an amount)', () => {
|
||||
wrap(
|
||||
<ConfirmDialog
|
||||
open
|
||||
onConfirm={jest.fn()}
|
||||
{...base}
|
||||
requireTypedConfirmation={['CONFIRM', '150000']}
|
||||
typedConfirmationLabel="Type to confirm"
|
||||
/>,
|
||||
);
|
||||
const confirmBtn = screen.getByRole('button', { name: 'Run' });
|
||||
fireEvent.change(screen.getByLabelText('Type to confirm'), { target: { value: '150000' } });
|
||||
expect(confirmBtn).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,15 @@ export interface ConfirmDialogProps {
|
||||
requireReason?: boolean;
|
||||
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<ConfirmDialogProps> = ({
|
||||
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 (
|
||||
<Dialog open={open} onClose={loading ? undefined : close} fullWidth maxWidth="xs">
|
||||
@@ -92,6 +111,17 @@ const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
) : null}
|
||||
{requireTypedConfirmation ? (
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
value={typedValue}
|
||||
onChange={(e) => setTypedValue(e.target.value)}
|
||||
label={typedConfirmationLabel}
|
||||
placeholder={typedConfirmationPlaceholder}
|
||||
sx={{ mt: 2 }}
|
||||
/>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<AppButton variant="text" color="inherit" onClick={close} disabled={loading}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../../theme';
|
||||
import PageHeader from './PageHeader';
|
||||
|
||||
@@ -16,6 +16,11 @@ describe('<PageHeader/>', () => {
|
||||
expect(screen.getByRole('button', { name: 'Do' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the meta slot below the title when provided', () => {
|
||||
wrap(<PageHeader title="T" meta={<span>Open</span>} />);
|
||||
expect(screen.getByText('Open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the back button when backTo is not given', () => {
|
||||
wrap(<PageHeader title="T" />);
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
@@ -25,4 +30,12 @@ describe('<PageHeader/>', () => {
|
||||
wrap(<PageHeader title="T" backTo="/patients" backLabel="Back" />);
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', '/patients');
|
||||
});
|
||||
|
||||
it('renders a back button that calls onBack, not a link, when onBack is given', () => {
|
||||
const onBack = jest.fn();
|
||||
wrap(<PageHeader title="T" onBack={onBack} backLabel="Back" />);
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
|
||||
expect(onBack).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,24 +11,53 @@ export interface PageHeaderProps {
|
||||
subtitle?: string;
|
||||
/** 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<PageHeaderProps> = ({ title, subtitle, actions, backTo, backLabel }) => (
|
||||
const PageHeader: FunctionComponent<PageHeaderProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
meta,
|
||||
backTo,
|
||||
backLabel,
|
||||
onBack,
|
||||
}) => (
|
||||
<Stack direction="row" sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'flex-start' }}>
|
||||
{backTo ? (
|
||||
<AppIconButton icon="back" to={backTo} title={backLabel} sx={{ mt: 0.25 }} iconProps={{ size: 20 }} />
|
||||
{backTo || onBack ? (
|
||||
<AppIconButton
|
||||
icon="back"
|
||||
to={onBack ? undefined : backTo}
|
||||
onClick={onBack}
|
||||
title={backLabel}
|
||||
sx={{ mt: 0.25 }}
|
||||
iconProps={{ size: 20 }}
|
||||
/>
|
||||
) : null}
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 700 }}>
|
||||
@@ -39,6 +68,7 @@ const PageHeader: FunctionComponent<PageHeaderProps> = ({ title, subtitle, actio
|
||||
{subtitle}
|
||||
</Typography>
|
||||
) : null}
|
||||
{meta ? <Box sx={{ mt: 1 }}>{meta}</Box> : null}
|
||||
</Box>
|
||||
</Stack>
|
||||
{actions ? <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>{actions}</Box> : null}
|
||||
|
||||
@@ -121,6 +121,10 @@ export const adminPayoutBatchPath = (batchId: number | string): string =>
|
||||
export const adminPartnerCenterPath = (centerId: number | string): string =>
|
||||
`${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}`;
|
||||
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from './auth';
|
||||
export * from './capabilities';
|
||||
export * from './event';
|
||||
export * from './layout';
|
||||
export * from './useAdminListState';
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
|
||||
const mockReplace = jest.fn();
|
||||
const mockBack = jest.fn();
|
||||
const mockPush = jest.fn();
|
||||
let mockSearchParams = new URLSearchParams();
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ replace: mockReplace, back: mockBack, push: mockPush }),
|
||||
usePathname: () => '/fa/admin/tickets',
|
||||
useSearchParams: () => mockSearchParams,
|
||||
}));
|
||||
|
||||
import { useAdminListState, useAdminBackToList } from './useAdminListState';
|
||||
|
||||
interface Filters {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
parse: (params: URLSearchParams): Filters => ({ status: params.get('status') ?? undefined }),
|
||||
serialize: (filters: Filters): Record<string, string> => (filters.status ? { status: filters.status } : {}),
|
||||
empty: {} as Filters,
|
||||
};
|
||||
|
||||
describe('useAdminListState', () => {
|
||||
beforeEach(() => {
|
||||
mockReplace.mockReset();
|
||||
mockBack.mockReset();
|
||||
mockPush.mockReset();
|
||||
mockSearchParams = new URLSearchParams();
|
||||
});
|
||||
|
||||
it('reads the initial applied filters + page from the URL', () => {
|
||||
mockSearchParams = new URLSearchParams('status=open&page=3');
|
||||
const { result } = renderHook(() => useAdminListState(CONFIG));
|
||||
expect(result.current.applied).toEqual({ status: 'open' });
|
||||
expect(result.current.draft).toEqual({ status: 'open' });
|
||||
expect(result.current.page).toBe(3);
|
||||
});
|
||||
|
||||
it('defaults to page 1 with empty filters when the URL carries none', () => {
|
||||
const { result } = renderHook(() => useAdminListState(CONFIG));
|
||||
expect(result.current.applied).toEqual({ status: undefined });
|
||||
expect(result.current.page).toBe(1);
|
||||
});
|
||||
|
||||
it('editing draft never touches applied or the URL', () => {
|
||||
const { result } = renderHook(() => useAdminListState(CONFIG));
|
||||
act(() => result.current.setDraft({ status: 'closed' }));
|
||||
expect(result.current.draft).toEqual({ status: 'closed' });
|
||||
expect(result.current.applied).toEqual({ status: undefined });
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('apply commits draft into applied, resets to page 1, and writes the URL', () => {
|
||||
const { result } = renderHook(() => useAdminListState(CONFIG));
|
||||
act(() => result.current.setDraft({ status: 'closed' }));
|
||||
act(() => result.current.apply());
|
||||
expect(result.current.applied).toEqual({ status: 'closed' });
|
||||
expect(result.current.page).toBe(1);
|
||||
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets?status=closed', { scroll: false });
|
||||
});
|
||||
|
||||
it('clear resets draft + applied and writes a bare URL', () => {
|
||||
mockSearchParams = new URLSearchParams('status=open');
|
||||
const { result } = renderHook(() => useAdminListState(CONFIG));
|
||||
act(() => result.current.clear());
|
||||
expect(result.current.draft).toEqual({});
|
||||
expect(result.current.applied).toEqual({});
|
||||
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets', { scroll: false });
|
||||
});
|
||||
|
||||
it('applyFilters commits an explicit value atomically (never a stale draft)', () => {
|
||||
const { result } = renderHook(() => useAdminListState(CONFIG));
|
||||
// No setDraft call first — applyFilters must not depend on draft having been set beforehand.
|
||||
act(() => result.current.applyFilters({ status: 'closed' }));
|
||||
expect(result.current.draft).toEqual({ status: 'closed' });
|
||||
expect(result.current.applied).toEqual({ status: 'closed' });
|
||||
expect(result.current.page).toBe(1);
|
||||
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets?status=closed', { scroll: false });
|
||||
});
|
||||
|
||||
it('goToPage keeps applied filters and appends page to the URL', () => {
|
||||
mockSearchParams = new URLSearchParams('status=open');
|
||||
const { result } = renderHook(() => useAdminListState(CONFIG));
|
||||
act(() => result.current.goToPage(2));
|
||||
expect(result.current.page).toBe(2);
|
||||
expect(mockReplace).toHaveBeenCalledWith('/fa/admin/tickets?status=open&page=2', { scroll: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAdminBackToList', () => {
|
||||
beforeEach(() => {
|
||||
mockReplace.mockReset();
|
||||
mockBack.mockReset();
|
||||
mockPush.mockReset();
|
||||
});
|
||||
|
||||
it('calls router.back() when there is browser history', () => {
|
||||
Object.defineProperty(window, 'history', { value: { length: 3 }, configurable: true });
|
||||
const { result } = renderHook(() => useAdminBackToList('/fa/admin/tickets'));
|
||||
act(() => result.current());
|
||||
expect(mockBack).toHaveBeenCalledTimes(1);
|
||||
expect(mockPush).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to pushing the list href when there is no history', () => {
|
||||
Object.defineProperty(window, 'history', { value: { length: 1 }, configurable: true });
|
||||
const { result } = renderHook(() => useAdminBackToList('/fa/admin/tickets'));
|
||||
act(() => result.current());
|
||||
expect(mockPush).toHaveBeenCalledWith('/fa/admin/tickets');
|
||||
expect(mockBack).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
import { useCallback, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
/** The URL query-param name every admin/partner worklist uses for its page number. */
|
||||
const PAGE_PARAM = 'page';
|
||||
|
||||
export interface AdminListStateConfig<F> {
|
||||
/** Rebuild a filters object from the URL's current search params (only defined keys are read). */
|
||||
parse: (params: URLSearchParams) => F;
|
||||
/** Serialize a filters object to a plain string record, omitting empty/absent filters. */
|
||||
serialize: (filters: F) => Record<string, string>;
|
||||
/** The "clear"/default filters value. */
|
||||
empty: F;
|
||||
}
|
||||
|
||||
export interface AdminListState<F> {
|
||||
/** Local, uncommitted filter edits — typing here never refetches or touches the URL. */
|
||||
draft: F;
|
||||
setDraft: Dispatch<SetStateAction<F>>;
|
||||
/** The filters actually driving the query (and the URL) — set only by `apply`/`clear`. */
|
||||
applied: F;
|
||||
page: number;
|
||||
/** Commits `draft` into `applied`, resets to page 1, and writes both into the URL. */
|
||||
apply: () => void;
|
||||
/**
|
||||
* Commits an EXPLICIT filters value (not `draft`) into `applied` and the URL, resetting to page 1 — for a
|
||||
* discrete control (a status tab/select) that should commit the instant it changes. Prefer this over
|
||||
* `setDraft(next); apply()` in the same handler: `apply()` closes over the `draft` from the render it was
|
||||
* created in, so calling it synchronously right after `setDraft` would commit the OLD value, not `next`.
|
||||
* Also updates `draft` to the same value, so the two states never drift apart.
|
||||
*/
|
||||
applyFilters: (filters: F) => void;
|
||||
/** Resets both draft and applied to `empty` and writes that (page 1) into the URL. */
|
||||
clear: () => void;
|
||||
/** Moves to a page, keeping `applied` filters, and writes it into the URL. */
|
||||
goToPage: (page: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-synced worklist state for the admin/partner consoles — mirrors **applied** filters + the current
|
||||
* page into `searchParams` via `router.replace` (never a full navigation, `scroll: false`), so browser
|
||||
* back/refresh/a pasted link all reproduce the exact same queue view. Draft filter state stays local per
|
||||
* the established draft-vs-applied pattern (tickets/audit): typing in `draft` never refetches and never
|
||||
* touches the URL — only `apply`/`clear`/`goToPage` do, mirroring `services/search/filterParams.ts`'s
|
||||
* "the filter object is the query key" model onto every other worklist.
|
||||
*
|
||||
* The initial `applied`/`page` are read from the URL **once, on mount** — after that the URL only ever
|
||||
* follows local state, so a user typing in `draft` can never have their input clobbered by a stale
|
||||
* `searchParams` re-read. Callers using this hook must be rendered inside a `<Suspense>` boundary
|
||||
* (`useSearchParams` requirement) — wrap the page body the way `SearchScreen`/`search/SearchScreen.tsx`
|
||||
* does.
|
||||
*/
|
||||
export function useAdminListState<F>({
|
||||
parse,
|
||||
serialize,
|
||||
empty,
|
||||
}: AdminListStateConfig<F>): AdminListState<F> {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [initial] = useState<{ filters: F; page: number }>(() => {
|
||||
const rawPage = Number(searchParams.get(PAGE_PARAM));
|
||||
return {
|
||||
filters: parse(searchParams),
|
||||
page: Number.isInteger(rawPage) && rawPage > 0 ? rawPage : 1,
|
||||
};
|
||||
});
|
||||
|
||||
const [draft, setDraft] = useState<F>(initial.filters);
|
||||
const [applied, setApplied] = useState<F>(initial.filters);
|
||||
const [page, setPage] = useState<number>(initial.page);
|
||||
|
||||
const writeUrl = useCallback(
|
||||
(filters: F, nextPage: number) => {
|
||||
const params = new URLSearchParams(serialize(filters));
|
||||
if (nextPage > 1) params.set(PAGE_PARAM, String(nextPage));
|
||||
const qs = params.toString();
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
||||
},
|
||||
[pathname, router, serialize],
|
||||
);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
setApplied(draft);
|
||||
setPage(1);
|
||||
writeUrl(draft, 1);
|
||||
}, [draft, writeUrl]);
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(filters: F) => {
|
||||
setDraft(filters);
|
||||
setApplied(filters);
|
||||
setPage(1);
|
||||
writeUrl(filters, 1);
|
||||
},
|
||||
[writeUrl],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setDraft(empty);
|
||||
setApplied(empty);
|
||||
setPage(1);
|
||||
writeUrl(empty, 1);
|
||||
}, [empty, writeUrl]);
|
||||
|
||||
const goToPage = useCallback(
|
||||
(next: number) => {
|
||||
setPage(next);
|
||||
writeUrl(applied, next);
|
||||
},
|
||||
[applied, writeUrl],
|
||||
);
|
||||
|
||||
return { draft, setDraft, applied, page, apply, applyFilters, clear, goToPage };
|
||||
}
|
||||
|
||||
/**
|
||||
* A detail page's "back" affordance: a real `router.back()` when there is browser history to return to
|
||||
* (the common case — a queue row was clicked to get here, so back restores its filter/page/scroll state),
|
||||
* falling back to pushing `listHref` when there isn't (a pasted/bookmarked detail link). `listHref` must
|
||||
* already be locale-prefixed (`next/navigation`'s router does not add it) — pass
|
||||
* `` `/${locale}${ROUTES.ADMIN_TICKETS}` ``.
|
||||
*/
|
||||
export function useAdminBackToList(listHref: string): () => void {
|
||||
const router = useRouter();
|
||||
return useCallback(() => {
|
||||
if (typeof window !== 'undefined' && window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
router.push(listHref);
|
||||
}
|
||||
}, [router, listHref]);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
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<PropsWithChildren> = ({ children }) => {
|
||||
const t = useTranslations('nav');
|
||||
const tPartner = useTranslations('partner');
|
||||
const { data: center, isLoading } = useMyPartnerCenter();
|
||||
|
||||
const sidebarItems: Array<LinkToPage> = useMemo(
|
||||
@@ -31,7 +35,18 @@ const PartnerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
return (
|
||||
<TopBarAndSideBarLayout
|
||||
sidebarItems={sidebarItems}
|
||||
identity={<ProfileSummary compact displayName={center?.name ?? ''} loading={isLoading} />}
|
||||
identity={
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', minWidth: 0 }}>
|
||||
<ProfileSummary compact displayName={center?.name ?? ''} loading={isLoading} />
|
||||
{!isLoading && center ? (
|
||||
<StatusChip
|
||||
status={center.isMerchantOfRecord ? 'active' : 'neutral'}
|
||||
label={center.isMerchantOfRecord ? tPartner('is_mor_yes') : tPartner('is_mor_no')}
|
||||
sx={{ height: 20, fontSize: '0.6875rem', display: { xs: 'none', sm: 'inline-flex' } }}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</TopBarAndSideBarLayout>
|
||||
|
||||
@@ -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<ApiEnvelope<AdminUserSummary[]>>(`${API}/admin_users/search?${q.toString()}`));
|
||||
},
|
||||
lookupUsers: async (userIds: number[]) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<AdminUserSummary[]>>(`${API}/admin_users/lookup`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userIds }),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -2,9 +2,11 @@ import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
import type {
|
||||
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));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminApi } from '../apis';
|
||||
import { adminKeys } from '../keys';
|
||||
import { ADMIN_GC_TIME } from '../constants';
|
||||
import type { AdminUserSummary } from '../types';
|
||||
|
||||
/**
|
||||
* Batch id→label resolve (REQ-061, gap) — one request for every actor/owner id a page renders (`AuditLogRow`,
|
||||
* role grants, …), never one request per row. Callers should memoize `userIds` (a stable array reference)
|
||||
* so the query key doesn't churn every render; pass a deduped list.
|
||||
*/
|
||||
export function useUserLookup(userIds: number[]) {
|
||||
return useQuery({
|
||||
queryKey: adminKeys.userLookup(userIds),
|
||||
queryFn: () => adminApi.lookupUsers(userIds),
|
||||
enabled: userIds.length > 0,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: ADMIN_GC_TIME,
|
||||
select: (data): Map<number, AdminUserSummary> => new Map(data.map((u) => [u.id, u])),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { adminApi } from '../apis';
|
||||
import { adminKeys } from '../keys';
|
||||
import { ADMIN_GC_TIME } from '../constants';
|
||||
import type { DirectoryUserRole } from '../types';
|
||||
|
||||
/** Minimum query length before searching — avoids a fan-out request per keystroke on 0–1 chars. */
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
|
||||
/**
|
||||
* Name/phone search over the admin user directory (REQ-061, gap) — backs `UserPicker`/`NursePicker`.
|
||||
* The caller debounces `query`; this hook only gates on length so an empty/near-empty query renders no
|
||||
* options instead of the whole directory.
|
||||
*/
|
||||
export function useUserSearch(query: string, roleFilter?: DirectoryUserRole) {
|
||||
const trimmed = query.trim();
|
||||
return useQuery({
|
||||
queryKey: adminKeys.userSearch(trimmed, roleFilter),
|
||||
queryFn: () => adminApi.searchUsers(trimmed, roleFilter),
|
||||
enabled: trimmed.length >= MIN_QUERY_LENGTH,
|
||||
staleTime: 30 * 1000,
|
||||
gcTime: ADMIN_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -15,3 +15,5 @@ export { useResolveSupportAlert } from './hooks/useResolveSupportAlert';
|
||||
export { useAdminRoles } from './hooks/useAdminRoles';
|
||||
export { useGrantRole } from './hooks/useGrantRole';
|
||||
export { useRevokeRole } from './hooks/useRevokeRole';
|
||||
export { useUserSearch } from './hooks/useUserSearch';
|
||||
export { useUserLookup } from './hooks/useUserLookup';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import type { 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,
|
||||
};
|
||||
|
||||
@@ -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<RoleGrant[]>;
|
||||
grantRole(userId: number, role: AdminRole): Promise<void>;
|
||||
revokeRole(userId: number, role: AdminRole): Promise<void>;
|
||||
// user directory (deferred-if-missing — REQ-061)
|
||||
/** Search by name/phone (min 2 chars); `roleFilter` narrows to one coarse role (e.g. `NursePicker`). */
|
||||
searchUsers(query: string, roleFilter?: DirectoryUserRole): Promise<AdminUserSummary[]>;
|
||||
/** Batch id→label resolve — powers `AuditLogRow`'s actor names without one request per row. */
|
||||
lookupUsers(userIds: number[]): Promise<AdminUserSummary[]>;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
PartnerCenterFilters,
|
||||
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<ApiEnvelope<Paginated<SponsoredBooking>>>(`${API}/centers/me/bookings?${q}`));
|
||||
},
|
||||
// REQ-064 — no single-booking read in the b15 contract yet; proposed shape (sibling of the list route).
|
||||
getMySponsoredBookingDetail: async (bookingId: number) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<SponsoredBookingDetail>>(`${API}/centers/me/bookings/${bookingId}`)),
|
||||
listMySettlement: async (params) => {
|
||||
const q = new URLSearchParams();
|
||||
q.set('page', String(params.page ?? 1));
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
PartnerCenterFilters,
|
||||
PartnerCenterInput,
|
||||
SponsoredBooking,
|
||||
SponsoredBookingDetail,
|
||||
SponsoredBookingFilters,
|
||||
SponsoredNurse,
|
||||
} from '../types';
|
||||
@@ -119,6 +120,44 @@ const BOOKINGS: Record<number, SponsoredBooking[]> = {
|
||||
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<SponsoredBookingDetail> => {
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { partnerCenterApi } from '../apis';
|
||||
import { centerKeys } from '../keys';
|
||||
import { PARTNER_DETAIL_STALE_TIME, PARTNER_GC_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The scoped, read-only booking detail behind a sponsored-bookings-list row (REQ-064) — dates + a status
|
||||
* timeline only, no clinical content. `bookingId` is `undefined` while the route param hasn't resolved yet
|
||||
* (mirrors the disabled-until-ready pattern used by the nurse payout detail hook).
|
||||
*/
|
||||
export function useMySponsoredBookingDetail(bookingId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: centerKeys.mySponsoredBookingDetail(bookingId ?? -1),
|
||||
queryFn: () => partnerCenterApi.getMySponsoredBookingDetail(bookingId as number),
|
||||
enabled: bookingId != null && Number.isFinite(bookingId),
|
||||
staleTime: PARTNER_DETAIL_STALE_TIME,
|
||||
gcTime: PARTNER_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -13,4 +13,5 @@ export { useAssignNurseToPartnerCenter } from './hooks/useAssignNurseToPartnerCe
|
||||
export { useMyPartnerCenter } from './hooks/useMyPartnerCenter';
|
||||
export { useMySponsoredNurses } from './hooks/useMySponsoredNurses';
|
||||
export { useMySponsoredBookings } from './hooks/useMySponsoredBookings';
|
||||
export { useMySponsoredBookingDetail } from './hooks/useMySponsoredBookingDetail';
|
||||
export { useMySettlement } from './hooks/useMySettlement';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>` generic constraint — a plain interface has no implicit index signature and
|
||||
* TS rejects it as a type argument there, even though it's structurally identical.
|
||||
*/
|
||||
export type SponsoredBookingFilters = {
|
||||
status?: string;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The partner-center API seam — admin-side management + the center-scoped portal reads. The real client
|
||||
@@ -127,6 +141,8 @@ export interface PartnerCenterApi {
|
||||
getMyCenter(): Promise<PartnerCenter>;
|
||||
listMySponsoredNurses(): Promise<SponsoredNurse[]>;
|
||||
listMySponsoredBookings(filters: SponsoredBookingFilters, params: PageParams): Promise<Paginated<SponsoredBooking>>;
|
||||
/** REQ-064 — the scoped read-only detail behind a bookings-list row (dates + status timeline only). */
|
||||
getMySponsoredBookingDetail(bookingId: number): Promise<SponsoredBookingDetail>;
|
||||
listMySettlement(params: PageParams): Promise<Paginated<CenterInvoice>>;
|
||||
}
|
||||
|
||||
|
||||
@@ -262,4 +262,18 @@ export const ticketsClientApi: TicketsApi = {
|
||||
}),
|
||||
),
|
||||
|
||||
// Lifecycle (REQ-063 — routes proposed; not live). Kept real-shaped so the swap is one line once they ship;
|
||||
// gated behind `TICKET_LIFECYCLE_ENABLED` on the caller side until then.
|
||||
closeTicket: async (ticketId: number): Promise<void> => {
|
||||
await clientFetch<ApiEnvelope<null>>(`${API}/tickets/${ticketId}/close`, { method: 'POST' });
|
||||
},
|
||||
reopenTicket: async (ticketId: number): Promise<void> => {
|
||||
await clientFetch<ApiEnvelope<null>>(`${API}/tickets/${ticketId}/reopen`, { method: 'POST' });
|
||||
},
|
||||
assignTicket: async (ticketId: number, ownerUserId: number): Promise<void> => {
|
||||
await clientFetch<ApiEnvelope<null>>(`${API}/tickets/${ticketId}/assign`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ownerUserId }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -67,6 +67,8 @@ interface StoredTicket {
|
||||
messages: StoredMessage[];
|
||||
/** 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<void> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const t = findTicket(ticketId);
|
||||
t.status = 'closed';
|
||||
t.closedAt = new Date().toISOString();
|
||||
},
|
||||
|
||||
reopenTicket: async (ticketId: number): Promise<void> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const t = findTicket(ticketId);
|
||||
t.status = 'open';
|
||||
t.closedAt = null;
|
||||
},
|
||||
|
||||
assignTicket: async (ticketId: number, ownerUserId: number): Promise<void> => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const t = findTicket(ticketId);
|
||||
t.assigneeUserId = ownerUserId;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,6 +43,14 @@ export const TICKETS_ATTACHMENTS_ENABLED = false;
|
||||
/** The admin global queue is a live worklist — a short stale window keeps it fresh without hammering. */
|
||||
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
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ticketsApi } from '../apis';
|
||||
import { ticketKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Assign a ticket to a staff owner (REQ-063 — gated behind `TICKET_LIFECYCLE_ENABLED`, the caller's job;
|
||||
* today the only caller is "assign to me"). Invalidates the admin thread + queue on success.
|
||||
*/
|
||||
export function useAssignTicket(ticketId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, { ownerUserId: number }>({
|
||||
mutationFn: ({ ownerUserId }) => ticketsApi.assignTicket(ticketId, ownerUserId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.adminDetail(ticketId) });
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ticketsApi } from '../apis';
|
||||
import { ticketKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Close an open ticket (REQ-063 — gated behind `TICKET_LIFECYCLE_ENABLED`, the caller's job). Invalidates
|
||||
* the admin thread + every admin queue page so the ticket leaves the open worklist immediately.
|
||||
*/
|
||||
export function useCloseTicket(ticketId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, void>({
|
||||
mutationFn: () => ticketsApi.closeTicket(ticketId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.adminDetail(ticketId) });
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ticketsApi } from '../apis';
|
||||
import { ticketKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Reopen a closed ticket (REQ-063 — gated behind `TICKET_LIFECYCLE_ENABLED`, the caller's job). Invalidates
|
||||
* the admin thread + every admin queue page so the ticket reappears in the open worklist immediately.
|
||||
*/
|
||||
export function useReopenTicket(ticketId: number) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, void>({
|
||||
mutationFn: () => ticketsApi.reopenTicket(ticketId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.adminDetail(ticketId) });
|
||||
queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -15,3 +15,8 @@ export { useAdminTickets } from './hooks/useAdminTickets';
|
||||
export { useAdminTicket } from './hooks/useAdminTicket';
|
||||
export { 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';
|
||||
|
||||
@@ -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<Paginated<AdminTicketSummary>>;
|
||||
getAdminTicket(ticketId: number, viewerUserId?: number): Promise<AdminTicketDetail>;
|
||||
postAdminMessage(ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult>;
|
||||
|
||||
/**
|
||||
* Ticket lifecycle mutations (REQ-063 — no live route yet, gated behind `TICKET_LIFECYCLE_ENABLED`). A
|
||||
* resolved ticket has no way to leave the admin queue today; these three close the loop. `assignTicket`
|
||||
* sets the (currently mock-only) `assigneeUserId`.
|
||||
*/
|
||||
closeTicket(ticketId: number): Promise<void>;
|
||||
reopenTicket(ticketId: number): Promise<void>;
|
||||
assignTicket(ticketId: number, ownerUserId: number): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
AdminVerificationCase,
|
||||
AdminVerificationQueueFilters,
|
||||
AdminVerificationQueueItem,
|
||||
AdminVerificationQueuePage,
|
||||
AdminVerificationStepDetail,
|
||||
CredentialDetailsInput,
|
||||
DecideStepInput,
|
||||
@@ -216,9 +217,12 @@ export const verificationClientApi: VerificationApi = {
|
||||
listVerificationQueue: async (
|
||||
filters: AdminVerificationQueueFilters,
|
||||
params: PageParams,
|
||||
): Promise<Paginated<AdminVerificationQueueItem>> => {
|
||||
): Promise<AdminVerificationQueuePage> => {
|
||||
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 };
|
||||
},
|
||||
|
||||
|
||||
@@ -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<AdminCaseRecord['status']> = filters.status ? [filters.status] : ['pending', 'in_review'];
|
||||
const matched = adminCases.filter((record) => wanted.includes(record.status)).map(toQueueItem);
|
||||
const search = filters.search?.trim().toLowerCase();
|
||||
const matched = adminCases
|
||||
.filter((record) => wanted.includes(record.status))
|
||||
.filter((record) => !search || record.nurseName.toLowerCase().includes(search))
|
||||
.map(toQueueItem);
|
||||
const page = params.page ?? 1;
|
||||
const 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) => {
|
||||
|
||||
@@ -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<AdminVerificationQueueItem> {
|
||||
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<TrustBadge>;
|
||||
|
||||
// --- 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<Paginated<AdminVerificationQueueItem>>;
|
||||
/** The review queue, folded to one item per nurse. `status`/`search` filter (status default `in_review`); paginated. */
|
||||
listVerificationQueue(filters: AdminVerificationQueueFilters, params: PageParams): Promise<AdminVerificationQueuePage>;
|
||||
/** The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check. */
|
||||
getVerificationCase(nurseVerificationId: number): Promise<AdminVerificationCase>;
|
||||
/** A freshly-signed, short-lived GET URL for a document — fetched on demand (URLs expire; never long-cached). */
|
||||
|
||||
@@ -6,5 +6,6 @@ export * from './navigation';
|
||||
export * from './number';
|
||||
export * from './sessionStorage';
|
||||
export * from './sleep';
|
||||
export * from './toCsv';
|
||||
export * from './type';
|
||||
export * from './text';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { toCsv } from './toCsv';
|
||||
|
||||
describe('toCsv', () => {
|
||||
const headers = [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
];
|
||||
|
||||
it('serializes a header row + one row per item', () => {
|
||||
const csv = toCsv(
|
||||
[
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
],
|
||||
headers,
|
||||
);
|
||||
expect(csv).toBe('ID,Name\r\n1,Alice\r\n2,Bob');
|
||||
});
|
||||
|
||||
it('quotes a field containing a comma', () => {
|
||||
const csv = toCsv([{ id: 1, name: 'Doe, Jane' }], headers);
|
||||
expect(csv).toBe('ID,Name\r\n1,"Doe, Jane"');
|
||||
});
|
||||
|
||||
it('quotes a field containing an embedded quote, doubling it', () => {
|
||||
const csv = toCsv([{ id: 1, name: 'Say "hi"' }], headers);
|
||||
expect(csv).toBe('ID,Name\r\n1,"Say ""hi"""');
|
||||
});
|
||||
|
||||
it('uses CRLF line endings throughout', () => {
|
||||
const csv = toCsv(
|
||||
[
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
],
|
||||
headers,
|
||||
);
|
||||
expect(csv.split('\r\n')).toHaveLength(3);
|
||||
expect(csv).not.toMatch(/(?<!\r)\n/);
|
||||
});
|
||||
|
||||
it('renders an empty rows list as just the header row', () => {
|
||||
expect(toCsv([], headers)).toBe('ID,Name');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* A small, dependency-free CSV serializer (RFC 4180-ish). Used for client-side "export the currently loaded
|
||||
* page" affordances (e.g. the partner settlement invoices table) — no server round-trip, no library.
|
||||
*/
|
||||
|
||||
export interface CsvColumn {
|
||||
/** The row-object key this column reads. */
|
||||
key: string;
|
||||
/** Already-translated column header. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Wraps a field in double quotes (doubling any internal quote) when it contains a comma, quote, or newline. */
|
||||
function quoteField(value: string): string {
|
||||
if (/[",\r\n]/.test(value)) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a CSV string (header row + one row per item, `\r\n` line endings — Excel expects CRLF) from a list
|
||||
* of plain row objects and a column spec. Values are stringified as-is (callers pre-format money/dates);
|
||||
* any field containing a comma, double quote, or newline is quoted per RFC 4180.
|
||||
*/
|
||||
export function toCsv(rows: Record<string, string | number>[], headers: CsvColumn[]): string {
|
||||
const lines = [
|
||||
headers.map((h) => quoteField(h.label)).join(','),
|
||||
...rows.map((row) => headers.map((h) => quoteField(String(row[h.key] ?? ''))).join(',')),
|
||||
];
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
Reference in New Issue
Block a user