frontend phase 15
This commit is contained in:
+47
-9
@@ -173,11 +173,27 @@ client/
|
|||||||
│ │ │ ├── earnings/ # /nurse/earnings — f12 nurse earnings (read-only): page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + cadence/dispute-window explainer + state-segmented EarningsRow list (deep-links to /nurse/visits/[id]) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links)
|
│ │ │ ├── earnings/ # /nurse/earnings — f12 nurse earnings (read-only): page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + cadence/dispute-window explainer + state-segmented EarningsRow list (deep-links to /nurse/visits/[id]) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links)
|
||||||
│ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx
|
│ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx
|
||||||
│ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here
|
│ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here
|
||||||
│ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell
|
│ │ ├── 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).
|
||||||
│ │ ├── layout.tsx # 'use client' — wraps AdminLayout
|
│ │ │ ├── layout.tsx # 'use client' — wraps AdminLayout (capability-gated nav)
|
||||||
│ │ ├── page.tsx # /admin (overview)
|
│ │ │ ├── page.tsx # /admin — f15 overview landing: a capability-gated grid of console cards
|
||||||
│ │ ├── users/page.tsx # /admin/users
|
│ │ │ ├── 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)
|
||||||
│ │ └── notifications/page.tsx # /admin/notifications
|
│ │ │ ├── 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
|
||||||
|
│ │ └── 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' — wraps PartnerLayout (own partner nav)
|
||||||
|
│ │ ├── page.tsx # /partner — 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
|
||||||
│ └── (public-routes)/
|
│ └── (public-routes)/
|
||||||
│ ├── layout.tsx # 'use client' — wraps PublicLayout
|
│ ├── layout.tsx # 'use client' — wraps PublicLayout
|
||||||
│ └── login/page.tsx # /login — phone-OTP login (A1/A2 customer, B1/B2 nurse switch)
|
│ └── login/page.tsx # /login — phone-OTP login (A1/A2 customer, B1/B2 nurse switch)
|
||||||
@@ -281,6 +297,14 @@ client/
|
|||||||
│ ├── patientRecords/ # F13 continuity-of-care (b14) — patient-scoped, NOT booking-scoped. usePatientCareRecord(family record)/useRecordAccess(gates before any clinical fetch)/usePatientHistory(paged visit-note history)/useUpdateCareRecord(CUSTOMER-only edit → setQueryData)/useCreateVisitNote(NURSE-only append → invalidates history). seam+mock(PRIMARY)+client. The nurse-authored visit-note history/append (getPatientHistory/createVisitNote) are REAL b14 (GET/POST patients/{id}/care_records, mapped 1:1; the append folds the ticked task checklist into the note body); the family-owned editable record (medications/routine/tasks) + the access check have NO backend (REQ-027) and are mocked. Nurse is APPEND-ONLY (never wires useUpdateCareRecord). Access-denied (canView=false / 403) is a first-class non-leaking state; MOCK_FOREIGN_PATIENT_ID=8888 exercises it. Clinical text is never logged/localStorage/query-string
|
│ ├── patientRecords/ # F13 continuity-of-care (b14) — patient-scoped, NOT booking-scoped. usePatientCareRecord(family record)/useRecordAccess(gates before any clinical fetch)/usePatientHistory(paged visit-note history)/useUpdateCareRecord(CUSTOMER-only edit → setQueryData)/useCreateVisitNote(NURSE-only append → invalidates history). seam+mock(PRIMARY)+client. The nurse-authored visit-note history/append (getPatientHistory/createVisitNote) are REAL b14 (GET/POST patients/{id}/care_records, mapped 1:1; the append folds the ticked task checklist into the note body); the family-owned editable record (medications/routine/tasks) + the access check have NO backend (REQ-027) and are mocked. Nurse is APPEND-ONLY (never wires useUpdateCareRecord). Access-denied (canView=false / 403) is a first-class non-leaking state; MOCK_FOREIGN_PATIENT_ID=8888 exercises it. Clinical text is never logged/localStorage/query-string
|
||||||
│ ├── tickets/ # F14 tickets — the ONLY sanctioned post-booking channel (b15). useMyTickets/useTicket(one detail(id) = the whole thread; no message pagination)/useTicketThread(select over detail)/useOpenTicket(invalidates lists)/usePostMessage(OPTIMISTIC: onMutate append pending, onError rollback+keep composer draft, onSuccess replace by clientMessageId, onSettled invalidate). 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. Wire summary lacks unreadCount/lastMessageAt (REQ-028) → mock-only
|
│ ├── tickets/ # F14 tickets — the ONLY sanctioned post-booking channel (b15). useMyTickets/useTicket(one detail(id) = the whole thread; no message pagination)/useTicketThread(select over detail)/useOpenTicket(invalidates lists)/usePostMessage(OPTIMISTIC: onMutate append pending, onError rollback+keep composer draft, onSuccess replace by clientMessageId, onSettled invalidate). 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. Wire summary lacks unreadCount/lastMessageAt (REQ-028) → mock-only
|
||||||
│ ├── notifications/ # F14 in-app notification center (b1) — polled, no push. useNotifications(unread-first, growing limit)/useUnreadCount(the POLLING bell: refetchInterval 60s + staleTime 45s + refetchOnFocus, auth-gated — count only, list never polled)/useMarkNotificationRead+useMarkAllRead(OPTIMISTIC setQueryData flips isRead + decrements/zeros the cached count, rollback on error, invalidate on settle). seam+mock(PRIMARY)+client(maps b1 1:1). data_json is a TYPED contract: parseNotificationData(type,dataJson)→discriminated NotificationData union (snake/camel tolerant, degrades to {kind:'none'} on malformed/unknown/missing id — never trusts a blob); notificationDeepLink(n,role) centralises the role-aware route (null when nothing to open). Mock seeds every deep-link class + __mockPushNotification for the bell-increment demo
|
│ ├── notifications/ # F14 in-app notification center (b1) — polled, no push. useNotifications(unread-first, growing limit)/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
|
||||||
|
│ ├── 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)
|
||||||
|
│ │ # refunds → useRefundPreview/useInitiateRefund/useApproveRefund/useRejectRefund (b11, ticket-linked; REQ-035)
|
||||||
|
│ │ # payouts → usePayoutBatches/usePayoutBatchDetail/usePreviewPayoutBatch/useRunPayoutBatch(idempotency-keyed)/useRetryPayout/useRecordTransferReference (b13; REQ-036)
|
||||||
|
│ │ # reviews → useModerationQueue/useModerateReview (b14; REQ-037)
|
||||||
|
│ │ # tickets → useAdminTickets/useAdminTicket/useAdminTicketThread/usePostAdminMessage (b15; the ADMIN ticket types carry isInternal — the user-app types deliberately do NOT)
|
||||||
│ └── {domain}/
|
│ └── {domain}/
|
||||||
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
|
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
|
||||||
│ ├── keys.ts # React Query key factory (hierarchical)
|
│ ├── keys.ts # React Query key factory (hierarchical)
|
||||||
@@ -385,9 +409,12 @@ async function MyServerComponent() {
|
|||||||
- `'tickets'` — the f14 messaging surface (tickets are the only post-booking channel): the inbox (`title`/`contact_support`/`empty_*`/`error_body`), the category + status labels keyed off the code (`category_{support,coordination,refund,emergency}`/`status_{open,closed}`), the linked-entity hints (`linked_booking`/`linked_refund` with `{id}`), `ref_code_label`, the new-ticket dialog (`new_ticket_title`/`category_label`/`subject_label`/`message_label`/`submit`/`created_*`/`view_thread`), the thread (`back_to_tickets`/`thread_*`/`closed_notice`), the composer (`sending`/`send`/`send_failed`/`composer_placeholder`), the author-role labels (`author_{customer,nurse,support,system}` — `admin`→support), and the **emergency playbook** (`emergency_title`/`emergency_body`/`emergency_call {name}`/`emergency_call_generic`/`emergency_open_ticket`) + `open_from_booking`; consumed by the ticket screens, `MessageBubble`/`TicketListCard`/`EmergencyBanner`/`ContactSupportDialog`/`MessageComposer`/`BookingSupportEntry`
|
- `'tickets'` — the f14 messaging surface (tickets are the only post-booking channel): the inbox (`title`/`contact_support`/`empty_*`/`error_body`), the category + status labels keyed off the code (`category_{support,coordination,refund,emergency}`/`status_{open,closed}`), the linked-entity hints (`linked_booking`/`linked_refund` with `{id}`), `ref_code_label`, the new-ticket dialog (`new_ticket_title`/`category_label`/`subject_label`/`message_label`/`submit`/`created_*`/`view_thread`), the thread (`back_to_tickets`/`thread_*`/`closed_notice`), the composer (`sending`/`send`/`send_failed`/`composer_placeholder`), the author-role labels (`author_{customer,nurse,support,system}` — `admin`→support), and the **emergency playbook** (`emergency_title`/`emergency_body`/`emergency_call {name}`/`emergency_call_generic`/`emergency_open_ticket`) + `open_from_booking`; consumed by the ticket screens, `MessageBubble`/`TicketListCard`/`EmergencyBanner`/`ContactSupportDialog`/`MessageComposer`/`BookingSupportEntry`
|
||||||
- `'notifications'` — the f14 notification center + bell: `title`, `empty_*`, `error_body`, `retry`, `mark_all_read`, `load_more`, and the polled-bell aria (`bell_aria` with `{count, number}`); the row `title`/`body` are **server-rendered** copy, not keys. Consumed by `NotificationCenter` + `NotificationBell`
|
- `'notifications'` — the f14 notification center + bell: `title`, `empty_*`, `error_body`, `retry`, `mark_all_read`, `load_more`, and the polled-bell aria (`bell_aria` with `{count, number}`); the row `title`/`body` are **server-rendered** copy, not keys. Consumed by `NotificationCenter` + `NotificationBell`
|
||||||
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
|
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
|
||||||
|
- `'admin'` — the f15 backoffice consoles: verification queue/case, refund panel, payout dashboard/detail, review moderation, config editor + change-history, holiday manager, support-alert board, audit viewer, admin ticket queue/thread, RBAC grid, and admin-side partner management. Includes the **Persian legal terms** (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی) and the enum-label prefixes keyed off the stable code (`step_*`/`agg_*`/`atype_*`/`astatus_*`/`sev_*`/`htype_*`/`dtype_*`/`batch_status_*`/`pstatus_*`/`channel_*`/`rstatus_*`/`mstatus_*`/`center_state_*`/`role_*`/`tcat_*`/`tstatus_*`). Consumed by the `/admin/*` screens + the `@/components/admin` composites
|
||||||
|
- `'partner'` — the f15 partner-center portal (a separate authz scope): center home/onboarding-state, sponsored nurses/bookings, and the merchant-of-record settlement/invoice view (سامانه مودیان, commission/VAT decomposition). Consumed by the `/partner/*` screens + `PartnerSettlementRow`
|
||||||
|
|
||||||
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
|
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
|
||||||
files): `admin` (f15). Keep top-level keys as namespaces and both files in sync.
|
files): none — **MVP namespaces complete** (f15 seeded `admin` + `partner`). Keep top-level keys as
|
||||||
|
namespaces and both files in sync.
|
||||||
|
|
||||||
**Never hard-code UI strings in English.** Any user-visible text must have a translation key in both locale files.
|
**Never hard-code UI strings in English.** Any user-visible text must have a translation key in both locale files.
|
||||||
|
|
||||||
@@ -690,9 +717,20 @@ client design, but some hardening needs *server* coordination — don't silently
|
|||||||
- **The middleware check is UX-only, not a security boundary:** it decodes the JWT and checks `exp` but
|
- **The middleware check is UX-only, not a security boundary:** it decodes the JWT and checks `exp` but
|
||||||
does **not** verify the signature. The API is the only authority; never gate real authorization on the
|
does **not** verify the signature. The API is the only authority; never gate real authorization on the
|
||||||
middleware or `isTokenAlive`.
|
middleware or `isTokenAlive`.
|
||||||
- **Role gating is coarse:** the shells pick chrome from `currentUser.roles`, but cross-actor route access
|
- **Role gating is coarse for chrome, fine for the backoffice:** the shells pick chrome from the collapsed
|
||||||
isn't hard-guarded client-side yet (the server authorizes each call). Add route guards when a phase needs
|
`currentUser.roles` (`useActorRole`). **f15 adds `useAdminCapabilities()` (`@/hooks`)** — a memoized selector
|
||||||
them.
|
over the session's **fine-grained** `roleCodes` (hydrated from `/me` by `useSessionRoleSync`; `super_admin`/
|
||||||
|
`admin`/`support`/`finance`/`moderation`) that returns per-console booleans (`canVerify`/`canRefund`/
|
||||||
|
`canPayout`/`canModerate`/`canConfig`/`canManageAlerts`/`canManageTickets`/`canManagePartners`/`canViewAudit`/
|
||||||
|
`canManageRoles`). The `AdminLayout` nav and every admin action **hide/disable** on it so a role never sees a
|
||||||
|
control that will 403 — but it is a **display convenience only; the server authorizes every command** (never
|
||||||
|
gate real authz on it). Cross-actor route access still isn't hard-guarded client-side; add route guards when a
|
||||||
|
phase needs them. The **partner portal is a separate scope** — its pages resolve the caller's own center via
|
||||||
|
`useMyPartnerCenter()` (a 403/404 renders a non-leaking access-denied state), never a raw id.
|
||||||
|
- **Signed URLs are fetched on demand, never cached long-lived (f15):** verification documents load via a
|
||||||
|
short-lived signed URL fetched by `useVerificationDocumentUrl(documentId)` (short `staleTime`, `retry:false`) —
|
||||||
|
`DocumentViewer` re-requests it on expiry/error rather than reading the embedded URL from the long-lived case
|
||||||
|
query. Reuse this pattern for any short-lived signed asset (invoice PDFs, etc.).
|
||||||
- **Refresh-token rotation is wired** client-side (fetch-layer silent refresh + `useRefresh`), matching the
|
- **Refresh-token rotation is wired** client-side (fetch-layer silent refresh + `useRefresh`), matching the
|
||||||
server's rotation + reuse-detection. The `refresh_token` cookie TTL (7d) is shorter than the server session
|
server's rotation + reuse-detection. The `refresh_token` cookie TTL (7d) is shorter than the server session
|
||||||
default (30d) — a follow-up can align the cookie `maxAge` to `refreshExpiresAt`.
|
default (30d) — a follow-up can align the cookie `maxAge` to `refreshExpiresAt`.
|
||||||
|
|||||||
+430
-2
@@ -19,7 +19,20 @@
|
|||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"support": "Support",
|
"support": "Support",
|
||||||
"login": "Login",
|
"login": "Login",
|
||||||
"logout": "Logout"
|
"logout": "Logout",
|
||||||
|
"payouts": "Payouts",
|
||||||
|
"reviews": "Reviews",
|
||||||
|
"config": "Configuration",
|
||||||
|
"holidays": "Holidays",
|
||||||
|
"alerts": "Alerts",
|
||||||
|
"audit": "Audit log",
|
||||||
|
"partners": "Partner centers",
|
||||||
|
"roles": "Roles",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"partner_home": "Center",
|
||||||
|
"partner_nurses": "Sponsored nurses",
|
||||||
|
"partner_bookings": "Bookings",
|
||||||
|
"partner_settlement": "Settlement"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dark_mode": "Dark mode",
|
"dark_mode": "Dark mode",
|
||||||
@@ -44,7 +57,8 @@
|
|||||||
"customer_app": "Family app",
|
"customer_app": "Family app",
|
||||||
"nurse_app": "Nurse view",
|
"nurse_app": "Nurse view",
|
||||||
"admin_console": "Admin console",
|
"admin_console": "Admin console",
|
||||||
"placeholder_body": "This area will be built in a later phase."
|
"placeholder_body": "This area will be built in a later phase.",
|
||||||
|
"partner_console": "Partner portal"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"greeting_named": "Hi, {name}",
|
"greeting_named": "Hi, {name}",
|
||||||
@@ -1146,5 +1160,419 @@
|
|||||||
"mark_all_read": "Mark all read",
|
"mark_all_read": "Mark all read",
|
||||||
"load_more": "Load more",
|
"load_more": "Load more",
|
||||||
"bell_aria": "{count, number} unread notifications"
|
"bell_aria": "{count, number} unread notifications"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"overview_title": "Backoffice",
|
||||||
|
"overview_subtitle": "Operate the marketplace — verify, refund, pay out, moderate, configure.",
|
||||||
|
"filter_all": "All",
|
||||||
|
"filter_label": "Filter",
|
||||||
|
"apply": "Apply",
|
||||||
|
"clear": "Clear",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"saved": "Saved",
|
||||||
|
"retry": "Retry",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"error_generic": "Something went wrong. Please try again.",
|
||||||
|
"back": "Back",
|
||||||
|
"close": "Close",
|
||||||
|
"view": "View",
|
||||||
|
"open": "Open",
|
||||||
|
"none": "—",
|
||||||
|
"reason_label": "Reason",
|
||||||
|
"reason_required": "A reason is required.",
|
||||||
|
"note_label": "Note",
|
||||||
|
"actor": "Actor",
|
||||||
|
"timestamp": "Time",
|
||||||
|
"showing_range": "Showing {from}–{to} of {total}",
|
||||||
|
"page_indicator": "Page {page}",
|
||||||
|
"prev_page": "Previous",
|
||||||
|
"next_page": "Next",
|
||||||
|
"search_placeholder": "Search…",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"no_permission": "You don’t have access to this console.",
|
||||||
|
"role_gate_hint": "This action is limited to certain admin roles.",
|
||||||
|
"masked_iban_label": "IBAN",
|
||||||
|
"ver_title": "Verification queue",
|
||||||
|
"ver_subtitle": "Nurses awaiting document review.",
|
||||||
|
"ver_empty": "Queue clear — nothing to review.",
|
||||||
|
"ver_col_nurse": "Nurse",
|
||||||
|
"ver_col_step": "Step",
|
||||||
|
"ver_col_status": "Status",
|
||||||
|
"ver_col_submitted": "Submitted",
|
||||||
|
"ver_progress": "{done} of {total}",
|
||||||
|
"ver_next_step": "Next: {step}",
|
||||||
|
"ver_open_case": "Review",
|
||||||
|
"ver_case_title": "Verification case",
|
||||||
|
"ver_identity_name": "Identity on file",
|
||||||
|
"ver_steps_title": "Steps",
|
||||||
|
"ver_documents_title": "Documents",
|
||||||
|
"ver_credentials_title": "Credentials",
|
||||||
|
"ver_no_documents": "No documents for this step.",
|
||||||
|
"ver_automated_badge": "Automated check",
|
||||||
|
"ver_pass": "Pass",
|
||||||
|
"ver_reject": "Reject",
|
||||||
|
"ver_pass_step": "Pass step",
|
||||||
|
"ver_reject_step": "Reject step",
|
||||||
|
"ver_reject_reason_ph": "Why is this step rejected?",
|
||||||
|
"ver_credential_title": "Record credential",
|
||||||
|
"ver_credential_number": "Credential number",
|
||||||
|
"ver_holder_name": "Holder name",
|
||||||
|
"ver_holder_hint": "Must match the verified identity name, or the credential is rejected.",
|
||||||
|
"ver_issuing_authority": "Issuing authority",
|
||||||
|
"ver_issued_at": "Issued at",
|
||||||
|
"ver_expires_at": "Expires at",
|
||||||
|
"ver_expiry_required": "An expiry date is required for this credential.",
|
||||||
|
"ver_expiring_warning": "Expiring credential",
|
||||||
|
"ver_approve": "Approve verification",
|
||||||
|
"ver_reject_all": "Reject verification",
|
||||||
|
"ver_approve_hint": "Enabled only when every required step has passed.",
|
||||||
|
"ver_approve_confirm": "Approve this nurse? The server flips their verified status; this cannot be undone here.",
|
||||||
|
"ver_reject_confirm": "Reject this verification with the reason above?",
|
||||||
|
"ver_decided": "Decision recorded.",
|
||||||
|
"doc_loading": "Loading document…",
|
||||||
|
"doc_expired": "This secure link expired.",
|
||||||
|
"doc_reload": "Re-request link",
|
||||||
|
"doc_error": "Couldn’t load the document.",
|
||||||
|
"doc_open_new": "Open in a new tab",
|
||||||
|
"doc_file_meta": "{name} · {size}",
|
||||||
|
"payout_title": "Payout batches",
|
||||||
|
"payout_subtitle": "Weekly nurse payouts — preview, run, reconcile.",
|
||||||
|
"payout_empty": "No payout batches yet.",
|
||||||
|
"payout_col_period": "Period",
|
||||||
|
"payout_col_count": "Payouts",
|
||||||
|
"payout_col_total": "Total",
|
||||||
|
"payout_col_status": "Status",
|
||||||
|
"payout_col_processing": "Processing date",
|
||||||
|
"payout_holiday_shift": "Shifted off a bank holiday",
|
||||||
|
"payout_preview": "Preview next batch",
|
||||||
|
"payout_preview_title": "Eligibility preview",
|
||||||
|
"payout_period_start": "Period start",
|
||||||
|
"payout_period_end": "Period end",
|
||||||
|
"payout_eligible_nurses": "Eligible nurses",
|
||||||
|
"payout_col_gross": "Gross earnings",
|
||||||
|
"payout_col_clawback": "Clawback netted",
|
||||||
|
"payout_col_net": "Net",
|
||||||
|
"payout_no_iban": "No verified IBAN — will be skipped",
|
||||||
|
"payout_eligibility_note": "Only completed bookings past their dispute window appear here — computed server-side.",
|
||||||
|
"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_running": "Submitting to the bank rail…",
|
||||||
|
"payout_skipped": "Skipped nurses",
|
||||||
|
"payout_batch_title": "Batch #{id}",
|
||||||
|
"payout_rows_title": "Payouts in this batch",
|
||||||
|
"payout_row_nurse": "Nurse",
|
||||||
|
"payout_row_net": "Net",
|
||||||
|
"payout_row_status": "Status",
|
||||||
|
"payout_row_ref": "Transfer ref",
|
||||||
|
"payout_failure_reason": "Failure: {reason}",
|
||||||
|
"payout_retry": "Retry payout",
|
||||||
|
"payout_retry_confirm": "Retry this failed payout? (Idempotency-protected.)",
|
||||||
|
"payout_record_ref": "Record transfer reference",
|
||||||
|
"payout_record_ref_ph": "Bank transfer reference",
|
||||||
|
"payout_ref_saved": "Transfer reference recorded.",
|
||||||
|
"payout_decomp": "{gross} gross − {clawback} clawback = {net} net",
|
||||||
|
"payout_ran": "Batch submitted — now processing.",
|
||||||
|
"refund_title": "Refund",
|
||||||
|
"refund_open": "Open refund panel",
|
||||||
|
"refund_linked_booking": "Booking #{id}",
|
||||||
|
"refund_preview_title": "Refund preview",
|
||||||
|
"refund_percentage": "Refund rate",
|
||||||
|
"refund_row_fee": "Platform commission refunded",
|
||||||
|
"refund_row_payout": "Nurse payout refunded",
|
||||||
|
"refund_row_total": "Total refund",
|
||||||
|
"refund_channel": "Channel",
|
||||||
|
"refund_channel_hint": "How the money is returned — chosen by the server.",
|
||||||
|
"refund_eta": "Estimated to the customer: {date}",
|
||||||
|
"refund_eta_bnpl": "BNPL reverts take ~7–10 business days.",
|
||||||
|
"refund_clawback_notice": "The nurse was already paid — a clawback will be created automatically.",
|
||||||
|
"refund_initiate": "Initiate refund",
|
||||||
|
"refund_approve": "Approve",
|
||||||
|
"refund_reject": "Reject",
|
||||||
|
"refund_confirm": "Initiate this refund on booking #{id}?",
|
||||||
|
"refund_provider_failed": "The provider revert failed.",
|
||||||
|
"refund_done": "Refund processed.",
|
||||||
|
"refund_reason_category": "Reason",
|
||||||
|
"refund_notes_ph": "Notes (optional)",
|
||||||
|
"mod_title": "Review moderation",
|
||||||
|
"mod_subtitle": "Publish, hide, or reject submitted reviews.",
|
||||||
|
"mod_empty": "Nothing to moderate.",
|
||||||
|
"mod_low_rating": "Low rating",
|
||||||
|
"mod_col_rating": "Rating",
|
||||||
|
"mod_col_review": "Review",
|
||||||
|
"mod_col_context": "Nurse / booking",
|
||||||
|
"mod_publish": "Publish",
|
||||||
|
"mod_hide": "Hide",
|
||||||
|
"mod_reject": "Reject",
|
||||||
|
"mod_confirm_publish": "Publish this review? It becomes public and re-computes the nurse’s rating.",
|
||||||
|
"mod_confirm_hide": "Hide this review with the reason above?",
|
||||||
|
"mod_confirm_reject": "Reject this review with the reason above?",
|
||||||
|
"mod_done": "Review updated.",
|
||||||
|
"mod_nurse": "Nurse #{id}",
|
||||||
|
"mod_booking": "Booking #{id}",
|
||||||
|
"cfg_title": "Platform configuration",
|
||||||
|
"cfg_subtitle": "Typed, audited settings that drive fees, deadlines, and scheduling.",
|
||||||
|
"cfg_col_key": "Key",
|
||||||
|
"cfg_col_value": "Value",
|
||||||
|
"cfg_col_type": "Type",
|
||||||
|
"cfg_col_updated": "Last updated",
|
||||||
|
"cfg_edit": "Edit",
|
||||||
|
"cfg_updated_by": "by {actor}",
|
||||||
|
"cfg_range_error": "A rate must be between 0 and 1.",
|
||||||
|
"cfg_int_error": "This must be a whole number.",
|
||||||
|
"cfg_json_error": "Invalid JSON.",
|
||||||
|
"cfg_empty_error": "A value is required.",
|
||||||
|
"cfg_save_confirm_title": "Save this configuration change?",
|
||||||
|
"cfg_save_confirm_body": "This change is audited and takes effect immediately. It does NOT retroactively change already-computed bookings or ledger entries.",
|
||||||
|
"cfg_saved": "Configuration saved.",
|
||||||
|
"cfg_history": "Change history",
|
||||||
|
"cfg_history_title": "History — {key}",
|
||||||
|
"cfg_history_change": "{old} → {new}",
|
||||||
|
"cfg_history_empty": "No changes recorded yet.",
|
||||||
|
"cfg_group_fees": "Fees & VAT",
|
||||||
|
"cfg_group_deadlines": "Deadlines & windows",
|
||||||
|
"cfg_group_evv": "EVV",
|
||||||
|
"cfg_group_bnpl": "BNPL",
|
||||||
|
"cfg_group_cancellation": "Cancellation tiers",
|
||||||
|
"cfg_group_other": "Other",
|
||||||
|
"hol_title": "Holiday calendar",
|
||||||
|
"hol_subtitle": "Bank-closed days shift payout scheduling. The server computes the shift.",
|
||||||
|
"hol_empty": "No holidays in this range.",
|
||||||
|
"hol_col_date": "Date",
|
||||||
|
"hol_col_name": "Name",
|
||||||
|
"hol_col_type": "Type",
|
||||||
|
"hol_col_bank": "Bank closed",
|
||||||
|
"hol_add": "Add holiday",
|
||||||
|
"hol_edit": "Edit holiday",
|
||||||
|
"hol_name_fa": "Name (Persian)",
|
||||||
|
"hol_bank_hint": "When on, payouts falling on this day shift to the next business day.",
|
||||||
|
"hol_saved": "Holiday saved.",
|
||||||
|
"hol_year": "Year",
|
||||||
|
"alert_title": "Support alerts",
|
||||||
|
"alert_subtitle": "Internal triage — never shown to customers or nurses.",
|
||||||
|
"alert_empty": "No open alerts.",
|
||||||
|
"alert_col_type": "Type",
|
||||||
|
"alert_col_entity": "Linked to",
|
||||||
|
"alert_col_owner": "Owner",
|
||||||
|
"alert_col_status": "Status",
|
||||||
|
"alert_col_created": "Raised",
|
||||||
|
"alert_assign_me": "Assign to me",
|
||||||
|
"alert_assign": "Assign",
|
||||||
|
"alert_resolve": "Resolve",
|
||||||
|
"alert_resolve_title": "Resolve alert",
|
||||||
|
"alert_resolve_note_ph": "How was this resolved?",
|
||||||
|
"alert_assigned": "Alert assigned.",
|
||||||
|
"alert_resolved": "Alert resolved.",
|
||||||
|
"alert_link_booking": "Booking #{id}",
|
||||||
|
"alert_link_review": "Review #{id}",
|
||||||
|
"alert_link_entity": "{type} #{id}",
|
||||||
|
"alert_unassigned": "Unassigned",
|
||||||
|
"audit_title": "Audit log",
|
||||||
|
"audit_subtitle": "Append-only record of every admin state change. Read-only.",
|
||||||
|
"audit_empty": "No audit entries for this filter.",
|
||||||
|
"audit_col_entity": "Entity",
|
||||||
|
"audit_col_action": "Action",
|
||||||
|
"audit_col_actor": "Actor",
|
||||||
|
"audit_col_time": "Time",
|
||||||
|
"audit_entity_type_ph": "Entity type (e.g. PlatformConfig)",
|
||||||
|
"audit_entity_id_ph": "Entity id",
|
||||||
|
"audit_from": "From",
|
||||||
|
"audit_to": "To",
|
||||||
|
"audit_diff_title": "Changed fields",
|
||||||
|
"audit_diff_field": "Field",
|
||||||
|
"audit_diff_old": "Old",
|
||||||
|
"audit_diff_new": "New",
|
||||||
|
"audit_no_diff": "No field-level diff recorded.",
|
||||||
|
"audit_redacted": "<redacted>",
|
||||||
|
"ticket_title": "Ticket queue",
|
||||||
|
"ticket_subtitle": "Every ticket across the platform. Internal notes are staff-only.",
|
||||||
|
"ticket_empty": "No tickets match this filter.",
|
||||||
|
"ticket_col_ref": "Reference",
|
||||||
|
"ticket_col_subject": "Subject",
|
||||||
|
"ticket_col_category": "Category",
|
||||||
|
"ticket_col_status": "Status",
|
||||||
|
"ticket_col_booking": "Booking",
|
||||||
|
"ticket_search_ref_ph": "Search by reference code",
|
||||||
|
"ticket_thread_title": "Ticket {ref}",
|
||||||
|
"ticket_internal_badge": "Internal note",
|
||||||
|
"ticket_public_reply": "Reply",
|
||||||
|
"ticket_internal_note": "Internal note",
|
||||||
|
"ticket_composer_public_ph": "Reply to the participants…",
|
||||||
|
"ticket_composer_internal_ph": "Add a staff-only internal note…",
|
||||||
|
"ticket_send": "Send",
|
||||||
|
"ticket_sent": "Message sent.",
|
||||||
|
"ticket_participants": "Participants",
|
||||||
|
"ticket_linked_refund": "Refund #{id}",
|
||||||
|
"partner_title": "Partner centers",
|
||||||
|
"partner_subtitle": "Licensed centers that sponsor nurses and may be merchant-of-record.",
|
||||||
|
"partner_empty": "No partner centers yet.",
|
||||||
|
"partner_col_name": "Name",
|
||||||
|
"partner_col_mor": "Merchant of record",
|
||||||
|
"partner_col_nurses": "Nurses",
|
||||||
|
"partner_col_state": "State",
|
||||||
|
"partner_create": "Create center",
|
||||||
|
"partner_edit": "Edit center",
|
||||||
|
"partner_detail_title": "Center detail",
|
||||||
|
"partner_name": "Center name",
|
||||||
|
"partner_legal_type": "Legal entity type",
|
||||||
|
"partner_permit": "پروانه تأسیس",
|
||||||
|
"partner_permit_en": "MoH establishment permit",
|
||||||
|
"partner_tech_director": "مسئول فنی",
|
||||||
|
"partner_tech_director_license": "Technical director license",
|
||||||
|
"partner_enamad": "نماد اعتماد الکترونیکی",
|
||||||
|
"partner_iban": "Settlement IBAN",
|
||||||
|
"partner_iban_write_hint": "Enter the full IBAN to set it; it is stored masked and only the last 4 digits are shown afterward.",
|
||||||
|
"partner_commission": "Commission rate",
|
||||||
|
"partner_is_mor": "Merchant of record",
|
||||||
|
"partner_is_mor_hint": "When on, this center issues invoices and is the settlement target.",
|
||||||
|
"partner_admin_user": "Center admin user id",
|
||||||
|
"partner_verify": "Verify & activate",
|
||||||
|
"partner_verify_confirm": "Record licensing approval and activate this center?",
|
||||||
|
"partner_activate": "Activate",
|
||||||
|
"partner_suspend": "Suspend",
|
||||||
|
"partner_saved": "Center saved.",
|
||||||
|
"partner_verified_toast": "Center verified and activated.",
|
||||||
|
"partner_roster_title": "Sponsored nurses",
|
||||||
|
"partner_assign_nurse": "Assign nurse",
|
||||||
|
"partner_assign_nurse_ph": "Nurse profile id",
|
||||||
|
"partner_unlink_nurse": "Remove",
|
||||||
|
"partner_nurse_assigned": "Nurse assignment updated.",
|
||||||
|
"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.",
|
||||||
|
"role_col_user": "User",
|
||||||
|
"role_col_role": "Role",
|
||||||
|
"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_updated": "Role updated.",
|
||||||
|
"agg_not_started": "Not started",
|
||||||
|
"agg_pending": "Pending",
|
||||||
|
"agg_in_review": "In review",
|
||||||
|
"agg_approved": "Approved",
|
||||||
|
"agg_rejected": "Rejected",
|
||||||
|
"agg_suspended": "Suspended",
|
||||||
|
"step_not_started": "Not started",
|
||||||
|
"step_pending": "Pending",
|
||||||
|
"step_in_review": "In review",
|
||||||
|
"step_passed": "Passed",
|
||||||
|
"step_failed": "Failed",
|
||||||
|
"step_expired": "Expired",
|
||||||
|
"step_identity_kyc": "Identity (KYC)",
|
||||||
|
"step_shahkar_match": "Shahkar match",
|
||||||
|
"step_moh_competency_license": "MoH competency license",
|
||||||
|
"step_ino_membership": "INO membership",
|
||||||
|
"step_criminal_record": "Criminal record",
|
||||||
|
"step_bank_account_verification": "Bank account check",
|
||||||
|
"batch_status_draft": "Draft",
|
||||||
|
"batch_status_processing": "Processing",
|
||||||
|
"batch_status_partially_failed": "Partially failed",
|
||||||
|
"batch_status_completed": "Completed",
|
||||||
|
"batch_status_failed": "Failed",
|
||||||
|
"pstatus_pending": "Pending",
|
||||||
|
"pstatus_submitted": "Submitted",
|
||||||
|
"pstatus_paid": "Paid",
|
||||||
|
"pstatus_failed": "Failed",
|
||||||
|
"channel_psp_card": "Card (PSP)",
|
||||||
|
"channel_bnpl_revert": "BNPL revert",
|
||||||
|
"channel_manual": "Manual bank",
|
||||||
|
"rstatus_requested": "Requested",
|
||||||
|
"rstatus_approved": "Approved",
|
||||||
|
"rstatus_processing": "Processing",
|
||||||
|
"rstatus_succeeded": "Succeeded",
|
||||||
|
"rstatus_failed": "Failed",
|
||||||
|
"rstatus_rejected": "Rejected",
|
||||||
|
"mstatus_pending_moderation": "Pending",
|
||||||
|
"mstatus_published": "Published",
|
||||||
|
"mstatus_hidden": "Hidden",
|
||||||
|
"mstatus_rejected": "Rejected",
|
||||||
|
"dtype_string": "Text",
|
||||||
|
"dtype_int": "Integer",
|
||||||
|
"dtype_decimal": "Decimal",
|
||||||
|
"dtype_bool": "Boolean",
|
||||||
|
"dtype_json": "JSON",
|
||||||
|
"htype_official": "Official",
|
||||||
|
"htype_religious": "Religious",
|
||||||
|
"htype_national": "National",
|
||||||
|
"atype_low_rating": "Low rating",
|
||||||
|
"atype_evv_no_show": "No-show",
|
||||||
|
"atype_evv_location_mismatch": "EVV location mismatch",
|
||||||
|
"atype_verification_expired": "Verification expired",
|
||||||
|
"atype_shared_sim": "Shared SIM",
|
||||||
|
"atype_payment_anomaly": "Payment anomaly",
|
||||||
|
"atype_fraud_signal": "Fraud signal",
|
||||||
|
"atype_nurse_clawback": "Nurse clawback",
|
||||||
|
"atype_emergency": "Emergency",
|
||||||
|
"astatus_open": "Open",
|
||||||
|
"astatus_assigned": "Assigned",
|
||||||
|
"astatus_resolved": "Resolved",
|
||||||
|
"sev_low": "Low",
|
||||||
|
"sev_medium": "Medium",
|
||||||
|
"sev_high": "High",
|
||||||
|
"tstatus_open": "Open",
|
||||||
|
"tstatus_closed": "Closed",
|
||||||
|
"tcat_coordination": "Coordination",
|
||||||
|
"tcat_support": "Support",
|
||||||
|
"tcat_refund": "Refund",
|
||||||
|
"tcat_emergency": "Emergency",
|
||||||
|
"role_super_admin": "Super admin",
|
||||||
|
"role_admin": "Admin",
|
||||||
|
"role_support": "Support",
|
||||||
|
"role_finance": "Finance",
|
||||||
|
"role_moderation": "Moderation",
|
||||||
|
"center_state_draft": "Draft",
|
||||||
|
"center_state_pending_verification": "Pending verification",
|
||||||
|
"center_state_verified": "Verified",
|
||||||
|
"center_state_suspended": "Suspended",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No"
|
||||||
|
},
|
||||||
|
"partner": {
|
||||||
|
"home_title": "Your center",
|
||||||
|
"home_subtitle": "Onboarding, sponsored nurses, and settlement at a glance.",
|
||||||
|
"state_banner_draft": "Your center is a draft — complete onboarding to go live.",
|
||||||
|
"state_banner_pending": "Your center is pending verification.",
|
||||||
|
"state_banner_suspended": "Your center is suspended. Contact Balinyaar.",
|
||||||
|
"license_title": "License details",
|
||||||
|
"permit": "پروانه تأسیس",
|
||||||
|
"tech_director": "مسئول فنی",
|
||||||
|
"enamad": "نماد اعتماد الکترونیکی",
|
||||||
|
"legal_type": "Legal entity type",
|
||||||
|
"is_mor_yes": "Merchant of record",
|
||||||
|
"is_mor_no": "Settlement runs through Balinyaar",
|
||||||
|
"access_denied": "You don’t have access to a partner center.",
|
||||||
|
"nurses_title": "Sponsored nurses",
|
||||||
|
"nurses_empty": "No nurses sponsored yet.",
|
||||||
|
"nurses_col_name": "Nurse",
|
||||||
|
"nurses_col_verified": "Verification",
|
||||||
|
"bookings_title": "Sponsored bookings",
|
||||||
|
"bookings_empty": "No bookings under this center yet.",
|
||||||
|
"bookings_col_id": "Booking",
|
||||||
|
"bookings_col_patient": "Patient",
|
||||||
|
"bookings_col_date": "Date",
|
||||||
|
"bookings_col_status": "Status",
|
||||||
|
"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.",
|
||||||
|
"settlement_col_booking": "Booking",
|
||||||
|
"settlement_col_gross": "Gross",
|
||||||
|
"settlement_col_total": "Total",
|
||||||
|
"settlement_moadian": "سامانه مودیان",
|
||||||
|
"settlement_moadian_ref": "Moadian reference",
|
||||||
|
"settlement_moadian_pending": "Not yet submitted to سامانه مودیان",
|
||||||
|
"invoice_row_gross": "Gross service fee",
|
||||||
|
"invoice_row_commission": "Platform commission",
|
||||||
|
"invoice_row_bnpl_commission": "BNPL commission",
|
||||||
|
"invoice_row_vat": "VAT (on commission)",
|
||||||
|
"invoice_row_total": "Invoice total",
|
||||||
|
"invoice_download": "Download PDF",
|
||||||
|
"invoice_pdf_error": "Couldn’t open the invoice PDF.",
|
||||||
|
"invoice_number": "Invoice #{number}",
|
||||||
|
"settlement_iban": "Settlement IBAN"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+430
-2
@@ -19,7 +19,20 @@
|
|||||||
"notifications": "اعلانها",
|
"notifications": "اعلانها",
|
||||||
"support": "پشتیبانی",
|
"support": "پشتیبانی",
|
||||||
"login": "ورود",
|
"login": "ورود",
|
||||||
"logout": "خروج"
|
"logout": "خروج",
|
||||||
|
"payouts": "تسویهها",
|
||||||
|
"reviews": "نظرات",
|
||||||
|
"config": "پیکربندی",
|
||||||
|
"holidays": "تعطیلات",
|
||||||
|
"alerts": "هشدارها",
|
||||||
|
"audit": "گزارش ممیزی",
|
||||||
|
"partners": "مراکز همکار",
|
||||||
|
"roles": "نقشها",
|
||||||
|
"tickets": "تیکتها",
|
||||||
|
"partner_home": "مرکز",
|
||||||
|
"partner_nurses": "پرستاران تحت پوشش",
|
||||||
|
"partner_bookings": "رزروها",
|
||||||
|
"partner_settlement": "تسویه"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"dark_mode": "حالت تاریک",
|
"dark_mode": "حالت تاریک",
|
||||||
@@ -44,7 +57,8 @@
|
|||||||
"customer_app": "اپلیکیشن خانواده",
|
"customer_app": "اپلیکیشن خانواده",
|
||||||
"nurse_app": "نمای پرستار",
|
"nurse_app": "نمای پرستار",
|
||||||
"admin_console": "کنسول مدیریت",
|
"admin_console": "کنسول مدیریت",
|
||||||
"placeholder_body": "این بخش در فازهای بعدی تکمیل میشود."
|
"placeholder_body": "این بخش در فازهای بعدی تکمیل میشود.",
|
||||||
|
"partner_console": "پرتال همکار"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"greeting_named": "سلام، {name}",
|
"greeting_named": "سلام، {name}",
|
||||||
@@ -1146,5 +1160,419 @@
|
|||||||
"mark_all_read": "علامتگذاری همه بهعنوان خواندهشده",
|
"mark_all_read": "علامتگذاری همه بهعنوان خواندهشده",
|
||||||
"load_more": "نمایش بیشتر",
|
"load_more": "نمایش بیشتر",
|
||||||
"bell_aria": "{count, number} اعلان خواندهنشده"
|
"bell_aria": "{count, number} اعلان خواندهنشده"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"overview_title": "پیشخان مدیریت",
|
||||||
|
"overview_subtitle": "راهبری بازار — احراز، بازپرداخت، تسویه، بررسی و پیکربندی.",
|
||||||
|
"filter_all": "همه",
|
||||||
|
"filter_label": "فیلتر",
|
||||||
|
"apply": "اعمال",
|
||||||
|
"clear": "پاک کردن",
|
||||||
|
"cancel": "انصراف",
|
||||||
|
"confirm": "تأیید",
|
||||||
|
"save": "ذخیره",
|
||||||
|
"saving": "در حال ذخیره…",
|
||||||
|
"saved": "ذخیره شد",
|
||||||
|
"retry": "تلاش دوباره",
|
||||||
|
"loading": "در حال بارگذاری…",
|
||||||
|
"error_generic": "خطایی رخ داد. دوباره تلاش کنید.",
|
||||||
|
"back": "بازگشت",
|
||||||
|
"close": "بستن",
|
||||||
|
"view": "مشاهده",
|
||||||
|
"open": "باز کردن",
|
||||||
|
"none": "—",
|
||||||
|
"reason_label": "دلیل",
|
||||||
|
"reason_required": "ذکر دلیل الزامی است.",
|
||||||
|
"note_label": "یادداشت",
|
||||||
|
"actor": "عامل",
|
||||||
|
"timestamp": "زمان",
|
||||||
|
"showing_range": "نمایش {from}–{to} از {total}",
|
||||||
|
"page_indicator": "صفحه {page}",
|
||||||
|
"prev_page": "قبلی",
|
||||||
|
"next_page": "بعدی",
|
||||||
|
"search_placeholder": "جستجو…",
|
||||||
|
"refresh": "بهروزرسانی",
|
||||||
|
"no_permission": "شما به این بخش دسترسی ندارید.",
|
||||||
|
"role_gate_hint": "این اقدام تنها برای نقشهای مشخصی از مدیران در دسترس است.",
|
||||||
|
"masked_iban_label": "شبا",
|
||||||
|
"ver_title": "صف احراز هویت",
|
||||||
|
"ver_subtitle": "پرستاران در انتظار بررسی مدارک.",
|
||||||
|
"ver_empty": "صف خالی است — موردی برای بررسی نیست.",
|
||||||
|
"ver_col_nurse": "پرستار",
|
||||||
|
"ver_col_step": "مرحله",
|
||||||
|
"ver_col_status": "وضعیت",
|
||||||
|
"ver_col_submitted": "زمان ارسال",
|
||||||
|
"ver_progress": "{done} از {total}",
|
||||||
|
"ver_next_step": "بعدی: {step}",
|
||||||
|
"ver_open_case": "بررسی",
|
||||||
|
"ver_case_title": "پرونده احراز هویت",
|
||||||
|
"ver_identity_name": "نام ثبتشده",
|
||||||
|
"ver_steps_title": "مراحل",
|
||||||
|
"ver_documents_title": "مدارک",
|
||||||
|
"ver_credentials_title": "مدارک حرفهای",
|
||||||
|
"ver_no_documents": "مدرکی برای این مرحله ثبت نشده.",
|
||||||
|
"ver_automated_badge": "بررسی خودکار",
|
||||||
|
"ver_pass": "تأیید",
|
||||||
|
"ver_reject": "رد",
|
||||||
|
"ver_pass_step": "تأیید مرحله",
|
||||||
|
"ver_reject_step": "رد مرحله",
|
||||||
|
"ver_reject_reason_ph": "چرا این مرحله رد میشود؟",
|
||||||
|
"ver_credential_title": "ثبت مدرک",
|
||||||
|
"ver_credential_number": "شماره مدرک",
|
||||||
|
"ver_holder_name": "نام دارنده",
|
||||||
|
"ver_holder_hint": "باید با نام احرازشده مطابقت داشته باشد وگرنه رد میشود.",
|
||||||
|
"ver_issuing_authority": "مرجع صادرکننده",
|
||||||
|
"ver_issued_at": "تاریخ صدور",
|
||||||
|
"ver_expires_at": "تاریخ انقضا",
|
||||||
|
"ver_expiry_required": "برای این مدرک تاریخ انقضا الزامی است.",
|
||||||
|
"ver_expiring_warning": "مدرک در حال انقضا",
|
||||||
|
"ver_approve": "تأیید نهایی احراز",
|
||||||
|
"ver_reject_all": "رد احراز",
|
||||||
|
"ver_approve_hint": "تنها زمانی فعال است که همهٔ مراحل لازم تأیید شده باشند.",
|
||||||
|
"ver_approve_confirm": "این پرستار تأیید شود؟ وضعیت تأیید توسط سرور اعمال میشود و اینجا قابل بازگشت نیست.",
|
||||||
|
"ver_reject_confirm": "این احراز با دلیل واردشده رد شود؟",
|
||||||
|
"ver_decided": "تصمیم ثبت شد.",
|
||||||
|
"doc_loading": "در حال بارگذاری مدرک…",
|
||||||
|
"doc_expired": "این پیوند امن منقضی شده است.",
|
||||||
|
"doc_reload": "درخواست پیوند تازه",
|
||||||
|
"doc_error": "بارگذاری مدرک ناموفق بود.",
|
||||||
|
"doc_open_new": "باز کردن در تب جدید",
|
||||||
|
"doc_file_meta": "{name} · {size}",
|
||||||
|
"payout_title": "دستههای تسویه",
|
||||||
|
"payout_subtitle": "تسویههای هفتگی پرستاران — پیشنمایش، اجرا و تطبیق.",
|
||||||
|
"payout_empty": "هنوز دستهای ثبت نشده.",
|
||||||
|
"payout_col_period": "دوره",
|
||||||
|
"payout_col_count": "تعداد",
|
||||||
|
"payout_col_total": "مبلغ کل",
|
||||||
|
"payout_col_status": "وضعیت",
|
||||||
|
"payout_col_processing": "تاریخ پردازش",
|
||||||
|
"payout_holiday_shift": "بهدلیل تعطیلی بانکی جابهجا شد",
|
||||||
|
"payout_preview": "پیشنمایش دستهٔ بعدی",
|
||||||
|
"payout_preview_title": "پیشنمایش واجدین شرایط",
|
||||||
|
"payout_period_start": "شروع دوره",
|
||||||
|
"payout_period_end": "پایان دوره",
|
||||||
|
"payout_eligible_nurses": "پرستاران واجد شرایط",
|
||||||
|
"payout_col_gross": "درآمد ناخالص",
|
||||||
|
"payout_col_clawback": "کسر بازپسگیری",
|
||||||
|
"payout_col_net": "خالص",
|
||||||
|
"payout_no_iban": "شبای تأییدشده ندارد — نادیده گرفته میشود",
|
||||||
|
"payout_eligibility_note": "تنها رزروهای تکمیلشدهای که پنجرهٔ اعتراض آنها پایان یافته اینجا نمایش داده میشوند — محاسبه سمت سرور.",
|
||||||
|
"payout_run": "اجرای دسته",
|
||||||
|
"payout_run_confirm_title": "این دستهٔ تسویه اجرا شود؟",
|
||||||
|
"payout_run_confirm_body": "مبلغ به پرستاران منتقل میشود. این اقدام با کلید یکتا محافظت میشود — کلیک دوباره باعث پرداخت مضاعف نمیشود.",
|
||||||
|
"payout_running": "در حال ارسال به سامانهٔ بانکی…",
|
||||||
|
"payout_skipped": "پرستاران نادیدهگرفتهشده",
|
||||||
|
"payout_batch_title": "دسته #{id}",
|
||||||
|
"payout_rows_title": "تسویههای این دسته",
|
||||||
|
"payout_row_nurse": "پرستار",
|
||||||
|
"payout_row_net": "خالص",
|
||||||
|
"payout_row_status": "وضعیت",
|
||||||
|
"payout_row_ref": "کد تراکنش",
|
||||||
|
"payout_failure_reason": "خطا: {reason}",
|
||||||
|
"payout_retry": "تلاش دوبارهٔ تسویه",
|
||||||
|
"payout_retry_confirm": "این تسویهٔ ناموفق دوباره تلاش شود؟ (با کلید یکتا محافظتشده.)",
|
||||||
|
"payout_record_ref": "ثبت کد تراکنش",
|
||||||
|
"payout_record_ref_ph": "کد تراکنش بانکی",
|
||||||
|
"payout_ref_saved": "کد تراکنش ثبت شد.",
|
||||||
|
"payout_decomp": "{gross} ناخالص − {clawback} بازپسگیری = {net} خالص",
|
||||||
|
"payout_ran": "دسته ارسال شد — در حال پردازش.",
|
||||||
|
"refund_title": "بازپرداخت",
|
||||||
|
"refund_open": "باز کردن پنل بازپرداخت",
|
||||||
|
"refund_linked_booking": "رزرو #{id}",
|
||||||
|
"refund_preview_title": "پیشنمایش بازپرداخت",
|
||||||
|
"refund_percentage": "نرخ بازپرداخت",
|
||||||
|
"refund_row_fee": "کارمزد بازگرداندهشده",
|
||||||
|
"refund_row_payout": "سهم پرستار بازگرداندهشده",
|
||||||
|
"refund_row_total": "مبلغ کل بازپرداخت",
|
||||||
|
"refund_channel": "کانال",
|
||||||
|
"refund_channel_hint": "نحوهٔ بازگرداندن وجه — تعیینشده توسط سرور.",
|
||||||
|
"refund_eta": "زمان تقریبی به مشتری: {date}",
|
||||||
|
"refund_eta_bnpl": "بازگشت اقساطی حدود ۷ تا ۱۰ روز کاری زمان میبرد.",
|
||||||
|
"refund_clawback_notice": "سهم پرستار قبلاً پرداخت شده — بازپسگیری بهصورت خودکار ایجاد میشود.",
|
||||||
|
"refund_initiate": "شروع بازپرداخت",
|
||||||
|
"refund_approve": "تأیید",
|
||||||
|
"refund_reject": "رد",
|
||||||
|
"refund_confirm": "بازپرداخت رزرو #{id} آغاز شود؟",
|
||||||
|
"refund_provider_failed": "بازگشت از سمت ارائهدهنده ناموفق بود.",
|
||||||
|
"refund_done": "بازپرداخت انجام شد.",
|
||||||
|
"refund_reason_category": "دلیل",
|
||||||
|
"refund_notes_ph": "یادداشت (اختیاری)",
|
||||||
|
"mod_title": "بررسی نظرات",
|
||||||
|
"mod_subtitle": "انتشار، پنهانسازی یا رد نظرات ارسالی.",
|
||||||
|
"mod_empty": "موردی برای بررسی نیست.",
|
||||||
|
"mod_low_rating": "امتیاز پایین",
|
||||||
|
"mod_col_rating": "امتیاز",
|
||||||
|
"mod_col_review": "نظر",
|
||||||
|
"mod_col_context": "پرستار / رزرو",
|
||||||
|
"mod_publish": "انتشار",
|
||||||
|
"mod_hide": "پنهانسازی",
|
||||||
|
"mod_reject": "رد",
|
||||||
|
"mod_confirm_publish": "این نظر منتشر شود؟ عمومی میشود و امتیاز پرستار بازمحاسبه میگردد.",
|
||||||
|
"mod_confirm_hide": "این نظر با دلیل واردشده پنهان شود؟",
|
||||||
|
"mod_confirm_reject": "این نظر با دلیل واردشده رد شود؟",
|
||||||
|
"mod_done": "نظر بهروزرسانی شد.",
|
||||||
|
"mod_nurse": "پرستار #{id}",
|
||||||
|
"mod_booking": "رزرو #{id}",
|
||||||
|
"cfg_title": "پیکربندی سامانه",
|
||||||
|
"cfg_subtitle": "تنظیمات نوعدار و ممیزیشده که کارمزد، مهلتها و زمانبندی را تعیین میکنند.",
|
||||||
|
"cfg_col_key": "کلید",
|
||||||
|
"cfg_col_value": "مقدار",
|
||||||
|
"cfg_col_type": "نوع",
|
||||||
|
"cfg_col_updated": "آخرین تغییر",
|
||||||
|
"cfg_edit": "ویرایش",
|
||||||
|
"cfg_updated_by": "توسط {actor}",
|
||||||
|
"cfg_range_error": "نرخ باید بین ۰ و ۱ باشد.",
|
||||||
|
"cfg_int_error": "باید عدد صحیح باشد.",
|
||||||
|
"cfg_json_error": "JSON نامعتبر است.",
|
||||||
|
"cfg_empty_error": "مقدار الزامی است.",
|
||||||
|
"cfg_save_confirm_title": "این تغییر پیکربندی ذخیره شود؟",
|
||||||
|
"cfg_save_confirm_body": "این تغییر ممیزی میشود و بلافاصله اعمال میگردد. رزروها و ثبتهای مالی قبلی را بازنمیگرداند.",
|
||||||
|
"cfg_saved": "پیکربندی ذخیره شد.",
|
||||||
|
"cfg_history": "تاریخچهٔ تغییرات",
|
||||||
|
"cfg_history_title": "تاریخچه — {key}",
|
||||||
|
"cfg_history_change": "{old} ← {new}",
|
||||||
|
"cfg_history_empty": "هنوز تغییری ثبت نشده.",
|
||||||
|
"cfg_group_fees": "کارمزد و مالیات",
|
||||||
|
"cfg_group_deadlines": "مهلتها و پنجرهها",
|
||||||
|
"cfg_group_evv": "ثبت حضور (EVV)",
|
||||||
|
"cfg_group_bnpl": "خرید اقساطی",
|
||||||
|
"cfg_group_cancellation": "سطوح لغو",
|
||||||
|
"cfg_group_other": "سایر",
|
||||||
|
"hol_title": "تقویم تعطیلات",
|
||||||
|
"hol_subtitle": "روزهای تعطیلی بانکی، زمانبندی تسویه را جابهجا میکنند. محاسبهٔ جابهجایی با سرور است.",
|
||||||
|
"hol_empty": "تعطیلاتی در این بازه نیست.",
|
||||||
|
"hol_col_date": "تاریخ",
|
||||||
|
"hol_col_name": "عنوان",
|
||||||
|
"hol_col_type": "نوع",
|
||||||
|
"hol_col_bank": "تعطیلی بانکی",
|
||||||
|
"hol_add": "افزودن تعطیلی",
|
||||||
|
"hol_edit": "ویرایش تعطیلی",
|
||||||
|
"hol_name_fa": "عنوان (فارسی)",
|
||||||
|
"hol_bank_hint": "در صورت فعال بودن، تسویههای این روز به روز کاری بعد منتقل میشوند.",
|
||||||
|
"hol_saved": "تعطیلی ذخیره شد.",
|
||||||
|
"hol_year": "سال",
|
||||||
|
"alert_title": "هشدارهای پشتیبانی",
|
||||||
|
"alert_subtitle": "صف داخلی — هرگز به مشتری یا پرستار نمایش داده نمیشود.",
|
||||||
|
"alert_empty": "هشدار بازی وجود ندارد.",
|
||||||
|
"alert_col_type": "نوع",
|
||||||
|
"alert_col_entity": "مرتبط با",
|
||||||
|
"alert_col_owner": "مسئول",
|
||||||
|
"alert_col_status": "وضعیت",
|
||||||
|
"alert_col_created": "زمان ایجاد",
|
||||||
|
"alert_assign_me": "واگذاری به من",
|
||||||
|
"alert_assign": "واگذاری",
|
||||||
|
"alert_resolve": "رفع",
|
||||||
|
"alert_resolve_title": "رفع هشدار",
|
||||||
|
"alert_resolve_note_ph": "این هشدار چگونه رفع شد؟",
|
||||||
|
"alert_assigned": "هشدار واگذار شد.",
|
||||||
|
"alert_resolved": "هشدار رفع شد.",
|
||||||
|
"alert_link_booking": "رزرو #{id}",
|
||||||
|
"alert_link_review": "نظر #{id}",
|
||||||
|
"alert_link_entity": "{type} #{id}",
|
||||||
|
"alert_unassigned": "بدون مسئول",
|
||||||
|
"audit_title": "گزارش ممیزی",
|
||||||
|
"audit_subtitle": "ثبت غیرقابلتغییرِ هر تغییر مدیریتی. فقطخواندنی.",
|
||||||
|
"audit_empty": "برای این فیلتر رکوردی نیست.",
|
||||||
|
"audit_col_entity": "موجودیت",
|
||||||
|
"audit_col_action": "اقدام",
|
||||||
|
"audit_col_actor": "عامل",
|
||||||
|
"audit_col_time": "زمان",
|
||||||
|
"audit_entity_type_ph": "نوع موجودیت (مثلاً PlatformConfig)",
|
||||||
|
"audit_entity_id_ph": "شناسهٔ موجودیت",
|
||||||
|
"audit_from": "از",
|
||||||
|
"audit_to": "تا",
|
||||||
|
"audit_diff_title": "فیلدهای تغییریافته",
|
||||||
|
"audit_diff_field": "فیلد",
|
||||||
|
"audit_diff_old": "قبلی",
|
||||||
|
"audit_diff_new": "جدید",
|
||||||
|
"audit_no_diff": "تغییر فیلدی ثبت نشده.",
|
||||||
|
"audit_redacted": "<حذفشده>",
|
||||||
|
"ticket_title": "صف تیکتها",
|
||||||
|
"ticket_subtitle": "همهٔ تیکتهای سامانه. یادداشتهای داخلی تنها برای کارکنان است.",
|
||||||
|
"ticket_empty": "تیکتی با این فیلتر یافت نشد.",
|
||||||
|
"ticket_col_ref": "کد پیگیری",
|
||||||
|
"ticket_col_subject": "موضوع",
|
||||||
|
"ticket_col_category": "دسته",
|
||||||
|
"ticket_col_status": "وضعیت",
|
||||||
|
"ticket_col_booking": "رزرو",
|
||||||
|
"ticket_search_ref_ph": "جستجو با کد پیگیری",
|
||||||
|
"ticket_thread_title": "تیکت {ref}",
|
||||||
|
"ticket_internal_badge": "یادداشت داخلی",
|
||||||
|
"ticket_public_reply": "پاسخ",
|
||||||
|
"ticket_internal_note": "یادداشت داخلی",
|
||||||
|
"ticket_composer_public_ph": "پاسخ به شرکتکنندگان…",
|
||||||
|
"ticket_composer_internal_ph": "افزودن یادداشت داخلی (فقط کارکنان)…",
|
||||||
|
"ticket_send": "ارسال",
|
||||||
|
"ticket_sent": "پیام ارسال شد.",
|
||||||
|
"ticket_participants": "شرکتکنندگان",
|
||||||
|
"ticket_linked_refund": "بازپرداخت #{id}",
|
||||||
|
"partner_title": "مراکز همکار",
|
||||||
|
"partner_subtitle": "مراکز دارای مجوز که پرستاران را پشتیبانی میکنند و میتوانند فروشندهٔ رسمی باشند.",
|
||||||
|
"partner_empty": "هنوز مرکزی ثبت نشده.",
|
||||||
|
"partner_col_name": "نام",
|
||||||
|
"partner_col_mor": "فروشندهٔ رسمی",
|
||||||
|
"partner_col_nurses": "پرستاران",
|
||||||
|
"partner_col_state": "وضعیت",
|
||||||
|
"partner_create": "ایجاد مرکز",
|
||||||
|
"partner_edit": "ویرایش مرکز",
|
||||||
|
"partner_detail_title": "جزئیات مرکز",
|
||||||
|
"partner_name": "نام مرکز",
|
||||||
|
"partner_legal_type": "نوع شخصیت حقوقی",
|
||||||
|
"partner_permit": "پروانه تأسیس",
|
||||||
|
"partner_permit_en": "مجوز تأسیس وزارت بهداشت",
|
||||||
|
"partner_tech_director": "مسئول فنی",
|
||||||
|
"partner_tech_director_license": "شمارهٔ نظام مسئول فنی",
|
||||||
|
"partner_enamad": "نماد اعتماد الکترونیکی",
|
||||||
|
"partner_iban": "شبای تسویه",
|
||||||
|
"partner_iban_write_hint": "شبا را کامل وارد کنید؛ بهصورت پوشیده ذخیره میشود و پس از آن تنها ۴ رقم آخر نمایش داده میشود.",
|
||||||
|
"partner_commission": "نرخ کارمزد",
|
||||||
|
"partner_is_mor": "فروشندهٔ رسمی",
|
||||||
|
"partner_is_mor_hint": "در صورت فعال بودن، این مرکز فاکتور صادر میکند و مقصد تسویه است.",
|
||||||
|
"partner_admin_user": "شناسهٔ کاربر مدیر مرکز",
|
||||||
|
"partner_verify": "تأیید و فعالسازی",
|
||||||
|
"partner_verify_confirm": "تأیید مجوز ثبت و این مرکز فعال شود؟",
|
||||||
|
"partner_activate": "فعالسازی",
|
||||||
|
"partner_suspend": "تعلیق",
|
||||||
|
"partner_saved": "مرکز ذخیره شد.",
|
||||||
|
"partner_verified_toast": "مرکز تأیید و فعال شد.",
|
||||||
|
"partner_roster_title": "پرستاران تحت پوشش",
|
||||||
|
"partner_assign_nurse": "افزودن پرستار",
|
||||||
|
"partner_assign_nurse_ph": "شناسهٔ پروفایل پرستار",
|
||||||
|
"partner_unlink_nurse": "حذف",
|
||||||
|
"partner_nurse_assigned": "وابستگی پرستار بهروزرسانی شد.",
|
||||||
|
"role_title": "نقشها و دسترسی",
|
||||||
|
"role_subtitle": "اعطا یا لغو نقشهای مدیریتی. (در انتظار سرویسهای نقش سمت سرور.)",
|
||||||
|
"role_deferred": "این بخش تا آمادهشدن سرویسهای نقش، با دادههای موقتِ سمتکاربر نمایش داده میشود.",
|
||||||
|
"role_col_user": "کاربر",
|
||||||
|
"role_col_role": "نقش",
|
||||||
|
"role_col_granted": "اعطا شده",
|
||||||
|
"role_grant": "اعطای نقش",
|
||||||
|
"role_revoke": "لغو",
|
||||||
|
"role_grant_confirm": "نقش {role} به کاربر #{id} اعطا شود؟",
|
||||||
|
"role_revoke_confirm": "نقش {role} از کاربر #{id} لغو شود؟",
|
||||||
|
"role_updated": "نقش بهروزرسانی شد.",
|
||||||
|
"agg_not_started": "شروعنشده",
|
||||||
|
"agg_pending": "در انتظار",
|
||||||
|
"agg_in_review": "در حال بررسی",
|
||||||
|
"agg_approved": "تأییدشده",
|
||||||
|
"agg_rejected": "ردشده",
|
||||||
|
"agg_suspended": "معلق",
|
||||||
|
"step_not_started": "شروعنشده",
|
||||||
|
"step_pending": "در انتظار",
|
||||||
|
"step_in_review": "در حال بررسی",
|
||||||
|
"step_passed": "تأییدشده",
|
||||||
|
"step_failed": "ناموفق",
|
||||||
|
"step_expired": "منقضی",
|
||||||
|
"step_identity_kyc": "احراز هویت",
|
||||||
|
"step_shahkar_match": "تطبیق شاهکار",
|
||||||
|
"step_moh_competency_license": "پروانه صلاحیت وزارت بهداشت",
|
||||||
|
"step_ino_membership": "عضویت نظام پرستاری",
|
||||||
|
"step_criminal_record": "گواهی عدم سوءپیشینه",
|
||||||
|
"step_bank_account_verification": "تأیید حساب بانکی",
|
||||||
|
"batch_status_draft": "پیشنویس",
|
||||||
|
"batch_status_processing": "در حال پردازش",
|
||||||
|
"batch_status_partially_failed": "ناموفق جزئی",
|
||||||
|
"batch_status_completed": "تکمیلشده",
|
||||||
|
"batch_status_failed": "ناموفق",
|
||||||
|
"pstatus_pending": "در انتظار",
|
||||||
|
"pstatus_submitted": "ارسالشده",
|
||||||
|
"pstatus_paid": "پرداختشده",
|
||||||
|
"pstatus_failed": "ناموفق",
|
||||||
|
"channel_psp_card": "کارت (درگاه)",
|
||||||
|
"channel_bnpl_revert": "بازگشت اقساطی",
|
||||||
|
"channel_manual": "بانکی دستی",
|
||||||
|
"rstatus_requested": "درخواستشده",
|
||||||
|
"rstatus_approved": "تأییدشده",
|
||||||
|
"rstatus_processing": "در حال پردازش",
|
||||||
|
"rstatus_succeeded": "موفق",
|
||||||
|
"rstatus_failed": "ناموفق",
|
||||||
|
"rstatus_rejected": "ردشده",
|
||||||
|
"mstatus_pending_moderation": "در انتظار بررسی",
|
||||||
|
"mstatus_published": "منتشرشده",
|
||||||
|
"mstatus_hidden": "پنهان",
|
||||||
|
"mstatus_rejected": "ردشده",
|
||||||
|
"dtype_string": "متن",
|
||||||
|
"dtype_int": "عدد صحیح",
|
||||||
|
"dtype_decimal": "اعشاری",
|
||||||
|
"dtype_bool": "بولی",
|
||||||
|
"dtype_json": "JSON",
|
||||||
|
"htype_official": "رسمی",
|
||||||
|
"htype_religious": "مذهبی",
|
||||||
|
"htype_national": "ملی",
|
||||||
|
"atype_low_rating": "امتیاز پایین",
|
||||||
|
"atype_evv_no_show": "عدم حضور",
|
||||||
|
"atype_evv_location_mismatch": "مغایرت موقعیت EVV",
|
||||||
|
"atype_verification_expired": "انقضای احراز",
|
||||||
|
"atype_shared_sim": "سیمکارت مشترک",
|
||||||
|
"atype_payment_anomaly": "ناهنجاری پرداخت",
|
||||||
|
"atype_fraud_signal": "نشانهٔ تقلب",
|
||||||
|
"atype_nurse_clawback": "بازپسگیری پرستار",
|
||||||
|
"atype_emergency": "اورژانس",
|
||||||
|
"astatus_open": "باز",
|
||||||
|
"astatus_assigned": "واگذارشده",
|
||||||
|
"astatus_resolved": "رفعشده",
|
||||||
|
"sev_low": "کم",
|
||||||
|
"sev_medium": "متوسط",
|
||||||
|
"sev_high": "زیاد",
|
||||||
|
"tstatus_open": "باز",
|
||||||
|
"tstatus_closed": "بسته",
|
||||||
|
"tcat_coordination": "هماهنگی",
|
||||||
|
"tcat_support": "پشتیبانی",
|
||||||
|
"tcat_refund": "بازپرداخت",
|
||||||
|
"tcat_emergency": "اورژانس",
|
||||||
|
"role_super_admin": "مدیر ارشد",
|
||||||
|
"role_admin": "مدیر",
|
||||||
|
"role_support": "پشتیبانی",
|
||||||
|
"role_finance": "مالی",
|
||||||
|
"role_moderation": "بررسی محتوا",
|
||||||
|
"center_state_draft": "پیشنویس",
|
||||||
|
"center_state_pending_verification": "در انتظار تأیید",
|
||||||
|
"center_state_verified": "تأییدشده",
|
||||||
|
"center_state_suspended": "معلق",
|
||||||
|
"yes": "بله",
|
||||||
|
"no": "خیر"
|
||||||
|
},
|
||||||
|
"partner": {
|
||||||
|
"home_title": "مرکز شما",
|
||||||
|
"home_subtitle": "وضعیت پذیرش، پرستاران تحت پوشش و تسویه در یک نگاه.",
|
||||||
|
"state_banner_draft": "مرکز شما پیشنویس است — برای فعالسازی، مراحل پذیرش را کامل کنید.",
|
||||||
|
"state_banner_pending": "مرکز شما در انتظار تأیید است.",
|
||||||
|
"state_banner_suspended": "مرکز شما معلق است. با بالینیار تماس بگیرید.",
|
||||||
|
"license_title": "اطلاعات مجوز",
|
||||||
|
"permit": "پروانه تأسیس",
|
||||||
|
"tech_director": "مسئول فنی",
|
||||||
|
"enamad": "نماد اعتماد الکترونیکی",
|
||||||
|
"legal_type": "نوع شخصیت حقوقی",
|
||||||
|
"is_mor_yes": "فروشندهٔ رسمی",
|
||||||
|
"is_mor_no": "تسویه از طریق بالینیار انجام میشود",
|
||||||
|
"access_denied": "شما به هیچ مرکز همکاری دسترسی ندارید.",
|
||||||
|
"nurses_title": "پرستاران تحت پوشش",
|
||||||
|
"nurses_empty": "هنوز پرستاری تحت پوشش نیست.",
|
||||||
|
"nurses_col_name": "پرستار",
|
||||||
|
"nurses_col_verified": "احراز",
|
||||||
|
"bookings_title": "رزروهای تحت پوشش",
|
||||||
|
"bookings_empty": "هنوز رزروی تحت این مرکز نیست.",
|
||||||
|
"bookings_col_id": "رزرو",
|
||||||
|
"bookings_col_patient": "بیمار",
|
||||||
|
"bookings_col_date": "تاریخ",
|
||||||
|
"bookings_col_status": "وضعیت",
|
||||||
|
"settlement_title": "تسویه و فاکتورها",
|
||||||
|
"settlement_not_mor": "این مرکز فروشندهٔ رسمی نیست — تسویه از طریق بالینیار انجام میشود و فاکتور کارمزدی اینجا صادر نمیشود.",
|
||||||
|
"settlement_empty": "هنوز فاکتوری نیست.",
|
||||||
|
"settlement_col_booking": "رزرو",
|
||||||
|
"settlement_col_gross": "ناخالص",
|
||||||
|
"settlement_col_total": "کل",
|
||||||
|
"settlement_moadian": "سامانه مودیان",
|
||||||
|
"settlement_moadian_ref": "شمارهٔ مرجع مودیان",
|
||||||
|
"settlement_moadian_pending": "هنوز به سامانه مودیان ارسال نشده",
|
||||||
|
"invoice_row_gross": "هزینهٔ ناخالص خدمت",
|
||||||
|
"invoice_row_commission": "کارمزد پلتفرم",
|
||||||
|
"invoice_row_bnpl_commission": "کارمزد اقساطی",
|
||||||
|
"invoice_row_vat": "مالیات (بر کارمزد)",
|
||||||
|
"invoice_row_total": "مبلغ کل فاکتور",
|
||||||
|
"invoice_download": "دانلود PDF",
|
||||||
|
"invoice_pdf_error": "باز کردن فایل فاکتور ناموفق بود.",
|
||||||
|
"invoice_number": "فاکتور #{number}",
|
||||||
|
"settlement_iban": "شبای تسویه"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useSnackbar } from 'notistack';
|
||||||
|
import { Box, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
|
||||||
|
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog, SupportAlertCard } from '@/components/admin';
|
||||||
|
import { useAdminCapabilities } 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';
|
||||||
|
|
||||||
|
const STATUSES: readonly SupportAlertStatus[] = ['open', 'assigned', 'resolved'];
|
||||||
|
const TYPES: readonly SupportAlertType[] = [
|
||||||
|
'low_rating',
|
||||||
|
'evv_no_show',
|
||||||
|
'evv_location_mismatch',
|
||||||
|
'verification_expired',
|
||||||
|
'shared_sim',
|
||||||
|
'payment_anomaly',
|
||||||
|
'fraud_signal',
|
||||||
|
'nurse_clawback',
|
||||||
|
'emergency',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const caps = useAdminCapabilities();
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
const [authState] = useAuth();
|
||||||
|
const meId = authState.currentUser?.id ?? 1;
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<SupportAlertStatus | ''>('open');
|
||||||
|
const [type, setType] = useState<SupportAlertType | ''>('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
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 onAssignSelf = (alert: SupportAlert) => {
|
||||||
|
assign.mutate(
|
||||||
|
{ alertId: alert.id, ownerUserId: meId },
|
||||||
|
{ onSuccess: () => enqueueSnackbar(t('alert_assigned'), { variant: 'success' }) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResolveConfirm = (note?: string) => {
|
||||||
|
if (!resolving) return;
|
||||||
|
resolve.mutate(
|
||||||
|
{ alertId: resolving.id, note: note ?? '' },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
enqueueSnackbar(t('alert_resolved'), { variant: 'success' });
|
||||||
|
setResolving(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader
|
||||||
|
title={t('alert_title')}
|
||||||
|
subtitle={t('alert_subtitle')}
|
||||||
|
actions={
|
||||||
|
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
label={t('alert_col_status')}
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatus(e.target.value as SupportAlertStatus | '');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
sx={{ minWidth: 140 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">{t('filter_all')}</MenuItem>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<MenuItem key={s} value={s}>
|
||||||
|
{t(`astatus_${s}`)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
label={t('alert_col_type')}
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => {
|
||||||
|
setType(e.target.value as SupportAlertType | '');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
sx={{ minWidth: 180 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">{t('filter_all')}</MenuItem>
|
||||||
|
{TYPES.map((ty) => (
|
||||||
|
<MenuItem key={ty} value={ty}>
|
||||||
|
{t(`atype_${ty}`)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{alerts.isLoading ? (
|
||||||
|
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={110} />)}</Stack>
|
||||||
|
) : alerts.isError ? (
|
||||||
|
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => alerts.refetch()} />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<AdminEmptyState icon="alerts" title={t('alert_empty')} />
|
||||||
|
) : (
|
||||||
|
<Stack sx={{ gap: 2 }}>
|
||||||
|
{items.map((alert) => (
|
||||||
|
<SupportAlertCard
|
||||||
|
key={alert.id}
|
||||||
|
alert={alert}
|
||||||
|
canAct={caps.canManageAlerts}
|
||||||
|
onAssignSelf={onAssignSelf}
|
||||||
|
onResolve={setResolving}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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 })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={resolving != null}
|
||||||
|
title={t('alert_resolve_title')}
|
||||||
|
confirmLabel={t('alert_resolve')}
|
||||||
|
cancelLabel={t('cancel')}
|
||||||
|
onConfirm={onResolveConfirm}
|
||||||
|
onClose={() => setResolving(null)}
|
||||||
|
loading={resolve.isPending}
|
||||||
|
requireReason
|
||||||
|
reasonLabel={t('note_label')}
|
||||||
|
reasonPlaceholder={t('alert_resolve_note_ph')}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Box, Skeleton, Stack, TextField } from '@mui/material';
|
||||||
|
import { AppButton } from '@/components';
|
||||||
|
import { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, AuditLogRow } from '@/components/admin';
|
||||||
|
import { AUDIT_PAGE_SIZE } from '@/services/admin/constants';
|
||||||
|
import type { AuditFilters } from '@/services/admin/types';
|
||||||
|
import { useAuditLogs } from '@/services/admin';
|
||||||
|
|
||||||
|
const EMPTY: AuditFilters = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* filter draft is committed to the query only on Apply, so typing never refetches; the applied filters +
|
||||||
|
* page are the cache key, so switching filters/pages never refetches data already held.
|
||||||
|
*/
|
||||||
|
export default function AdminAuditPage() {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const [draft, setDraft] = useState<AuditFilters>(EMPTY);
|
||||||
|
const [applied, setApplied] = useState<AuditFilters>(EMPTY);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
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 apply = () => {
|
||||||
|
setApplied(draft);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
const clear = () => {
|
||||||
|
setDraft(EMPTY);
|
||||||
|
setApplied(EMPTY);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader title={t('audit_title')} subtitle={t('audit_subtitle')} />
|
||||||
|
|
||||||
|
<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('audit_col_entity')}
|
||||||
|
placeholder={t('audit_entity_type_ph')}
|
||||||
|
value={draft.entityType ?? ''}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, entityType: e.target.value || undefined }))}
|
||||||
|
sx={{ minWidth: 200 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="ID"
|
||||||
|
placeholder={t('audit_entity_id_ph')}
|
||||||
|
value={draft.entityId ?? ''}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, entityId: e.target.value || undefined }))}
|
||||||
|
sx={{ minWidth: 120 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="date"
|
||||||
|
label={t('audit_from')}
|
||||||
|
value={draft.from ?? ''}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, from: e.target.value || undefined }))}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
type="date"
|
||||||
|
label={t('audit_to')}
|
||||||
|
value={draft.to ?? ''}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, to: e.target.value || undefined }))}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
<AppButton variant="contained" color="primary" onClick={apply} sx={{ m: 0 }}>
|
||||||
|
{t('apply')}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton variant="text" color="inherit" onClick={clear} sx={{ m: 0 }}>
|
||||||
|
{t('clear')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{audit.isLoading ? (
|
||||||
|
<Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack>
|
||||||
|
) : audit.isError ? (
|
||||||
|
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => audit.refetch()} />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<AdminEmptyState icon="audit" title={t('audit_empty')} />
|
||||||
|
) : (
|
||||||
|
<Stack sx={{ gap: 1 }}>
|
||||||
|
{items.map((entry) => (
|
||||||
|
<AuditLogRow key={entry.id} entry={entry} />
|
||||||
|
))}
|
||||||
|
</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 })}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
'use client';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useSnackbar } from 'notistack';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
Drawer,
|
||||||
|
FormControlLabel,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { AppButton, AppIcon } from '@/components';
|
||||||
|
import { AdminEmptyState, AdminErrorState, AdminPageHeader, ConfigRow } from '@/components/admin';
|
||||||
|
import { formatShamsiDateTime } from '@/utils';
|
||||||
|
import { useAdminCapabilities } 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';
|
||||||
|
|
||||||
|
const GROUP_ORDER = ['fees', 'deadlines', 'evv', 'bnpl', 'cancellation', 'other'] as const;
|
||||||
|
type GroupKey = (typeof GROUP_ORDER)[number];
|
||||||
|
|
||||||
|
/** Validate a candidate value against a config's `data_type` (+ the 0–1 rate rule). Returns an i18n key or null. */
|
||||||
|
function validate(config: PlatformConfig, value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length === 0) return 'cfg_empty_error';
|
||||||
|
if (config.dataType === 'int') {
|
||||||
|
if (!/^-?\d+$/.test(trimmed)) return 'cfg_int_error';
|
||||||
|
}
|
||||||
|
if (config.dataType === 'int' || config.dataType === 'decimal') {
|
||||||
|
const n = Number(trimmed);
|
||||||
|
if (Number.isNaN(n)) return 'cfg_int_error';
|
||||||
|
if (RATE_CONFIG_KEYS.includes(config.key) && (n < 0 || n > 1)) return 'cfg_range_error';
|
||||||
|
}
|
||||||
|
if (config.dataType === 'json') {
|
||||||
|
try {
|
||||||
|
JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return 'cfg_json_error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform config editor (f15) — every `platform_configs` row grouped by concern, each with a typed input
|
||||||
|
* by `data_type` and boundary validation (a rate is 0–1). Saving is audited server-side and takes effect
|
||||||
|
* immediately without re-pricing already-computed rows — the save dialog says so. The change-history drawer
|
||||||
|
* proves the value in effect at any past moment. The client never re-parses config beyond rendering by
|
||||||
|
* `data_type` (phase §5).
|
||||||
|
*/
|
||||||
|
export default function AdminConfigPage() {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const caps = useAdminCapabilities();
|
||||||
|
const configs = usePlatformConfigs(1);
|
||||||
|
const [editing, setEditing] = useState<PlatformConfig | null>(null);
|
||||||
|
const [historyKey, setHistoryKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const items = configs.data?.items ?? [];
|
||||||
|
const byKey = new Map(Object.entries(CONFIG_GROUPS).flatMap(([g, keys]) => keys.map((k) => [k, g as GroupKey])));
|
||||||
|
const result: Record<GroupKey, PlatformConfig[]> = { fees: [], deadlines: [], evv: [], bnpl: [], cancellation: [], other: [] };
|
||||||
|
for (const c of items) result[byKey.get(c.key) ?? 'other'].push(c);
|
||||||
|
return result;
|
||||||
|
}, [configs.data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader title={t('cfg_title')} subtitle={t('cfg_subtitle')} />
|
||||||
|
|
||||||
|
{configs.isLoading ? (
|
||||||
|
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={96} />)}</Stack>
|
||||||
|
) : configs.isError ? (
|
||||||
|
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => configs.refetch()} />
|
||||||
|
) : (configs.data?.items.length ?? 0) === 0 ? (
|
||||||
|
<AdminEmptyState icon="config" title={t('cfg_title')} />
|
||||||
|
) : (
|
||||||
|
GROUP_ORDER.filter((g) => grouped[g].length > 0).map((g) => (
|
||||||
|
<Stack key={g} sx={{ gap: 1.5 }}>
|
||||||
|
<Typography variant="overline" sx={{ color: 'text.secondary', fontWeight: 700 }}>
|
||||||
|
{t(`cfg_group_${g}`)}
|
||||||
|
</Typography>
|
||||||
|
{grouped[g].map((config) => (
|
||||||
|
<ConfigRow
|
||||||
|
key={config.key}
|
||||||
|
config={config}
|
||||||
|
canEdit={caps.canConfig}
|
||||||
|
onEdit={setEditing}
|
||||||
|
onHistory={(c) => setHistoryKey(c.key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing ? <ConfigEditDialog config={editing} onClose={() => setEditing(null)} /> : null}
|
||||||
|
<ConfigHistoryDrawer configKey={historyKey} onClose={() => setHistoryKey(null)} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The typed, validated, audited edit dialog for one config row. */
|
||||||
|
function ConfigEditDialog({ config, onClose }: { config: PlatformConfig; onClose: () => void }) {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
const update = useUpdatePlatformConfig();
|
||||||
|
const [value, setValue] = useState(config.value);
|
||||||
|
|
||||||
|
const errorKey = validate(config, value);
|
||||||
|
const isBool = config.dataType === 'bool';
|
||||||
|
|
||||||
|
const onSave = () => {
|
||||||
|
if (errorKey) return;
|
||||||
|
update.mutate(
|
||||||
|
{ key: config.key, value: isBool ? value : value.trim() },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
enqueueSnackbar(t('cfg_saved'), { variant: 'success' });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={update.isPending ? undefined : onClose} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle sx={{ fontWeight: 800, fontFamily: 'monospace' }}>{config.key}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{config.description ? (
|
||||||
|
<DialogContentText sx={{ mb: 2 }}>{config.description}</DialogContentText>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isBool ? (
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={value === 'true'} onChange={(e) => setValue(e.target.checked ? 'true' : 'false')} />}
|
||||||
|
label={t(`dtype_bool`)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
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')}
|
||||||
|
error={!!errorKey}
|
||||||
|
helperText={errorKey ? t(errorKey) : undefined}
|
||||||
|
slotProps={{ input: { sx: config.dataType === 'json' ? { fontFamily: 'monospace' } : undefined } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogContentText sx={{ mt: 2, color: 'var(--bal-warning)', fontWeight: 600 }}>
|
||||||
|
{t('cfg_save_confirm_body')}
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<AppButton variant="text" color="inherit" onClick={onClose} disabled={update.isPending} sx={{ m: 0 }}>
|
||||||
|
{t('cancel')}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!!errorKey || update.isPending} sx={{ m: 0 }}>
|
||||||
|
{update.isPending ? t('saving') : t('save')}
|
||||||
|
</AppButton>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The change-history drawer for one config key. */
|
||||||
|
function ConfigHistoryDrawer({ configKey, onClose }: { configKey: string | null; onClose: () => void }) {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const locale = useLocale();
|
||||||
|
const history = useConfigChangeHistory(configKey, 1, 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';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer anchor={anchor} open={configKey != null} onClose={onClose} 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={{ m: 0, minWidth: 0 }}>
|
||||||
|
<AppIcon icon="close" />
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{history.isLoading ? (
|
||||||
|
<Stack sx={{ gap: 1 }}>{[0, 1].map((k) => <Skeleton key={k} variant="rounded" height={64} />)}</Stack>
|
||||||
|
) : (history.data?.items.length ?? 0) === 0 ? (
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('cfg_history_empty')}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
|
{history.data?.items.map((change) => (
|
||||||
|
<Box key={change.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.5 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 700 }}>
|
||||||
|
{t('cfg_history_change', { old: change.oldValue ?? '—', new: change.newValue ?? '—' })}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{formatShamsiDateTime(change.occurredAt, locale)}
|
||||||
|
{change.actorUserId != null ? ` · #${change.actorUserId}` : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useSnackbar } from 'notistack';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControlLabel,
|
||||||
|
MenuItem,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { AppButton } from '@/components';
|
||||||
|
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, type AdminTableColumn } from '@/components/admin';
|
||||||
|
import { formatShamsiDate } from '@/utils';
|
||||||
|
import { useAdminCapabilities } from '@/hooks';
|
||||||
|
import type { Holiday, HolidayInput, HolidayType } from '@/services/admin/types';
|
||||||
|
import { useHolidays, useUpsertHoliday } from '@/services/admin';
|
||||||
|
|
||||||
|
const HOLIDAY_TYPES: readonly HolidayType[] = ['official', 'religious', 'national'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iranian-holiday calendar manager (f15). Lists `iranian_holidays`, each with its Shamsi date, name, type,
|
||||||
|
* and an `is_bank_closed` flag — the flag that shifts payout scheduling (the copy surfaces that consequence).
|
||||||
|
* The client only maintains the calendar the **server** uses for the next-business-day shift; it never
|
||||||
|
* computes the shift itself (phase §5).
|
||||||
|
*/
|
||||||
|
export default function AdminHolidaysPage() {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const locale = useLocale();
|
||||||
|
const caps = useAdminCapabilities();
|
||||||
|
const holidays = useHolidays({}, 1);
|
||||||
|
const [editing, setEditing] = useState<Holiday | 'new' | null>(null);
|
||||||
|
|
||||||
|
const columns: AdminTableColumn<Holiday>[] = [
|
||||||
|
{ key: 'date', header: t('hol_col_date'), render: (h) => formatShamsiDate(h.holidayDate, locale) },
|
||||||
|
{ key: 'name', header: t('hol_col_name'), render: (h) => h.nameFa },
|
||||||
|
{ key: 'type', header: t('hol_col_type'), render: (h) => <Chip size="small" variant="outlined" label={t(`htype_${h.type}`)} /> },
|
||||||
|
{
|
||||||
|
key: 'bank',
|
||||||
|
header: t('hol_col_bank'),
|
||||||
|
render: (h) => (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={h.isBankClosed ? t('yes') : t('no')}
|
||||||
|
sx={{
|
||||||
|
bgcolor: h.isBankClosed ? 'var(--bal-warning)' : 'var(--bal-divider)',
|
||||||
|
color: h.isBankClosed ? 'var(--bal-warning-contrast)' : 'var(--bal-text-secondary)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(caps.canConfig
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
render: (h: Holiday) => (
|
||||||
|
<AppButton variant="text" color="primary" startIcon="edit" onClick={() => setEditing(h)} sx={{ m: 0 }}>
|
||||||
|
{t('cfg_edit')}
|
||||||
|
</AppButton>
|
||||||
|
),
|
||||||
|
} as AdminTableColumn<Holiday>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader
|
||||||
|
title={t('hol_title')}
|
||||||
|
subtitle={t('hol_subtitle')}
|
||||||
|
actions={
|
||||||
|
caps.canConfig ? (
|
||||||
|
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setEditing('new')} sx={{ m: 0 }}>
|
||||||
|
{t('hol_add')}
|
||||||
|
</AppButton>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{holidays.isLoading ? (
|
||||||
|
<Skeleton variant="rounded" height={200} />
|
||||||
|
) : holidays.isError ? (
|
||||||
|
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => holidays.refetch()} />
|
||||||
|
) : (holidays.data?.items.length ?? 0) === 0 ? (
|
||||||
|
<AdminEmptyState icon="calendar" title={t('hol_empty')} />
|
||||||
|
) : (
|
||||||
|
<AdminDataTable columns={columns} rows={holidays.data?.items ?? []} getRowKey={(h) => h.id} ariaLabel={t('hol_title')} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<HolidayDialog holiday={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
nameFa: holiday?.nameFa ?? '',
|
||||||
|
type: holiday?.type ?? 'official',
|
||||||
|
isBankClosed: holiday?.isBankClosed ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const valid = form.holidayDate.length > 0 && form.nameFa.trim().length > 0;
|
||||||
|
|
||||||
|
const onSave = () => {
|
||||||
|
if (!valid) return;
|
||||||
|
upsert.mutate(
|
||||||
|
{ ...form, nameFa: form.nameFa.trim() },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
enqueueSnackbar(t('hol_saved'), { variant: 'success' });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={upsert.isPending ? undefined : onClose} fullWidth maxWidth="xs">
|
||||||
|
<DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
type="date"
|
||||||
|
label={t('hol_col_date')}
|
||||||
|
value={form.holidayDate}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, holidayDate: e.target.value }))}
|
||||||
|
disabled={!!holiday}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={t('hol_name_fa')}
|
||||||
|
value={form.nameFa}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, nameFa: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label={t('hol_col_type')}
|
||||||
|
value={form.type}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as HolidayType }))}
|
||||||
|
>
|
||||||
|
{HOLIDAY_TYPES.map((ty) => (
|
||||||
|
<MenuItem key={ty} value={ty}>
|
||||||
|
{t(`htype_${ty}`)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={form.isBankClosed} onChange={(e) => setForm((f) => ({ ...f, isBankClosed: e.target.checked }))} />}
|
||||||
|
label={t('hol_bank_hint')}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<AppButton variant="text" color="inherit" onClick={onClose} disabled={upsert.isPending} sx={{ m: 0 }}>
|
||||||
|
{t('cancel')}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || upsert.isPending} sx={{ m: 0 }}>
|
||||||
|
{upsert.isPending ? t('saving') : t('save')}
|
||||||
|
</AppButton>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,81 @@
|
|||||||
import { getTranslations } from 'next-intl/server';
|
'use client';
|
||||||
import { PlaceholderScreen } from '@/components';
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { Box, Paper, Typography } from '@mui/material';
|
||||||
|
import { AppIcon, AppLink } from '@/components';
|
||||||
|
import { AdminPageHeader } from '@/components/admin';
|
||||||
|
import { useAdminCapabilities } from '@/hooks';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
|
||||||
export default async function AdminOverviewPage() {
|
/**
|
||||||
const t = await getTranslations('nav');
|
* Admin overview landing (f15) — the backoffice home. Renders one **console card** per worklist the current
|
||||||
const tShell = await getTranslations('shell');
|
* principal may act on, derived from `useAdminCapabilities()` (a UI hint; the server still enforces every
|
||||||
return <PlaceholderScreen icon="admin" title={t('overview')} description={tShell('placeholder_body')} />;
|
* command's role scope). A `support` admin sees verification/tickets/alerts; a `finance` admin sees
|
||||||
|
* payouts/config; only a `super_admin` sees roles. Each card deep-links into its console.
|
||||||
|
*/
|
||||||
|
export default function AdminOverviewPage() {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const tNav = useTranslations('nav');
|
||||||
|
const locale = useLocale();
|
||||||
|
const caps = useAdminCapabilities();
|
||||||
|
|
||||||
|
// `key` doubles as the `nav` i18n key for the card label.
|
||||||
|
const consoles: { key: string; route: string; icon: string; enabled: boolean }[] = [
|
||||||
|
{ key: 'verification', route: ROUTES.ADMIN_VERIFICATION, icon: 'verification', enabled: caps.canVerify },
|
||||||
|
{ key: 'tickets', route: ROUTES.ADMIN_TICKETS, icon: 'support', enabled: caps.canManageTickets },
|
||||||
|
{ key: 'payouts', route: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', enabled: caps.canPayout },
|
||||||
|
{ key: 'reviews', route: ROUTES.ADMIN_REVIEWS, icon: 'moderation', enabled: caps.canModerate },
|
||||||
|
{ key: 'config', route: ROUTES.ADMIN_CONFIG, icon: 'config', enabled: caps.canConfig },
|
||||||
|
{ key: 'holidays', route: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', enabled: caps.canConfig },
|
||||||
|
{ key: 'alerts', route: ROUTES.ADMIN_ALERTS, icon: 'alerts', enabled: caps.canManageAlerts },
|
||||||
|
{ key: 'audit', route: ROUTES.ADMIN_AUDIT, icon: 'audit', enabled: caps.canViewAudit },
|
||||||
|
{ key: 'partners', route: ROUTES.ADMIN_PARTNERS, icon: 'partners', enabled: caps.canManagePartners },
|
||||||
|
{ key: 'roles', route: ROUTES.ADMIN_ROLES, icon: 'roles', enabled: caps.canManageRoles },
|
||||||
|
].filter((c) => c.enabled);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader title={t('overview_title')} subtitle={t('overview_subtitle')} />
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', md: '1fr 1fr 1fr' },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{consoles.map((c) => (
|
||||||
|
<AppLink
|
||||||
|
key={c.key}
|
||||||
|
to={`/${locale}${c.route}`}
|
||||||
|
color="inherit"
|
||||||
|
underline="none"
|
||||||
|
sx={{ display: 'block', height: '100%' }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
height: '100%',
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 1.5,
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'border-color 150ms ease, box-shadow 150ms ease',
|
||||||
|
'&:hover': { borderColor: 'primary.main', boxShadow: 3 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AppIcon icon={c.icon} size={32} color="var(--bal-primary)" />
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
{tNav(c.key)}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
</AppLink>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
'use client';
|
||||||
|
import { ReactNode, useState } from 'react';
|
||||||
|
import { useParams, useRouter } 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 { ROUTES } from '@/constants';
|
||||||
|
import {
|
||||||
|
usePartnerCenter,
|
||||||
|
useCenterSponsoredNurses,
|
||||||
|
useVerifyPartnerCenter,
|
||||||
|
useSetPartnerCenterActive,
|
||||||
|
useAssignNurseToPartnerCenter,
|
||||||
|
} from '@/services/partnerCenter';
|
||||||
|
import { CENTER_STATE_KIND, PartnerCenterFormDialog } from '../page';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partner-center admin detail (f15) — the licensing/settlement record for one center, its lifecycle actions,
|
||||||
|
* and its sponsored-nurse roster. Admins with `canManagePartners` may verify & activate a center (records
|
||||||
|
* licensing approval), suspend/reactivate it, edit it, and add/remove sponsored nurses. The settlement IBAN
|
||||||
|
* is only ever shown masked (last-4); it is never rendered in plaintext (write-then-masked). The server
|
||||||
|
* enforces every command's scope — the capability flag only hides controls.
|
||||||
|
*/
|
||||||
|
export default function AdminPartnerCenterDetailPage() {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const locale = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const caps = useAdminCapabilities();
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
|
||||||
|
const params = useParams<{ id: string }>();
|
||||||
|
const parsed = Number(params?.id);
|
||||||
|
const centerId = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||||
|
|
||||||
|
const center = usePartnerCenter(centerId || null);
|
||||||
|
const roster = useCenterSponsoredNurses(centerId || null);
|
||||||
|
const verify = useVerifyPartnerCenter(centerId);
|
||||||
|
const setActive = useSetPartnerCenterActive(centerId);
|
||||||
|
const assignNurse = useAssignNurseToPartnerCenter(centerId);
|
||||||
|
|
||||||
|
const [confirmVerify, setConfirmVerify] = useState(false);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [assignId, setAssignId] = useState('');
|
||||||
|
|
||||||
|
const data = center.data;
|
||||||
|
|
||||||
|
const onVerifyConfirm = () => {
|
||||||
|
verify.mutate(undefined, {
|
||||||
|
onSuccess: () => {
|
||||||
|
enqueueSnackbar(t('partner_verified_toast'), { variant: 'success' });
|
||||||
|
setConfirmVerify(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAssign = () => {
|
||||||
|
const nurseProfileId = Number(assignId);
|
||||||
|
if (!Number.isFinite(nurseProfileId) || nurseProfileId <= 0) return;
|
||||||
|
assignNurse.mutate(
|
||||||
|
{ nurseProfileId, unlink: false },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' });
|
||||||
|
setAssignId('');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRemove = (nurseProfileId: number) => {
|
||||||
|
assignNurse.mutate(
|
||||||
|
{ nurseProfileId, unlink: true },
|
||||||
|
{ onSuccess: () => enqueueSnackbar(t('partner_nurse_assigned'), { variant: 'success' }) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const back = (
|
||||||
|
<AppButton
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
startIcon="partners"
|
||||||
|
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PARTNERS}`)}
|
||||||
|
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||||
|
>
|
||||||
|
{t('back')}
|
||||||
|
</AppButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (center.isLoading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{back}
|
||||||
|
<Skeleton variant="rounded" height={80} />
|
||||||
|
<Skeleton variant="rounded" height={220} />
|
||||||
|
<Skeleton variant="rounded" height={160} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (center.isError) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{back}
|
||||||
|
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => center.refetch()} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
{back}
|
||||||
|
<AdminEmptyState icon="partners" title={t('partner_empty')} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commissionPercent = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 2 }).format(
|
||||||
|
data.commissionRate,
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||||
|
<Stack divider={<Divider flexItem />} sx={{ gap: 1.25 }}>
|
||||||
|
<DetailRow label={t('partner_legal_type')}>{data.legalEntityType || '—'}</DetailRow>
|
||||||
|
<DetailRow label={t('partner_permit')}>{data.mohEstablishmentPermitNo || '—'}</DetailRow>
|
||||||
|
<DetailRow label={t('partner_tech_director')}>{data.technicalDirectorLicenseNo ?? '—'}</DetailRow>
|
||||||
|
<DetailRow label={t('partner_enamad')}>{data.enamadCode ?? '—'}</DetailRow>
|
||||||
|
<DetailRow label={t('partner_commission')}>{commissionPercent}</DetailRow>
|
||||||
|
<DetailRow label={t('partner_is_mor')}>{t(data.isMerchantOfRecord ? 'yes' : 'no')}</DetailRow>
|
||||||
|
<DetailRow label={t('partner_iban')}>
|
||||||
|
<Box component="span" dir="ltr">
|
||||||
|
{data.settlementIbanMasked ?? '—'}
|
||||||
|
</Box>
|
||||||
|
</DetailRow>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{caps.canManagePartners ? (
|
||||||
|
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{data.verifiedAt == null ? (
|
||||||
|
<AppButton
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
startIcon="verified"
|
||||||
|
onClick={() => setConfirmVerify(true)}
|
||||||
|
disabled={verify.isPending}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
>
|
||||||
|
{t('partner_verify')}
|
||||||
|
</AppButton>
|
||||||
|
) : null}
|
||||||
|
<AppButton
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => setActive.mutate(!data.isActive)}
|
||||||
|
disabled={setActive.isPending}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
>
|
||||||
|
{t(data.isActive ? 'partner_suspend' : 'partner_activate')}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton variant="outlined" color="inherit" startIcon="edit" onClick={() => setEditing(true)} sx={{ m: 0 }}>
|
||||||
|
{t('partner_edit')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
{t('partner_roster_title')}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{roster.isLoading ? (
|
||||||
|
<Skeleton variant="rounded" height={120} />
|
||||||
|
) : (
|
||||||
|
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
|
||||||
|
<Stack divider={<Divider />}>
|
||||||
|
{(roster.data ?? []).map((nurse) => (
|
||||||
|
<Stack
|
||||||
|
key={nurse.nurseProfileId}
|
||||||
|
direction="row"
|
||||||
|
sx={{ p: 1.75, gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
|
{nurse.name}
|
||||||
|
</Typography>
|
||||||
|
<TrustBadge state={nurse.isVerified ? 'verified' : 'unverified'} />
|
||||||
|
</Stack>
|
||||||
|
{caps.canManagePartners ? (
|
||||||
|
<AppButton
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
startIcon="delete"
|
||||||
|
onClick={() => onRemove(nurse.nurseProfileId)}
|
||||||
|
disabled={assignNurse.isPending}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
>
|
||||||
|
{t('partner_unlink_nurse')}
|
||||||
|
</AppButton>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 }}
|
||||||
|
/>
|
||||||
|
<AppButton
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
startIcon="assign"
|
||||||
|
onClick={onAssign}
|
||||||
|
disabled={assignNurse.isPending || Number(assignId) <= 0}
|
||||||
|
sx={{ m: 0, mt: 0.25 }}
|
||||||
|
>
|
||||||
|
{t('partner_assign_nurse')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmVerify}
|
||||||
|
title={t('partner_verify')}
|
||||||
|
body={t('partner_verify_confirm')}
|
||||||
|
confirmLabel={t('confirm')}
|
||||||
|
cancelLabel={t('cancel')}
|
||||||
|
onConfirm={onVerifyConfirm}
|
||||||
|
onClose={() => setConfirmVerify(false)}
|
||||||
|
loading={verify.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{editing ? <PartnerCenterFormDialog center={data} onClose={() => setEditing(false)} /> : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One label/value line in the license/settlement block. */
|
||||||
|
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Stack direction="row" sx={{ gap: 1.5, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" component="div" sx={{ fontWeight: 600, textAlign: 'end' }}>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useSnackbar } from 'notistack';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControlLabel,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { AppButton, StatusChip } from '@/components';
|
||||||
|
import type { StatusKind } from '@/components';
|
||||||
|
import {
|
||||||
|
AdminDataTable,
|
||||||
|
AdminEmptyState,
|
||||||
|
AdminErrorState,
|
||||||
|
AdminPageHeader,
|
||||||
|
AdminPager,
|
||||||
|
type AdminTableColumn,
|
||||||
|
} from '@/components/admin';
|
||||||
|
import { useAdminCapabilities } from '@/hooks';
|
||||||
|
import { adminPartnerCenterPath } from '@/constants';
|
||||||
|
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
|
||||||
|
import type { CenterOnboardingState, PartnerCenter, PartnerCenterInput } from '@/services/partnerCenter/types';
|
||||||
|
import { usePartnerCenters, useCreatePartnerCenter, useUpdatePartnerCenter } from '@/services/partnerCenter';
|
||||||
|
|
||||||
|
/** State → semantic chip color. verified = green, pending = amber, suspended = red, draft = neutral. */
|
||||||
|
export const CENTER_STATE_KIND: Record<CenterOnboardingState, StatusKind> = {
|
||||||
|
verified: 'verified',
|
||||||
|
pending_verification: 'pending',
|
||||||
|
suspended: 'rejected',
|
||||||
|
draft: 'neutral',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partner-center admin list (f15) — the licensed sponsoring centers (پروانه تأسیس + مسئول فنی + نماد
|
||||||
|
* اعتماد الکترونیکی) that may be the merchant-of-record. Each row shows whether it issues invoices, its
|
||||||
|
* sponsored-nurse count, and its onboarding state; a row opens the center detail. Admins with
|
||||||
|
* `canManagePartners` may create a new center (inactive until verified). The full IBAN is write-then-masked —
|
||||||
|
* it is only ever entered here, never displayed (the list carries no IBAN at all).
|
||||||
|
*/
|
||||||
|
export default function AdminPartnersPage() {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const locale = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const caps = useAdminCapabilities();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const centers = usePartnerCenters({}, page);
|
||||||
|
const items = centers.data?.items ?? [];
|
||||||
|
const pageCount = Math.max(1, Math.ceil((centers.data?.total ?? 0) / PARTNER_PAGE_SIZE));
|
||||||
|
|
||||||
|
const columns: AdminTableColumn<PartnerCenter>[] = [
|
||||||
|
{ key: 'name', header: t('partner_col_name'), render: (c) => c.name },
|
||||||
|
{ key: 'mor', header: t('partner_col_mor'), render: (c) => t(c.isMerchantOfRecord ? 'yes' : 'no') },
|
||||||
|
{ key: 'nurses', header: t('partner_col_nurses'), render: (c) => c.sponsoredNurseCount },
|
||||||
|
{
|
||||||
|
key: 'state',
|
||||||
|
header: t('partner_col_state'),
|
||||||
|
render: (c) => <StatusChip status={CENTER_STATE_KIND[c.onboardingState]} label={t(`center_state_${c.onboardingState}`)} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader
|
||||||
|
title={t('partner_title')}
|
||||||
|
subtitle={t('partner_subtitle')}
|
||||||
|
actions={
|
||||||
|
caps.canManagePartners ? (
|
||||||
|
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setCreating(true)} sx={{ m: 0 }}>
|
||||||
|
{t('partner_create')}
|
||||||
|
</AppButton>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{centers.isLoading ? (
|
||||||
|
<Skeleton variant="rounded" height={220} />
|
||||||
|
) : centers.isError ? (
|
||||||
|
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => centers.refetch()} />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<AdminEmptyState icon="partners" title={t('partner_empty')} />
|
||||||
|
) : (
|
||||||
|
<AdminDataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={items}
|
||||||
|
getRowKey={(c) => c.id}
|
||||||
|
ariaLabel={t('partner_title')}
|
||||||
|
onRowClick={(c) => router.push(`/${locale}${adminPartnerCenterPath(c.id)}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{creating ? <PartnerCenterFormDialog center={null} onClose={() => setCreating(false)} /> : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The editable slice of `PartnerCenterInput`, kept as strings for controlled text/number inputs. */
|
||||||
|
interface CenterFormState {
|
||||||
|
name: string;
|
||||||
|
legalEntityType: string;
|
||||||
|
mohEstablishmentPermitNo: string;
|
||||||
|
technicalDirectorLicenseNo: string;
|
||||||
|
enamadCode: string;
|
||||||
|
settlementIban: string;
|
||||||
|
isMerchantOfRecord: boolean;
|
||||||
|
commissionRate: string;
|
||||||
|
adminUserId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialForm(center: PartnerCenter | null): CenterFormState {
|
||||||
|
return {
|
||||||
|
name: center?.name ?? '',
|
||||||
|
legalEntityType: center?.legalEntityType ?? '',
|
||||||
|
mohEstablishmentPermitNo: center?.mohEstablishmentPermitNo ?? '',
|
||||||
|
technicalDirectorLicenseNo: center?.technicalDirectorLicenseNo ?? '',
|
||||||
|
enamadCode: center?.enamadCode ?? '',
|
||||||
|
// Write-then-masked: always blank on open. On edit, a blank IBAN keeps the existing masked value.
|
||||||
|
settlementIban: '',
|
||||||
|
isMerchantOfRecord: center?.isMerchantOfRecord ?? false,
|
||||||
|
commissionRate: center != null ? String(center.commissionRate) : '',
|
||||||
|
adminUserId: center?.adminUserId != null ? String(center.adminUserId) : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The create/edit dialog for a partner center — shared by the list (create, `center=null`) and the detail
|
||||||
|
* (edit, `center` prefilled). `settlementIban` is write-then-masked: the field is always blank on open and a
|
||||||
|
* blank submit on edit keeps the stored masked value. Validates name + permit non-empty, `commissionRate ∈
|
||||||
|
* [0, 1)`, and (create only) an IBAN when the center is merchant-of-record.
|
||||||
|
*/
|
||||||
|
export function PartnerCenterFormDialog({ center, onClose }: { center: PartnerCenter | null; onClose: () => void }) {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
const isEdit = center != null;
|
||||||
|
const create = useCreatePartnerCenter();
|
||||||
|
const update = useUpdatePartnerCenter(center?.id ?? 0);
|
||||||
|
const mutation = isEdit ? update : create;
|
||||||
|
const [form, setForm] = useState<CenterFormState>(() => initialForm(center));
|
||||||
|
|
||||||
|
const set = <K extends keyof CenterFormState>(key: K, value: CenterFormState[K]) =>
|
||||||
|
setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
|
const commission = Number(form.commissionRate);
|
||||||
|
const commissionValid = form.commissionRate.trim() !== '' && Number.isFinite(commission) && commission >= 0 && commission < 1;
|
||||||
|
// On edit a blank IBAN is allowed (it keeps the stored value); on create an MoR center must supply one.
|
||||||
|
const ibanValid = !form.isMerchantOfRecord || isEdit || form.settlementIban.trim() !== '';
|
||||||
|
const valid =
|
||||||
|
form.name.trim() !== '' && form.mohEstablishmentPermitNo.trim() !== '' && commissionValid && ibanValid;
|
||||||
|
|
||||||
|
const onSave = () => {
|
||||||
|
if (!valid) return;
|
||||||
|
const input: PartnerCenterInput = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
legalEntityType: form.legalEntityType.trim(),
|
||||||
|
mohEstablishmentPermitNo: form.mohEstablishmentPermitNo.trim(),
|
||||||
|
technicalDirectorLicenseNo: form.technicalDirectorLicenseNo.trim() || null,
|
||||||
|
enamadCode: form.enamadCode.trim() || null,
|
||||||
|
settlementIban: form.settlementIban.trim() || null,
|
||||||
|
isMerchantOfRecord: form.isMerchantOfRecord,
|
||||||
|
commissionRate: commission,
|
||||||
|
adminUserId: form.adminUserId.trim() === '' ? null : Number(form.adminUserId),
|
||||||
|
};
|
||||||
|
mutation.mutate(input, {
|
||||||
|
onSuccess: () => {
|
||||||
|
enqueueSnackbar(t('partner_saved'), { variant: 'success' });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={mutation.isPending ? undefined : onClose} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle sx={{ fontWeight: 800 }}>{isEdit ? t('partner_edit') : t('partner_create')}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, mt: 1 }}>
|
||||||
|
<TextField label={t('partner_name')} value={form.name} onChange={(e) => set('name', e.target.value)} />
|
||||||
|
<TextField
|
||||||
|
label={t('partner_legal_type')}
|
||||||
|
value={form.legalEntityType}
|
||||||
|
onChange={(e) => set('legalEntityType', e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={t('partner_permit')}
|
||||||
|
value={form.mohEstablishmentPermitNo}
|
||||||
|
onChange={(e) => set('mohEstablishmentPermitNo', e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={t('partner_tech_director_license')}
|
||||||
|
value={form.technicalDirectorLicenseNo}
|
||||||
|
onChange={(e) => set('technicalDirectorLicenseNo', e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField label={t('partner_enamad')} value={form.enamadCode} onChange={(e) => set('enamadCode', e.target.value)} />
|
||||||
|
<TextField
|
||||||
|
label={t('partner_iban')}
|
||||||
|
value={form.settlementIban}
|
||||||
|
onChange={(e) => set('settlementIban', e.target.value)}
|
||||||
|
helperText={t('partner_iban_write_hint')}
|
||||||
|
placeholder={center?.settlementIbanMasked ?? undefined}
|
||||||
|
slotProps={{ htmlInput: { dir: 'ltr' } }}
|
||||||
|
/>
|
||||||
|
<Box>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={form.isMerchantOfRecord} onChange={(e) => set('isMerchantOfRecord', e.target.checked)} />}
|
||||||
|
label={t('partner_is_mor')}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', color: 'text.secondary' }}>
|
||||||
|
{t('partner_is_mor_hint')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label={t('partner_commission')}
|
||||||
|
value={form.commissionRate}
|
||||||
|
onChange={(e) => set('commissionRate', e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label={t('partner_admin_user')}
|
||||||
|
value={form.adminUserId}
|
||||||
|
onChange={(e) => set('adminUserId', e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: 1, step: 1 } }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<AppButton variant="text" color="inherit" onClick={onClose} disabled={mutation.isPending} sx={{ m: 0 }}>
|
||||||
|
{t('cancel')}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || mutation.isPending} sx={{ m: 0 }}>
|
||||||
|
{mutation.isPending ? t('saving') : t('save')}
|
||||||
|
</AppButton>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
'use client';
|
||||||
|
import { FunctionComponent, ReactNode, useState } from 'react';
|
||||||
|
import { useParams, useRouter } 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 type { StatusKind } from '@/components';
|
||||||
|
import { AdminEmptyState, AdminErrorState, AdminPager, ConfirmDialog } from '@/components/admin';
|
||||||
|
import { useAdminCapabilities } 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';
|
||||||
|
|
||||||
|
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
|
||||||
|
draft: 'neutral',
|
||||||
|
processing: 'info',
|
||||||
|
partially_failed: 'pending',
|
||||||
|
completed: 'verified',
|
||||||
|
failed: 'rejected',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
|
||||||
|
pending: 'pending',
|
||||||
|
submitted: 'info',
|
||||||
|
paid: 'verified',
|
||||||
|
failed: 'rejected',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin payout-batch detail (f15) — one batch expanded: its window + holiday-shifted processing date, and its
|
||||||
|
* paginated per-payout rows (money decomposition, masked IBAN, transfer reference, status). A failed payout
|
||||||
|
* can be retried (idempotency-keyed) and a reconciled bank transfer reference recorded — both gated on
|
||||||
|
* `canPayout`. Money is display-only Toman; the client never recomputes amounts, eligibility, or dates.
|
||||||
|
*/
|
||||||
|
export default function AdminPayoutBatchDetailPage() {
|
||||||
|
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 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={{ m: 0, alignSelf: 'flex-start' }}
|
||||||
|
>
|
||||||
|
{t('back')}
|
||||||
|
</AppButton>
|
||||||
|
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
|
||||||
|
{t('payout_batch_title', { id: batchId })}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{detail.isLoading ? (
|
||||||
|
<Stack sx={{ gap: 2 }}>
|
||||||
|
<Skeleton variant="rounded" height={120} />
|
||||||
|
<Skeleton variant="rounded" height={96} />
|
||||||
|
<Skeleton variant="rounded" height={96} />
|
||||||
|
</Stack>
|
||||||
|
) : detail.isError ? (
|
||||||
|
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => detail.refetch()} />
|
||||||
|
) : !data ? (
|
||||||
|
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||||
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
|
<StatusChip
|
||||||
|
status={BATCH_STATUS_KIND[data.batch.status]}
|
||||||
|
label={t(`batch_status_${data.batch.status}`)}
|
||||||
|
sx={{ alignSelf: 'flex-start' }}
|
||||||
|
/>
|
||||||
|
<MetaLine label={t('payout_col_period')}>
|
||||||
|
{formatShamsiDate(data.batch.periodStart, locale)} – {formatShamsiDate(data.batch.periodEnd, locale)}
|
||||||
|
</MetaLine>
|
||||||
|
<MetaLine label={t('payout_col_processing')}>
|
||||||
|
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<span>{formatShamsiDate(data.batch.processingDate, locale)}</span>
|
||||||
|
{data.batch.holidayShifted ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={t('payout_holiday_shift')}
|
||||||
|
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</MetaLine>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
{t('payout_rows_title')}
|
||||||
|
</Typography>
|
||||||
|
{data.payouts.length === 0 ? (
|
||||||
|
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
|
||||||
|
) : (
|
||||||
|
data.payouts.map((row) => (
|
||||||
|
<PayoutRowCard key={row.id} row={row} batchId={batchId} canPayout={caps.canPayout} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</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 })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One `nurse_payouts` row — the money decomposition (`gross − clawback = net`), masked IBAN + transfer
|
||||||
|
* reference, status, and (for a failed payout) the reason + an idempotency-keyed retry. Recording a
|
||||||
|
* reconciled bank transfer reference is an inline per-row action. Both writes are gated on `canPayout`.
|
||||||
|
*/
|
||||||
|
const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; canPayout: boolean }> = ({
|
||||||
|
row,
|
||||||
|
batchId,
|
||||||
|
canPayout,
|
||||||
|
}) => {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const locale = useLocale();
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
|
||||||
|
const retry = useRetryPayout();
|
||||||
|
const record = useRecordTransferReference();
|
||||||
|
const [retryOpen, setRetryOpen] = useState(false);
|
||||||
|
const [reference, setReference] = useState('');
|
||||||
|
|
||||||
|
const onRetryConfirm = () => {
|
||||||
|
retry.mutate(
|
||||||
|
{ payoutId: row.id, idempotencyKey: crypto?.randomUUID?.() ?? String(Date.now()), batchId },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setRetryOpen(false);
|
||||||
|
enqueueSnackbar(t('saved'), { variant: 'success' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRecord = () => {
|
||||||
|
record.mutate(
|
||||||
|
{ payoutId: row.id, reference: reference.trim(), batchId },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
enqueueSnackbar(t('payout_ref_saved'), { variant: 'success' });
|
||||||
|
setReference('');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||||
|
<Stack sx={{ gap: 1.25 }}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{ justifyContent: 'space-between', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||||
|
{t('payout_row_nurse')}: {row.nurseName ?? `#${row.nurseId}`}
|
||||||
|
</Typography>
|
||||||
|
<StatusChip status={PAYOUT_STATUS_KIND[row.status]} label={t(`pstatus_${row.status}`)} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('payout_decomp', {
|
||||||
|
gross: formatIrrToToman(row.grossEarningsIrr, locale),
|
||||||
|
clawback: formatIrrToToman(row.clawbackAppliedIrr, locale),
|
||||||
|
net: formatIrrToToman(row.netAmountIrr, locale),
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Stack direction="row" sx={{ gap: 3, flexWrap: 'wrap' }}>
|
||||||
|
<Field label={t('masked_iban_label')}>
|
||||||
|
<Box component="span" dir="ltr">
|
||||||
|
{row.maskedIban}
|
||||||
|
</Box>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('payout_row_ref')}>
|
||||||
|
<Box component="span" dir="ltr">
|
||||||
|
{row.transferReference ?? '—'}
|
||||||
|
</Box>
|
||||||
|
</Field>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{row.status === 'failed' ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.25,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderInlineStart: '3px solid',
|
||||||
|
borderInlineStartColor: 'var(--bal-error)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
|
||||||
|
{t('payout_failure_reason', { reason: row.failureReason ?? '—' })}
|
||||||
|
</Typography>
|
||||||
|
{canPayout ? (
|
||||||
|
<Box sx={{ mt: 1 }}>
|
||||||
|
<AppButton
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setRetryOpen(true)}
|
||||||
|
disabled={retry.isPending}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
>
|
||||||
|
{t('payout_retry')}
|
||||||
|
</AppButton>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canPayout ? (
|
||||||
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mt: 0.5 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label={t('payout_record_ref')}
|
||||||
|
placeholder={t('payout_record_ref_ph')}
|
||||||
|
value={reference}
|
||||||
|
onChange={(e) => setReference(e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { dir: 'ltr' } }}
|
||||||
|
sx={{ minWidth: 220 }}
|
||||||
|
/>
|
||||||
|
<AppButton
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={onRecord}
|
||||||
|
disabled={reference.trim().length === 0 || record.isPending}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
>
|
||||||
|
{record.isPending ? t('saving') : t('save')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={retryOpen}
|
||||||
|
title={t('payout_retry')}
|
||||||
|
body={t('payout_retry_confirm')}
|
||||||
|
confirmLabel={t('confirm')}
|
||||||
|
cancelLabel={t('cancel')}
|
||||||
|
onConfirm={onRetryConfirm}
|
||||||
|
onClose={() => setRetryOpen(false)}
|
||||||
|
loading={retry.isPending}
|
||||||
|
confirmColor="error"
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => (
|
||||||
|
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ fontWeight: 600, typography: 'body2' }}>{children}</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Field: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => (
|
||||||
|
<Stack sx={{ gap: 0.25 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useSnackbar } from 'notistack';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Divider,
|
||||||
|
MenuItem,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { AppButton, StatusChip } from '@/components';
|
||||||
|
import type { StatusKind } from '@/components';
|
||||||
|
import {
|
||||||
|
AdminDataTable,
|
||||||
|
AdminEmptyState,
|
||||||
|
AdminErrorState,
|
||||||
|
AdminPageHeader,
|
||||||
|
AdminPager,
|
||||||
|
ConfirmDialog,
|
||||||
|
} from '@/components/admin';
|
||||||
|
import type { AdminTableColumn } from '@/components/admin';
|
||||||
|
import { useAdminCapabilities } from '@/hooks';
|
||||||
|
import { adminPayoutBatchPath } from '@/constants';
|
||||||
|
import { formatIrrToToman, 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';
|
||||||
|
|
||||||
|
/** Batch lifecycle → semantic chip color (server truth; the client only renders it). */
|
||||||
|
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
|
||||||
|
draft: 'neutral',
|
||||||
|
processing: 'info',
|
||||||
|
partially_failed: 'pending',
|
||||||
|
completed: 'verified',
|
||||||
|
failed: 'rejected',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BATCH_STATUSES: readonly PayoutBatchStatus[] = [
|
||||||
|
'draft',
|
||||||
|
'processing',
|
||||||
|
'partially_failed',
|
||||||
|
'completed',
|
||||||
|
'failed',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** UTC ISO date (`YYYY-MM-DD`) — the wire shape for the batch window. */
|
||||||
|
const isoDate = (d: Date): string => d.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin payout-batch dashboard (f15) — the reconciliation list of weekly `nurse_payout_batches` and the
|
||||||
|
* entry point to previewing + running the next batch. Money is IRR digit-strings rendered as display-only
|
||||||
|
* Toman; the server owns eligibility and the holiday-shifted processing date — the client never computes
|
||||||
|
* them. Running a batch moves money, so it is gated (`canPayout`), idempotency-keyed, and confirmed.
|
||||||
|
*/
|
||||||
|
export default function AdminPayoutsPage() {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
|
const locale = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const caps = useAdminCapabilities();
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<PayoutBatchStatus | ''>('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [previewOpen, setPreviewOpen] = useState(false);
|
||||||
|
|
||||||
|
const filters = { status: status || undefined };
|
||||||
|
const batches = usePayoutBatches(filters, page);
|
||||||
|
|
||||||
|
const items = batches.data?.items ?? [];
|
||||||
|
const pageCount = Math.max(1, Math.ceil((batches.data?.total ?? 0) / PAYOUTS_PAGE_SIZE));
|
||||||
|
|
||||||
|
const columns: AdminTableColumn<PayoutBatchSummary>[] = [
|
||||||
|
{
|
||||||
|
key: 'period',
|
||||||
|
header: t('payout_col_period'),
|
||||||
|
render: (b) => `${formatShamsiDate(b.periodStart, locale)} – ${formatShamsiDate(b.periodEnd, locale)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'count',
|
||||||
|
header: t('payout_col_count'),
|
||||||
|
render: (b) => b.payoutCount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'total',
|
||||||
|
header: t('payout_col_total'),
|
||||||
|
render: (b) => `${formatIrrToToman(b.totalAmount, locale)} ${tCommon('currency_toman')}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: t('payout_col_status'),
|
||||||
|
render: (b) => <StatusChip status={BATCH_STATUS_KIND[b.status]} label={t(`batch_status_${b.status}`)} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'processing',
|
||||||
|
header: t('payout_col_processing'),
|
||||||
|
render: (b) => (
|
||||||
|
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<span>{formatShamsiDate(b.processingDate, locale)}</span>
|
||||||
|
{b.holidayShifted ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={t('payout_holiday_shift')}
|
||||||
|
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader
|
||||||
|
title={t('payout_title')}
|
||||||
|
subtitle={t('payout_subtitle')}
|
||||||
|
actions={
|
||||||
|
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
label={t('payout_col_status')}
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatus(e.target.value as PayoutBatchStatus | '');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
sx={{ minWidth: 160 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">{t('filter_all')}</MenuItem>
|
||||||
|
{BATCH_STATUSES.map((s) => (
|
||||||
|
<MenuItem key={s} value={s}>
|
||||||
|
{t(`batch_status_${s}`)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
{caps.canPayout ? (
|
||||||
|
<AppButton variant="contained" color="primary" onClick={() => setPreviewOpen(true)} sx={{ m: 0 }}>
|
||||||
|
{t('payout_preview')}
|
||||||
|
</AppButton>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{batches.isLoading ? (
|
||||||
|
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={64} />)}</Stack>
|
||||||
|
) : batches.isError ? (
|
||||||
|
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => batches.refetch()} />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
|
||||||
|
) : (
|
||||||
|
<AdminDataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={items}
|
||||||
|
getRowKey={(b) => b.id}
|
||||||
|
onRowClick={(b) => router.push(`/${locale}${adminPayoutBatchPath(b.id)}`)}
|
||||||
|
ariaLabel={t('payout_title')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{previewOpen ? <PreviewBatchDialog canPayout={caps.canPayout} onClose={() => setPreviewOpen(false)} /> : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The eligibility dry-run + run-batch dialog. Preview is a mutation (runs only when the admin asks), and its
|
||||||
|
* eligible/skipped breakdown + the server's holiday-shifted processing date are read straight from the
|
||||||
|
* mutation's `data`. Running is idempotency-keyed and confirmed; on success it deep-links to the new batch.
|
||||||
|
*/
|
||||||
|
function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClose: () => void }) {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
|
const locale = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
|
||||||
|
// Default the window to the last 7 days (end = today). A lazy initializer runs the `Date` read once.
|
||||||
|
const [periodStart, setPeriodStart] = useState(() =>
|
||||||
|
isoDate(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)),
|
||||||
|
);
|
||||||
|
const [periodEnd, setPeriodEnd] = useState(() => isoDate(new Date()));
|
||||||
|
const [runConfirmOpen, setRunConfirmOpen] = useState(false);
|
||||||
|
|
||||||
|
const preview = usePreviewPayoutBatch();
|
||||||
|
const run = useRunPayoutBatch();
|
||||||
|
const result = preview.data;
|
||||||
|
|
||||||
|
const onPreview = () => {
|
||||||
|
if (!periodStart || !periodEnd) return;
|
||||||
|
preview.mutate({ periodStart, periodEnd });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRunConfirm = () => {
|
||||||
|
const idempotencyKey = crypto?.randomUUID
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `batch_${periodStart}_${periodEnd}_${Date.now()}`;
|
||||||
|
run.mutate(
|
||||||
|
{ periodStart, periodEnd, idempotencyKey },
|
||||||
|
{
|
||||||
|
onSuccess: (batch) => {
|
||||||
|
setRunConfirmOpen(false);
|
||||||
|
onClose();
|
||||||
|
enqueueSnackbar(t('payout_ran'), { variant: 'success' });
|
||||||
|
router.push(`/${locale}${adminPayoutBatchPath(batch.id)}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={run.isPending ? undefined : onClose} fullWidth maxWidth="sm">
|
||||||
|
<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"
|
||||||
|
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 } }}
|
||||||
|
/>
|
||||||
|
<AppButton
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
onClick={onPreview}
|
||||||
|
disabled={!periodStart || !periodEnd || preview.isPending}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
>
|
||||||
|
{t('payout_preview')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<Stack sx={{ gap: 2, mt: 2.5 }}>
|
||||||
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('payout_col_processing')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
|
{formatShamsiDate(result.processingDate, locale)}
|
||||||
|
</Typography>
|
||||||
|
{result.holidayShifted ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={t('payout_holiday_shift')}
|
||||||
|
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack sx={{ gap: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||||
|
{t('payout_eligible_nurses')}
|
||||||
|
</Typography>
|
||||||
|
{result.eligible.length === 0 ? (
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
—
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||||
|
{result.eligible.map((n) => (
|
||||||
|
<Stack key={n.nurseId} sx={{ p: 1.5, gap: 0.5 }}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{ justifyContent: 'space-between', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
|
{n.nurseName ?? `#${n.nurseId}`}
|
||||||
|
</Typography>
|
||||||
|
{!n.hasVerifiedPrimaryIban ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={t('payout_no_iban')}
|
||||||
|
sx={{ bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('payout_col_gross')}: {formatIrrToToman(n.grossEarningsIrr, locale)} ·{' '}
|
||||||
|
{t('payout_col_clawback')}: {formatIrrToToman(n.clawbackAppliedIrr, locale)} ·{' '}
|
||||||
|
{t('payout_col_net')}: {formatIrrToToman(n.netAmountIrr, locale)} {tCommon('currency_toman')}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{result.skipped.length > 0 ? (
|
||||||
|
<Stack sx={{ gap: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||||
|
{t('payout_skipped')}
|
||||||
|
</Typography>
|
||||||
|
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||||
|
{result.skipped.map((n) => (
|
||||||
|
<Stack
|
||||||
|
key={n.nurseId}
|
||||||
|
direction="row"
|
||||||
|
sx={{ p: 1.5, justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
|
{n.nurseName ?? `#${n.nurseId}`}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
|
||||||
|
{n.reason}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('payout_eligibility_note')}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<AppButton variant="text" color="inherit" onClick={onClose} disabled={run.isPending} sx={{ m: 0 }}>
|
||||||
|
{t('cancel')}
|
||||||
|
</AppButton>
|
||||||
|
{canPayout ? (
|
||||||
|
<AppButton
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => setRunConfirmOpen(true)}
|
||||||
|
disabled={!result || run.isPending}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
>
|
||||||
|
{t('payout_run')}
|
||||||
|
</AppButton>
|
||||||
|
) : null}
|
||||||
|
</DialogActions>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={runConfirmOpen}
|
||||||
|
title={t('payout_run_confirm_title')}
|
||||||
|
body={t('payout_run_confirm_body')}
|
||||||
|
confirmLabel={t('payout_run')}
|
||||||
|
cancelLabel={t('cancel')}
|
||||||
|
onConfirm={onRunConfirm}
|
||||||
|
onClose={() => setRunConfirmOpen(false)}
|
||||||
|
loading={run.isPending}
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
'use client';
|
||||||
|
import { 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 { AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager, ConfirmDialog } from '@/components/admin';
|
||||||
|
import { useAdminCapabilities } 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';
|
||||||
|
|
||||||
|
/** The moderation worklist tabs — the four `moderationStatus` values; the queue defaults to the pending backlog. */
|
||||||
|
const MODERATION_STATUSES: readonly ModerationStatus[] = ['pending_moderation', 'published', 'hidden', 'rejected'];
|
||||||
|
|
||||||
|
/** Status → MUI chip color. **Never** styles `pending_moderation` like a published review (it reads as a warning). */
|
||||||
|
const STATUS_CHIP_COLOR: Record<ModerationStatus, 'default' | 'success' | 'warning' | 'error'> = {
|
||||||
|
pending_moderation: 'warning',
|
||||||
|
published: 'success',
|
||||||
|
hidden: 'default',
|
||||||
|
rejected: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Review moderation queue (f15) — the admin worklist over `reviews` awaiting a decision (b14). Each row carries
|
||||||
|
* moderation internals (`lowRatingAlertId`, the nurse/booking context) that are **never** rendered on a
|
||||||
|
* customer/nurse surface. A review is born `pending_moderation` and is never public / never counted until an
|
||||||
|
* admin publishes it, so the card presents pending content as an under-review item, not as a published review.
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export default function AdminReviewsPage() {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const caps = useAdminCapabilities();
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<ModerationStatus>('pending_moderation');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pending, setPending] = useState<{ item: ModerationQueueItem; action: ModerationAction } | null>(null);
|
||||||
|
|
||||||
|
const queue = useModerationQueue({ status }, page);
|
||||||
|
const moderate = useModerateReview();
|
||||||
|
|
||||||
|
const items = queue.data?.items ?? [];
|
||||||
|
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / REVIEWS_PAGE_SIZE));
|
||||||
|
|
||||||
|
const requireReason = pending?.action === 'hide' || pending?.action === 'reject';
|
||||||
|
|
||||||
|
const onConfirm = (reason?: string) => {
|
||||||
|
if (!pending) return;
|
||||||
|
moderate.mutate(
|
||||||
|
{ reviewId: pending.item.id, action: pending.action, reason },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
enqueueSnackbar(t('mod_done'), { variant: 'success' });
|
||||||
|
setPending(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader
|
||||||
|
title={t('mod_title')}
|
||||||
|
subtitle={t('mod_subtitle')}
|
||||||
|
actions={
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
label={t('filter_label')}
|
||||||
|
value={status}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatus(e.target.value as ModerationStatus);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
sx={{ minWidth: 180 }}
|
||||||
|
>
|
||||||
|
{MODERATION_STATUSES.map((s) => (
|
||||||
|
<MenuItem key={s} value={s}>
|
||||||
|
{t(`mstatus_${s}`)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{queue.isLoading ? (
|
||||||
|
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={150} />)}</Stack>
|
||||||
|
) : queue.isError ? (
|
||||||
|
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => queue.refetch()} />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<AdminEmptyState icon="moderation" title={t('mod_empty')} />
|
||||||
|
) : (
|
||||||
|
<Stack sx={{ gap: 2 }}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<ModerationCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
canModerate={caps.canModerate}
|
||||||
|
onAct={(action) => setPending({ item, action })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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 })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pending != null}
|
||||||
|
title={pending ? t(`mod_${pending.action}`) : ''}
|
||||||
|
body={pending ? t(`mod_confirm_${pending.action}`) : undefined}
|
||||||
|
confirmLabel={pending ? t(`mod_${pending.action}`) : t('confirm')}
|
||||||
|
cancelLabel={t('cancel')}
|
||||||
|
confirmColor={pending?.action === 'reject' ? 'error' : 'primary'}
|
||||||
|
loading={moderate.isPending}
|
||||||
|
requireReason={requireReason}
|
||||||
|
reasonLabel={t('reason_label')}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onClose={() => setPending(null)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One review awaiting a decision. Presentational; the actions bubble up to the page-level confirm dialog. */
|
||||||
|
function ModerationCard({
|
||||||
|
item,
|
||||||
|
canModerate,
|
||||||
|
onAct,
|
||||||
|
}: {
|
||||||
|
item: ModerationQueueItem;
|
||||||
|
canModerate: boolean;
|
||||||
|
onAct: (action: ModerationAction) => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const tReviews = useTranslations('reviews');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}
|
||||||
|
>
|
||||||
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<RatingInput value={item.rating} readOnly size={20} ariaLabel={t('mod_col_rating')} />
|
||||||
|
<Chip size="small" color={STATUS_CHIP_COLOR[item.moderationStatus]} label={t(`mstatus_${item.moderationStatus}`)} />
|
||||||
|
{item.lowRatingAlertId != null ? (
|
||||||
|
<Chip size="small" color="warning" variant="outlined" label={t('mod_low_rating')} />
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{item.body ? (
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.primary' }}>
|
||||||
|
{item.body}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{item.tagCodes.length > 0 ? (
|
||||||
|
<Stack direction="row" sx={{ gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{item.tagCodes.map((code) => (
|
||||||
|
<Chip key={code} size="small" variant="outlined" label={tReviews(`tag_${code}`)} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Stack direction="row" sx={{ gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('mod_nurse', { id: item.nurseProfileId })}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('mod_booking', { id: item.bookingId })}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{canModerate ? (
|
||||||
|
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
<AppButton variant="contained" color="primary" startIcon="publish" onClick={() => onAct('publish')} sx={{ m: 0 }}>
|
||||||
|
{t('mod_publish')}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton variant="outlined" color="inherit" onClick={() => onAct('hide')} sx={{ m: 0 }}>
|
||||||
|
{t('mod_hide')}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton variant="outlined" color="error" onClick={() => onAct('reject')} sx={{ m: 0 }}>
|
||||||
|
{t('mod_reject')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
'use client';
|
||||||
|
/**
|
||||||
|
* RBAC roles grid (f15) — **DEFERRED-IF-MISSING.** The b15 contract does not yet expose role grant/revoke
|
||||||
|
* endpoints, so this console is served by the admin **mock** (REQ-031); an info banner says so. When the
|
||||||
|
* endpoints land, only `services/admin/apis` flips — this screen is unchanged.
|
||||||
|
*
|
||||||
|
* Grants/revokes are gated on `canManageRoles` (only a `super_admin`); the server remains the authority. The
|
||||||
|
* grid lists **active** grants (revoked rows are filtered out); an audited confirm dialog fronts every
|
||||||
|
* revoke and grant.
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useSnackbar } from 'notistack';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
MenuItem,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { AppAlert, AppButton } from '@/components';
|
||||||
|
import {
|
||||||
|
AdminDataTable,
|
||||||
|
AdminEmptyState,
|
||||||
|
AdminErrorState,
|
||||||
|
AdminPageHeader,
|
||||||
|
ConfirmDialog,
|
||||||
|
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';
|
||||||
|
|
||||||
|
/** The fine-grained admin roles the grid grants (aligned with the b2 `AdminRole` enum). */
|
||||||
|
const ROLES: readonly AdminRole[] = ['super_admin', 'admin', 'support', 'finance', 'moderation'];
|
||||||
|
|
||||||
|
export default function AdminRolesPage() {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const locale = useLocale();
|
||||||
|
const caps = useAdminCapabilities();
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
|
||||||
|
const roles = useAdminRoles();
|
||||||
|
const revoke = useRevokeRole();
|
||||||
|
|
||||||
|
const [granting, setGranting] = useState(false);
|
||||||
|
const [revoking, setRevoking] = useState<RoleGrant | null>(null);
|
||||||
|
|
||||||
|
// Only active grants — a revoked grant leaves the grid.
|
||||||
|
const active = (roles.data ?? []).filter((g) => g.revokedAt == null);
|
||||||
|
|
||||||
|
const columns: AdminTableColumn<RoleGrant>[] = [
|
||||||
|
{ key: 'user', header: t('role_col_user'), render: (g) => `#${g.userId}` },
|
||||||
|
{
|
||||||
|
key: 'role',
|
||||||
|
header: t('role_col_role'),
|
||||||
|
render: (g) => <Chip size="small" variant="outlined" label={t(`role_${g.role}`)} />,
|
||||||
|
},
|
||||||
|
{ key: 'granted', header: t('role_col_granted'), render: (g) => formatShamsiDate(g.grantedAt, locale) },
|
||||||
|
...(caps.canManageRoles
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
align: 'right',
|
||||||
|
render: (g: RoleGrant) => (
|
||||||
|
<AppButton variant="text" color="error" startIcon="delete" onClick={() => setRevoking(g)} sx={{ m: 0 }}>
|
||||||
|
{t('role_revoke')}
|
||||||
|
</AppButton>
|
||||||
|
),
|
||||||
|
} as AdminTableColumn<RoleGrant>,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const onRevokeConfirm = () => {
|
||||||
|
if (!revoking) return;
|
||||||
|
revoke.mutate(
|
||||||
|
{ userId: revoking.userId, role: revoking.role },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
enqueueSnackbar(t('role_updated'), { variant: 'success' });
|
||||||
|
setRevoking(null);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader
|
||||||
|
title={t('role_title')}
|
||||||
|
subtitle={t('role_subtitle')}
|
||||||
|
actions={
|
||||||
|
caps.canManageRoles ? (
|
||||||
|
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setGranting(true)} sx={{ m: 0 }}>
|
||||||
|
{t('role_grant')}
|
||||||
|
</AppButton>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AppAlert severity="info" variant="outlined">
|
||||||
|
{t('role_deferred')}
|
||||||
|
</AppAlert>
|
||||||
|
|
||||||
|
{roles.isLoading ? (
|
||||||
|
<Skeleton variant="rounded" height={200} />
|
||||||
|
) : roles.isError ? (
|
||||||
|
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => roles.refetch()} />
|
||||||
|
) : active.length === 0 ? (
|
||||||
|
<AdminEmptyState icon="roles" title={t('role_title')} />
|
||||||
|
) : (
|
||||||
|
<AdminDataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={active}
|
||||||
|
getRowKey={(g) => `${g.userId}:${g.role}`}
|
||||||
|
ariaLabel={t('role_title')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{granting ? <GrantRoleDialog onClose={() => setGranting(false)} /> : null}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={revoking != null}
|
||||||
|
title={t('role_revoke')}
|
||||||
|
body={revoking ? t('role_revoke_confirm', { role: t(`role_${revoking.role}`), id: revoking.userId }) : undefined}
|
||||||
|
confirmLabel={t('role_revoke')}
|
||||||
|
cancelLabel={t('cancel')}
|
||||||
|
confirmColor="error"
|
||||||
|
loading={revoke.isPending}
|
||||||
|
onConfirm={onRevokeConfirm}
|
||||||
|
onClose={() => setRevoking(null)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect a target user id + a role, then grant it (inline confirm copy once both are set). */
|
||||||
|
function GrantRoleDialog({ onClose }: { onClose: () => void }) {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
const grant = useGrantRole();
|
||||||
|
|
||||||
|
const [userId, setUserId] = useState('');
|
||||||
|
const [role, setRole] = useState<AdminRole>('support');
|
||||||
|
|
||||||
|
const parsedId = Number(userId);
|
||||||
|
const valid = /^\d+$/.test(userId.trim()) && parsedId > 0;
|
||||||
|
|
||||||
|
const onGrant = () => {
|
||||||
|
if (!valid) return;
|
||||||
|
grant.mutate(
|
||||||
|
{ userId: parsedId, role },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
enqueueSnackbar(t('role_updated'), { variant: 'success' });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={grant.isPending ? undefined : onClose} fullWidth maxWidth="xs">
|
||||||
|
<DialogTitle sx={{ fontWeight: 800 }}>{t('role_grant')}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label={t('role_col_user')}
|
||||||
|
type="number"
|
||||||
|
value={userId}
|
||||||
|
onChange={(e) => setUserId(e.target.value)}
|
||||||
|
slotProps={{ htmlInput: { min: 1 } }}
|
||||||
|
/>
|
||||||
|
<TextField select label={t('role_col_role')} value={role} onChange={(e) => setRole(e.target.value as AdminRole)}>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<MenuItem key={r} value={r}>
|
||||||
|
{t(`role_${r}`)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
{valid ? (
|
||||||
|
<DialogContentText>{t('role_grant_confirm', { role: t(`role_${role}`), id: parsedId })}</DialogContentText>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<AppButton variant="text" color="inherit" onClick={onClose} disabled={grant.isPending} sx={{ m: 0 }}>
|
||||||
|
{t('cancel')}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton variant="contained" color="primary" onClick={onGrant} disabled={!valid || grant.isPending} sx={{ m: 0 }}>
|
||||||
|
{grant.isPending ? t('saving') : t('confirm')}
|
||||||
|
</AppButton>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useSnackbar } from 'notistack';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Collapse,
|
||||||
|
Paper,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { AppButton, StatusChip } from '@/components';
|
||||||
|
import type { StatusKind } from '@/components';
|
||||||
|
import { AdminEmptyState, AdminErrorState, AdminMessageBubble, RefundPanel } from '@/components/admin';
|
||||||
|
import { useAdminCapabilities } from '@/hooks';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
import { useAdminTicket, usePostAdminMessage } from '@/services/tickets';
|
||||||
|
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). */
|
||||||
|
const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' };
|
||||||
|
|
||||||
|
/** The `tickets` author-label key. `admin` has no `author_admin` key — staff read as "support" (`author_support`). */
|
||||||
|
function authorLabelKey(role: TicketAuthorRole): string {
|
||||||
|
return `author_${role === 'admin' ? 'support' : role}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A client id so the optimistic bubble reconciles to the server message by identity (never double-rendered). */
|
||||||
|
function makeClientMessageId(): string {
|
||||||
|
return crypto?.randomUUID?.() ?? String(Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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 params = useParams<{ id: string }>();
|
||||||
|
const parsed = Number(params?.id);
|
||||||
|
const ticketId = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||||
|
|
||||||
|
const { data: detail, isLoading, isError, refetch } = useAdminTicket(ticketId || null);
|
||||||
|
const post = usePostAdminMessage(ticketId);
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<'reply' | 'internal'>('reply');
|
||||||
|
const [body, setBody] = useState('');
|
||||||
|
const [refundShown, setRefundShown] = useState(false);
|
||||||
|
|
||||||
|
const isInternal = mode === 'internal';
|
||||||
|
|
||||||
|
const send = () => {
|
||||||
|
const trimmed = body.trim();
|
||||||
|
if (!trimmed || post.isPending) return;
|
||||||
|
post.mutate(
|
||||||
|
{ body: trimmed, isInternal, clientMessageId: makeClientMessageId() },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setBody('');
|
||||||
|
enqueueSnackbar(t('ticket_sent'), { variant: 'success' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showRefund = !!detail && detail.category === 'refund' && detail.bookingId != null && caps.canRefund;
|
||||||
|
|
||||||
|
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={{ m: 0, alignSelf: 'flex-start' }}
|
||||||
|
>
|
||||||
|
{t('back')}
|
||||||
|
</AppButton>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
|
<Skeleton variant="rounded" height={96} />
|
||||||
|
<Skeleton variant="rounded" height={56} sx={{ width: '70%' }} />
|
||||||
|
<Skeleton variant="rounded" height={56} sx={{ width: '70%', alignSelf: 'flex-end' }} />
|
||||||
|
</Stack>
|
||||||
|
) : isError ? (
|
||||||
|
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => refetch()} />
|
||||||
|
) : !detail ? (
|
||||||
|
<AdminEmptyState icon="support" title={t('ticket_empty')} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{showRefund ? (
|
||||||
|
<Box>
|
||||||
|
<AppButton
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
startIcon="refunds"
|
||||||
|
onClick={() => setRefundShown((v) => !v)}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5, maxHeight: 520, overflowY: 'auto', p: 0.5 }}>
|
||||||
|
{detail.messages.map((m) => (
|
||||||
|
<AdminMessageBubble
|
||||||
|
key={m.clientMessageId ?? String(m.id)}
|
||||||
|
message={m}
|
||||||
|
authorLabel={tickets(authorLabelKey(m.authorRole))}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{caps.canManageTickets ? (
|
||||||
|
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||||
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
size="small"
|
||||||
|
exclusive
|
||||||
|
value={mode}
|
||||||
|
onChange={(_e, next: 'reply' | 'internal' | null) => next && setMode(next)}
|
||||||
|
>
|
||||||
|
<ToggleButton value="reply">{t('ticket_public_reply')}</ToggleButton>
|
||||||
|
<ToggleButton value="internal">{t('ticket_internal_note')}</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
maxRows={6}
|
||||||
|
size="small"
|
||||||
|
placeholder={isInternal ? t('ticket_composer_internal_ph') : t('ticket_composer_public_ph')}
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
/>
|
||||||
|
<AppButton
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
startIcon="send"
|
||||||
|
onClick={send}
|
||||||
|
disabled={post.isPending || body.trim().length === 0}
|
||||||
|
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||||
|
>
|
||||||
|
{t('ticket_send')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } 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 type { StatusKind } from '@/components';
|
||||||
|
import {
|
||||||
|
AdminDataTable,
|
||||||
|
AdminEmptyState,
|
||||||
|
AdminErrorState,
|
||||||
|
AdminPageHeader,
|
||||||
|
AdminPager,
|
||||||
|
type AdminTableColumn,
|
||||||
|
} from '@/components/admin';
|
||||||
|
import { adminTicketThreadPath } from '@/constants';
|
||||||
|
import { useAdminTickets } from '@/services/tickets';
|
||||||
|
import { TICKETS_PAGE_SIZE } from '@/services/tickets/constants';
|
||||||
|
import type { AdminTicketFilters, AdminTicketSummary, TicketCategory, TicketStatus } from '@/services/tickets/types';
|
||||||
|
|
||||||
|
const STATUSES: readonly TicketStatus[] = ['open', 'closed'];
|
||||||
|
const CATEGORIES: readonly TicketCategory[] = ['coordination', 'support', 'refund', 'emergency'];
|
||||||
|
/** Queue status → chip color: an open ticket is pending work, a closed one is neutral (phase §5). */
|
||||||
|
const STATUS_KIND: Record<TicketStatus, StatusKind> = { open: 'pending', closed: 'neutral' };
|
||||||
|
const EMPTY: AdminTicketFilters = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export default function AdminTicketsPage() {
|
||||||
|
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 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 columns: AdminTableColumn<AdminTicketSummary>[] = [
|
||||||
|
{
|
||||||
|
key: 'ref',
|
||||||
|
header: t('ticket_col_ref'),
|
||||||
|
render: (row) => (
|
||||||
|
<Box component="span" dir="ltr" sx={{ fontWeight: 700 }}>
|
||||||
|
{row.referenceCode}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'subject', header: t('ticket_col_subject'), render: (row) => row.subject ?? '—' },
|
||||||
|
{
|
||||||
|
key: 'category',
|
||||||
|
header: t('ticket_col_category'),
|
||||||
|
render: (row) => <Chip size="small" label={t(`tcat_${row.category}`)} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: t('ticket_col_status'),
|
||||||
|
render: (row) => <StatusChip status={STATUS_KIND[row.status]} label={t(`tstatus_${row.status}`)} />,
|
||||||
|
},
|
||||||
|
{ key: 'booking', header: t('ticket_col_booking'), render: (row) => row.bookingId ?? '—' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader title={t('ticket_title')} subtitle={t('ticket_subtitle')} />
|
||||||
|
|
||||||
|
<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('ticket_col_status')}
|
||||||
|
value={draft.status ?? ''}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, status: (e.target.value || undefined) as TicketStatus | undefined }))}
|
||||||
|
sx={{ minWidth: 140 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">{t('filter_all')}</MenuItem>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<MenuItem key={s} value={s}>
|
||||||
|
{t(`tstatus_${s}`)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
label={t('ticket_col_category')}
|
||||||
|
value={draft.category ?? ''}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, category: (e.target.value || undefined) as TicketCategory | undefined }))}
|
||||||
|
sx={{ minWidth: 160 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">{t('filter_all')}</MenuItem>
|
||||||
|
{CATEGORIES.map((c) => (
|
||||||
|
<MenuItem key={c} value={c}>
|
||||||
|
{t(`tcat_${c}`)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label={t('ticket_col_ref')}
|
||||||
|
placeholder={t('ticket_search_ref_ph')}
|
||||||
|
value={draft.referenceCode ?? ''}
|
||||||
|
onChange={(e) => setDraft((d) => ({ ...d, referenceCode: e.target.value || undefined }))}
|
||||||
|
sx={{ minWidth: 220 }}
|
||||||
|
/>
|
||||||
|
<AppButton variant="contained" color="primary" onClick={apply} sx={{ m: 0 }}>
|
||||||
|
{t('apply')}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton variant="text" color="inherit" onClick={clear} sx={{ m: 0 }}>
|
||||||
|
{t('clear')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{tickets.isLoading ? (
|
||||||
|
<Stack sx={{ gap: 1 }}>
|
||||||
|
{[0, 1, 2, 3].map((k) => (
|
||||||
|
<Skeleton key={k} variant="rounded" height={48} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : tickets.isError ? (
|
||||||
|
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => tickets.refetch()} />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<AdminEmptyState icon="support" title={t('ticket_empty')} />
|
||||||
|
) : (
|
||||||
|
<AdminDataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={items}
|
||||||
|
getRowKey={(row) => row.id}
|
||||||
|
ariaLabel={t('ticket_title')}
|
||||||
|
onRowClick={(row) => router.push(`/${locale}${adminTicketThreadPath(row.id)}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 })}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useSnackbar } from 'notistack';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Skeleton,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { AppButton, StatusChip } from '@/components';
|
||||||
|
import type { StatusKind } from '@/components';
|
||||||
|
import {
|
||||||
|
AdminEmptyState,
|
||||||
|
AdminErrorState,
|
||||||
|
AdminPageHeader,
|
||||||
|
ConfirmDialog,
|
||||||
|
DocumentViewer,
|
||||||
|
} from '@/components/admin';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
import { formatShamsiDate } from '@/utils';
|
||||||
|
import { useAdminCapabilities } from '@/hooks';
|
||||||
|
import {
|
||||||
|
useApproveVerification,
|
||||||
|
useDecideStep,
|
||||||
|
useRejectVerification,
|
||||||
|
useVerificationCase,
|
||||||
|
} from '@/services/verification';
|
||||||
|
import type { AdminVerificationStepDetail, VerificationStepStatus } from '@/services/verification/types';
|
||||||
|
|
||||||
|
/** 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'];
|
||||||
|
|
||||||
|
/** Per-step status → chip kind. `expired`/`failed` read red; `in_review`/`pending` amber; `passed` green. */
|
||||||
|
const STEP_STATUS_KIND: Record<VerificationStepStatus, StatusKind> = {
|
||||||
|
not_started: 'neutral',
|
||||||
|
pending: 'pending',
|
||||||
|
in_review: 'pending',
|
||||||
|
passed: 'verified',
|
||||||
|
failed: 'rejected',
|
||||||
|
expired: 'rejected',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* short-lived URL on demand), and the manual-step decisions. A credential-bearing step records the
|
||||||
|
* (encrypted) credential via a structured form; recorded credentials are listed by **type** only — the
|
||||||
|
* number never crosses the wire. The whole verification is approvable only when every required step has
|
||||||
|
* passed; a decision re-aggregates server-side (flipping `is_verified`) and removes the case from the queue.
|
||||||
|
*/
|
||||||
|
export default function AdminVerificationCasePage() {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const locale = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams<{ nurseId: string }>();
|
||||||
|
const nurseVerificationId = Number(params?.nurseId);
|
||||||
|
const caps = useAdminCapabilities();
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useVerificationCase(
|
||||||
|
Number.isFinite(nurseVerificationId) ? nurseVerificationId : null,
|
||||||
|
);
|
||||||
|
const approve = useApproveVerification();
|
||||||
|
const reject = useRejectVerification();
|
||||||
|
|
||||||
|
const [approveOpen, setApproveOpen] = useState(false);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
|
||||||
|
const backToQueue = () => router.push(`/${locale}${ROUTES.ADMIN_VERIFICATION}`);
|
||||||
|
|
||||||
|
const allPassed = !!data && data.steps.length > 0 && data.steps.every((step) => step.status === 'passed');
|
||||||
|
|
||||||
|
const onApprove = () => {
|
||||||
|
approve.mutate(nurseVerificationId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
|
||||||
|
setApproveOpen(false);
|
||||||
|
backToQueue();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReject = (reason?: string) => {
|
||||||
|
reject.mutate(
|
||||||
|
{ nurseVerificationId, reason: reason ?? '' },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
|
||||||
|
setRejectOpen(false);
|
||||||
|
backToQueue();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<Stack sx={{ gap: 1 }}>
|
||||||
|
<AppButton variant="text" color="primary" onClick={backToQueue} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
||||||
|
{t('back')}
|
||||||
|
</AppButton>
|
||||||
|
<AdminPageHeader title={t('ver_case_title')} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<Stack sx={{ gap: 2 }}>
|
||||||
|
<Skeleton variant="rounded" height={72} />
|
||||||
|
<Skeleton variant="rounded" height={220} />
|
||||||
|
<Skeleton variant="rounded" height={160} />
|
||||||
|
</Stack>
|
||||||
|
) : isError ? (
|
||||||
|
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => refetch()} />
|
||||||
|
) : !data ? (
|
||||||
|
<AdminEmptyState icon="verified" title={t('ver_empty')} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('ver_identity_name')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||||
|
{data.identityName}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
|
<Typography variant="h6">{t('ver_steps_title')}</Typography>
|
||||||
|
{data.steps.map((step) => (
|
||||||
|
<StepCard
|
||||||
|
key={step.id}
|
||||||
|
step={step}
|
||||||
|
nurseVerificationId={nurseVerificationId}
|
||||||
|
canVerify={caps.canVerify}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{data.credentials.length > 0 ? (
|
||||||
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
|
<Typography variant="h6">{t('ver_credentials_title')}</Typography>
|
||||||
|
{data.credentials.map((cred) => (
|
||||||
|
<Box key={cred.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.75 }}>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||||
|
{t(`step_${cred.credentialType}`)}
|
||||||
|
</Typography>
|
||||||
|
{cred.expiresAt ? (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('ver_expires_at')}: {formatShamsiDate(cred.expiresAt, locale)}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2">{cred.holderNameSnapshot}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{cred.issuingAuthority}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Stack sx={{ gap: 0.5 }}>
|
||||||
|
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
<AppButton
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => setApproveOpen(true)}
|
||||||
|
disabled={!allPassed || !caps.canVerify}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
>
|
||||||
|
{t('ver_approve')}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setRejectOpen(true)}
|
||||||
|
disabled={!caps.canVerify}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
>
|
||||||
|
{t('ver_reject_all')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('ver_approve_hint')}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={approveOpen}
|
||||||
|
title={t('ver_approve')}
|
||||||
|
body={t('ver_approve_confirm')}
|
||||||
|
confirmLabel={t('confirm')}
|
||||||
|
cancelLabel={t('cancel')}
|
||||||
|
onConfirm={onApprove}
|
||||||
|
onClose={() => setApproveOpen(false)}
|
||||||
|
loading={approve.isPending}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={rejectOpen}
|
||||||
|
title={t('ver_reject_all')}
|
||||||
|
body={t('ver_reject_confirm')}
|
||||||
|
confirmLabel={t('ver_reject_all')}
|
||||||
|
cancelLabel={t('cancel')}
|
||||||
|
onConfirm={onReject}
|
||||||
|
onClose={() => setRejectOpen(false)}
|
||||||
|
loading={reject.isPending}
|
||||||
|
requireReason
|
||||||
|
reasonLabel={t('reason_label')}
|
||||||
|
reasonPlaceholder={t('ver_reject_reason_ph')}
|
||||||
|
confirmColor="error"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One step of the case: label + status chip (+ automated badge), its documents, and — for a decidable
|
||||||
|
* manual step — Pass / Reject. A credential-bearing Pass opens the structured credential form. */
|
||||||
|
function StepCard({
|
||||||
|
step,
|
||||||
|
nurseVerificationId,
|
||||||
|
canVerify,
|
||||||
|
}: {
|
||||||
|
step: AdminVerificationStepDetail;
|
||||||
|
nurseVerificationId: number;
|
||||||
|
canVerify: boolean;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
const decide = useDecideStep();
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [credentialOpen, setCredentialOpen] = useState(false);
|
||||||
|
|
||||||
|
const isManual = !step.isAutomated;
|
||||||
|
const isCredentialStep = CREDENTIAL_STEP_CODES.includes(step.code);
|
||||||
|
const isDecidable = isManual && (step.status === 'in_review' || step.status === 'pending');
|
||||||
|
|
||||||
|
const onPass = () => {
|
||||||
|
decide.mutate(
|
||||||
|
{ stepId: step.id, nurseVerificationId, input: { approve: true } },
|
||||||
|
{ onSuccess: () => enqueueSnackbar(t('ver_decided'), { variant: 'success' }) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReject = (reason?: string) => {
|
||||||
|
decide.mutate(
|
||||||
|
{ stepId: step.id, nurseVerificationId, input: { approve: false, rejectionReason: reason ?? '' } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
|
||||||
|
setRejectOpen(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 2 }}>
|
||||||
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, flexGrow: 1 }}>
|
||||||
|
{t(`step_${step.code}`)}
|
||||||
|
</Typography>
|
||||||
|
<StatusChip status={STEP_STATUS_KIND[step.status]} label={t(`step_${step.status}`)} />
|
||||||
|
{step.isAutomated ? (
|
||||||
|
<Chip size="small" label={t('ver_automated_badge')} sx={{ bgcolor: 'action.hover', fontWeight: 600 }} />
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{step.documents.length > 0 ? (
|
||||||
|
<Stack sx={{ gap: 1.5, mt: 1.5 }}>
|
||||||
|
{step.documents.map((doc) => (
|
||||||
|
<DocumentViewer key={doc.id} document={doc} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : isManual ? (
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1 }}>
|
||||||
|
{t('ver_no_documents')}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step.failureReason ? (
|
||||||
|
<Typography variant="caption" sx={{ color: 'var(--bal-error)', mt: 1, display: 'block' }}>
|
||||||
|
{step.failureReason}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isDecidable && canVerify ? (
|
||||||
|
<Stack direction="row" sx={{ gap: 1, mt: 1.5, flexWrap: 'wrap' }}>
|
||||||
|
<AppButton
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => (isCredentialStep ? setCredentialOpen(true) : onPass())}
|
||||||
|
disabled={decide.isPending}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
>
|
||||||
|
{t('ver_pass')}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setRejectOpen(true)}
|
||||||
|
disabled={decide.isPending}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
>
|
||||||
|
{t('ver_reject')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={rejectOpen}
|
||||||
|
title={t('ver_reject_step')}
|
||||||
|
confirmLabel={t('ver_reject')}
|
||||||
|
cancelLabel={t('cancel')}
|
||||||
|
onConfirm={onReject}
|
||||||
|
onClose={() => setRejectOpen(false)}
|
||||||
|
loading={decide.isPending}
|
||||||
|
requireReason
|
||||||
|
reasonLabel={t('reason_label')}
|
||||||
|
reasonPlaceholder={t('ver_reject_reason_ph')}
|
||||||
|
confirmColor="error"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isCredentialStep && credentialOpen ? (
|
||||||
|
<CredentialDialog step={step} nurseVerificationId={nurseVerificationId} onClose={() => setCredentialOpen(false)} />
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The structured credential form recorded on approving a credential-bearing step. `criminal_record`
|
||||||
|
* requires an expiry date; `credentialNumber` is accepted as input and never echoed back. */
|
||||||
|
function CredentialDialog({
|
||||||
|
step,
|
||||||
|
nurseVerificationId,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
step: AdminVerificationStepDetail;
|
||||||
|
nurseVerificationId: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
const decide = useDecideStep();
|
||||||
|
const [credentialNumber, setCredentialNumber] = useState('');
|
||||||
|
const [holderName, setHolderName] = useState('');
|
||||||
|
const [issuingAuthority, setIssuingAuthority] = useState('');
|
||||||
|
const [issuedAt, setIssuedAt] = useState('');
|
||||||
|
const [expiresAt, setExpiresAt] = useState('');
|
||||||
|
|
||||||
|
const expiryRequired = step.code === 'criminal_record';
|
||||||
|
const expiryMissing = expiryRequired && expiresAt.trim().length === 0;
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
if (expiryMissing) return;
|
||||||
|
decide.mutate(
|
||||||
|
{
|
||||||
|
stepId: step.id,
|
||||||
|
nurseVerificationId,
|
||||||
|
input: {
|
||||||
|
approve: true,
|
||||||
|
credentialNumber: credentialNumber.trim() || undefined,
|
||||||
|
holderName: holderName.trim() || undefined,
|
||||||
|
issuingAuthority: issuingAuthority.trim() || undefined,
|
||||||
|
issuedAt: issuedAt || undefined,
|
||||||
|
expiresAt: expiresAt || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
enqueueSnackbar(t('ver_decided'), { variant: 'success' });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={decide.isPending ? undefined : onClose} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle sx={{ fontWeight: 800 }}>{t('ver_credential_title')}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack sx={{ gap: 2, mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
label={t('ver_credential_number')}
|
||||||
|
value={credentialNumber}
|
||||||
|
onChange={(e) => setCredentialNumber(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={t('ver_holder_name')}
|
||||||
|
helperText={t('ver_holder_hint')}
|
||||||
|
value={holderName}
|
||||||
|
onChange={(e) => setHolderName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={t('ver_issuing_authority')}
|
||||||
|
value={issuingAuthority}
|
||||||
|
onChange={(e) => setIssuingAuthority(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
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)}
|
||||||
|
required={expiryRequired}
|
||||||
|
error={expiryMissing}
|
||||||
|
helperText={expiryMissing ? t('ver_expiry_required') : undefined}
|
||||||
|
slotProps={{ inputLabel: { shrink: true } }}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<AppButton variant="text" color="inherit" onClick={onClose} disabled={decide.isPending} sx={{ m: 0 }}>
|
||||||
|
{t('cancel')}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={decide.isPending || expiryMissing}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
>
|
||||||
|
{decide.isPending ? t('saving') : t('save')}
|
||||||
|
</AppButton>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } 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 type { StatusKind } from '@/components';
|
||||||
|
import {
|
||||||
|
AdminDataTable,
|
||||||
|
AdminEmptyState,
|
||||||
|
AdminErrorState,
|
||||||
|
AdminPageHeader,
|
||||||
|
AdminPager,
|
||||||
|
} from '@/components/admin';
|
||||||
|
import type { AdminTableColumn } from '@/components/admin';
|
||||||
|
import { adminVerificationCasePath } from '@/constants';
|
||||||
|
import { formatShamsiDate } from '@/utils';
|
||||||
|
import { useVerificationQueue } from '@/services/verification';
|
||||||
|
import { ADMIN_QUEUE_PAGE_SIZE } from '@/services/verification/constants';
|
||||||
|
import type { AdminVerificationQueueItem, VerificationAggregateStatus } from '@/services/verification/types';
|
||||||
|
|
||||||
|
/** The queue status filter — a subset of the aggregate statuses the desk works (default all). */
|
||||||
|
type QueueStatusFilter = '' | 'pending' | 'in_review';
|
||||||
|
|
||||||
|
/** Aggregate status → chip kind. `in_review` reads as informational; a rejected/suspended case shows red. */
|
||||||
|
const AGG_STATUS_KIND: Record<VerificationAggregateStatus, StatusKind> = {
|
||||||
|
not_started: 'neutral',
|
||||||
|
pending: 'pending',
|
||||||
|
in_review: 'info',
|
||||||
|
approved: 'verified',
|
||||||
|
rejected: 'rejected',
|
||||||
|
suspended: 'rejected',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export default function AdminVerificationQueuePage() {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const locale = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<QueueStatusFilter>('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const queue = useVerificationQueue({ status: status || undefined }, page);
|
||||||
|
const items = queue.data?.items ?? [];
|
||||||
|
const pageCount = Math.max(1, Math.ceil((queue.data?.total ?? 0) / ADMIN_QUEUE_PAGE_SIZE));
|
||||||
|
|
||||||
|
const columns: AdminTableColumn<AdminVerificationQueueItem>[] = [
|
||||||
|
{
|
||||||
|
key: 'nurse',
|
||||||
|
header: t('ver_col_nurse'),
|
||||||
|
render: (item) => (
|
||||||
|
<Stack sx={{ gap: 0.5, alignItems: 'flex-start' }}>
|
||||||
|
<Box sx={{ fontWeight: 700 }}>{item.nurseName}</Box>
|
||||||
|
{item.hasExpiringCredential ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
icon={<AppIcon icon="warning" size={14} color="var(--bal-warning-contrast)" />}
|
||||||
|
label={t('ver_expiring_warning')}
|
||||||
|
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: t('ver_col_status'),
|
||||||
|
render: (item) => <StatusChip status={AGG_STATUS_KIND[item.status]} label={t(`agg_${item.status}`)} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'step',
|
||||||
|
header: t('ver_col_step'),
|
||||||
|
render: (item) => (
|
||||||
|
<Stack sx={{ gap: 0.25 }}>
|
||||||
|
<Box>{t('ver_progress', { done: item.stepsPassed, total: item.stepsTotal })}</Box>
|
||||||
|
<Box sx={{ color: 'text.secondary', fontSize: 13 }}>
|
||||||
|
{t('ver_next_step', { step: item.nextPendingStepCode ? t(`step_${item.nextPendingStepCode}`) : '—' })}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'submitted',
|
||||||
|
header: t('ver_col_submitted'),
|
||||||
|
render: (item) => (item.submittedAt ? formatShamsiDate(item.submittedAt, locale) : '—'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{queue.isLoading ? (
|
||||||
|
<Stack sx={{ gap: 1 }}>{[0, 1, 2, 3].map((k) => <Skeleton key={k} variant="rounded" height={56} />)}</Stack>
|
||||||
|
) : queue.isError ? (
|
||||||
|
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => queue.refetch()} />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<AdminEmptyState icon="verified" title={t('ver_empty')} />
|
||||||
|
) : (
|
||||||
|
<AdminDataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={items}
|
||||||
|
getRowKey={(item) => item.nurseVerificationId}
|
||||||
|
ariaLabel={t('ver_title')}
|
||||||
|
onRowClick={(item) => router.push(`/${locale}${adminVerificationCasePath(item.nurseVerificationId)}`)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 })}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { Box, Chip, MenuItem, Skeleton, Stack, TextField } from '@mui/material';
|
||||||
|
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, AdminPager } from '@/components/admin';
|
||||||
|
import type { AdminTableColumn } from '@/components/admin';
|
||||||
|
import { formatShamsiDate } from '@/utils';
|
||||||
|
import type { SponsoredBooking } 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.
|
||||||
|
*/
|
||||||
|
const BOOKING_STATUS_OPTIONS = ['pending_payment', 'confirmed', 'in_progress', 'completed', 'disputed', 'closed', 'cancelled'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export default function PartnerBookingsPage() {
|
||||||
|
const t = useTranslations('partner');
|
||||||
|
const ta = useTranslations('admin');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<string>('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const filters = { status: status || undefined };
|
||||||
|
const bookings = useMySponsoredBookings(filters, page);
|
||||||
|
|
||||||
|
const items = bookings.data?.items ?? [];
|
||||||
|
const pageCount = Math.max(1, Math.ceil((bookings.data?.total ?? 0) / PARTNER_PAGE_SIZE));
|
||||||
|
|
||||||
|
const columns: AdminTableColumn<SponsoredBooking>[] = [
|
||||||
|
{ key: 'id', header: t('bookings_col_id'), render: (b) => `#${b.bookingId}` },
|
||||||
|
{ key: 'patient', header: t('bookings_col_patient'), render: (b) => b.patientName },
|
||||||
|
{ key: 'date', header: t('bookings_col_date'), render: (b) => formatShamsiDate(b.scheduledDate, locale) },
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: t('bookings_col_status'),
|
||||||
|
render: (b) => <Chip size="small" variant="outlined" label={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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{bookings.isLoading ? (
|
||||||
|
<Stack sx={{ gap: 2 }}>
|
||||||
|
{[0, 1, 2].map((k) => (
|
||||||
|
<Skeleton key={k} variant="rounded" height={56} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : bookings.isError ? (
|
||||||
|
<AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => bookings.refetch()} />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<AdminEmptyState icon="bookings" title={t('bookings_empty')} />
|
||||||
|
) : (
|
||||||
|
<AdminDataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={items}
|
||||||
|
getRowKey={(b) => b.bookingId}
|
||||||
|
ariaLabel={t('bookings_title')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AdminPager
|
||||||
|
page={page}
|
||||||
|
pageCount={pageCount}
|
||||||
|
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||||
|
prevLabel={ta('prev_page')}
|
||||||
|
nextLabel={ta('next_page')}
|
||||||
|
indicator={ta('page_indicator', { page })}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
'use client';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { PartnerLayout } from '@/layout';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Partner-center portal route group (/partner/…) — a separate authz scope from /admin (f15). A center
|
||||||
|
* admin sees only their own center; tenancy is server-enforced and each portal page resolves the caller's
|
||||||
|
* own center via `useMyPartnerCenter` (a 403/404 renders the access-denied state).
|
||||||
|
*/
|
||||||
|
export default function PartnerRouteLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <PartnerLayout>{children}</PartnerLayout>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
'use client';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Box, Skeleton, Stack } from '@mui/material';
|
||||||
|
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader } from '@/components/admin';
|
||||||
|
import type { AdminTableColumn } from '@/components/admin';
|
||||||
|
import { StatusChip } from '@/components';
|
||||||
|
import type { SponsoredNurse } from '@/services/partnerCenter/types';
|
||||||
|
import { useMySponsoredNurses } from '@/services/partnerCenter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partner portal — sponsored-nurses roster (f15). The read-only list of nurses the signed-in center
|
||||||
|
* sponsors (portal scope; server-enforced tenancy). Two columns: name + a verification `StatusChip`. Not
|
||||||
|
* paginated (the portal roster is a bounded set), so no pager.
|
||||||
|
*/
|
||||||
|
export default function PartnerNursesPage() {
|
||||||
|
const t = useTranslations('partner');
|
||||||
|
const ta = useTranslations('admin');
|
||||||
|
const nurses = useMySponsoredNurses();
|
||||||
|
const items = nurses.data ?? [];
|
||||||
|
|
||||||
|
const columns: AdminTableColumn<SponsoredNurse>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
header: t('nurses_col_name'),
|
||||||
|
render: (n) => n.name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'verified',
|
||||||
|
header: t('nurses_col_verified'),
|
||||||
|
render: (n) => (
|
||||||
|
<StatusChip
|
||||||
|
status={n.isVerified ? 'verified' : 'neutral'}
|
||||||
|
label={n.isVerified ? ta('center_state_verified') : ta('center_state_pending_verification')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader title={t('nurses_title')} />
|
||||||
|
|
||||||
|
{nurses.isLoading ? (
|
||||||
|
<Stack sx={{ gap: 2 }}>
|
||||||
|
{[0, 1, 2].map((k) => (
|
||||||
|
<Skeleton key={k} variant="rounded" height={56} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : nurses.isError ? (
|
||||||
|
<AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => nurses.refetch()} />
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<AdminEmptyState icon="patients" title={t('nurses_empty')} />
|
||||||
|
) : (
|
||||||
|
<AdminDataTable
|
||||||
|
columns={columns}
|
||||||
|
rows={items}
|
||||||
|
getRowKey={(n) => n.nurseProfileId}
|
||||||
|
ariaLabel={t('nurses_title')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
'use client';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||||
|
import { AdminEmptyState, AdminPageHeader } from '@/components/admin';
|
||||||
|
import { StatusChip } from '@/components';
|
||||||
|
import type { CenterOnboardingState, PartnerCenter } from '@/services/partnerCenter/types';
|
||||||
|
import { useMyPartnerCenter } from '@/services/partnerCenter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partner portal home (f15) — the signed-in center admin's **own** center at a glance (a separate authz
|
||||||
|
* scope from /admin; tenancy is server-enforced). `useMyPartnerCenter` doubles as the access gate: a
|
||||||
|
* 403/404 (non-owner / no center) surfaces the non-leaking access-denied state, never any center data.
|
||||||
|
* On success it shows the onboarding banner (draft/pending/suspended), the license block, the
|
||||||
|
* merchant-of-record indicator, and the masked settlement IBAN. Read-only.
|
||||||
|
*/
|
||||||
|
export default function PartnerHomePage() {
|
||||||
|
const t = useTranslations('partner');
|
||||||
|
const center = useMyPartnerCenter();
|
||||||
|
|
||||||
|
if (center.isLoading) {
|
||||||
|
return (
|
||||||
|
<Stack sx={{ gap: 3 }}>
|
||||||
|
<Skeleton variant="rounded" height={72} />
|
||||||
|
<Skeleton variant="rounded" height={64} />
|
||||||
|
<Skeleton variant="rounded" height={220} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The center query is the portal's access gate — a 403/404 means the caller owns no center.
|
||||||
|
if (center.isError || !center.data) {
|
||||||
|
return <AdminEmptyState icon="lock" title={t('access_denied')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = center.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader
|
||||||
|
title={t('home_title')}
|
||||||
|
subtitle={t('home_subtitle')}
|
||||||
|
actions={
|
||||||
|
<StatusChip
|
||||||
|
status={c.isMerchantOfRecord ? 'active' : 'neutral'}
|
||||||
|
label={c.isMerchantOfRecord ? t('is_mor_yes') : t('is_mor_no')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OnboardingBanner state={c.onboardingState} />
|
||||||
|
|
||||||
|
<LicenseBlock center={c} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding/verification banner keyed off `onboardingState`. `verified` shows a subtle chip instead of a
|
||||||
|
* banner; every other state shows an MUI `Alert` (draft/suspended → warning, pending → info).
|
||||||
|
*/
|
||||||
|
function OnboardingBanner({ state }: { state: CenterOnboardingState }) {
|
||||||
|
const t = useTranslations('partner');
|
||||||
|
const ta = useTranslations('admin');
|
||||||
|
|
||||||
|
if (state === 'verified') {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<StatusChip status="verified" label={ta('center_state_verified')} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const banner: Record<Exclude<CenterOnboardingState, 'verified'>, { severity: 'warning' | 'info'; key: string }> = {
|
||||||
|
draft: { severity: 'warning', key: 'state_banner_draft' },
|
||||||
|
pending_verification: { severity: 'info', key: 'state_banner_pending' },
|
||||||
|
suspended: { severity: 'warning', key: 'state_banner_suspended' },
|
||||||
|
};
|
||||||
|
const { severity, key } = banner[state];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert severity={severity} sx={{ borderRadius: 2 }}>
|
||||||
|
{t(key)}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** License details + merchant-of-record settlement IBAN (masked last-4). Nulls render as an em dash. */
|
||||||
|
function LicenseBlock({ center }: { center: PartnerCenter }) {
|
||||||
|
const t = useTranslations('partner');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 2 }}>
|
||||||
|
{t('license_title')}
|
||||||
|
</Typography>
|
||||||
|
<Stack sx={{ gap: 1.5 }}>
|
||||||
|
<DetailRow label={t('permit')} value={center.mohEstablishmentPermitNo} />
|
||||||
|
<DetailRow label={t('tech_director')} value={center.technicalDirectorLicenseNo} />
|
||||||
|
<DetailRow label={t('enamad')} value={center.enamadCode} />
|
||||||
|
<DetailRow label={t('legal_type')} value={center.legalEntityType} />
|
||||||
|
{center.isMerchantOfRecord ? (
|
||||||
|
<DetailRow label={t('settlement_iban')} value={center.settlementIbanMasked} ltr />
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One label → value row. `ltr` forces LTR display for latin/numeric values (IBAN) inside an RTL page. */
|
||||||
|
function DetailRow({ label, value, ltr }: { label: string; value: string | null; ltr?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{ gap: 2, alignItems: 'baseline', justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
{ltr ? (
|
||||||
|
<Typography component="span" dir="ltr" variant="body2" sx={{ fontWeight: 600, fontFamily: 'monospace' }}>
|
||||||
|
{value ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||||
|
{value ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Alert, Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||||
|
import {
|
||||||
|
AdminEmptyState,
|
||||||
|
AdminErrorState,
|
||||||
|
AdminPageHeader,
|
||||||
|
AdminPager,
|
||||||
|
PartnerSettlementRow,
|
||||||
|
} from '@/components/admin';
|
||||||
|
import { PARTNER_PAGE_SIZE } from '@/services/partnerCenter/constants';
|
||||||
|
import { useMyPartnerCenter, useMySettlement } from '@/services/partnerCenter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export default function PartnerSettlementPage() {
|
||||||
|
const t = useTranslations('partner');
|
||||||
|
const ta = useTranslations('admin');
|
||||||
|
const center = useMyPartnerCenter();
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
// Called unconditionally (rules of hooks); only rendered for a merchant-of-record center.
|
||||||
|
const settlement = useMySettlement(page);
|
||||||
|
|
||||||
|
if (center.isLoading) {
|
||||||
|
return (
|
||||||
|
<Stack sx={{ gap: 3 }}>
|
||||||
|
<Skeleton variant="rounded" height={72} />
|
||||||
|
<Skeleton variant="rounded" height={200} />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (center.isError || !center.data) {
|
||||||
|
return <AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => center.refetch()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = center.data;
|
||||||
|
|
||||||
|
if (!c.isMerchantOfRecord) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader title={t('settlement_title')} />
|
||||||
|
<Alert severity="info" sx={{ borderRadius: 2 }}>
|
||||||
|
{t('settlement_not_mor')}
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoices = settlement.data?.items ?? [];
|
||||||
|
const pageCount = Math.max(1, Math.ceil((settlement.data?.total ?? 0) / PARTNER_PAGE_SIZE));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<AdminPageHeader title={t('settlement_title')} />
|
||||||
|
|
||||||
|
<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' }}>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('settlement_iban')}
|
||||||
|
</Typography>
|
||||||
|
<Typography component="span" dir="ltr" variant="body2" sx={{ fontWeight: 600, fontFamily: 'monospace' }}>
|
||||||
|
{c.settlementIbanMasked ?? '—'}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{settlement.isLoading ? (
|
||||||
|
<Stack sx={{ gap: 2 }}>
|
||||||
|
{[0, 1].map((k) => (
|
||||||
|
<Skeleton key={k} variant="rounded" height={200} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : settlement.isError ? (
|
||||||
|
<AdminErrorState message={ta('error_generic')} retryLabel={ta('retry')} onRetry={() => settlement.refetch()} />
|
||||||
|
) : invoices.length === 0 ? (
|
||||||
|
<AdminEmptyState icon="payment" title={t('settlement_empty')} />
|
||||||
|
) : (
|
||||||
|
<Stack sx={{ gap: 2 }}>
|
||||||
|
{invoices.map((inv) => (
|
||||||
|
<PartnerSettlementRow
|
||||||
|
key={inv.id}
|
||||||
|
invoice={inv}
|
||||||
|
onDownloadPdf={(i) => {
|
||||||
|
if (i.pdfUrl) window.open(i.pdfUrl, '_blank', 'noopener');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AdminPager
|
||||||
|
page={page}
|
||||||
|
pageCount={pageCount}
|
||||||
|
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||||
|
prevLabel={ta('prev_page')}
|
||||||
|
nextLabel={ta('next_page')}
|
||||||
|
indicator={ta('page_indicator', { page })}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
import AdminDataTable, { type AdminTableColumn } from './AdminDataTable';
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROWS: Row[] = [
|
||||||
|
{ id: 1, name: 'Alpha' },
|
||||||
|
{ id: 2, name: 'Beta' },
|
||||||
|
];
|
||||||
|
const COLUMNS: AdminTableColumn<Row>[] = [
|
||||||
|
{ key: 'id', header: 'ID', render: (r) => r.id },
|
||||||
|
{ key: 'name', header: 'Name', render: (r) => r.name },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('<AdminDataTable/>', () => {
|
||||||
|
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||||
|
|
||||||
|
it('renders headers and every row cell', () => {
|
||||||
|
wrap(<AdminDataTable columns={COLUMNS} rows={ROWS} getRowKey={(r) => r.id} />);
|
||||||
|
expect(screen.getByText('ID')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Beta')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a data-col attribute per column', () => {
|
||||||
|
const { container } = wrap(<AdminDataTable columns={COLUMNS} rows={ROWS} getRowKey={(r) => r.id} />);
|
||||||
|
expect(container.querySelectorAll('[data-col="name"]').length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onRowClick with the clicked row', () => {
|
||||||
|
const onRowClick = jest.fn();
|
||||||
|
wrap(<AdminDataTable columns={COLUMNS} rows={ROWS} getRowKey={(r) => r.id} onRowClick={onRowClick} />);
|
||||||
|
fireEvent.click(screen.getByText('Beta'));
|
||||||
|
expect(onRowClick).toHaveBeenCalledWith(ROWS[1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
} from '@mui/material';
|
||||||
|
|
||||||
|
export interface AdminTableColumn<T> {
|
||||||
|
/** Stable column key (also `data-col` for tests). */
|
||||||
|
key: string;
|
||||||
|
/** Already-translated header label. */
|
||||||
|
header: string;
|
||||||
|
/** Cell renderer for a row. */
|
||||||
|
render: (row: T) => ReactNode;
|
||||||
|
/** Optional cell alignment (defaults to `inherit`, which follows text direction — RTL-safe). */
|
||||||
|
align?: 'inherit' | 'left' | 'center' | 'right';
|
||||||
|
width?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDataTableProps<T> {
|
||||||
|
columns: AdminTableColumn<T>[];
|
||||||
|
rows: T[];
|
||||||
|
getRowKey: (row: T) => string | number;
|
||||||
|
onRowClick?: (row: T) => void;
|
||||||
|
dense?: boolean;
|
||||||
|
/** Accessible table name (already translated). */
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared dense worklist table for the backoffice. Columns are declared with a typed `render`; the
|
||||||
|
* 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.
|
||||||
|
* @component AdminDataTable
|
||||||
|
*/
|
||||||
|
function AdminDataTable<T>({ columns, rows, getRowKey, onRowClick, dense = true, ariaLabel }: 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 } }}
|
||||||
|
>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<TableCell key={col.key} data-col={col.key} align={col.align ?? 'inherit'}>
|
||||||
|
{col.render(row)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminDataTable;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
import AdminEmptyState from './AdminEmptyState';
|
||||||
|
|
||||||
|
describe('<AdminEmptyState/>', () => {
|
||||||
|
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||||
|
|
||||||
|
it('renders the title and body', () => {
|
||||||
|
wrap(<AdminEmptyState title="Queue clear" body="Nothing to review" />);
|
||||||
|
expect(screen.getByText('Queue clear')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Nothing to review')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders without a body', () => {
|
||||||
|
wrap(<AdminEmptyState title="Empty" />);
|
||||||
|
expect(screen.getByText('Empty')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { Paper, Typography } from '@mui/material';
|
||||||
|
import AppIcon from '../common/AppIcon';
|
||||||
|
|
||||||
|
export interface AdminEmptyStateProps {
|
||||||
|
/** AppIcon registry name. */
|
||||||
|
icon?: string;
|
||||||
|
/** Already-translated title. */
|
||||||
|
title: string;
|
||||||
|
/** Optional already-translated body. */
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared empty-state panel for admin worklists ("Queue clear", "No open alerts", …). Dashed border,
|
||||||
|
* muted icon; tokens only.
|
||||||
|
* @component AdminEmptyState
|
||||||
|
*/
|
||||||
|
const AdminEmptyState: FunctionComponent<AdminEmptyStateProps> = ({ icon = 'info', title, body }) => (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{ p: 5, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
<AppIcon icon={icon} size={40} color="var(--bal-text-secondary)" />
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{body ? (
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||||
|
{body}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AdminEmptyState;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
import AdminErrorState from './AdminErrorState';
|
||||||
|
|
||||||
|
describe('<AdminErrorState/>', () => {
|
||||||
|
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||||
|
|
||||||
|
it('renders the message and calls onRetry on click', () => {
|
||||||
|
const onRetry = jest.fn();
|
||||||
|
wrap(<AdminErrorState message="Failed" retryLabel="Retry" onRetry={onRetry} />);
|
||||||
|
expect(screen.getByText('Failed')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Retry/ }));
|
||||||
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { Paper, Typography } from '@mui/material';
|
||||||
|
import AppButton from '../common/AppButton';
|
||||||
|
|
||||||
|
export interface AdminErrorStateProps {
|
||||||
|
/** Already-translated message. */
|
||||||
|
message: string;
|
||||||
|
/** Already-translated retry label. */
|
||||||
|
retryLabel: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared error panel for admin worklists — a muted message + a retry button. `clientFetch` already
|
||||||
|
* toasts 401/403/5xx, so this is the inline recovery affordance, not the notification.
|
||||||
|
* @component AdminErrorState
|
||||||
|
*/
|
||||||
|
const AdminErrorState: FunctionComponent<AdminErrorStateProps> = ({ message, retryLabel, onRetry }) => (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{ p: 5, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
|
||||||
|
{message}
|
||||||
|
</Typography>
|
||||||
|
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={onRetry} sx={{ m: 0 }}>
|
||||||
|
{retryLabel}
|
||||||
|
</AppButton>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AdminErrorState;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' }));
|
||||||
|
|
||||||
|
import AdminMessageBubble from './AdminMessageBubble';
|
||||||
|
import type { AdminTicketMessage } from '@/services/tickets/types';
|
||||||
|
|
||||||
|
const baseMsg: AdminTicketMessage = {
|
||||||
|
id: 1,
|
||||||
|
ticketId: 12,
|
||||||
|
body: 'hello there',
|
||||||
|
authorRole: 'admin',
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
isMine: true,
|
||||||
|
isInternal: false,
|
||||||
|
sendStatus: 'sent',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('<AdminMessageBubble/>', () => {
|
||||||
|
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||||
|
|
||||||
|
it('renders the body and author label', () => {
|
||||||
|
const { container } = wrap(<AdminMessageBubble message={baseMsg} authorLabel="Support" />);
|
||||||
|
expect(screen.getByText('hello there')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Support')).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('[data-internal="false"]')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks an internal note distinctly with the internal badge', () => {
|
||||||
|
const { container } = wrap(
|
||||||
|
<AdminMessageBubble message={{ ...baseMsg, isInternal: true }} authorLabel="Support" />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('[data-internal="true"]')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('ticket_internal_badge')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { Box, Chip, Stack, Typography } from '@mui/material';
|
||||||
|
import { formatShamsiDateTime } from '@/utils';
|
||||||
|
import type { AdminTicketMessage } from '@/services/tickets/types';
|
||||||
|
|
||||||
|
export interface AdminMessageBubbleProps {
|
||||||
|
message: AdminTicketMessage;
|
||||||
|
/** Already-translated author-role label. */
|
||||||
|
authorLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One message in the **admin** ticket thread. Unlike the user-side `MessageBubble`, this renders
|
||||||
|
* `isInternal` notes **distinctly** (dashed warning-tinted panel + an "Internal note" badge) so staff can
|
||||||
|
* never confuse an internal note with a participant-visible reply. Internal notes exist only on the admin
|
||||||
|
* surface — the user-app types never carry `isInternal` (phase §5). Aligns start/end by `isMine`
|
||||||
|
* (RTL-safe via `alignSelf`); a pending/failed optimistic send is dimmed/marked.
|
||||||
|
* @component AdminMessageBubble
|
||||||
|
*/
|
||||||
|
const AdminMessageBubble: FunctionComponent<AdminMessageBubbleProps> = ({ message, authorLabel }) => {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const internal = message.isInternal;
|
||||||
|
const mine = message.isMine;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
data-internal={internal}
|
||||||
|
sx={{
|
||||||
|
alignSelf: mine ? 'flex-end' : 'flex-start',
|
||||||
|
maxWidth: { xs: '90%', sm: '75%' },
|
||||||
|
opacity: message.sendStatus === 'sending' ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: internal ? '1px dashed' : '1px solid',
|
||||||
|
borderColor: internal ? 'var(--bal-warning)' : 'divider',
|
||||||
|
bgcolor: internal ? 'var(--bal-secondary-soft)' : mine ? 'var(--bal-primary-soft)' : 'background.paper',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mb: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 700, color: 'text.secondary' }}>
|
||||||
|
{authorLabel}
|
||||||
|
</Typography>
|
||||||
|
{internal ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={t('ticket_internal_badge')}
|
||||||
|
sx={{ height: 20, bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 700 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||||
|
{message.body}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mt: 0.5 }}>
|
||||||
|
{formatShamsiDateTime(message.createdAt, locale)}
|
||||||
|
{message.sendStatus === 'failed' ? ` · ${t('error_generic')}` : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminMessageBubble;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
import AdminPageHeader from './AdminPageHeader';
|
||||||
|
|
||||||
|
describe('<AdminPageHeader/>', () => {
|
||||||
|
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||||
|
|
||||||
|
it('renders the title and subtitle', () => {
|
||||||
|
wrap(<AdminPageHeader title="Backoffice" subtitle="Run the marketplace" />);
|
||||||
|
expect(screen.getByRole('heading', { name: 'Backoffice' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Run the marketplace')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the actions slot when provided', () => {
|
||||||
|
wrap(<AdminPageHeader title="T" actions={<button>Do</button>} />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Do' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the subtitle when not given', () => {
|
||||||
|
wrap(<AdminPageHeader title="T" />);
|
||||||
|
expect(screen.getByRole('heading', { name: 'T' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { FunctionComponent, ReactNode } from 'react';
|
||||||
|
import { Box, Stack, Typography } from '@mui/material';
|
||||||
|
|
||||||
|
export interface AdminPageHeaderProps {
|
||||||
|
/** Already-translated title (i18n is the caller's job — labels are keys). */
|
||||||
|
title: string;
|
||||||
|
/** Optional already-translated subtitle. */
|
||||||
|
subtitle?: string;
|
||||||
|
/** Optional action node (a button / filter) rendered end-aligned on desktop, wrapping on mobile. */
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The standard backoffice page header — a title + optional subtitle and an end-aligned actions slot. Shared
|
||||||
|
* by every admin console so the worklists read as one system (phase §3). Presentational; RTL-safe (logical
|
||||||
|
* flex, no directional hard-coding).
|
||||||
|
* @component AdminPageHeader
|
||||||
|
*/
|
||||||
|
const AdminPageHeader: FunctionComponent<AdminPageHeaderProps> = ({ title, subtitle, actions }) => (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{subtitle ? (
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
{actions ? <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>{actions}</Box> : null}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AdminPageHeader;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
jest.mock('next-intl', () => ({ useLocale: () => 'en' }));
|
||||||
|
|
||||||
|
import AdminPager from './AdminPager';
|
||||||
|
|
||||||
|
describe('<AdminPager/>', () => {
|
||||||
|
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||||
|
const base = { prevLabel: 'Prev', nextLabel: 'Next', indicator: 'Page 2' };
|
||||||
|
|
||||||
|
it('renders nothing when there is a single page', () => {
|
||||||
|
const { container } = wrap(
|
||||||
|
<AdminPager page={1} pageCount={1} onPrev={jest.fn()} onNext={jest.fn()} {...base} />,
|
||||||
|
);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onPrev/onNext and disables at the ends', () => {
|
||||||
|
const onPrev = jest.fn();
|
||||||
|
const onNext = jest.fn();
|
||||||
|
wrap(<AdminPager page={2} pageCount={3} onPrev={onPrev} onNext={onNext} {...base} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Prev' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||||
|
expect(onPrev).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onNext).toHaveBeenCalledTimes(1);
|
||||||
|
expect(screen.getByText('Page 2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables prev on the first page', () => {
|
||||||
|
wrap(<AdminPager page={1} pageCount={3} onPrev={jest.fn()} onNext={jest.fn()} {...base} />);
|
||||||
|
expect(screen.getByRole('button', { name: 'Prev' })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { useLocale } from 'next-intl';
|
||||||
|
import { Stack, Typography } from '@mui/material';
|
||||||
|
import AppButton from '../common/AppButton';
|
||||||
|
|
||||||
|
export interface AdminPagerProps {
|
||||||
|
page: number;
|
||||||
|
pageCount: number;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
/** Already-translated labels — `indicator` is a template string that received {page}/{total}. */
|
||||||
|
prevLabel: string;
|
||||||
|
nextLabel: string;
|
||||||
|
indicator: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prev/next pager for admin worklists — rendered only when there is more than one page. Locale-aware digits
|
||||||
|
* are the caller's job for the indicator; the labels are passed already-translated.
|
||||||
|
* @component AdminPager
|
||||||
|
*/
|
||||||
|
const AdminPager: FunctionComponent<AdminPagerProps> = ({
|
||||||
|
page,
|
||||||
|
pageCount,
|
||||||
|
onPrev,
|
||||||
|
onNext,
|
||||||
|
prevLabel,
|
||||||
|
nextLabel,
|
||||||
|
indicator,
|
||||||
|
}) => {
|
||||||
|
useLocale();
|
||||||
|
if (pageCount <= 1) return null;
|
||||||
|
return (
|
||||||
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center', mt: 1 }}>
|
||||||
|
<AppButton variant="text" color="primary" onClick={onPrev} disabled={page <= 1} sx={{ m: 0 }}>
|
||||||
|
{prevLabel}
|
||||||
|
</AppButton>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{indicator}
|
||||||
|
</Typography>
|
||||||
|
<AppButton variant="text" color="primary" onClick={onNext} disabled={page >= pageCount} sx={{ m: 0 }}>
|
||||||
|
{nextLabel}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminPager;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' }));
|
||||||
|
|
||||||
|
import AuditLogRow from './AuditLogRow';
|
||||||
|
import type { AuditLogEntry } from '@/services/admin/types';
|
||||||
|
|
||||||
|
const ENTRY: AuditLogEntry = {
|
||||||
|
id: 1,
|
||||||
|
entityType: 'PlatformConfig',
|
||||||
|
entityId: 'vat_rate',
|
||||||
|
action: 'updated',
|
||||||
|
actorUserId: 3,
|
||||||
|
occurredAt: '2026-01-01T00:00:00Z',
|
||||||
|
changedFields: { Value: { old: '0.09', new: '0.10' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('<AuditLogRow/>', () => {
|
||||||
|
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||||
|
|
||||||
|
it('renders the entity and action', () => {
|
||||||
|
wrap(<AuditLogRow entry={ENTRY} />);
|
||||||
|
expect(screen.getByText('PlatformConfig #vat_rate')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('updated')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals the changed-fields diff on expand', () => {
|
||||||
|
wrap(<AuditLogRow entry={ENTRY} />);
|
||||||
|
// Collapsed diff is not visible; expand by clicking the row header.
|
||||||
|
fireEvent.click(screen.getByText('PlatformConfig #vat_rate'));
|
||||||
|
expect(screen.getByText('Value')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('0.09')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('0.10')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
import { FunctionComponent, 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 AppIcon from '../common/AppIcon';
|
||||||
|
|
||||||
|
export interface AuditLogRowProps {
|
||||||
|
entry: AuditLogEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stringify a diff value (JSON for objects, `—` for null). */
|
||||||
|
function displayValue(v: unknown): string {
|
||||||
|
if (v == null) return '—';
|
||||||
|
if (typeof v === 'object') return JSON.stringify(v);
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* @component AuditLogRow
|
||||||
|
*/
|
||||||
|
const AuditLogRow: FunctionComponent<AuditLogRowProps> = ({ entry }) => {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const locale = useLocale();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const fields = entry.changedFields ? Object.entries(entry.changedFields) : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
data-audit-id={entry.id}
|
||||||
|
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}
|
||||||
|
>
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
sx={{ gap: 2, alignItems: 'center', p: 1.5, cursor: fields.length ? 'pointer' : 'default', flexWrap: 'wrap' }}
|
||||||
|
onClick={fields.length ? () => setOpen((v) => !v) : 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}` : '—'}
|
||||||
|
</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}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Collapse in={open && fields.length > 0}>
|
||||||
|
<Box sx={{ px: 1.5, pb: 1.5 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
|
||||||
|
{t('audit_diff_title')}
|
||||||
|
</Typography>
|
||||||
|
<Table size="small" sx={{ mt: 0.5 }}>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ color: 'text.secondary' }}>{t('audit_diff_field')}</TableCell>
|
||||||
|
<TableCell sx={{ color: 'text.secondary' }}>{t('audit_diff_old')}</TableCell>
|
||||||
|
<TableCell sx={{ color: 'text.secondary' }}>{t('audit_diff_new')}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{fields.map(([field, delta]) => (
|
||||||
|
<TableRow key={field}>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>{field}</TableCell>
|
||||||
|
<TableCell sx={{ color: 'text.secondary' }}>{displayValue(delta.old)}</TableCell>
|
||||||
|
<TableCell>{displayValue(delta.new)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AuditLogRow;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' }));
|
||||||
|
|
||||||
|
import ConfigRow from './ConfigRow';
|
||||||
|
import type { PlatformConfig } from '@/services/admin/types';
|
||||||
|
|
||||||
|
const CONFIG: PlatformConfig = {
|
||||||
|
key: 'vat_rate',
|
||||||
|
value: '0.10',
|
||||||
|
dataType: 'decimal',
|
||||||
|
description: 'VAT on commission',
|
||||||
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
updatedBy: 'admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('<ConfigRow/>', () => {
|
||||||
|
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||||
|
|
||||||
|
it('renders the key, value and description', () => {
|
||||||
|
wrap(<ConfigRow config={CONFIG} />);
|
||||||
|
expect(screen.getByText('vat_rate')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('0.10')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('VAT on commission')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the edit control only when canEdit and fires onEdit', () => {
|
||||||
|
const onEdit = jest.fn();
|
||||||
|
const { rerender } = wrap(<ConfigRow config={CONFIG} onEdit={onEdit} />);
|
||||||
|
expect(screen.queryByText('cfg_edit')).not.toBeInTheDocument();
|
||||||
|
rerender(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ConfigRow config={CONFIG} canEdit onEdit={onEdit} />
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('cfg_edit'));
|
||||||
|
expect(onEdit).toHaveBeenCalledWith(CONFIG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onHistory', () => {
|
||||||
|
const onHistory = jest.fn();
|
||||||
|
wrap(<ConfigRow config={CONFIG} onHistory={onHistory} />);
|
||||||
|
fireEvent.click(screen.getByText('cfg_history'));
|
||||||
|
expect(onHistory).toHaveBeenCalledWith(CONFIG);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
'use client';
|
||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { Box, Chip, Paper, Stack, Typography } from '@mui/material';
|
||||||
|
import { formatShamsiDateTime } from '@/utils';
|
||||||
|
import type { PlatformConfig } from '@/services/admin/types';
|
||||||
|
import AppButton from '../common/AppButton';
|
||||||
|
|
||||||
|
export interface ConfigRowProps {
|
||||||
|
config: PlatformConfig;
|
||||||
|
/** Whether the current admin may edit config (finance/admin). Server enforces; this hides the control. */
|
||||||
|
canEdit?: boolean;
|
||||||
|
onEdit?: (config: PlatformConfig) => void;
|
||||||
|
onHistory?: (config: PlatformConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One platform-config row: the key + description, the current value rendered by `dataType` (bool → chip,
|
||||||
|
* json → monospace, else text), the last-updated meta, and Edit / History affordances. Presentational —
|
||||||
|
* the typed-input edit dialog + the change-history drawer live in the config screen; this row only emits
|
||||||
|
* the intents. The client never re-parses config beyond rendering by `dataType` (phase §5).
|
||||||
|
* @component ConfigRow
|
||||||
|
*/
|
||||||
|
const ConfigRow: FunctionComponent<ConfigRowProps> = ({ config, canEdit = false, onEdit, onHistory }) => {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
data-config-key={config.key}
|
||||||
|
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
|
||||||
|
>
|
||||||
|
<Stack direction="row" sx={{ gap: 2, alignItems: 'flex-start', justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 800, fontFamily: 'monospace' }}>
|
||||||
|
{config.key}
|
||||||
|
</Typography>
|
||||||
|
<Chip size="small" variant="outlined" label={t(`dtype_${config.dataType}`)} />
|
||||||
|
</Stack>
|
||||||
|
{config.description ? (
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||||
|
{config.description}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
<AppButton variant="text" color="primary" startIcon="history" onClick={() => onHistory?.(config)} sx={{ m: 0 }}>
|
||||||
|
{t('cfg_history')}
|
||||||
|
</AppButton>
|
||||||
|
{canEdit ? (
|
||||||
|
<AppButton variant="outlined" color="primary" startIcon="edit" onClick={() => onEdit?.(config)} sx={{ m: 0 }}>
|
||||||
|
{t('cfg_edit')}
|
||||||
|
</AppButton>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mt: 1.5, flexWrap: 'wrap' }}>
|
||||||
|
{config.dataType === 'bool' ? (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={config.value}
|
||||||
|
sx={{ bgcolor: config.value === 'true' ? 'var(--bal-success)' : 'var(--bal-divider)', color: config.value === 'true' ? 'var(--bal-success-contrast)' : 'var(--bal-text-secondary)', fontWeight: 700 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
data-config-value
|
||||||
|
sx={{ fontFamily: 'monospace', fontWeight: 700, wordBreak: 'break-all', bgcolor: 'action.hover', px: 1, py: 0.5, borderRadius: 1 }}
|
||||||
|
>
|
||||||
|
{config.value}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{config.updatedAt ? (
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{formatShamsiDateTime(config.updatedAt, locale)}
|
||||||
|
{config.updatedBy ? ` · ${t('cfg_updated_by', { actor: config.updatedBy })}` : ''}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfigRow;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
import ConfirmDialog from './ConfirmDialog';
|
||||||
|
|
||||||
|
describe('<ConfirmDialog/>', () => {
|
||||||
|
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||||
|
const base = {
|
||||||
|
title: 'Run batch?',
|
||||||
|
confirmLabel: 'Run',
|
||||||
|
cancelLabel: 'Cancel',
|
||||||
|
onClose: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders the title and body when open', () => {
|
||||||
|
wrap(<ConfirmDialog open body="This moves money" onConfirm={jest.fn()} {...base} />);
|
||||||
|
expect(screen.getByText('Run batch?')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('This moves money')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onConfirm with no reason for a plain confirm', () => {
|
||||||
|
const onConfirm = jest.fn();
|
||||||
|
wrap(<ConfirmDialog open onConfirm={onConfirm} {...base} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
|
||||||
|
expect(onConfirm).toHaveBeenCalledWith(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables confirm until a required reason is entered, then passes it', () => {
|
||||||
|
const onConfirm = jest.fn();
|
||||||
|
wrap(<ConfirmDialog open requireReason reasonLabel="Reason" onConfirm={onConfirm} {...base} />);
|
||||||
|
const confirmBtn = screen.getByRole('button', { name: 'Run' });
|
||||||
|
expect(confirmBtn).toBeDisabled();
|
||||||
|
fireEvent.change(screen.getByLabelText('Reason'), { target: { value: 'bad docs' } });
|
||||||
|
expect(confirmBtn).not.toBeDisabled();
|
||||||
|
fireEvent.click(confirmBtn);
|
||||||
|
expect(onConfirm).toHaveBeenCalledWith('bad docs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose from cancel', () => {
|
||||||
|
const onClose = jest.fn();
|
||||||
|
wrap(<ConfirmDialog open onConfirm={jest.fn()} {...base} onClose={onClose} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
'use client';
|
||||||
|
import { FunctionComponent, ReactNode, useState } from 'react';
|
||||||
|
import {
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
TextField,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AppButton from '../common/AppButton';
|
||||||
|
|
||||||
|
export interface ConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
/** Already-translated title. */
|
||||||
|
title: string;
|
||||||
|
/** Already-translated body (string or node). */
|
||||||
|
body?: ReactNode;
|
||||||
|
confirmLabel: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
/** Called with the entered reason (undefined when `requireReason` is false). */
|
||||||
|
onConfirm: (reason?: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
/** When true a required reason field shows; confirm is disabled until it is non-empty. */
|
||||||
|
requireReason?: boolean;
|
||||||
|
reasonLabel?: string;
|
||||||
|
reasonPlaceholder?: string;
|
||||||
|
/** MUI color for the confirm button — `error` for a destructive action. */
|
||||||
|
confirmColor?: 'primary' | 'error' | 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared confirmation dialog behind every audited/irreversible admin action — approve/reject a
|
||||||
|
* verification, run/retry a payout, save a config, resolve an alert, verify a center. Optionally collects a
|
||||||
|
* **required reason** (reject/hide/resolve) and disables confirm until it is provided. The dialog owns the
|
||||||
|
* reason field only; the caller owns the mutation and closes on success. `loading` disables the buttons and
|
||||||
|
* shows a spinner so a double-submit is impossible.
|
||||||
|
* @component ConfirmDialog
|
||||||
|
*/
|
||||||
|
const ConfirmDialog: FunctionComponent<ConfirmDialogProps> = ({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
confirmLabel,
|
||||||
|
cancelLabel,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
loading = false,
|
||||||
|
requireReason = false,
|
||||||
|
reasonLabel,
|
||||||
|
reasonPlaceholder,
|
||||||
|
confirmColor = 'primary',
|
||||||
|
}) => {
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setReason('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirm = () => {
|
||||||
|
onConfirm(requireReason ? reason.trim() : undefined);
|
||||||
|
setReason('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDisabled = loading || (requireReason && reason.trim().length === 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={loading ? undefined : close} fullWidth maxWidth="xs">
|
||||||
|
<DialogTitle sx={{ fontWeight: 800 }}>{title}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{body ? (
|
||||||
|
typeof body === 'string' ? (
|
||||||
|
<DialogContentText sx={{ color: 'text.secondary' }}>{body}</DialogContentText>
|
||||||
|
) : (
|
||||||
|
body
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
{requireReason ? (
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
label={reasonLabel}
|
||||||
|
placeholder={reasonPlaceholder}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||||
|
<AppButton variant="text" color="inherit" onClick={close} disabled={loading} sx={{ m: 0 }}>
|
||||||
|
{cancelLabel}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton
|
||||||
|
variant="contained"
|
||||||
|
color={confirmColor}
|
||||||
|
onClick={confirm}
|
||||||
|
disabled={confirmDisabled}
|
||||||
|
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : undefined}
|
||||||
|
sx={{ m: 0 }}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</AppButton>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmDialog;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' }));
|
||||||
|
|
||||||
|
const mockUseDocUrl = jest.fn();
|
||||||
|
jest.mock('@/services/verification', () => ({ useVerificationDocumentUrl: (...a: unknown[]) => mockUseDocUrl(...a) }));
|
||||||
|
|
||||||
|
import DocumentViewer from './DocumentViewer';
|
||||||
|
import type { VerificationDocument } from '@/services/verification/types';
|
||||||
|
|
||||||
|
const DOC: VerificationDocument = {
|
||||||
|
id: 5,
|
||||||
|
contentType: 'image/png',
|
||||||
|
fileSizeBytes: 2048,
|
||||||
|
originalFileName: 'license.png',
|
||||||
|
url: 'ignored-embedded-url',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('<DocumentViewer/>', () => {
|
||||||
|
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||||
|
afterEach(() => mockUseDocUrl.mockReset());
|
||||||
|
|
||||||
|
it('renders the loaded image from the on-demand signed url (never the embedded url)', () => {
|
||||||
|
mockUseDocUrl.mockReturnValue({ data: { url: 'https://signed/fresh.png', expiresInSeconds: 60 }, isLoading: false, isFetching: false, isError: false, refetch: jest.fn() });
|
||||||
|
const { container } = wrap(<DocumentViewer document={DOC} />);
|
||||||
|
const img = container.querySelector('img') as HTMLImageElement;
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.src).toContain('signed/fresh.png');
|
||||||
|
expect(img.src).not.toContain('ignored-embedded-url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers a re-request affordance on error and calls refetch', () => {
|
||||||
|
const refetch = jest.fn();
|
||||||
|
mockUseDocUrl.mockReturnValue({ data: undefined, isLoading: false, isFetching: false, isError: true, refetch });
|
||||||
|
wrap(<DocumentViewer document={DOC} />);
|
||||||
|
expect(screen.getByText('doc_error')).toBeInTheDocument();
|
||||||
|
// Two re-request buttons (header + error panel); click the first.
|
||||||
|
fireEvent.click(screen.getAllByText('doc_reload')[0]);
|
||||||
|
expect(refetch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a skeleton while the signed url is loading', () => {
|
||||||
|
mockUseDocUrl.mockReturnValue({ data: undefined, isLoading: true, isFetching: true, isError: false, refetch: jest.fn() });
|
||||||
|
const { container } = wrap(<DocumentViewer document={DOC} />);
|
||||||
|
expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
'use client';
|
||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
||||||
|
import type { VerificationDocument } from '@/services/verification/types';
|
||||||
|
import { useVerificationDocumentUrl } from '@/services/verification';
|
||||||
|
import AppButton from '../common/AppButton';
|
||||||
|
import AppIcon from '../common/AppIcon';
|
||||||
|
|
||||||
|
export interface DocumentViewerProps {
|
||||||
|
document: VerificationDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable file size. */
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A verification-document viewer that fetches its **signed URL on demand** (never the embedded one — those
|
||||||
|
* are short-lived) via `useVerificationDocumentUrl`. Handles the full lifecycle: **loading** the link,
|
||||||
|
* **error / expired → re-request** (the URL is short-lived, so a manual re-request re-signs it), and the
|
||||||
|
* loaded state (inline image preview for images, otherwise an "open in a new tab" affordance). PII → only
|
||||||
|
* the signed URL is ever surfaced, never a public asset (phase §5).
|
||||||
|
* @component DocumentViewer
|
||||||
|
*/
|
||||||
|
const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document }) => {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const signed = useVerificationDocumentUrl(document.id);
|
||||||
|
const isImage = document.contentType.startsWith('image/');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
data-document-id={document.id}
|
||||||
|
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.5 }}
|
||||||
|
>
|
||||||
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mb: 1 }}>
|
||||||
|
<AppIcon icon="document" size={18} color="var(--bal-text-secondary)" />
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, flexGrow: 1, wordBreak: 'break-all' }}>
|
||||||
|
{t('doc_file_meta', { name: document.originalFileName ?? `#${document.id}`, size: formatSize(document.fileSizeBytes) })}
|
||||||
|
</Typography>
|
||||||
|
<AppButton
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
startIcon="refresh"
|
||||||
|
onClick={() => signed.refetch()}
|
||||||
|
disabled={signed.isFetching}
|
||||||
|
sx={{ m: 0, minWidth: 0 }}
|
||||||
|
>
|
||||||
|
{t('doc_reload')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{signed.isLoading || signed.isFetching ? (
|
||||||
|
<Skeleton variant="rounded" height={isImage ? 180 : 44} />
|
||||||
|
) : signed.isError ? (
|
||||||
|
<Stack sx={{ gap: 1, alignItems: 'flex-start' }}>
|
||||||
|
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
|
||||||
|
{t('doc_error')}
|
||||||
|
</Typography>
|
||||||
|
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => signed.refetch()} sx={{ m: 0 }}>
|
||||||
|
{t('doc_reload')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
) : signed.data?.url ? (
|
||||||
|
isImage ? (
|
||||||
|
// Signed, short-lived, cross-host URL (not a static asset) — a plain <img> via Box, not next/image.
|
||||||
|
<Box
|
||||||
|
component="img"
|
||||||
|
src={signed.data.url}
|
||||||
|
alt={document.originalFileName ?? String(document.id)}
|
||||||
|
sx={{ maxWidth: '100%', maxHeight: 320, borderRadius: 1, display: 'block' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AppButton variant="outlined" color="primary" endIcon="external" href={signed.data.url} openInNewTab sx={{ m: 0 }}>
|
||||||
|
{t('doc_open_new')}
|
||||||
|
</AppButton>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentViewer;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' }));
|
||||||
|
|
||||||
|
import PartnerSettlementRow from './PartnerSettlementRow';
|
||||||
|
import type { CenterInvoice } from '@/services/partnerCenter/types';
|
||||||
|
|
||||||
|
// Reconciles: commission 750000 + bnpl 60000 + vat 75000 = 885000 total (VAT on commission only).
|
||||||
|
const INVOICE: CenterInvoice = {
|
||||||
|
id: 7,
|
||||||
|
bookingId: 5003,
|
||||||
|
invoiceNumber: 'INV-1405-1007',
|
||||||
|
grossIrr: '5000000',
|
||||||
|
platformCommissionIrr: '750000',
|
||||||
|
bnplCommissionIrr: '60000',
|
||||||
|
vatRate: 0.1,
|
||||||
|
vatIrr: '75000',
|
||||||
|
totalIrr: '885000',
|
||||||
|
moadianReferenceNumber: '1234567890123456789012',
|
||||||
|
moadianStatus: 'registered',
|
||||||
|
pdfUrl: 'https://mock.local/7.pdf',
|
||||||
|
issuedAt: '2026-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('<PartnerSettlementRow/>', () => {
|
||||||
|
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||||
|
|
||||||
|
it('renders a reconciling commission breakdown without a console error', () => {
|
||||||
|
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
wrap(<PartnerSettlementRow invoice={INVOICE} />);
|
||||||
|
expect(screen.getByText('invoice_number')).toBeInTheDocument();
|
||||||
|
// PriceBreakdown dev-guard would console.error if commission+bnpl+vat !== total.
|
||||||
|
expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('must reconcile'));
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the مودیان reference', () => {
|
||||||
|
wrap(<PartnerSettlementRow invoice={INVOICE} />);
|
||||||
|
expect(screen.getByText(/1234567890123456789012/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onDownloadPdf', () => {
|
||||||
|
const onDownloadPdf = jest.fn();
|
||||||
|
wrap(<PartnerSettlementRow invoice={INVOICE} onDownloadPdf={onDownloadPdf} />);
|
||||||
|
fireEvent.click(screen.getByText('invoice_download'));
|
||||||
|
expect(onDownloadPdf).toHaveBeenCalledWith(INVOICE);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { Box, Chip, Paper, Stack, Typography } from '@mui/material';
|
||||||
|
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||||
|
import type { CenterInvoice } from '@/services/partnerCenter/types';
|
||||||
|
import AppButton from '../common/AppButton';
|
||||||
|
import PriceBreakdown, { type PriceBreakdownRow } from '../PriceBreakdown';
|
||||||
|
|
||||||
|
export interface PartnerSettlementRowProps {
|
||||||
|
invoice: CenterInvoice;
|
||||||
|
onDownloadPdf?: (invoice: CenterInvoice) => void;
|
||||||
|
downloadError?: boolean;
|
||||||
|
onRetryDownload?: (invoice: CenterInvoice) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One per-booking commission-invoice row in a merchant-of-record center's settlement view. The reconciling
|
||||||
|
* breakdown is **platform commission + BNPL commission + VAT = total** (VAT on the commission line only,
|
||||||
|
* never the gross service fee — `grossIrr` is shown as context, not summed). Money is served IRR
|
||||||
|
* digit-strings formatted to Toman via the shared util. The سامانه مودیان reference + a signed-URL PDF
|
||||||
|
* download round it out. Enum/label copy comes from the `partner` namespace.
|
||||||
|
* @component PartnerSettlementRow
|
||||||
|
*/
|
||||||
|
const PartnerSettlementRow: FunctionComponent<PartnerSettlementRowProps> = ({ invoice, onDownloadPdf, downloadError, onRetryDownload }) => {
|
||||||
|
const t = useTranslations('partner');
|
||||||
|
const tc = useTranslations('common');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const rows: PriceBreakdownRow[] = [
|
||||||
|
{ key: 'commission', label: t('invoice_row_commission'), amountIrr: invoice.platformCommissionIrr },
|
||||||
|
];
|
||||||
|
if (invoice.bnplCommissionIrr) {
|
||||||
|
rows.push({ key: 'bnpl_commission', label: t('invoice_row_bnpl_commission'), amountIrr: invoice.bnplCommissionIrr });
|
||||||
|
}
|
||||||
|
rows.push({ key: 'vat', label: t('invoice_row_vat'), amountIrr: invoice.vatIrr });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
data-invoice-id={invoice.id}
|
||||||
|
sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}
|
||||||
|
>
|
||||||
|
<Stack direction="row" sx={{ gap: 2, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', mb: 1.5 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||||
|
{t('invoice_number', { number: invoice.invoiceNumber })}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('settlement_col_booking')} #{invoice.bookingId} · {formatShamsiDate(invoice.issuedAt, locale)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack sx={{ alignItems: 'flex-end' }}>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{t('invoice_row_gross')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||||
|
{formatIrrToToman(invoice.grossIrr, locale)} {tc('currency_toman')}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<PriceBreakdown rows={rows} totalLabel={t('invoice_row_total')} totalAmountIrr={invoice.totalIrr} />
|
||||||
|
|
||||||
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', mt: 1.5, flexWrap: 'wrap' }}>
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
label={
|
||||||
|
invoice.moadianReferenceNumber
|
||||||
|
? `${t('settlement_moadian_ref')}: ${invoice.moadianReferenceNumber}`
|
||||||
|
: t('settlement_moadian_pending')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{invoice.pdfUrl ? (
|
||||||
|
downloadError ? (
|
||||||
|
<AppButton variant="outlined" color="error" startIcon="refresh" onClick={() => onRetryDownload?.(invoice)} sx={{ m: 0 }}>
|
||||||
|
{t('invoice_pdf_error')}
|
||||||
|
</AppButton>
|
||||||
|
) : (
|
||||||
|
<AppButton variant="outlined" color="primary" startIcon="download" onClick={() => onDownloadPdf?.(invoice)} sx={{ m: 0 }}>
|
||||||
|
{t('invoice_download')}
|
||||||
|
</AppButton>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PartnerSettlementRow;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
jest.mock('next-intl', () => ({ useTranslations: () => (k: string, v?: Record<string, unknown>) => (v ? `${k}` : k), useLocale: () => 'en' }));
|
||||||
|
jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() }) }));
|
||||||
|
|
||||||
|
const mockUsePreview = jest.fn();
|
||||||
|
const mutation = () => ({ mutate: jest.fn(), isPending: false });
|
||||||
|
jest.mock('@/services/refunds', () => ({
|
||||||
|
useRefundPreview: (...a: unknown[]) => mockUsePreview(...a),
|
||||||
|
useInitiateRefund: () => mutation(),
|
||||||
|
useApproveRefund: () => mutation(),
|
||||||
|
useRejectRefund: () => mutation(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import RefundPanel from './RefundPanel';
|
||||||
|
|
||||||
|
// Reconciles: fee 1500000 + payout 8500000 = 10000000 amount.
|
||||||
|
const PREVIEW = {
|
||||||
|
bookingId: 42,
|
||||||
|
refundPercentageApplied: 1,
|
||||||
|
amountIrr: '10000000',
|
||||||
|
platformFeeRefundedIrr: '1500000',
|
||||||
|
nursePayoutRefundedIrr: '8500000',
|
||||||
|
refundChannel: 'psp_card' as const,
|
||||||
|
expectedCustomerRefundEta: null,
|
||||||
|
willCreateClawback: false,
|
||||||
|
cancellationPolicyCode: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('<RefundPanel/>', () => {
|
||||||
|
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||||
|
afterEach(() => mockUsePreview.mockReset());
|
||||||
|
|
||||||
|
it('renders the server-computed reconciling decomposition', () => {
|
||||||
|
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
mockUsePreview.mockReturnValue({ data: PREVIEW, isLoading: false, isError: false });
|
||||||
|
wrap(<RefundPanel bookingId={42} ticketId={9} />);
|
||||||
|
expect(screen.getByText('refund_preview_title')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('refund_initiate')).toBeInTheDocument();
|
||||||
|
expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('must reconcile'));
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the clawback notice when the nurse was already paid', () => {
|
||||||
|
mockUsePreview.mockReturnValue({ data: { ...PREVIEW, willCreateClawback: true }, isLoading: false, isError: false });
|
||||||
|
wrap(<RefundPanel bookingId={42} ticketId={9} />);
|
||||||
|
expect(screen.getByText('refund_clawback_notice')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a loading skeleton while the preview loads', () => {
|
||||||
|
mockUsePreview.mockReturnValue({ data: undefined, isLoading: true, isError: false });
|
||||||
|
const { container } = wrap(<RefundPanel bookingId={42} ticketId={9} />);
|
||||||
|
expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
'use client';
|
||||||
|
import { FunctionComponent, useState } from 'react';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { useSnackbar } from 'notistack';
|
||||||
|
import { Alert, Box, Chip, MenuItem, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||||
|
import { formatShamsiDate } from '@/utils';
|
||||||
|
import type { AdminRefundResult, RefundChannel } from '@/services/refunds/types';
|
||||||
|
import { useApproveRefund, useInitiateRefund, useRefundPreview, useRejectRefund } from '@/services/refunds';
|
||||||
|
import AppButton from '../common/AppButton';
|
||||||
|
import PriceBreakdown, { type PriceBreakdownRow } from '../PriceBreakdown';
|
||||||
|
import ConfirmDialog from './ConfirmDialog';
|
||||||
|
|
||||||
|
export interface RefundPanelProps {
|
||||||
|
bookingId: number;
|
||||||
|
ticketId: number;
|
||||||
|
onDone?: (result: AdminRefundResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANNELS: readonly RefundChannel[] = ['psp_card', 'bnpl_revert', 'manual'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The admin refund tool — always opened **from a ticket** (never a standalone form; every initiate carries
|
||||||
|
* the `ticketId`, phase §5). Renders the **server-computed** tiered percentage + fee/payout decomposition
|
||||||
|
* (via the shared PriceBreakdown; the client never recomputes the split), a channel selector, the BNPL ETA
|
||||||
|
* banner, and the read-only clawback notice when the nurse was already paid. Drives
|
||||||
|
* initiate → (provider-revert failure) retry / reject. Money stays IRR digit-strings end to end.
|
||||||
|
* @component RefundPanel
|
||||||
|
*/
|
||||||
|
const RefundPanel: FunctionComponent<RefundPanelProps> = ({ bookingId, ticketId, onDone }) => {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const locale = useLocale();
|
||||||
|
const { enqueueSnackbar } = useSnackbar();
|
||||||
|
|
||||||
|
const preview = useRefundPreview(bookingId, ticketId);
|
||||||
|
const initiate = useInitiateRefund();
|
||||||
|
const approve = useApproveRefund();
|
||||||
|
const reject = useRejectRefund();
|
||||||
|
|
||||||
|
const [channel, setChannel] = useState<RefundChannel | ''>('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [result, setResult] = useState<AdminRefundResult | null>(null);
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
|
||||||
|
const p = preview.data;
|
||||||
|
const effectiveChannel = (channel || p?.refundChannel) as RefundChannel | undefined;
|
||||||
|
const busy = initiate.isPending || approve.isPending || reject.isPending;
|
||||||
|
|
||||||
|
const doInitiate = () => {
|
||||||
|
if (!p) return;
|
||||||
|
initiate.mutate(
|
||||||
|
{ bookingId, ticketId, refundChannel: effectiveChannel, reasonCategory: 'customer_request', reasonNotes: notes.trim() || undefined },
|
||||||
|
{
|
||||||
|
onSuccess: (r) => {
|
||||||
|
setResult(r);
|
||||||
|
setConfirmOpen(false);
|
||||||
|
if (r.status === 'succeeded' || r.status === 'processing') {
|
||||||
|
enqueueSnackbar(t('refund_done'), { variant: 'success' });
|
||||||
|
onDone?.(r);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => setConfirmOpen(false),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doRetry = () => {
|
||||||
|
if (!result) return;
|
||||||
|
approve.mutate(result.refundId, {
|
||||||
|
onSuccess: (r) => {
|
||||||
|
setResult(r);
|
||||||
|
if (r.status === 'succeeded' || r.status === 'processing') {
|
||||||
|
enqueueSnackbar(t('refund_done'), { variant: 'success' });
|
||||||
|
onDone?.(r);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const doReject = (reason?: string) => {
|
||||||
|
if (!result) return;
|
||||||
|
reject.mutate(
|
||||||
|
{ refundId: result.refundId, reason: reason ?? '' },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setRejectOpen(false);
|
||||||
|
setResult({ ...result, status: 'rejected' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (preview.isLoading) return <Skeleton variant="rounded" height={260} />;
|
||||||
|
if (preview.isError || !p) {
|
||||||
|
return (
|
||||||
|
<Alert severity="error" variant="outlined">
|
||||||
|
{t('error_generic')}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: PriceBreakdownRow[] = [
|
||||||
|
{ key: 'fee', label: t('refund_row_fee'), amountIrr: p.platformFeeRefundedIrr },
|
||||||
|
{ key: 'payout', label: t('refund_row_payout'), amountIrr: p.nursePayoutRefundedIrr },
|
||||||
|
];
|
||||||
|
const activeResult = result;
|
||||||
|
const failed = activeResult?.status === 'failed';
|
||||||
|
const finalDone = activeResult && (activeResult.status === 'succeeded' || activeResult.status === 'processing' || activeResult.status === 'rejected');
|
||||||
|
const pct = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', { style: 'percent', maximumFractionDigits: 0 }).format(p.refundPercentageApplied);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
|
||||||
|
{t('refund_preview_title')}
|
||||||
|
</Typography>
|
||||||
|
<Chip size="small" variant="outlined" label={t('refund_linked_booking', { id: bookingId })} />
|
||||||
|
<Chip size="small" label={`${t('refund_percentage')}: ${pct}`} sx={{ bgcolor: 'var(--bal-primary-soft)', fontWeight: 700 }} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<PriceBreakdown rows={rows} totalLabel={t('refund_row_total')} totalAmountIrr={p.amountIrr} />
|
||||||
|
|
||||||
|
{!activeResult ? (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
label={t('refund_channel')}
|
||||||
|
helperText={t('refund_channel_hint')}
|
||||||
|
value={channel || p.refundChannel}
|
||||||
|
onChange={(e) => setChannel(e.target.value as RefundChannel)}
|
||||||
|
sx={{ maxWidth: 260 }}
|
||||||
|
>
|
||||||
|
{CHANNELS.map((c) => (
|
||||||
|
<MenuItem key={c} value={c}>
|
||||||
|
{t(`channel_${c}`)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
{p.expectedCustomerRefundEta ? (
|
||||||
|
<Alert severity="info" variant="outlined" icon={false}>
|
||||||
|
{t('refund_eta', { date: formatShamsiDate(p.expectedCustomerRefundEta, locale) })}
|
||||||
|
{effectiveChannel === 'bnpl_revert' ? ` — ${t('refund_eta_bnpl')}` : ''}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{p.willCreateClawback ? (
|
||||||
|
<Alert severity="warning" variant="outlined">
|
||||||
|
{t('refund_clawback_notice')}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label={t('refund_notes_ph')}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AppButton variant="contained" color="primary" startIcon="refunds" onClick={() => setConfirmOpen(true)} disabled={busy} sx={{ m: 0, alignSelf: 'flex-start' }}>
|
||||||
|
{t('refund_initiate')}
|
||||||
|
</AppButton>
|
||||||
|
</>
|
||||||
|
) : failed ? (
|
||||||
|
<>
|
||||||
|
<Alert severity="error" variant="outlined">
|
||||||
|
{t('refund_provider_failed')}
|
||||||
|
</Alert>
|
||||||
|
<Stack direction="row" sx={{ gap: 1 }}>
|
||||||
|
<AppButton variant="contained" color="primary" startIcon="refresh" onClick={doRetry} disabled={busy} sx={{ m: 0 }}>
|
||||||
|
{t('refund_approve')}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton variant="outlined" color="error" onClick={() => setRejectOpen(true)} disabled={busy} sx={{ m: 0 }}>
|
||||||
|
{t('refund_reject')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
) : finalDone ? (
|
||||||
|
<Alert severity={activeResult?.status === 'rejected' ? 'warning' : 'success'} variant="outlined">
|
||||||
|
{t(`rstatus_${activeResult!.status}`)}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmOpen}
|
||||||
|
title={t('refund_initiate')}
|
||||||
|
body={t('refund_confirm', { id: bookingId })}
|
||||||
|
confirmLabel={t('refund_initiate')}
|
||||||
|
cancelLabel={t('cancel')}
|
||||||
|
onConfirm={doInitiate}
|
||||||
|
onClose={() => setConfirmOpen(false)}
|
||||||
|
loading={initiate.isPending}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={rejectOpen}
|
||||||
|
title={t('refund_reject')}
|
||||||
|
confirmLabel={t('refund_reject')}
|
||||||
|
cancelLabel={t('cancel')}
|
||||||
|
confirmColor="error"
|
||||||
|
onConfirm={doReject}
|
||||||
|
onClose={() => setRejectOpen(false)}
|
||||||
|
loading={reject.isPending}
|
||||||
|
requireReason
|
||||||
|
reasonLabel={t('reason_label')}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RefundPanel;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
jest.mock('next-intl', () => ({ useTranslations: () => (k: string) => k, useLocale: () => 'en' }));
|
||||||
|
|
||||||
|
import SupportAlertCard from './SupportAlertCard';
|
||||||
|
import type { SupportAlert } from '@/services/admin/types';
|
||||||
|
|
||||||
|
const OPEN_ALERT: SupportAlert = {
|
||||||
|
id: 42,
|
||||||
|
type: 'low_rating',
|
||||||
|
severity: 'high',
|
||||||
|
status: 'open',
|
||||||
|
entityType: 'Review',
|
||||||
|
entityId: '44',
|
||||||
|
bookingId: 5001,
|
||||||
|
reviewId: 44,
|
||||||
|
ownerUserId: null,
|
||||||
|
resolutionNote: null,
|
||||||
|
resolvedAt: null,
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('<SupportAlertCard/>', () => {
|
||||||
|
const wrap = (ui: React.ReactNode) => render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||||
|
|
||||||
|
it('renders the alert type and status labels', () => {
|
||||||
|
wrap(<SupportAlertCard alert={OPEN_ALERT} />);
|
||||||
|
expect(screen.getByText('atype_low_rating')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('astatus_open')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides actions unless canAct', () => {
|
||||||
|
wrap(<SupportAlertCard alert={OPEN_ALERT} />);
|
||||||
|
expect(screen.queryByText('alert_resolve')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires assign/resolve callbacks when actionable', () => {
|
||||||
|
const onAssignSelf = jest.fn();
|
||||||
|
const onResolve = jest.fn();
|
||||||
|
wrap(<SupportAlertCard alert={OPEN_ALERT} canAct onAssignSelf={onAssignSelf} onResolve={onResolve} />);
|
||||||
|
fireEvent.click(screen.getByText('alert_assign_me'));
|
||||||
|
fireEvent.click(screen.getByText('alert_resolve'));
|
||||||
|
expect(onAssignSelf).toHaveBeenCalledWith(OPEN_ALERT);
|
||||||
|
expect(onResolve).toHaveBeenCalledWith(OPEN_ALERT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer actions on a resolved alert', () => {
|
||||||
|
wrap(<SupportAlertCard alert={{ ...OPEN_ALERT, status: 'resolved', resolutionNote: 'done' }} canAct />);
|
||||||
|
expect(screen.queryByText('alert_resolve')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('done')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
import { FunctionComponent } from 'react';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { Chip, Paper, Stack, Typography } from '@mui/material';
|
||||||
|
import { formatShamsiDateTime } from '@/utils';
|
||||||
|
import type { SupportAlert, SupportAlertSeverity } from '@/services/admin/types';
|
||||||
|
import AppButton from '../common/AppButton';
|
||||||
|
import StatusChip, { type StatusKind } from '../StatusChip';
|
||||||
|
|
||||||
|
export interface SupportAlertCardProps {
|
||||||
|
alert: SupportAlert;
|
||||||
|
/** Whether the current admin may act (assign/resolve). Server enforces; this only hides controls. */
|
||||||
|
canAct?: boolean;
|
||||||
|
onAssignSelf?: (alert: SupportAlert) => void;
|
||||||
|
onResolve?: (alert: SupportAlert) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alert status → semantic chip kind. */
|
||||||
|
const STATUS_KIND: Record<SupportAlert['status'], StatusKind> = {
|
||||||
|
open: 'pending',
|
||||||
|
assigned: 'info',
|
||||||
|
resolved: 'verified',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Severity → the inline-start accent token. */
|
||||||
|
const SEVERITY_ACCENT: Record<SupportAlertSeverity, string> = {
|
||||||
|
high: 'var(--bal-error)',
|
||||||
|
medium: 'var(--bal-warning)',
|
||||||
|
low: 'var(--bal-divider)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single internal support-alert card for the triage board. Shows the alert type, a severity accent, the
|
||||||
|
* linked entity reference, the owner, the raised time (Shamsi), and — for a non-resolved alert — assign/
|
||||||
|
* resolve actions (only when `canAct`). **Internal-only**: this card is rendered exclusively inside admin
|
||||||
|
* routes and its data never reaches a non-admin surface (phase §5). Enum labels come from the `admin`
|
||||||
|
* namespace keyed off the stable code.
|
||||||
|
* @component SupportAlertCard
|
||||||
|
*/
|
||||||
|
const SupportAlertCard: FunctionComponent<SupportAlertCardProps> = ({ alert, canAct = false, onAssignSelf, onResolve }) => {
|
||||||
|
const t = useTranslations('admin');
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const entityLabel = alert.bookingId != null
|
||||||
|
? t('alert_link_booking', { id: alert.bookingId })
|
||||||
|
: alert.reviewId != null
|
||||||
|
? t('alert_link_review', { id: alert.reviewId })
|
||||||
|
: t('alert_link_entity', { type: alert.entityType, id: alert.entityId });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
data-alert-id={alert.id}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderInlineStart: '4px solid',
|
||||||
|
borderInlineStartColor: SEVERITY_ACCENT[alert.severity],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mb: 1 }}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||||
|
{t(`atype_${alert.type}`)}
|
||||||
|
</Typography>
|
||||||
|
<StatusChip status={STATUS_KIND[alert.status]} label={t(`astatus_${alert.status}`)} />
|
||||||
|
<Chip size="small" variant="outlined" label={t(`sev_${alert.severity}`)} />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack direction="row" sx={{ gap: 2, flexWrap: 'wrap', color: 'text.secondary' }}>
|
||||||
|
<Typography variant="body2">{entityLabel}</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{alert.ownerUserId != null ? `#${alert.ownerUserId}` : t('alert_unassigned')}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">{formatShamsiDateTime(alert.createdAt, locale)}</Typography>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{alert.status === 'resolved' && alert.resolutionNote ? (
|
||||||
|
<Typography variant="body2" sx={{ mt: 1, fontStyle: 'italic', color: 'text.secondary' }}>
|
||||||
|
{alert.resolutionNote}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{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)} sx={{ m: 0 }}>
|
||||||
|
{t('alert_assign_me')}
|
||||||
|
</AppButton>
|
||||||
|
) : null}
|
||||||
|
<AppButton variant="contained" color="primary" startIcon="verified" onClick={() => onResolve?.(alert)} sx={{ m: 0 }}>
|
||||||
|
{t('alert_resolve')}
|
||||||
|
</AppButton>
|
||||||
|
</Stack>
|
||||||
|
) : null}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SupportAlertCard;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Admin/backoffice + partner shared composites (import from `@/components/admin`). Generic worklist
|
||||||
|
* primitives (page header / empty / error / pager / confirm dialog / data table) + the domain rows
|
||||||
|
* (config / audit / support-alert / partner settlement). The extension-typed composites (document viewer,
|
||||||
|
* refund panel, payout batch rows, moderation card, admin message bubble) are added alongside as they land.
|
||||||
|
*/
|
||||||
|
export { default as AdminPageHeader } from './AdminPageHeader';
|
||||||
|
export type { AdminPageHeaderProps } from './AdminPageHeader';
|
||||||
|
export { default as AdminEmptyState } from './AdminEmptyState';
|
||||||
|
export type { AdminEmptyStateProps } from './AdminEmptyState';
|
||||||
|
export { default as AdminErrorState } from './AdminErrorState';
|
||||||
|
export type { AdminErrorStateProps } from './AdminErrorState';
|
||||||
|
export { default as AdminPager } from './AdminPager';
|
||||||
|
export type { AdminPagerProps } from './AdminPager';
|
||||||
|
export { default as ConfirmDialog } from './ConfirmDialog';
|
||||||
|
export type { ConfirmDialogProps } from './ConfirmDialog';
|
||||||
|
export { default as AdminDataTable } from './AdminDataTable';
|
||||||
|
export type { AdminDataTableProps, AdminTableColumn } from './AdminDataTable';
|
||||||
|
export { default as ConfigRow } from './ConfigRow';
|
||||||
|
export type { ConfigRowProps } from './ConfigRow';
|
||||||
|
export { default as AuditLogRow } from './AuditLogRow';
|
||||||
|
export type { AuditLogRowProps } from './AuditLogRow';
|
||||||
|
export { default as SupportAlertCard } from './SupportAlertCard';
|
||||||
|
export type { SupportAlertCardProps } from './SupportAlertCard';
|
||||||
|
export { default as PartnerSettlementRow } from './PartnerSettlementRow';
|
||||||
|
export type { PartnerSettlementRowProps } from './PartnerSettlementRow';
|
||||||
|
export { default as DocumentViewer } from './DocumentViewer';
|
||||||
|
export type { DocumentViewerProps } from './DocumentViewer';
|
||||||
|
export { default as RefundPanel } from './RefundPanel';
|
||||||
|
export type { RefundPanelProps } from './RefundPanel';
|
||||||
|
export { default as AdminMessageBubble } from './AdminMessageBubble';
|
||||||
|
export type { AdminMessageBubbleProps } from './AdminMessageBubble';
|
||||||
@@ -83,6 +83,19 @@ import FamilyIcon from '@mui/icons-material/FamilyRestroomOutlined';
|
|||||||
// Messaging (tickets) & notifications (f14/b15): support inbox + the message-send action
|
// Messaging (tickets) & notifications (f14/b15): support inbox + the message-send action
|
||||||
import SupportIcon from '@mui/icons-material/SupportAgentOutlined';
|
import SupportIcon from '@mui/icons-material/SupportAgentOutlined';
|
||||||
import SendIcon from '@mui/icons-material/SendOutlined';
|
import SendIcon from '@mui/icons-material/SendOutlined';
|
||||||
|
// Admin backoffice & partner consoles (f15/b15): config, holidays, audit, alerts, moderation, partners, refunds
|
||||||
|
import ConfigIcon from '@mui/icons-material/TuneOutlined';
|
||||||
|
import CalendarIcon from '@mui/icons-material/CalendarMonthOutlined';
|
||||||
|
import AuditIcon from '@mui/icons-material/FactCheckOutlined';
|
||||||
|
import AlertsIcon from '@mui/icons-material/NotificationImportantOutlined';
|
||||||
|
import ModerationIcon from '@mui/icons-material/GavelOutlined';
|
||||||
|
import PartnersIcon from '@mui/icons-material/ApartmentOutlined';
|
||||||
|
import RolesIcon from '@mui/icons-material/ManageAccountsOutlined';
|
||||||
|
import RefundsIcon from '@mui/icons-material/CurrencyExchangeOutlined';
|
||||||
|
import DownloadIcon from '@mui/icons-material/FileDownloadOutlined';
|
||||||
|
import ExpandIcon from '@mui/icons-material/ExpandMoreOutlined';
|
||||||
|
import ExternalIcon from '@mui/icons-material/OpenInNewOutlined';
|
||||||
|
import AssignIcon from '@mui/icons-material/AssignmentIndOutlined';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of all available Icon names
|
* List of all available Icon names
|
||||||
@@ -172,4 +185,16 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
|
|||||||
family: FamilyIcon,
|
family: FamilyIcon,
|
||||||
support: SupportIcon,
|
support: SupportIcon,
|
||||||
send: SendIcon,
|
send: SendIcon,
|
||||||
|
config: ConfigIcon,
|
||||||
|
calendar: CalendarIcon,
|
||||||
|
audit: AuditIcon,
|
||||||
|
alerts: AlertsIcon,
|
||||||
|
moderation: ModerationIcon,
|
||||||
|
partners: PartnersIcon,
|
||||||
|
roles: RolesIcon,
|
||||||
|
refunds: RefundsIcon,
|
||||||
|
download: DownloadIcon,
|
||||||
|
expand: ExpandIcon,
|
||||||
|
external: ExternalIcon,
|
||||||
|
assign: AssignIcon,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,3 +13,19 @@ export const APP_ROLES = {
|
|||||||
export type AppRole = (typeof APP_ROLES)[keyof typeof APP_ROLES];
|
export type AppRole = (typeof APP_ROLES)[keyof typeof APP_ROLES];
|
||||||
|
|
||||||
export const DEFAULT_ROLE: AppRole = APP_ROLES.CUSTOMER;
|
export const DEFAULT_ROLE: AppRole = APP_ROLES.CUSTOMER;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's **fine-grained** admin sub-role codes (b2 `AdminRole` / b15 RBAC). The coarse
|
||||||
|
* `AppRole` collapses all of these to the single ADMIN shell (`toAppRoles`), but the f15 backoffice
|
||||||
|
* needs the fine grain to gate individual consoles (a `support` admin can't run a payout, a
|
||||||
|
* `moderation` admin can't refund). Kept aligned with the auth contract enum — note it is
|
||||||
|
* `moderation`, not `moderator`. Stored raw on the session (`SessionUser.roleCodes`) and read by
|
||||||
|
* `useAdminCapabilities()`.
|
||||||
|
*/
|
||||||
|
export const ADMIN_ROLE_CODES = ['super_admin', 'admin', 'support', 'finance', 'moderation'] as const;
|
||||||
|
|
||||||
|
export type AdminRoleCode = (typeof ADMIN_ROLE_CODES)[number];
|
||||||
|
|
||||||
|
export function isAdminRoleCode(code: string): code is AdminRoleCode {
|
||||||
|
return (ADMIN_ROLE_CODES as readonly string[]).includes(code);
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,12 +69,54 @@ export const ROUTES = {
|
|||||||
// Nurse in-app notification center (f14).
|
// Nurse in-app notification center (f14).
|
||||||
NURSE_NOTIFICATIONS: '/nurse/notifications',
|
NURSE_NOTIFICATIONS: '/nurse/notifications',
|
||||||
|
|
||||||
// Admin / backoffice console
|
// Admin / backoffice console (f15) — desktop sidebar shell, role-gated worklists.
|
||||||
ADMIN: '/admin',
|
ADMIN: '/admin',
|
||||||
ADMIN_USERS: '/admin/users',
|
ADMIN_USERS: '/admin/users',
|
||||||
ADMIN_NOTIFICATIONS: '/admin/notifications',
|
ADMIN_NOTIFICATIONS: '/admin/notifications',
|
||||||
|
// Verification review queue — pending nurses; append `/{nurseVerificationId}` for the per-nurse case.
|
||||||
|
ADMIN_VERIFICATION: '/admin/verification',
|
||||||
|
// Global ticket queue (refund panel opens from a ticket); append `/{id}` for the admin thread.
|
||||||
|
ADMIN_TICKETS: '/admin/tickets',
|
||||||
|
// Weekly payout dashboard — batch preview → run → detail; append `/{batchId}` for the batch drill-down.
|
||||||
|
ADMIN_PAYOUTS: '/admin/payouts',
|
||||||
|
// Review moderation queue — publish/hide/reject.
|
||||||
|
ADMIN_REVIEWS: '/admin/reviews',
|
||||||
|
// Platform config editor + change history.
|
||||||
|
ADMIN_CONFIG: '/admin/config',
|
||||||
|
// Iranian-holiday calendar manager (drives payout scheduling; is_bank_closed toggle).
|
||||||
|
ADMIN_HOLIDAYS: '/admin/holidays',
|
||||||
|
// Support-alert triage board — internal-only (assign/resolve).
|
||||||
|
ADMIN_ALERTS: '/admin/alerts',
|
||||||
|
// Append-only audit-log viewer — read-only, filtered, paginated.
|
||||||
|
ADMIN_AUDIT: '/admin/audit',
|
||||||
|
// Partner-center management — list/create/verify/activate; append `/{id}` for the center detail.
|
||||||
|
ADMIN_PARTNERS: '/admin/partners',
|
||||||
|
// RBAC role grid — (DEFERRED-IF-MISSING) built against the mock until the b15 role endpoints land.
|
||||||
|
ADMIN_ROLES: '/admin/roles',
|
||||||
|
|
||||||
|
// Partner-center portal (f15) — a SEPARATE authz scope (a center admin is not a Balinyaar admin).
|
||||||
|
PARTNER: '/partner',
|
||||||
|
PARTNER_NURSES: '/partner/nurses',
|
||||||
|
PARTNER_BOOKINGS: '/partner/bookings',
|
||||||
|
PARTNER_SETTLEMENT: '/partner/settlement',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/** The admin verification case for one nurse (f15) — a queue row deep-links here. */
|
||||||
|
export const adminVerificationCasePath = (nurseVerificationId: number | string): string =>
|
||||||
|
`${ROUTES.ADMIN_VERIFICATION}/${nurseVerificationId}`;
|
||||||
|
|
||||||
|
/** The admin ticket thread (f15) — the global queue links here; the refund panel opens inside. */
|
||||||
|
export const adminTicketThreadPath = (ticketId: number | string): string =>
|
||||||
|
`${ROUTES.ADMIN_TICKETS}/${ticketId}`;
|
||||||
|
|
||||||
|
/** The admin payout-batch detail (f15) — per-nurse rows, masked IBAN, transfer-reference reconcile. */
|
||||||
|
export const adminPayoutBatchPath = (batchId: number | string): string =>
|
||||||
|
`${ROUTES.ADMIN_PAYOUTS}/${batchId}`;
|
||||||
|
|
||||||
|
/** The admin partner-center detail (f15) — verify/activate + sponsored-nurse roster. */
|
||||||
|
export const adminPartnerCenterPath = (centerId: number | string): string =>
|
||||||
|
`${ROUTES.ADMIN_PARTNERS}/${centerId}`;
|
||||||
|
|
||||||
/** A booking's invoice view (f9) — keyed by the booking the invoice belongs to. */
|
/** A booking's invoice view (f9) — keyed by the booking the invoice belongs to. */
|
||||||
export const bookingInvoicePath = (bookingId: number | string): string =>
|
export const bookingInvoicePath = (bookingId: number | string): string =>
|
||||||
`${ROUTES.BOOKINGS}/${bookingId}/invoice`;
|
`${ROUTES.BOOKINGS}/${bookingId}/invoice`;
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ export interface SessionUser {
|
|||||||
id?: number;
|
id?: number;
|
||||||
phone: string;
|
phone: string;
|
||||||
roles: AppRole[];
|
roles: AppRole[];
|
||||||
|
/**
|
||||||
|
* The server's **fine-grained** role codes (e.g. `super_admin`/`support`/`finance`/`moderation`),
|
||||||
|
* preserved alongside the collapsed `roles` so the f15 admin backoffice can gate individual consoles
|
||||||
|
* via `useAdminCapabilities()`. Only hydrated from `/me` (useSessionRoleSync); the coarse `roles`
|
||||||
|
* still drive shell chrome. Empty/undefined for a session that hasn't loaded `/me` yet.
|
||||||
|
*/
|
||||||
|
roleCodes?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useAuth } from '@/context/auth';
|
||||||
|
import { APP_ROLES, ADMIN_ROLE_CODES, isAdminRoleCode, type AdminRoleCode } from '@/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The set of admin consoles the current principal may act on. **UI hint only** — the server enforces
|
||||||
|
* every command's role scope (a `support` admin who forges a payout request still 403s). We derive it so
|
||||||
|
* the shell never *shows* a control the current role can't use (phase §3 "Routing & RBAC", §5).
|
||||||
|
*
|
||||||
|
* The matrix mirrors the product's five worklists (verification / refund / payout / support-alert / RBAC)
|
||||||
|
* plus the config/holiday/audit/moderation/partner surfaces, keyed off the fine-grained role codes:
|
||||||
|
* - `super_admin` — everything (only role that may grant/revoke roles).
|
||||||
|
* - `admin` — everything except role management.
|
||||||
|
* - `finance` — refunds, payouts, config, audit.
|
||||||
|
* - `support` — verification, support-alerts, tickets.
|
||||||
|
* - `moderation` — review moderation.
|
||||||
|
*/
|
||||||
|
export interface AdminCapabilities {
|
||||||
|
/** True when the principal holds any admin role at all (drives whether the console is reachable). */
|
||||||
|
isAdmin: boolean;
|
||||||
|
canVerify: boolean;
|
||||||
|
canRefund: boolean;
|
||||||
|
canPayout: boolean;
|
||||||
|
canModerate: boolean;
|
||||||
|
canConfig: boolean;
|
||||||
|
canManageAlerts: boolean;
|
||||||
|
canManageTickets: boolean;
|
||||||
|
canManagePartners: boolean;
|
||||||
|
canViewAudit: boolean;
|
||||||
|
canManageRoles: boolean;
|
||||||
|
/** The effective fine-grained roles used for the matrix (post dev-fallback). */
|
||||||
|
roles: AdminRoleCode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const has = (roles: AdminRoleCode[], ...allowed: AdminRoleCode[]) => roles.some((r) => allowed.includes(r));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the session's fine-grained `roleCodes` (hydrated from `/me` by `useSessionRoleSync`). When a
|
||||||
|
* session is coarse-admin (mock auth, or `/me` not yet fine-hydrated) but carries no admin code, we fall
|
||||||
|
* back to a plain `admin` so the console is usable in dev — never `super_admin`, so the roles screen stays
|
||||||
|
* correctly hidden. This is a display convenience; authorization is always the server's.
|
||||||
|
*/
|
||||||
|
export function useAdminCapabilities(): AdminCapabilities {
|
||||||
|
const [state] = useAuth();
|
||||||
|
|
||||||
|
return useMemo<AdminCapabilities>(() => {
|
||||||
|
const user = state.currentUser;
|
||||||
|
const coarseAdmin = !!user?.roles?.includes(APP_ROLES.ADMIN);
|
||||||
|
let roles = (user?.roleCodes ?? []).filter(isAdminRoleCode);
|
||||||
|
if (roles.length === 0 && coarseAdmin) roles = ['admin'];
|
||||||
|
|
||||||
|
const isAdmin = roles.length > 0;
|
||||||
|
return {
|
||||||
|
isAdmin,
|
||||||
|
canVerify: has(roles, 'super_admin', 'admin', 'support'),
|
||||||
|
canRefund: has(roles, 'super_admin', 'admin', 'finance'),
|
||||||
|
canPayout: has(roles, 'super_admin', 'admin', 'finance'),
|
||||||
|
canModerate: has(roles, 'super_admin', 'admin', 'moderation'),
|
||||||
|
canConfig: has(roles, 'super_admin', 'admin', 'finance'),
|
||||||
|
canManageAlerts: has(roles, 'super_admin', 'admin', 'support'),
|
||||||
|
canManageTickets: has(roles, 'super_admin', 'admin', 'support'),
|
||||||
|
canManagePartners: has(roles, 'super_admin', 'admin'),
|
||||||
|
canViewAudit: has(roles, 'super_admin', 'admin'),
|
||||||
|
canManageRoles: has(roles, 'super_admin'),
|
||||||
|
roles,
|
||||||
|
};
|
||||||
|
}, [state.currentUser]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full ordered admin role-code list — for the (deferred) RBAC grid + any role picker. */
|
||||||
|
export { ADMIN_ROLE_CODES };
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './auth';
|
export * from './auth';
|
||||||
|
export * from './capabilities';
|
||||||
export * from './event';
|
export * from './event';
|
||||||
export * from './layout';
|
export * from './layout';
|
||||||
|
|||||||
@@ -2,26 +2,39 @@
|
|||||||
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
|
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { ROUTES } from '@/constants';
|
import { ROUTES } from '@/constants';
|
||||||
|
import { useAdminCapabilities } from '@/hooks';
|
||||||
import { LinkToPage } from '@/utils';
|
import { LinkToPage } from '@/utils';
|
||||||
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin / backoffice shell — desktop-oriented ops console (f15). Uses the shared
|
* Admin / backoffice shell — the desktop ops console (f15). The sidebar is **role-gated**: each console
|
||||||
* TopBar + SideBar engine with a persistent sidebar on desktop.
|
* appears only when the current admin role can act on it (`useAdminCapabilities`). This is a display
|
||||||
|
* convenience — the server enforces every command's scope — so a `support` admin never sees the payout or
|
||||||
|
* refund controls, a `moderation` admin only sees moderation, etc. (phase §3 "Routing & RBAC", §5).
|
||||||
* @layout AdminLayout
|
* @layout AdminLayout
|
||||||
*/
|
*/
|
||||||
const AdminLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
const AdminLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||||
const t = useTranslations('nav');
|
const t = useTranslations('nav');
|
||||||
const tShell = useTranslations('shell');
|
const tShell = useTranslations('shell');
|
||||||
|
const caps = useAdminCapabilities();
|
||||||
|
|
||||||
const sidebarItems: Array<LinkToPage> = useMemo(
|
const sidebarItems: Array<LinkToPage> = useMemo(() => {
|
||||||
() => [
|
const items: Array<LinkToPage & { show: boolean }> = [
|
||||||
{ title: t('overview'), path: ROUTES.ADMIN, icon: 'admin' },
|
{ title: t('overview'), path: ROUTES.ADMIN, icon: 'dashboard', show: true },
|
||||||
{ title: t('users'), path: ROUTES.ADMIN_USERS, icon: 'users' },
|
{ title: t('verification'), path: ROUTES.ADMIN_VERIFICATION, icon: 'verification', show: caps.canVerify },
|
||||||
{ title: t('notifications'), path: ROUTES.ADMIN_NOTIFICATIONS, icon: 'notifications' },
|
{ title: t('tickets'), path: ROUTES.ADMIN_TICKETS, icon: 'support', show: caps.canManageTickets },
|
||||||
],
|
{ title: t('payouts'), path: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', show: caps.canPayout },
|
||||||
[t]
|
{ title: t('reviews'), path: ROUTES.ADMIN_REVIEWS, icon: 'moderation', show: caps.canModerate },
|
||||||
);
|
{ title: t('config'), path: ROUTES.ADMIN_CONFIG, icon: 'config', show: caps.canConfig },
|
||||||
|
{ title: t('holidays'), path: ROUTES.ADMIN_HOLIDAYS, icon: 'calendar', show: caps.canConfig },
|
||||||
|
{ title: t('alerts'), path: ROUTES.ADMIN_ALERTS, icon: 'alerts', show: caps.canManageAlerts },
|
||||||
|
{ title: t('audit'), path: ROUTES.ADMIN_AUDIT, icon: 'audit', show: caps.canViewAudit },
|
||||||
|
{ title: t('partners'), path: ROUTES.ADMIN_PARTNERS, icon: 'partners', show: caps.canManagePartners },
|
||||||
|
{ title: t('roles'), path: ROUTES.ADMIN_ROLES, icon: 'roles', show: caps.canManageRoles },
|
||||||
|
{ title: t('notifications'), path: ROUTES.ADMIN_NOTIFICATIONS, icon: 'notifications', show: true },
|
||||||
|
];
|
||||||
|
return items.filter((i) => i.show).map(({ show: _show, ...rest }) => rest);
|
||||||
|
}, [t, caps]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TopBarAndSideBarLayout
|
<TopBarAndSideBarLayout
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { ROUTES } from '@/constants';
|
||||||
|
import { LinkToPage } from '@/utils';
|
||||||
|
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 desktop sidebar engine as the admin shell, its own nav. The settlement tab always appears; the
|
||||||
|
* settlement page itself renders the "via Balinyaar" state for a non-merchant-of-record center.
|
||||||
|
* @layout PartnerLayout
|
||||||
|
*/
|
||||||
|
const PartnerLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||||
|
const t = useTranslations('nav');
|
||||||
|
const tShell = useTranslations('shell');
|
||||||
|
|
||||||
|
const sidebarItems: Array<LinkToPage> = useMemo(
|
||||||
|
() => [
|
||||||
|
{ title: t('partner_home'), path: ROUTES.PARTNER, icon: 'partners' },
|
||||||
|
{ title: t('partner_nurses'), path: ROUTES.PARTNER_NURSES, icon: 'patients' },
|
||||||
|
{ title: t('partner_bookings'), path: ROUTES.PARTNER_BOOKINGS, icon: 'bookings' },
|
||||||
|
{ title: t('partner_settlement'), path: ROUTES.PARTNER_SETTLEMENT, icon: 'earnings' },
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TopBarAndSideBarLayout
|
||||||
|
sidebarItems={sidebarItems}
|
||||||
|
title={tShell('partner_console')}
|
||||||
|
variant="sidebarPersistentOnDesktop"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TopBarAndSideBarLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PartnerLayout;
|
||||||
@@ -3,5 +3,6 @@ import PublicLayout from './PublicLayout';
|
|||||||
import CustomerLayout from './CustomerLayout';
|
import CustomerLayout from './CustomerLayout';
|
||||||
import NurseLayout from './NurseLayout';
|
import NurseLayout from './NurseLayout';
|
||||||
import AdminLayout from './AdminLayout';
|
import AdminLayout from './AdminLayout';
|
||||||
|
import PartnerLayout from './PartnerLayout';
|
||||||
|
|
||||||
export { PublicLayout, PrivateLayout, CustomerLayout, NurseLayout, AdminLayout };
|
export { PublicLayout, PrivateLayout, CustomerLayout, NurseLayout, AdminLayout, PartnerLayout };
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { clientFetch } from '@/lib/api/client';
|
||||||
|
import { unwrap, type ApiEnvelope, type PageParams, type Paginated } from '@/lib/api/types';
|
||||||
|
import { ADMIN_PAGE_SIZE } from '../constants';
|
||||||
|
import type {
|
||||||
|
AdminApi,
|
||||||
|
AdminRole,
|
||||||
|
AuditFilters,
|
||||||
|
AuditLogEntry,
|
||||||
|
ConfigChange,
|
||||||
|
Holiday,
|
||||||
|
HolidayFilters,
|
||||||
|
HolidayInput,
|
||||||
|
PlatformConfig,
|
||||||
|
RoleGrant,
|
||||||
|
SupportAlert,
|
||||||
|
SupportAlertFilters,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
/** Build a `page`/`page_size` query (snake_case per b1 api-conventions). */
|
||||||
|
function pageQuery(params: PageParams, extra?: Record<string, string | undefined>): string {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('page', String(params.page ?? 1));
|
||||||
|
q.set('page_size', String(params.pageSize ?? ADMIN_PAGE_SIZE));
|
||||||
|
for (const [k, v] of Object.entries(extra ?? {})) if (v != null && v !== '') q.set(k, v);
|
||||||
|
return q.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the b1 `changedFieldsJson` (`{ "Field": { "old": …, "new": … } }`) into a typed record. */
|
||||||
|
function parseChangedFields(json: string | null): AuditLogEntry['changedFields'] {
|
||||||
|
if (!json) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(json) as AuditLogEntry['changedFields'];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse a config change's `changedFieldsJson` into the single value delta the drawer shows. */
|
||||||
|
function parseValueDelta(json: string | null): { oldValue: string | null; newValue: string | null } {
|
||||||
|
const parsed = parseChangedFields(json);
|
||||||
|
const field = parsed && (parsed['Value'] ?? Object.values(parsed)[0]);
|
||||||
|
const toStr = (v: unknown): string | null => (v == null ? null : String(v));
|
||||||
|
return { oldValue: toStr(field?.old), newValue: toStr(field?.new) };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigChangeWire {
|
||||||
|
id: number;
|
||||||
|
action: ConfigChange['action'];
|
||||||
|
changedFieldsJson: string | null;
|
||||||
|
actorUserId: number | null;
|
||||||
|
occurredAt: string;
|
||||||
|
}
|
||||||
|
interface AuditWire {
|
||||||
|
id: number;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
action: string;
|
||||||
|
changedFieldsJson: string | null;
|
||||||
|
actorUserId: number | null;
|
||||||
|
occurredAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real HTTP implementation of the `AdminApi` seam (b1 config/holiday/audit/support-alert routes + the b15
|
||||||
|
* RBAC routes). **Not primary this phase** (`USE_ADMIN_MOCK = true`) — the config audit columns and rich
|
||||||
|
* audit filters aren't on the wire (REQ-029/030) and the RBAC routes don't exist yet (REQ-031). When each
|
||||||
|
* upstream lands, flip the seam in `apis/index.ts`; the hooks/screens are unchanged.
|
||||||
|
*/
|
||||||
|
export const adminClientApi: AdminApi = {
|
||||||
|
listConfigs: async (params) =>
|
||||||
|
unwrap(await clientFetch<ApiEnvelope<Paginated<PlatformConfig>>>(`${API}/platform_config/get_platform_configs?${pageQuery(params)}`)),
|
||||||
|
|
||||||
|
updateConfig: async (key, value) => {
|
||||||
|
await clientFetch<ApiEnvelope<boolean>>(`${API}/platform_config/update_platform_config`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ key, value }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
getConfigHistory: async (key, params) => {
|
||||||
|
const wire = unwrap(
|
||||||
|
await clientFetch<ApiEnvelope<Paginated<ConfigChangeWire>>>(
|
||||||
|
`${API}/platform_config/get_config_change_history?${pageQuery(params, { key })}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...wire,
|
||||||
|
items: wire.items.map((w): ConfigChange => ({
|
||||||
|
id: w.id,
|
||||||
|
action: w.action,
|
||||||
|
actorUserId: w.actorUserId,
|
||||||
|
occurredAt: w.occurredAt,
|
||||||
|
...parseValueDelta(w.changedFieldsJson),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
listHolidays: async (filters: HolidayFilters, params) =>
|
||||||
|
unwrap(
|
||||||
|
await clientFetch<ApiEnvelope<Paginated<Holiday>>>(
|
||||||
|
`${API}/holidays/get_holidays?${pageQuery(params, { from: filters.from, to: filters.to })}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
upsertHoliday: async (input: HolidayInput) => {
|
||||||
|
await clientFetch<ApiEnvelope<boolean>>(`${API}/holidays/upsert_holiday`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
listAuditLogs: async (filters: AuditFilters, params) => {
|
||||||
|
const wire = unwrap(
|
||||||
|
await clientFetch<ApiEnvelope<Paginated<AuditWire>>>(
|
||||||
|
`${API}/audit/get_audit_trail?${pageQuery(params, { entity_type: filters.entityType, entity_id: filters.entityId })}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...wire,
|
||||||
|
items: wire.items.map((w): AuditLogEntry => ({
|
||||||
|
id: w.id,
|
||||||
|
entityType: w.entityType,
|
||||||
|
entityId: w.entityId,
|
||||||
|
action: w.action,
|
||||||
|
actorUserId: w.actorUserId,
|
||||||
|
occurredAt: w.occurredAt,
|
||||||
|
changedFields: parseChangedFields(w.changedFieldsJson),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
listSupportAlerts: async (filters: SupportAlertFilters, params) =>
|
||||||
|
unwrap(
|
||||||
|
await clientFetch<ApiEnvelope<Paginated<SupportAlert>>>(
|
||||||
|
`${API}/support_alerts/get_support_alerts?${pageQuery(params, {
|
||||||
|
type: filters.type,
|
||||||
|
status: filters.status,
|
||||||
|
owner_user_id: filters.ownerUserId != null ? String(filters.ownerUserId) : undefined,
|
||||||
|
})}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
assignSupportAlert: async (alertId, ownerUserId) => {
|
||||||
|
await clientFetch<ApiEnvelope<boolean>>(`${API}/support_alerts/assign_support_alert`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ alertId, ownerUserId }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveSupportAlert: async (alertId, note) => {
|
||||||
|
await clientFetch<ApiEnvelope<boolean>>(`${API}/support_alerts/resolve_support_alert`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ alertId, note }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// RBAC (REQ-031 — routes proposed; not live). Kept real-shaped so the swap is one line once they ship.
|
||||||
|
listRoles: async (userId?: number) =>
|
||||||
|
unwrap(
|
||||||
|
await clientFetch<ApiEnvelope<RoleGrant[]>>(
|
||||||
|
`${API}/admin_roles/list_roles${userId != null ? `?user_id=${userId}` : ''}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
grantRole: async (userId, role: AdminRole) => {
|
||||||
|
await clientFetch<ApiEnvelope<boolean>>(`${API}/admin_roles/grant_role`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ userId, role }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
revokeRole: async (userId, role: AdminRole) => {
|
||||||
|
await clientFetch<ApiEnvelope<boolean>>(`${API}/admin_roles/revoke_role`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ userId, role }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { USE_ADMIN_MOCK } from '../constants';
|
||||||
|
import type { AdminApi } from '../types';
|
||||||
|
import { adminClientApi } from './clientApi';
|
||||||
|
import { adminMockApi } from './mockApi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selected `AdminApi` implementation — the single seam the hooks import. Mock-primary this phase
|
||||||
|
* (REQ-029/030/031); flipping to the real client is this one line once the upstream endpoints/columns land.
|
||||||
|
*/
|
||||||
|
export const adminApi: AdminApi = USE_ADMIN_MOCK ? adminMockApi : adminClientApi;
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||||
|
import type {
|
||||||
|
AdminApi,
|
||||||
|
AdminRole,
|
||||||
|
AuditFilters,
|
||||||
|
AuditLogEntry,
|
||||||
|
ConfigChange,
|
||||||
|
Holiday,
|
||||||
|
HolidayFilters,
|
||||||
|
HolidayInput,
|
||||||
|
PlatformConfig,
|
||||||
|
RoleGrant,
|
||||||
|
SupportAlert,
|
||||||
|
SupportAlertFilters,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory `AdminApi` — **the primary implementation this phase** (REQ-029/030/031: config audit
|
||||||
|
* columns, rich audit filters, and the whole RBAC surface are gaps). The fixtures are engineered to
|
||||||
|
* exercise every console state: one config **per `dataType`** (so all typed inputs + the 0–1 rate
|
||||||
|
* validation are reachable), a config change-history trail, holidays with **bank-closed** days, a paged
|
||||||
|
* audit log with `changedFields` diffs, a support-alert list spanning **every** alert type/status (so the
|
||||||
|
* worklist filters are testable), and RBAC grants. Mutations mutate the in-memory arrays so a save shows
|
||||||
|
* on the next read. Timestamps are relative to now so Shamsi rendering always reads sensibly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const isoDaysAgo = (d: number): string => new Date(Date.now() - d * DAY_MS).toISOString();
|
||||||
|
const dateDaysAgo = (d: number): string => isoDaysAgo(d).slice(0, 10);
|
||||||
|
const dateDaysAhead = (d: number): string => new Date(Date.now() + d * DAY_MS).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const LATENCY_MS = 200;
|
||||||
|
const delay = <T>(v: T): Promise<T> => new Promise((r) => setTimeout(() => r(v), LATENCY_MS));
|
||||||
|
|
||||||
|
function paginate<T>(all: T[], params: PageParams): Paginated<T> {
|
||||||
|
const page = Math.max(1, params.page ?? 1);
|
||||||
|
const pageSize = Math.max(1, params.pageSize ?? (all.length || 1));
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config (one row per data_type) ─────────────────────────────────────────────────────────────────────
|
||||||
|
const CONFIGS: PlatformConfig[] = [
|
||||||
|
{ key: 'platform_fee_rate', value: '0.15', dataType: 'decimal', description: 'Platform commission fraction of gross.', updatedAt: isoDaysAgo(30), updatedBy: 'admin@balinyaar' },
|
||||||
|
{ key: 'vat_rate', value: '0.10', dataType: 'decimal', description: 'VAT applied to the platform commission line only.', updatedAt: isoDaysAgo(90), updatedBy: 'finance@balinyaar' },
|
||||||
|
{ key: 'dispute_window_hours', value: '72', dataType: 'int', description: 'Hours after check-out before a payout becomes eligible.', updatedAt: isoDaysAgo(120), updatedBy: 'admin@balinyaar' },
|
||||||
|
{ key: 'nurse_payout_interval_days', value: '7', dataType: 'int', description: 'Payout batch cadence in days.', updatedAt: isoDaysAgo(120), updatedBy: 'admin@balinyaar' },
|
||||||
|
{ key: 'evv_location_tolerance_meters', value: '150', dataType: 'int', description: 'Advisory EVV geofence radius in meters.', updatedAt: isoDaysAgo(45), updatedBy: 'admin@balinyaar' },
|
||||||
|
{ key: 'payout_satna_threshold_irr', value: '150000000', dataType: 'int', description: 'Above this amount payouts use SATNA instead of PAYA.', updatedAt: isoDaysAgo(60), updatedBy: 'finance@balinyaar' },
|
||||||
|
{ key: 'min_rating_for_support_alert', value: '2', dataType: 'int', description: 'Reviews at or below this rating raise a low-rating alert.', updatedAt: isoDaysAgo(200), updatedBy: 'admin@balinyaar' },
|
||||||
|
{ key: 'bnpl_provider_enabled', value: 'true', dataType: 'bool', description: 'Whether the BNPL checkout branch is offered.', updatedAt: isoDaysAgo(15), updatedBy: 'admin@balinyaar' },
|
||||||
|
{ key: 'cancellation_policy_tiers', value: '{"tier1":1,"tier2":0.5,"tier3":0}', dataType: 'json', description: 'Refund fraction per cancellation lead-time tier.', updatedAt: isoDaysAgo(75), updatedBy: 'admin@balinyaar' },
|
||||||
|
{ key: 'support_contact_line', value: 'پشتیبانی بالینیار', dataType: 'string', description: 'Display name used in support messages.', updatedAt: isoDaysAgo(10), updatedBy: 'support@balinyaar' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONFIG_HISTORY: Record<string, ConfigChange[]> = {
|
||||||
|
vat_rate: [
|
||||||
|
{ id: 301, action: 'updated', actorUserId: 3, occurredAt: isoDaysAgo(90), oldValue: '0.09', newValue: '0.10' },
|
||||||
|
{ id: 300, action: 'updated', actorUserId: 3, occurredAt: isoDaysAgo(365), oldValue: '0.08', newValue: '0.09' },
|
||||||
|
],
|
||||||
|
platform_fee_rate: [
|
||||||
|
{ id: 310, action: 'updated', actorUserId: 2, occurredAt: isoDaysAgo(30), oldValue: '0.18', newValue: '0.15' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Holidays ───────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
const HOLIDAYS: Holiday[] = [
|
||||||
|
{ id: 501, holidayDate: dateDaysAhead(3), nameFa: 'عید فطر', type: 'religious', isBankClosed: true },
|
||||||
|
{ id: 502, holidayDate: dateDaysAgo(2), nameFa: 'رحلت امام', type: 'religious', isBankClosed: true },
|
||||||
|
{ id: 503, holidayDate: dateDaysAgo(20), nameFa: 'روز جمهوری اسلامی', type: 'national', isBankClosed: true },
|
||||||
|
{ id: 504, holidayDate: dateDaysAhead(30), nameFa: 'تعطیلی اداری', type: 'official', isBankClosed: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Audit log ──────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
const AUDIT: AuditLogEntry[] = [
|
||||||
|
{ id: 901, entityType: 'PlatformConfig', entityId: 'platform_fee_rate', action: 'updated', actorUserId: 2, occurredAt: isoDaysAgo(30), changedFields: { Value: { old: '0.18', new: '0.15' } } },
|
||||||
|
{ id: 902, entityType: 'Refund', entityId: '7', action: 'created', actorUserId: 4, occurredAt: isoDaysAgo(4), changedFields: { Status: { old: null, new: 'succeeded' }, Amount: { old: null, new: '10000000' } } },
|
||||||
|
{ id: 903, entityType: 'NurseVerification', entityId: '15', action: 'updated', actorUserId: 2, occurredAt: isoDaysAgo(6), changedFields: { Status: { old: 'in_review', new: 'approved' }, IsVerified: { old: false, new: true } } },
|
||||||
|
{ id: 904, entityType: 'PayoutBatch', entityId: '7003', action: 'created', actorUserId: 5, occurredAt: isoDaysAgo(1), changedFields: { Status: { old: null, new: 'processing' }, PayoutCount: { old: null, new: 12 } } },
|
||||||
|
{ id: 905, entityType: 'Review', entityId: '44', action: 'updated', actorUserId: 6, occurredAt: isoDaysAgo(2), changedFields: { ModerationStatus: { old: 'pending_moderation', new: 'published' } } },
|
||||||
|
{ id: 906, entityType: 'PartnerCenter', entityId: '1', action: 'updated', actorUserId: 2, occurredAt: isoDaysAgo(8), changedFields: { SettlementIban: { old: '<redacted>', new: '<redacted>' }, IsActive: { old: false, new: true } } },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Support alerts (one of every type; all statuses) ────────────────────────────────────────────────────
|
||||||
|
const ALERTS: SupportAlert[] = [
|
||||||
|
{ id: 801, type: 'low_rating', severity: 'medium', status: 'open', entityType: 'Review', entityId: '44', bookingId: 5001, reviewId: 44, ownerUserId: null, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(1) },
|
||||||
|
{ id: 802, type: 'evv_no_show', severity: 'high', status: 'open', entityType: 'Booking', entityId: '5002', bookingId: 5002, reviewId: null, ownerUserId: null, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(2) },
|
||||||
|
{ id: 803, type: 'evv_location_mismatch', severity: 'medium', status: 'assigned', entityType: 'Booking', entityId: '5003', bookingId: 5003, reviewId: null, ownerUserId: 3, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(3) },
|
||||||
|
{ id: 804, type: 'verification_expired', severity: 'high', status: 'open', entityType: 'NurseVerification', entityId: '15', bookingId: null, reviewId: null, ownerUserId: null, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(4) },
|
||||||
|
{ id: 805, type: 'shared_sim', severity: 'high', status: 'assigned', entityType: 'NurseVerification', entityId: '16', bookingId: null, reviewId: null, ownerUserId: 3, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(5) },
|
||||||
|
{ id: 806, type: 'payment_anomaly', severity: 'high', status: 'open', entityType: 'PaymentTransaction', entityId: '3001', bookingId: 5004, reviewId: null, ownerUserId: null, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(6) },
|
||||||
|
{ id: 807, type: 'fraud_signal', severity: 'high', status: 'open', entityType: 'User', entityId: '7099', bookingId: null, reviewId: null, ownerUserId: null, resolutionNote: null, resolvedAt: null, createdAt: isoDaysAgo(7) },
|
||||||
|
{ id: 808, type: 'nurse_clawback', severity: 'medium', status: 'resolved', entityType: 'NurseClawback', entityId: '210', bookingId: 5004, reviewId: null, ownerUserId: 4, resolutionNote: 'Netted in the next payout batch.', resolvedAt: isoDaysAgo(1), createdAt: isoDaysAgo(9) },
|
||||||
|
{ id: 809, type: 'emergency', severity: 'high', status: 'resolved', entityType: 'Booking', entityId: '5001', bookingId: 5001, reviewId: null, ownerUserId: 3, resolutionNote: 'Called 115; patient stable.', resolvedAt: isoDaysAgo(2), createdAt: isoDaysAgo(10) },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── RBAC grants ──────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
const ROLES: RoleGrant[] = [
|
||||||
|
{ userId: 2, role: 'admin', grantedBy: 1, grantedAt: isoDaysAgo(400), revokedAt: null },
|
||||||
|
{ userId: 3, role: 'support', grantedBy: 2, grantedAt: isoDaysAgo(200), revokedAt: null },
|
||||||
|
{ userId: 4, role: 'finance', grantedBy: 2, grantedAt: isoDaysAgo(180), revokedAt: null },
|
||||||
|
{ userId: 5, role: 'finance', grantedBy: 2, grantedAt: isoDaysAgo(90), revokedAt: null },
|
||||||
|
{ userId: 6, role: 'moderation', grantedBy: 2, grantedAt: isoDaysAgo(60), revokedAt: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const adminMockApi: AdminApi = {
|
||||||
|
listConfigs: async (params) => delay(paginate([...CONFIGS], params)),
|
||||||
|
|
||||||
|
updateConfig: async (key, value) => {
|
||||||
|
const row = CONFIGS.find((c) => c.key === key);
|
||||||
|
if (!row) throw new Error(`Mock config ${key} not found`);
|
||||||
|
const old = row.value;
|
||||||
|
row.value = value;
|
||||||
|
row.updatedAt = new Date().toISOString();
|
||||||
|
row.updatedBy = 'you@balinyaar';
|
||||||
|
(CONFIG_HISTORY[key] ??= []).unshift({
|
||||||
|
id: Math.floor(1000 + (CONFIG_HISTORY[key]?.length ?? 0)),
|
||||||
|
action: 'updated',
|
||||||
|
actorUserId: 1,
|
||||||
|
occurredAt: row.updatedAt,
|
||||||
|
oldValue: old,
|
||||||
|
newValue: value,
|
||||||
|
});
|
||||||
|
return delay(undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
getConfigHistory: async (key, params) => delay(paginate([...(CONFIG_HISTORY[key] ?? [])], params)),
|
||||||
|
|
||||||
|
listHolidays: async (filters, params) => {
|
||||||
|
let items = [...HOLIDAYS];
|
||||||
|
if (filters.from) items = items.filter((h) => h.holidayDate >= filters.from!);
|
||||||
|
if (filters.to) items = items.filter((h) => h.holidayDate <= filters.to!);
|
||||||
|
items.sort((a, b) => a.holidayDate.localeCompare(b.holidayDate));
|
||||||
|
return delay(paginate(items, params));
|
||||||
|
},
|
||||||
|
|
||||||
|
upsertHoliday: async (input) => {
|
||||||
|
const existing = HOLIDAYS.find((h) => h.holidayDate === input.holidayDate);
|
||||||
|
if (existing) Object.assign(existing, input);
|
||||||
|
else HOLIDAYS.push({ id: Math.max(0, ...HOLIDAYS.map((h) => h.id)) + 1, ...input });
|
||||||
|
return delay(undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
listAuditLogs: async (filters, params) => {
|
||||||
|
let items = [...AUDIT];
|
||||||
|
if (filters.entityType) items = items.filter((a) => a.entityType.toLowerCase().includes(filters.entityType!.toLowerCase()));
|
||||||
|
if (filters.entityId) items = items.filter((a) => a.entityId === filters.entityId);
|
||||||
|
if (filters.actorUserId != null) items = items.filter((a) => a.actorUserId === filters.actorUserId);
|
||||||
|
if (filters.action) items = items.filter((a) => a.action === filters.action);
|
||||||
|
if (filters.from) items = items.filter((a) => a.occurredAt >= filters.from!);
|
||||||
|
if (filters.to) items = items.filter((a) => a.occurredAt <= filters.to!);
|
||||||
|
items.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
|
||||||
|
return delay(paginate(items, params));
|
||||||
|
},
|
||||||
|
|
||||||
|
listSupportAlerts: async (filters, params) => {
|
||||||
|
let items = [...ALERTS];
|
||||||
|
if (filters.type) items = items.filter((a) => a.type === filters.type);
|
||||||
|
if (filters.status) items = items.filter((a) => a.status === filters.status);
|
||||||
|
if (filters.ownerUserId != null) items = items.filter((a) => a.ownerUserId === filters.ownerUserId);
|
||||||
|
items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
|
return delay(paginate(items, params));
|
||||||
|
},
|
||||||
|
|
||||||
|
assignSupportAlert: async (alertId, ownerUserId) => {
|
||||||
|
const a = ALERTS.find((x) => x.id === alertId);
|
||||||
|
if (!a || a.status === 'resolved') throw new Error(`Mock alert ${alertId} not assignable`);
|
||||||
|
a.status = 'assigned';
|
||||||
|
a.ownerUserId = ownerUserId;
|
||||||
|
return delay(undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveSupportAlert: async (alertId, note) => {
|
||||||
|
const a = ALERTS.find((x) => x.id === alertId);
|
||||||
|
if (!a || a.status === 'resolved') throw new Error(`Mock alert ${alertId} not resolvable`);
|
||||||
|
a.status = 'resolved';
|
||||||
|
a.resolutionNote = note;
|
||||||
|
a.resolvedAt = new Date().toISOString();
|
||||||
|
return delay(undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
listRoles: async (userId) => delay(userId == null ? [...ROLES] : ROLES.filter((r) => r.userId === userId)),
|
||||||
|
|
||||||
|
grantRole: async (userId, role) => {
|
||||||
|
const existing = ROLES.find((r) => r.userId === userId && r.role === role);
|
||||||
|
if (existing) existing.revokedAt = null;
|
||||||
|
else ROLES.push({ userId, role, grantedBy: 1, grantedAt: new Date().toISOString(), revokedAt: null });
|
||||||
|
return delay(undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
revokeRole: async (userId, role) => {
|
||||||
|
const r = ROLES.find((x) => x.userId === userId && x.role === role && !x.revokedAt);
|
||||||
|
if (r) r.revokedAt = new Date().toISOString();
|
||||||
|
return delay(undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* When true, the admin domain is served by the in-memory mock (`apis/mockApi.ts`) behind the `AdminApi`
|
||||||
|
* seam. **Mock is primary this phase:** the b1 config/holiday/audit/support-alert endpoints are live and
|
||||||
|
* the real `clientApi.ts` maps them 1:1, but (a) config `updatedAt/updatedBy` and the actor/action/date
|
||||||
|
* audit filters aren't on the wire yet (REQ-029/030) and (b) the RBAC role endpoints don't exist in the
|
||||||
|
* b15 contract at all (REQ-031). The mock supplies a realistic, filter-exercising world for every console;
|
||||||
|
* flip to `false` per area once each upstream is complete — the swap is the one line in `apis/index.ts`.
|
||||||
|
*/
|
||||||
|
export const USE_ADMIN_MOCK = true;
|
||||||
|
|
||||||
|
/** Worklist page sizes (api-conventions `pageSize`, default 50 / max 100). */
|
||||||
|
export const ADMIN_PAGE_SIZE = 20;
|
||||||
|
export const AUDIT_PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config + holidays are near-static reference data — a long `staleTime` avoids refetching on revisit; a
|
||||||
|
* mutation invalidates the relevant key so the change shows immediately. Audit + support-alerts move at
|
||||||
|
* ops speed (moderate staleness). All are gc'd after a few minutes off-screen.
|
||||||
|
*/
|
||||||
|
export const ADMIN_CONFIG_STALE_TIME = 5 * 60 * 1000;
|
||||||
|
export const ADMIN_HOLIDAYS_STALE_TIME = 5 * 60 * 1000;
|
||||||
|
export const ADMIN_AUDIT_STALE_TIME = 30 * 1000;
|
||||||
|
export const ADMIN_ALERTS_STALE_TIME = 20 * 1000;
|
||||||
|
export const ADMIN_GC_TIME = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Config keys that are **rates** and must validate to the closed-open interval [0, 1). */
|
||||||
|
export const RATE_CONFIG_KEYS: readonly string[] = [
|
||||||
|
'platform_fee_rate',
|
||||||
|
'vat_rate',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Grouping of config keys into UI sections (any key not listed falls into "other"). */
|
||||||
|
export const CONFIG_GROUPS: Record<string, readonly string[]> = {
|
||||||
|
fees: ['platform_fee_rate', 'vat_rate'],
|
||||||
|
deadlines: ['dispute_window_hours', 'nurse_payout_interval_days', 'payout_satna_threshold_irr', 'booking_request_response_deadline_minutes', 'payment_window_minutes'],
|
||||||
|
evv: ['evv_location_tolerance_meters'],
|
||||||
|
bnpl: ['bnpl_provider_enabled', 'bnpl_min_amount_irr'],
|
||||||
|
cancellation: ['cancellation_tier1_refund_rate', 'cancellation_tier2_refund_rate', 'cancellation_tier3_refund_rate'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { adminApi } from '../apis';
|
||||||
|
import { adminKeys } from '../keys';
|
||||||
|
import { ADMIN_CONFIG_STALE_TIME, ADMIN_GC_TIME } from '../constants';
|
||||||
|
|
||||||
|
/** The RBAC role grants (all, or scoped to one user). Deferred-if-missing — mock-primary (REQ-031). */
|
||||||
|
export function useAdminRoles(userId?: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: adminKeys.roleList(userId),
|
||||||
|
queryFn: () => adminApi.listRoles(userId),
|
||||||
|
staleTime: ADMIN_CONFIG_STALE_TIME,
|
||||||
|
gcTime: ADMIN_GC_TIME,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { adminApi } from '../apis';
|
||||||
|
import { adminKeys } from '../keys';
|
||||||
|
|
||||||
|
/** Assign an alert to an owner (open → assigned). Invalidate the alert worklist on success. */
|
||||||
|
export function useAssignSupportAlert() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<void, unknown, { alertId: number; ownerUserId: number }>({
|
||||||
|
mutationFn: ({ alertId, ownerUserId }) => adminApi.assignSupportAlert(alertId, ownerUserId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: adminKeys.supportAlerts() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { adminApi } from '../apis';
|
||||||
|
import { adminKeys } from '../keys';
|
||||||
|
import { ADMIN_AUDIT_STALE_TIME, ADMIN_GC_TIME, AUDIT_PAGE_SIZE } from '../constants';
|
||||||
|
import type { AuditFilters } from '../types';
|
||||||
|
|
||||||
|
/** The append-only audit trail (read-only), filtered + paginated. Filters + page key the cache. */
|
||||||
|
export function useAuditLogs(filters: AuditFilters, page = 1) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: adminKeys.auditList(filters, { page, pageSize: AUDIT_PAGE_SIZE }),
|
||||||
|
queryFn: () => adminApi.listAuditLogs(filters, { page, pageSize: AUDIT_PAGE_SIZE }),
|
||||||
|
staleTime: ADMIN_AUDIT_STALE_TIME,
|
||||||
|
gcTime: ADMIN_GC_TIME,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { adminApi } from '../apis';
|
||||||
|
import { adminKeys } from '../keys';
|
||||||
|
import { ADMIN_CONFIG_STALE_TIME, ADMIN_GC_TIME, ADMIN_PAGE_SIZE } from '../constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The audited change history for one config key (newest first) — finance proves the rate in effect at any
|
||||||
|
* past moment. `enabled` gates the fetch to when the drawer is open (a closed drawer never fetches).
|
||||||
|
*/
|
||||||
|
export function useConfigChangeHistory(key: string | null, page = 1, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: adminKeys.configHistory(key ?? '', { page, pageSize: ADMIN_PAGE_SIZE }),
|
||||||
|
queryFn: () => adminApi.getConfigHistory(key!, { page, pageSize: ADMIN_PAGE_SIZE }),
|
||||||
|
enabled: enabled && !!key,
|
||||||
|
staleTime: ADMIN_CONFIG_STALE_TIME,
|
||||||
|
gcTime: ADMIN_GC_TIME,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { adminApi } from '../apis';
|
||||||
|
import { adminKeys } from '../keys';
|
||||||
|
import type { AdminRole } from '../types';
|
||||||
|
|
||||||
|
/** Grant an admin role to a user (records `grantedBy`/`grantedAt`). Invalidate the roles list. */
|
||||||
|
export function useGrantRole() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<void, unknown, { userId: number; role: AdminRole }>({
|
||||||
|
mutationFn: ({ userId, role }) => adminApi.grantRole(userId, role),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: adminKeys.roles() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { adminApi } from '../apis';
|
||||||
|
import { adminKeys } from '../keys';
|
||||||
|
import { ADMIN_GC_TIME, ADMIN_HOLIDAYS_STALE_TIME, ADMIN_PAGE_SIZE } from '../constants';
|
||||||
|
import type { HolidayFilters } from '../types';
|
||||||
|
|
||||||
|
/** The `iranian_holidays` calendar for a date range. The filter object keys the cache separately. */
|
||||||
|
export function useHolidays(filters: HolidayFilters, page = 1) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: adminKeys.holidayList(filters, { page, pageSize: ADMIN_PAGE_SIZE }),
|
||||||
|
queryFn: () => adminApi.listHolidays(filters, { page, pageSize: ADMIN_PAGE_SIZE }),
|
||||||
|
staleTime: ADMIN_HOLIDAYS_STALE_TIME,
|
||||||
|
gcTime: ADMIN_GC_TIME,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { adminApi } from '../apis';
|
||||||
|
import { adminKeys } from '../keys';
|
||||||
|
import { ADMIN_CONFIG_STALE_TIME, ADMIN_GC_TIME, ADMIN_PAGE_SIZE } from '../constants';
|
||||||
|
|
||||||
|
/** All `platform_configs` rows (paginated). Near-static reference data → long `staleTime`. */
|
||||||
|
export function usePlatformConfigs(page = 1) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: adminKeys.configList({ page, pageSize: ADMIN_PAGE_SIZE }),
|
||||||
|
queryFn: () => adminApi.listConfigs({ page, pageSize: ADMIN_PAGE_SIZE }),
|
||||||
|
staleTime: ADMIN_CONFIG_STALE_TIME,
|
||||||
|
gcTime: ADMIN_GC_TIME,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { adminApi } from '../apis';
|
||||||
|
import { adminKeys } from '../keys';
|
||||||
|
|
||||||
|
/** Resolve an alert with a note (→ resolved). Invalidate the alert worklist on success. */
|
||||||
|
export function useResolveSupportAlert() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<void, unknown, { alertId: number; note: string }>({
|
||||||
|
mutationFn: ({ alertId, note }) => adminApi.resolveSupportAlert(alertId, note),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: adminKeys.supportAlerts() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { adminApi } from '../apis';
|
||||||
|
import { adminKeys } from '../keys';
|
||||||
|
import type { AdminRole } from '../types';
|
||||||
|
|
||||||
|
/** Revoke an admin role from a user (sets `revokedAt`). Invalidate the roles list. */
|
||||||
|
export function useRevokeRole() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<void, unknown, { userId: number; role: AdminRole }>({
|
||||||
|
mutationFn: ({ userId, role }) => adminApi.revokeRole(userId, role),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: adminKeys.roles() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { adminApi } from '../apis';
|
||||||
|
import { adminKeys } from '../keys';
|
||||||
|
import { ADMIN_ALERTS_STALE_TIME, ADMIN_GC_TIME, ADMIN_PAGE_SIZE } from '../constants';
|
||||||
|
import type { SupportAlertFilters } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The internal support-alert worklist, filtered by type/status/owner + paginated. **Internal-only** — this
|
||||||
|
* query is admin-scoped and its data never reaches a non-admin surface (phase §5). Filters + page key the cache.
|
||||||
|
*/
|
||||||
|
export function useSupportAlerts(filters: SupportAlertFilters, page = 1) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: adminKeys.supportAlertList(filters, { page, pageSize: ADMIN_PAGE_SIZE }),
|
||||||
|
queryFn: () => adminApi.listSupportAlerts(filters, { page, pageSize: ADMIN_PAGE_SIZE }),
|
||||||
|
staleTime: ADMIN_ALERTS_STALE_TIME,
|
||||||
|
gcTime: ADMIN_GC_TIME,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { adminApi } from '../apis';
|
||||||
|
import { adminKeys } from '../keys';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update one config row (audited server-side). On success invalidate the whole config sub-tree — both the
|
||||||
|
* list and the change-history drawer — so the new value + the new history row show without a manual refresh.
|
||||||
|
* A config change is **not** retroactive (the confirmation copy says so); the client never re-prices.
|
||||||
|
*/
|
||||||
|
export function useUpdatePlatformConfig() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<void, unknown, { key: string; value: string }>({
|
||||||
|
mutationFn: ({ key, value }) => adminApi.updateConfig(key, value),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: adminKeys.config() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { adminApi } from '../apis';
|
||||||
|
import { adminKeys } from '../keys';
|
||||||
|
import type { HolidayInput } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or edit a holiday (upsert keyed on `holidayDate`). Invalidate the holidays sub-tree so every cached
|
||||||
|
* range reflects the change. The client never computes the next-business-day shift — it only maintains the
|
||||||
|
* calendar the server uses for payout scheduling (phase §5).
|
||||||
|
*/
|
||||||
|
export function useUpsertHoliday() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<void, unknown, HolidayInput>({
|
||||||
|
mutationFn: (input) => adminApi.upsertHoliday(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: adminKeys.holidays() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Admin domain barrel — re-exports **hooks only** (per the `services/{domain}` convention). Import
|
||||||
|
* types/keys/apis directly from their files when needed (e.g. `import type { PlatformConfig } from
|
||||||
|
* '@/services/admin/types'`).
|
||||||
|
*/
|
||||||
|
export { usePlatformConfigs } from './hooks/usePlatformConfigs';
|
||||||
|
export { useUpdatePlatformConfig } from './hooks/useUpdatePlatformConfig';
|
||||||
|
export { useConfigChangeHistory } from './hooks/useConfigChangeHistory';
|
||||||
|
export { useHolidays } from './hooks/useHolidays';
|
||||||
|
export { useUpsertHoliday } from './hooks/useUpsertHoliday';
|
||||||
|
export { useAuditLogs } from './hooks/useAuditLogs';
|
||||||
|
export { useSupportAlerts } from './hooks/useSupportAlerts';
|
||||||
|
export { useAssignSupportAlert } from './hooks/useAssignSupportAlert';
|
||||||
|
export { useResolveSupportAlert } from './hooks/useResolveSupportAlert';
|
||||||
|
export { useAdminRoles } from './hooks/useAdminRoles';
|
||||||
|
export { useGrantRole } from './hooks/useGrantRole';
|
||||||
|
export { useRevokeRole } from './hooks/useRevokeRole';
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { PageParams } from '@/lib/api/types';
|
||||||
|
import type { AuditFilters, HolidayFilters, SupportAlertFilters } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React Query key factory for the admin domain (hierarchical, per the `services/{domain}` pattern). The
|
||||||
|
* **filters + page object keys each list** so every filter/page combination caches independently — paging
|
||||||
|
* or switching a worklist filter never refetches data already held (phase §5). Mutations invalidate the
|
||||||
|
* relevant sub-tree (`config()`/`holidays()`/`audit()`/`supportAlerts()`/`roles()`).
|
||||||
|
*/
|
||||||
|
export const adminKeys = {
|
||||||
|
all: ['admin'] as const,
|
||||||
|
|
||||||
|
config: () => [...adminKeys.all, 'config'] as const,
|
||||||
|
configList: (params: PageParams) => [...adminKeys.config(), 'list', params] as const,
|
||||||
|
configHistory: (key: string, params: PageParams) => [...adminKeys.config(), 'history', key, params] as const,
|
||||||
|
|
||||||
|
holidays: () => [...adminKeys.all, 'holidays'] as const,
|
||||||
|
holidayList: (filters: HolidayFilters, params: PageParams) =>
|
||||||
|
[...adminKeys.holidays(), 'list', filters, params] as const,
|
||||||
|
|
||||||
|
audit: () => [...adminKeys.all, 'audit'] as const,
|
||||||
|
auditList: (filters: AuditFilters, params: PageParams) => [...adminKeys.audit(), 'list', filters, params] as const,
|
||||||
|
|
||||||
|
supportAlerts: () => [...adminKeys.all, 'supportAlerts'] as const,
|
||||||
|
supportAlertList: (filters: SupportAlertFilters, params: PageParams) =>
|
||||||
|
[...adminKeys.supportAlerts(), 'list', filters, params] as const,
|
||||||
|
|
||||||
|
roles: () => [...adminKeys.all, 'roles'] as const,
|
||||||
|
roleList: (userId?: number) => [...adminKeys.roles(), 'list', userId ?? null] as const,
|
||||||
|
};
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin domain — the backoffice-owned data the f15 consoles read and act on: platform config, the
|
||||||
|
* Iranian-holiday calendar, the append-only audit trail, the internal support-alert worklist, and RBAC
|
||||||
|
* role grants. Shapes derive from the **b1** config/reference contract
|
||||||
|
* (`dev/contracts/domains/config-reference.md`) and the **b15** admin contract
|
||||||
|
* (`dev/contracts/domains/messaging-notifications-admin.md`); the wire is camelCase and `clientFetch`
|
||||||
|
* unwraps the `ApiResult<T>` envelope, so these are the post-`unwrap()` payloads.
|
||||||
|
*
|
||||||
|
* Load-bearing rules (phase §5):
|
||||||
|
* - **Internal-only:** `SupportAlert`s are staff-only and must never reach a non-admin surface.
|
||||||
|
* - **Server is the authority:** config parsing is *rendering by `dataType`* only; the client validates at
|
||||||
|
* the boundary (a rate is 0–1) but never re-derives money, holiday shifts, or eligibility.
|
||||||
|
* - **Append-only audit:** there is no edit/delete — the viewer is read-only.
|
||||||
|
*
|
||||||
|
* Enums cross the wire as stable string codes, mirrored here as string-literal unions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Config ────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
/** `platform_configs.data_type` — how to parse (and which typed input to render for) a config `value`. */
|
||||||
|
export type ConfigDataType = 'string' | 'int' | 'decimal' | 'bool' | 'json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `PlatformConfigDto` (b1). `value` is the **raw string** — parse per `dataType`. `updatedAt`/`updatedBy`
|
||||||
|
* are **not** on the b1 wire (REQ-029) — optional and mock-supplied until delivered; the row degrades
|
||||||
|
* gracefully without them.
|
||||||
|
*/
|
||||||
|
export interface PlatformConfig {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
dataType: ConfigDataType;
|
||||||
|
description: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
updatedBy?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `action` on an audit/config-change row. */
|
||||||
|
export type AuditAction = 'created' | 'updated' | 'deleted';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A config change-history row, derived from `ConfigChangeDto` (b1). The wire carries `changedFieldsJson`
|
||||||
|
* (`{ "Value": { "old": …, "new": … } }`); the client parses the value delta into `oldValue`/`newValue`
|
||||||
|
* for the drawer so finance can prove the rate in effect at any past moment.
|
||||||
|
*/
|
||||||
|
export interface ConfigChange {
|
||||||
|
id: number;
|
||||||
|
action: AuditAction;
|
||||||
|
actorUserId: number | null;
|
||||||
|
occurredAt: string;
|
||||||
|
oldValue: string | null;
|
||||||
|
newValue: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Holidays ──────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
/** `iranian_holidays.type`. */
|
||||||
|
export type HolidayType = 'official' | 'religious' | 'national';
|
||||||
|
|
||||||
|
/** `HolidayDto` (b1). `isBankClosed` is what shifts payout scheduling (server-computed). */
|
||||||
|
export interface Holiday {
|
||||||
|
id: number;
|
||||||
|
holidayDate: string;
|
||||||
|
nameFa: string;
|
||||||
|
type: HolidayType;
|
||||||
|
isBankClosed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert body (`upsert_holiday`, keyed on `holidayDate`). */
|
||||||
|
export interface HolidayInput {
|
||||||
|
holidayDate: string;
|
||||||
|
nameFa: string;
|
||||||
|
type: HolidayType;
|
||||||
|
isBankClosed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Holiday list range. */
|
||||||
|
export interface HolidayFilters {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Audit ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
/** `AuditLogDto` (b1). `changedFields` is the parsed `changedFieldsJson` (field → {old,new}); PII redacted. */
|
||||||
|
export interface AuditLogEntry {
|
||||||
|
id: number;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
action: string;
|
||||||
|
actorUserId: number | null;
|
||||||
|
occurredAt: string;
|
||||||
|
changedFields: Record<string, { old: unknown; new: unknown }> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Audit filters. The b1 wire supports only `entityType`/`entityId` (REQ-030 covers actor/action/date) —
|
||||||
|
* the extra filters are honoured by the mock and requested from the backend; the real client passes the
|
||||||
|
* supported ones and lets the rest degrade.
|
||||||
|
*/
|
||||||
|
export interface AuditFilters {
|
||||||
|
entityType?: string;
|
||||||
|
entityId?: string;
|
||||||
|
actorUserId?: number;
|
||||||
|
action?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Support alerts ────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
/** `support_alert.type` — the broad b15 union (superset of the b1 list). */
|
||||||
|
export type SupportAlertType =
|
||||||
|
| 'low_rating'
|
||||||
|
| 'evv_no_show'
|
||||||
|
| 'evv_location_mismatch'
|
||||||
|
| 'verification_expired'
|
||||||
|
| 'shared_sim'
|
||||||
|
| 'payment_anomaly'
|
||||||
|
| 'fraud_signal'
|
||||||
|
| 'nurse_clawback'
|
||||||
|
| 'emergency';
|
||||||
|
|
||||||
|
export type SupportAlertSeverity = 'low' | 'medium' | 'high';
|
||||||
|
export type SupportAlertStatus = 'open' | 'assigned' | 'resolved';
|
||||||
|
|
||||||
|
/** `SupportAlertDto` (b1/b15). **Internal-only** — never rendered outside an admin route. */
|
||||||
|
export interface SupportAlert {
|
||||||
|
id: number;
|
||||||
|
type: SupportAlertType;
|
||||||
|
severity: SupportAlertSeverity;
|
||||||
|
status: SupportAlertStatus;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
bookingId: number | null;
|
||||||
|
reviewId: number | null;
|
||||||
|
ownerUserId: number | null;
|
||||||
|
resolutionNote: string | null;
|
||||||
|
resolvedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupportAlertFilters {
|
||||||
|
type?: SupportAlertType;
|
||||||
|
status?: SupportAlertStatus;
|
||||||
|
ownerUserId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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';
|
||||||
|
|
||||||
|
/** A role grant row (`RoleGrant`). `revokedAt` set once revoked. */
|
||||||
|
export interface RoleGrant {
|
||||||
|
userId: number;
|
||||||
|
role: AdminRole;
|
||||||
|
grantedBy: number | null;
|
||||||
|
grantedAt: string;
|
||||||
|
revokedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The seam ──────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* The admin API seam — the real HTTP client and the in-memory mock both implement it; selection is by
|
||||||
|
* config (`USE_ADMIN_MOCK`), never scattered `if (mock)` checks. Mutations return void (the wire returns
|
||||||
|
* `true`); hooks invalidate the affected keys.
|
||||||
|
*/
|
||||||
|
export interface AdminApi {
|
||||||
|
// config
|
||||||
|
listConfigs(params: PageParams): Promise<Paginated<PlatformConfig>>;
|
||||||
|
updateConfig(key: string, value: string): Promise<void>;
|
||||||
|
getConfigHistory(key: string, params: PageParams): Promise<Paginated<ConfigChange>>;
|
||||||
|
// holidays
|
||||||
|
listHolidays(filters: HolidayFilters, params: PageParams): Promise<Paginated<Holiday>>;
|
||||||
|
upsertHoliday(input: HolidayInput): Promise<void>;
|
||||||
|
// audit
|
||||||
|
listAuditLogs(filters: AuditFilters, params: PageParams): Promise<Paginated<AuditLogEntry>>;
|
||||||
|
// support alerts
|
||||||
|
listSupportAlerts(filters: SupportAlertFilters, params: PageParams): Promise<Paginated<SupportAlert>>;
|
||||||
|
assignSupportAlert(alertId: number, ownerUserId: number): Promise<void>;
|
||||||
|
resolveSupportAlert(alertId: number, note: string): Promise<void>;
|
||||||
|
// rbac (deferred-if-missing)
|
||||||
|
listRoles(userId?: number): Promise<RoleGrant[]>;
|
||||||
|
grantRole(userId: number, role: AdminRole): Promise<void>;
|
||||||
|
revokeRole(userId: number, role: AdminRole): Promise<void>;
|
||||||
|
}
|
||||||
@@ -17,6 +17,11 @@ export function useSessionRoleSync(): void {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!me) return;
|
if (!me) return;
|
||||||
dispatch({ type: 'LOG_IN', user: { id: me.id, phone: me.phone, roles: toAppRoles(me.roles) } });
|
dispatch({
|
||||||
|
type: 'LOG_IN',
|
||||||
|
// `roleCodes` preserves the server's fine-grained codes for the f15 admin-capability gate;
|
||||||
|
// the collapsed `roles` still drive shell chrome.
|
||||||
|
user: { id: me.id, phone: me.phone, roles: toAppRoles(me.roles), roleCodes: me.roles },
|
||||||
|
});
|
||||||
}, [me, dispatch]);
|
}, [me, dispatch]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { clientFetch } from '@/lib/api/client';
|
||||||
|
import { unwrap, type ApiEnvelope, type PageParams, type Paginated } from '@/lib/api/types';
|
||||||
|
import { PARTNER_PAGE_SIZE } from '../constants';
|
||||||
|
import type {
|
||||||
|
CenterInvoice,
|
||||||
|
PartnerCenter,
|
||||||
|
PartnerCenterApi,
|
||||||
|
PartnerCenterFilters,
|
||||||
|
PartnerCenterInput,
|
||||||
|
SponsoredBooking,
|
||||||
|
SponsoredBookingFilters,
|
||||||
|
SponsoredNurse,
|
||||||
|
} from '../types';
|
||||||
|
import { deriveCenterState } from '../types';
|
||||||
|
|
||||||
|
const API = '/api/v1';
|
||||||
|
|
||||||
|
/** Wire `PartnerCenterDto` (b15) — `onboardingState` is derived client-side from `isActive`/`verifiedAt`. */
|
||||||
|
interface CenterWire {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
legalEntityType: string;
|
||||||
|
mohEstablishmentPermitNo: string;
|
||||||
|
technicalDirectorNurseUserId: number | null;
|
||||||
|
technicalDirectorLicenseNo: string | null;
|
||||||
|
enamadCode: string | null;
|
||||||
|
settlementIbanMasked: string | null;
|
||||||
|
isMerchantOfRecord: boolean;
|
||||||
|
commissionRate: number;
|
||||||
|
adminUserId: number | null;
|
||||||
|
isActive: boolean;
|
||||||
|
verifiedAt: string | null;
|
||||||
|
sponsoredNurseCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCenter(w: CenterWire): PartnerCenter {
|
||||||
|
return { ...w, onboardingState: deriveCenterState(w.isActive, w.verifiedAt) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real HTTP implementation of the `PartnerCenterApi` seam. **Not primary this phase** (`USE_PARTNER_MOCK =
|
||||||
|
* true`). Admin CRUD/verify/sponsor map the live b15 routes; the activate/suspend toggle and the portal's
|
||||||
|
* split reads (my-center / nurses / bookings / settlement) are proposed routes (REQ-032/033) kept
|
||||||
|
* real-shaped so the seam flips in one file once they land.
|
||||||
|
*/
|
||||||
|
export const partnerCenterClientApi: PartnerCenterApi = {
|
||||||
|
listCenters: async (filters: PartnerCenterFilters, params) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('page', String(params.page ?? 1));
|
||||||
|
q.set('pageSize', String(params.pageSize ?? PARTNER_PAGE_SIZE));
|
||||||
|
if (filters.isMerchantOfRecord != null) q.set('isMerchantOfRecord', String(filters.isMerchantOfRecord));
|
||||||
|
if (filters.isActive != null) q.set('isActive', String(filters.isActive));
|
||||||
|
const wire = unwrap(await clientFetch<ApiEnvelope<Paginated<CenterWire>>>(`${API}/admin/partner-centers?${q}`));
|
||||||
|
return { ...wire, items: wire.items.map(mapCenter) };
|
||||||
|
},
|
||||||
|
|
||||||
|
getCenter: async (id) => mapCenter(unwrap(await clientFetch<ApiEnvelope<CenterWire>>(`${API}/admin/partner-centers/${id}`))),
|
||||||
|
|
||||||
|
createCenter: async (input: PartnerCenterInput) =>
|
||||||
|
mapCenter(
|
||||||
|
unwrap(
|
||||||
|
await clientFetch<ApiEnvelope<CenterWire>>(`${API}/admin/partner-centers`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
updateCenter: async (id, input: PartnerCenterInput) =>
|
||||||
|
mapCenter(
|
||||||
|
unwrap(
|
||||||
|
await clientFetch<ApiEnvelope<CenterWire>>(`${API}/admin/partner-centers/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
verifyCenter: async (id) => {
|
||||||
|
await clientFetch<ApiEnvelope<boolean>>(`${API}/admin/partner-centers/${id}/verify`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
|
// REQ-032 — no activate/suspend route in the b15 contract yet; proposed shape.
|
||||||
|
setCenterActive: async (id, isActive) => {
|
||||||
|
await clientFetch<ApiEnvelope<boolean>>(`${API}/admin/partner-centers/${id}/set-active`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ isActive }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
assignNurse: async (id, nurseProfileId, unlink) => {
|
||||||
|
await clientFetch<ApiEnvelope<boolean>>(`${API}/admin/partner-centers/${id}/sponsor-nurse`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ nurseProfileId, unlink }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// REQ-032 — admin roster read (the b15 dashboard is portal-auth); proposed shape.
|
||||||
|
getCenterSponsoredNurses: async (id) =>
|
||||||
|
unwrap(await clientFetch<ApiEnvelope<SponsoredNurse[]>>(`${API}/admin/partner-centers/${id}/nurses`)),
|
||||||
|
|
||||||
|
// ── portal (center-scoped; REQ-032/033 — proposed split reads over `GET /centers/{id}/dashboard`) ──
|
||||||
|
getMyCenter: async () => mapCenter(unwrap(await clientFetch<ApiEnvelope<CenterWire>>(`${API}/centers/me`))),
|
||||||
|
listMySponsoredNurses: async () =>
|
||||||
|
unwrap(await clientFetch<ApiEnvelope<SponsoredNurse[]>>(`${API}/centers/me/nurses`)),
|
||||||
|
listMySponsoredBookings: async (filters: SponsoredBookingFilters, params) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('page', String(params.page ?? 1));
|
||||||
|
q.set('pageSize', String(params.pageSize ?? PARTNER_PAGE_SIZE));
|
||||||
|
if (filters.status) q.set('status', filters.status);
|
||||||
|
return unwrap(await clientFetch<ApiEnvelope<Paginated<SponsoredBooking>>>(`${API}/centers/me/bookings?${q}`));
|
||||||
|
},
|
||||||
|
listMySettlement: async (params) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('page', String(params.page ?? 1));
|
||||||
|
q.set('pageSize', String(params.pageSize ?? PARTNER_PAGE_SIZE));
|
||||||
|
return unwrap(await clientFetch<ApiEnvelope<Paginated<CenterInvoice>>>(`${API}/centers/me/settlement?${q}`));
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { USE_PARTNER_MOCK } from '../constants';
|
||||||
|
import type { PartnerCenterApi } from '../types';
|
||||||
|
import { partnerCenterClientApi } from './clientApi';
|
||||||
|
import { partnerCenterMockApi } from './mockApi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The selected `PartnerCenterApi` implementation — the single seam the hooks import. Mock-primary this
|
||||||
|
* phase (REQ-032/033); the swap to the real client is this one line.
|
||||||
|
*/
|
||||||
|
export const partnerCenterApi: PartnerCenterApi = USE_PARTNER_MOCK ? partnerCenterMockApi : partnerCenterClientApi;
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||||
|
import { MOCK_MY_CENTER_ID } from '../constants';
|
||||||
|
import type {
|
||||||
|
CenterInvoice,
|
||||||
|
PartnerCenter,
|
||||||
|
PartnerCenterApi,
|
||||||
|
PartnerCenterFilters,
|
||||||
|
PartnerCenterInput,
|
||||||
|
SponsoredBooking,
|
||||||
|
SponsoredBookingFilters,
|
||||||
|
SponsoredNurse,
|
||||||
|
} from '../types';
|
||||||
|
import { deriveCenterState } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory `PartnerCenterApi` — **the primary implementation this phase** (REQ-032/033). Fixtures:
|
||||||
|
* - center **#1 = merchant-of-record** (settlement/invoice view renders) and **#2 = non-MoR** (the
|
||||||
|
* "settlement runs through Balinyaar" state) plus a **draft** center #3 (unverified banner);
|
||||||
|
* - sponsored nurses (verified + unverified) and sponsored bookings;
|
||||||
|
* - commission invoices whose **platform commission + BNPL commission + VAT = total** (VAT on the
|
||||||
|
* commission line only), a fake 22-digit مودیان reference, and a stub PDF url;
|
||||||
|
* - `settlementIbanMasked` is **last-4 only** — the full IBAN never leaves the mock.
|
||||||
|
* Admin mutations mutate the in-memory arrays; "my center" resolves to `MOCK_MY_CENTER_ID`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const isoDaysAgo = (d: number): string => new Date(Date.now() - d * DAY_MS).toISOString();
|
||||||
|
const dateDaysAgo = (d: number): string => isoDaysAgo(d).slice(0, 10);
|
||||||
|
|
||||||
|
const LATENCY_MS = 220;
|
||||||
|
const delay = <T>(v: T): Promise<T> => new Promise((r) => setTimeout(() => r(v), LATENCY_MS));
|
||||||
|
|
||||||
|
function paginate<T>(all: T[], params: PageParams): Paginated<T> {
|
||||||
|
const page = Math.max(1, params.page ?? 1);
|
||||||
|
const pageSize = Math.max(1, params.pageSize ?? (all.length || 1));
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mask an IBAN to last-4 (`"••••0001"`) — the mock never surfaces the full value. */
|
||||||
|
function maskIban(full: string): string {
|
||||||
|
return `••••${full.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CENTERS: PartnerCenter[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'مرکز پرستاری آسانگستر',
|
||||||
|
legalEntityType: 'llc',
|
||||||
|
mohEstablishmentPermitNo: 'MOH-12345',
|
||||||
|
technicalDirectorNurseUserId: 7015,
|
||||||
|
technicalDirectorLicenseNo: 'INO-88231',
|
||||||
|
enamadCode: 'EN-999',
|
||||||
|
settlementIbanMasked: '••••0001',
|
||||||
|
isMerchantOfRecord: true,
|
||||||
|
commissionRate: 0.05,
|
||||||
|
adminUserId: 8,
|
||||||
|
isActive: true,
|
||||||
|
verifiedAt: isoDaysAgo(40),
|
||||||
|
sponsoredNurseCount: 3,
|
||||||
|
onboardingState: 'verified',
|
||||||
|
createdAt: isoDaysAgo(120),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'خانه سلامت مهرآوران',
|
||||||
|
legalEntityType: 'cooperative',
|
||||||
|
mohEstablishmentPermitNo: 'MOH-55621',
|
||||||
|
technicalDirectorNurseUserId: null,
|
||||||
|
technicalDirectorLicenseNo: 'INO-44120',
|
||||||
|
enamadCode: 'EN-514',
|
||||||
|
settlementIbanMasked: '••••7788',
|
||||||
|
isMerchantOfRecord: false,
|
||||||
|
commissionRate: 0.05,
|
||||||
|
adminUserId: 9,
|
||||||
|
isActive: true,
|
||||||
|
verifiedAt: isoDaysAgo(15),
|
||||||
|
sponsoredNurseCount: 1,
|
||||||
|
onboardingState: 'verified',
|
||||||
|
createdAt: isoDaysAgo(60),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'مرکز نمونه (پیشنویس)',
|
||||||
|
legalEntityType: 'llc',
|
||||||
|
mohEstablishmentPermitNo: 'MOH-00099',
|
||||||
|
technicalDirectorNurseUserId: null,
|
||||||
|
technicalDirectorLicenseNo: null,
|
||||||
|
enamadCode: null,
|
||||||
|
settlementIbanMasked: null,
|
||||||
|
isMerchantOfRecord: false,
|
||||||
|
commissionRate: 0.05,
|
||||||
|
adminUserId: 10,
|
||||||
|
isActive: false,
|
||||||
|
verifiedAt: null,
|
||||||
|
sponsoredNurseCount: 0,
|
||||||
|
onboardingState: 'pending_verification',
|
||||||
|
createdAt: isoDaysAgo(5),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const NURSES: Record<number, SponsoredNurse[]> = {
|
||||||
|
1: [
|
||||||
|
{ nurseProfileId: 15, name: 'زهرا موسوی', isVerified: true },
|
||||||
|
{ nurseProfileId: 16, name: 'مریم رضایی', isVerified: true },
|
||||||
|
{ nurseProfileId: 17, name: 'سارا کاظمی', isVerified: false },
|
||||||
|
],
|
||||||
|
2: [{ nurseProfileId: 22, name: 'نگار احمدی', isVerified: true }],
|
||||||
|
3: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const BOOKINGS: Record<number, SponsoredBooking[]> = {
|
||||||
|
1: [
|
||||||
|
{ bookingId: 5001, patientName: 'حاجآقا موسوی', scheduledDate: dateDaysAgo(1), status: 'completed' },
|
||||||
|
{ bookingId: 5002, patientName: 'خانم احمدی', scheduledDate: dateDaysAgo(3), status: 'in_progress' },
|
||||||
|
{ bookingId: 5003, patientName: 'آقای کریمی', scheduledDate: dateDaysAgo(9), status: 'completed' },
|
||||||
|
],
|
||||||
|
2: [{ bookingId: 5101, patientName: 'خانم صادقی', scheduledDate: dateDaysAgo(2), status: 'confirmed' }],
|
||||||
|
3: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
const total = commissionIrr + bnplIrr + vatIrr;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
bookingId,
|
||||||
|
invoiceNumber: `INV-1405-${1000 + id}`,
|
||||||
|
grossIrr: String(grossIrr),
|
||||||
|
platformCommissionIrr: String(commissionIrr),
|
||||||
|
bnplCommissionIrr: bnplIrr > BigInt(0) ? String(bnplIrr) : null,
|
||||||
|
vatRate,
|
||||||
|
vatIrr: String(vatIrr),
|
||||||
|
totalIrr: String(total),
|
||||||
|
moadianReferenceNumber: id % 2 === 0 ? '1234567890123456789012' : null,
|
||||||
|
moadianStatus: id % 2 === 0 ? 'registered' : 'pending',
|
||||||
|
pdfUrl: `https://mock.balinyaar.local/invoices/${id}.pdf`,
|
||||||
|
issuedAt: isoDaysAgo(days),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const INVOICES: CenterInvoice[] = [
|
||||||
|
makeInvoice(1, 5001, BigInt(5_000_000), BigInt(750_000), BigInt(0), 0.1, 2),
|
||||||
|
makeInvoice(2, 5003, BigInt(5_000_000), BigInt(750_000), BigInt(60_000), 0.1, 9),
|
||||||
|
];
|
||||||
|
|
||||||
|
function centerById(id: number): PartnerCenter {
|
||||||
|
const c = CENTERS.find((x) => x.id === id);
|
||||||
|
if (!c) throw new Error(`Mock center ${id} not found`);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const partnerCenterMockApi: PartnerCenterApi = {
|
||||||
|
listCenters: async (filters: PartnerCenterFilters, params) => {
|
||||||
|
let items = [...CENTERS];
|
||||||
|
if (filters.isMerchantOfRecord != null) items = items.filter((c) => c.isMerchantOfRecord === filters.isMerchantOfRecord);
|
||||||
|
if (filters.isActive != null) items = items.filter((c) => c.isActive === filters.isActive);
|
||||||
|
return delay(paginate(items, params));
|
||||||
|
},
|
||||||
|
|
||||||
|
getCenter: async (id) => delay(centerById(id)),
|
||||||
|
|
||||||
|
createCenter: async (input: PartnerCenterInput) => {
|
||||||
|
const id = Math.max(0, ...CENTERS.map((c) => c.id)) + 1;
|
||||||
|
const center: PartnerCenter = {
|
||||||
|
id,
|
||||||
|
name: input.name,
|
||||||
|
legalEntityType: input.legalEntityType,
|
||||||
|
mohEstablishmentPermitNo: input.mohEstablishmentPermitNo,
|
||||||
|
technicalDirectorNurseUserId: input.technicalDirectorNurseUserId ?? null,
|
||||||
|
technicalDirectorLicenseNo: input.technicalDirectorLicenseNo ?? null,
|
||||||
|
enamadCode: input.enamadCode ?? null,
|
||||||
|
settlementIbanMasked: input.settlementIban ? maskIban(input.settlementIban) : null,
|
||||||
|
isMerchantOfRecord: input.isMerchantOfRecord,
|
||||||
|
commissionRate: input.commissionRate,
|
||||||
|
adminUserId: input.adminUserId ?? null,
|
||||||
|
isActive: false,
|
||||||
|
verifiedAt: null,
|
||||||
|
sponsoredNurseCount: 0,
|
||||||
|
onboardingState: 'pending_verification',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
CENTERS.push(center);
|
||||||
|
NURSES[id] = [];
|
||||||
|
BOOKINGS[id] = [];
|
||||||
|
return delay(center);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCenter: async (id, input: PartnerCenterInput) => {
|
||||||
|
const c = centerById(id);
|
||||||
|
Object.assign(c, {
|
||||||
|
name: input.name,
|
||||||
|
legalEntityType: input.legalEntityType,
|
||||||
|
mohEstablishmentPermitNo: input.mohEstablishmentPermitNo,
|
||||||
|
technicalDirectorNurseUserId: input.technicalDirectorNurseUserId ?? null,
|
||||||
|
technicalDirectorLicenseNo: input.technicalDirectorLicenseNo ?? null,
|
||||||
|
enamadCode: input.enamadCode ?? null,
|
||||||
|
isMerchantOfRecord: input.isMerchantOfRecord,
|
||||||
|
commissionRate: input.commissionRate,
|
||||||
|
adminUserId: input.adminUserId ?? null,
|
||||||
|
});
|
||||||
|
// write-then-masked: a supplied full IBAN is stored masked; never echoed back in plaintext
|
||||||
|
if (input.settlementIban) c.settlementIbanMasked = maskIban(input.settlementIban);
|
||||||
|
return delay(c);
|
||||||
|
},
|
||||||
|
|
||||||
|
verifyCenter: async (id) => {
|
||||||
|
const c = centerById(id);
|
||||||
|
c.verifiedAt = new Date().toISOString();
|
||||||
|
c.isActive = true;
|
||||||
|
c.onboardingState = 'verified';
|
||||||
|
return delay(undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCenterActive: async (id, isActive) => {
|
||||||
|
const c = centerById(id);
|
||||||
|
c.isActive = isActive;
|
||||||
|
c.onboardingState = deriveCenterState(c.isActive, c.verifiedAt);
|
||||||
|
return delay(undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
assignNurse: async (id, nurseProfileId, unlink) => {
|
||||||
|
const roster = (NURSES[id] ??= []);
|
||||||
|
if (unlink) {
|
||||||
|
NURSES[id] = roster.filter((n) => n.nurseProfileId !== nurseProfileId);
|
||||||
|
} else if (!roster.some((n) => n.nurseProfileId === nurseProfileId)) {
|
||||||
|
roster.push({ nurseProfileId, name: `پرستار #${nurseProfileId}`, isVerified: false });
|
||||||
|
}
|
||||||
|
centerById(id).sponsoredNurseCount = (NURSES[id] ?? []).length;
|
||||||
|
return delay(undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
getCenterSponsoredNurses: async (id) => delay([...(NURSES[id] ?? [])]),
|
||||||
|
|
||||||
|
// ── portal (my center) ──
|
||||||
|
getMyCenter: async () => delay(centerById(MOCK_MY_CENTER_ID)),
|
||||||
|
listMySponsoredNurses: async () => delay([...(NURSES[MOCK_MY_CENTER_ID] ?? [])]),
|
||||||
|
listMySponsoredBookings: async (filters: SponsoredBookingFilters, params) => {
|
||||||
|
let items = [...(BOOKINGS[MOCK_MY_CENTER_ID] ?? [])];
|
||||||
|
if (filters.status) items = items.filter((b) => b.status === filters.status);
|
||||||
|
return delay(paginate(items, params));
|
||||||
|
},
|
||||||
|
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.
|
||||||
|
const items = center.isMerchantOfRecord ? [...INVOICES] : [];
|
||||||
|
return delay(paginate(items, params));
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* When true, the partner-center domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
|
||||||
|
* `PartnerCenterApi` seam. **Mock is primary this phase:** the b15 contract exposes admin CRUD/verify/
|
||||||
|
* sponsor + a single `GET /centers/{id}/dashboard` portal endpoint, but the portal's split reads
|
||||||
|
* (my-center / sponsored-nurses / sponsored-bookings / settlement invoices), the activate/suspend toggle,
|
||||||
|
* and the write-then-masked IBAN flow are gaps (REQ-032/033). The mock returns **both** a merchant-of-record
|
||||||
|
* center (settlement view renders) and a non-MoR center (the "settlement via Balinyaar" state), verified +
|
||||||
|
* unverified nurses, sponsored bookings, and commission invoices with a fake مودیان reference + stub PDF.
|
||||||
|
*/
|
||||||
|
export const USE_PARTNER_MOCK = true;
|
||||||
|
|
||||||
|
/** Which mock center the portal ("my center") resolves to — flip to demo the MoR vs non-MoR states. */
|
||||||
|
export const MOCK_MY_CENTER_ID = 1;
|
||||||
|
|
||||||
|
export const PARTNER_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export const PARTNER_LIST_STALE_TIME = 60 * 1000;
|
||||||
|
export const PARTNER_DETAIL_STALE_TIME = 30 * 1000;
|
||||||
|
export const PARTNER_GC_TIME = 5 * 60 * 1000;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { partnerCenterApi } from '../apis';
|
||||||
|
import { centerKeys } from '../keys';
|
||||||
|
|
||||||
|
/** Set/clear a nurse's sponsorship link to a center. Invalidate the roster + detail. */
|
||||||
|
export function useAssignNurseToPartnerCenter(id: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<void, unknown, { nurseProfileId: number; unlink: boolean }>({
|
||||||
|
mutationFn: ({ nurseProfileId, unlink }) => partnerCenterApi.assignNurse(id, nurseProfileId, unlink),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: centerKeys.sponsoredNurses(id) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: centerKeys.detail(id) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: centerKeys.lists() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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 sponsored-nurse roster for one center (admin detail). */
|
||||||
|
export function useCenterSponsoredNurses(id: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: centerKeys.sponsoredNurses(id ?? -1),
|
||||||
|
queryFn: () => partnerCenterApi.getCenterSponsoredNurses(id!),
|
||||||
|
enabled: id != null && id > 0,
|
||||||
|
staleTime: PARTNER_DETAIL_STALE_TIME,
|
||||||
|
gcTime: PARTNER_GC_TIME,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { partnerCenterApi } from '../apis';
|
||||||
|
import { centerKeys } from '../keys';
|
||||||
|
import type { PartnerCenter, PartnerCenterInput } from '../types';
|
||||||
|
|
||||||
|
/** Create a partner center (inactive until verified). Invalidate the center lists. */
|
||||||
|
export function useCreatePartnerCenter() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<PartnerCenter, unknown, PartnerCenterInput>({
|
||||||
|
mutationFn: (input) => partnerCenterApi.createCenter(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: centerKeys.lists() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 signed-in center admin's **own** center (portal scope; server-resolved — never a raw id). Also the
|
||||||
|
* de-facto access gate for the `/partner` shell: a resolved center means in-scope; a 403/404 means the
|
||||||
|
* caller has no center (access-denied state).
|
||||||
|
*/
|
||||||
|
export function useMyPartnerCenter() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: centerKeys.myCenter(),
|
||||||
|
queryFn: () => partnerCenterApi.getMyCenter(),
|
||||||
|
staleTime: PARTNER_DETAIL_STALE_TIME,
|
||||||
|
gcTime: PARTNER_GC_TIME,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { partnerCenterApi } from '../apis';
|
||||||
|
import { centerKeys } from '../keys';
|
||||||
|
import { PARTNER_GC_TIME, PARTNER_LIST_STALE_TIME, PARTNER_PAGE_SIZE } from '../constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The center's per-booking commission invoices (portal settlement view). Only meaningful for a
|
||||||
|
* merchant-of-record center — a non-MoR center returns an empty page and the portal shows the
|
||||||
|
* "settlement runs through Balinyaar" state.
|
||||||
|
*/
|
||||||
|
export function useMySettlement(page = 1) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: centerKeys.mySettlement({ page, pageSize: PARTNER_PAGE_SIZE }),
|
||||||
|
queryFn: () => partnerCenterApi.listMySettlement({ page, pageSize: PARTNER_PAGE_SIZE }),
|
||||||
|
staleTime: PARTNER_LIST_STALE_TIME,
|
||||||
|
gcTime: PARTNER_GC_TIME,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { partnerCenterApi } from '../apis';
|
||||||
|
import { centerKeys } from '../keys';
|
||||||
|
import { PARTNER_GC_TIME, PARTNER_LIST_STALE_TIME, PARTNER_PAGE_SIZE } from '../constants';
|
||||||
|
import type { SponsoredBookingFilters } from '../types';
|
||||||
|
|
||||||
|
/** The bookings the signed-in center legally covers (portal; read-only summaries). Filter+page key cache. */
|
||||||
|
export function useMySponsoredBookings(filters: SponsoredBookingFilters, page = 1) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: centerKeys.mySponsoredBookings(filters, { page, pageSize: PARTNER_PAGE_SIZE }),
|
||||||
|
queryFn: () => partnerCenterApi.listMySponsoredBookings(filters, { page, pageSize: PARTNER_PAGE_SIZE }),
|
||||||
|
staleTime: PARTNER_LIST_STALE_TIME,
|
||||||
|
gcTime: PARTNER_GC_TIME,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { partnerCenterApi } from '../apis';
|
||||||
|
import { centerKeys } from '../keys';
|
||||||
|
import { PARTNER_LIST_STALE_TIME, PARTNER_GC_TIME } from '../constants';
|
||||||
|
|
||||||
|
/** The nurses the signed-in center sponsors (portal). */
|
||||||
|
export function useMySponsoredNurses() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: centerKeys.mySponsoredNurses(),
|
||||||
|
queryFn: () => partnerCenterApi.listMySponsoredNurses(),
|
||||||
|
staleTime: PARTNER_LIST_STALE_TIME,
|
||||||
|
gcTime: PARTNER_GC_TIME,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { partnerCenterApi } from '../apis';
|
||||||
|
import { centerKeys } from '../keys';
|
||||||
|
import { PARTNER_DETAIL_STALE_TIME, PARTNER_GC_TIME } from '../constants';
|
||||||
|
|
||||||
|
/** Admin partner-center detail (IBAN masked last-4). */
|
||||||
|
export function usePartnerCenter(id: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: centerKeys.detail(id ?? -1),
|
||||||
|
queryFn: () => partnerCenterApi.getCenter(id!),
|
||||||
|
enabled: id != null && id > 0,
|
||||||
|
staleTime: PARTNER_DETAIL_STALE_TIME,
|
||||||
|
gcTime: PARTNER_GC_TIME,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { partnerCenterApi } from '../apis';
|
||||||
|
import { centerKeys } from '../keys';
|
||||||
|
import { PARTNER_GC_TIME, PARTNER_LIST_STALE_TIME, PARTNER_PAGE_SIZE } from '../constants';
|
||||||
|
import type { PartnerCenterFilters } from '../types';
|
||||||
|
|
||||||
|
/** Admin list of partner centers (no IBAN, sponsored-nurse counts). Filters + page key the cache. */
|
||||||
|
export function usePartnerCenters(filters: PartnerCenterFilters, page = 1) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: centerKeys.list(filters, { page, pageSize: PARTNER_PAGE_SIZE }),
|
||||||
|
queryFn: () => partnerCenterApi.listCenters(filters, { page, pageSize: PARTNER_PAGE_SIZE }),
|
||||||
|
staleTime: PARTNER_LIST_STALE_TIME,
|
||||||
|
gcTime: PARTNER_GC_TIME,
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { partnerCenterApi } from '../apis';
|
||||||
|
import { centerKeys } from '../keys';
|
||||||
|
|
||||||
|
/** Activate / suspend a center (REQ-032). Invalidate list + detail. */
|
||||||
|
export function useSetPartnerCenterActive(id: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<void, unknown, boolean>({
|
||||||
|
mutationFn: (isActive) => partnerCenterApi.setCenterActive(id, isActive),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: centerKeys.lists() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: centerKeys.detail(id) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { partnerCenterApi } from '../apis';
|
||||||
|
import { centerKeys } from '../keys';
|
||||||
|
import type { PartnerCenter, PartnerCenterInput } from '../types';
|
||||||
|
|
||||||
|
/** Update a partner center (replace semantics; IBAN write-then-masked). Invalidate list + detail. */
|
||||||
|
export function useUpdatePartnerCenter(id: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<PartnerCenter, unknown, PartnerCenterInput>({
|
||||||
|
mutationFn: (input) => partnerCenterApi.updateCenter(id, input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: centerKeys.lists() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: centerKeys.detail(id) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { partnerCenterApi } from '../apis';
|
||||||
|
import { centerKeys } from '../keys';
|
||||||
|
|
||||||
|
/** Record licensing approval and activate a center. Invalidate list + detail. */
|
||||||
|
export function useVerifyPartnerCenter(id: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<void, unknown, void>({
|
||||||
|
mutationFn: () => partnerCenterApi.verifyCenter(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: centerKeys.lists() });
|
||||||
|
queryClient.invalidateQueries({ queryKey: centerKeys.detail(id) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Partner-center domain barrel — re-exports **hooks only** (per the `services/{domain}` convention).
|
||||||
|
* Import types/keys/apis directly from their files when needed.
|
||||||
|
*/
|
||||||
|
export { usePartnerCenters } from './hooks/usePartnerCenters';
|
||||||
|
export { usePartnerCenter } from './hooks/usePartnerCenter';
|
||||||
|
export { useCenterSponsoredNurses } from './hooks/useCenterSponsoredNurses';
|
||||||
|
export { useCreatePartnerCenter } from './hooks/useCreatePartnerCenter';
|
||||||
|
export { useUpdatePartnerCenter } from './hooks/useUpdatePartnerCenter';
|
||||||
|
export { useVerifyPartnerCenter } from './hooks/useVerifyPartnerCenter';
|
||||||
|
export { useSetPartnerCenterActive } from './hooks/useSetPartnerCenterActive';
|
||||||
|
export { useAssignNurseToPartnerCenter } from './hooks/useAssignNurseToPartnerCenter';
|
||||||
|
export { useMyPartnerCenter } from './hooks/useMyPartnerCenter';
|
||||||
|
export { useMySponsoredNurses } from './hooks/useMySponsoredNurses';
|
||||||
|
export { useMySponsoredBookings } from './hooks/useMySponsoredBookings';
|
||||||
|
export { useMySettlement } from './hooks/useMySettlement';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { PageParams } from '@/lib/api/types';
|
||||||
|
import type { PartnerCenterFilters, SponsoredBookingFilters } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React Query key factory for the partner-center domain. Admin lists key on filters+page; the portal keys
|
||||||
|
* are scoped to "my center" (the server resolves the caller's own center — never a raw id). Mutations
|
||||||
|
* invalidate the affected sub-tree.
|
||||||
|
*/
|
||||||
|
export const centerKeys = {
|
||||||
|
all: ['partnerCenter'] as const,
|
||||||
|
|
||||||
|
lists: () => [...centerKeys.all, 'list'] as const,
|
||||||
|
list: (filters: PartnerCenterFilters, params: PageParams) => [...centerKeys.lists(), filters, params] as const,
|
||||||
|
|
||||||
|
details: () => [...centerKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: number) => [...centerKeys.details(), id] as const,
|
||||||
|
sponsoredNurses: (id: number) => [...centerKeys.detail(id), 'sponsoredNurses'] as const,
|
||||||
|
|
||||||
|
// portal (my center)
|
||||||
|
myCenter: () => [...centerKeys.all, 'me'] as const,
|
||||||
|
mySponsoredNurses: () => [...centerKeys.myCenter(), 'nurses'] as const,
|
||||||
|
mySponsoredBookings: (filters: SponsoredBookingFilters, params: PageParams) =>
|
||||||
|
[...centerKeys.myCenter(), 'bookings', filters, params] as const,
|
||||||
|
mySettlement: (params: PageParams) => [...centerKeys.myCenter(), 'settlement', params] as const,
|
||||||
|
};
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partner-center domain — the licensed sponsoring centers (پروانه تأسیس + مسئول فنی + نماد اعتماد
|
||||||
|
* الکترونیکی) that may be the **merchant-of-record / invoice issuer**, and the two audiences that read
|
||||||
|
* them: Balinyaar **admins** (list/create/verify/activate/sponsor) and a **center admin** in the separate
|
||||||
|
* partner-portal scope (their own center only). Shapes derive from the b15 contract
|
||||||
|
* (`dev/contracts/domains/messaging-notifications-admin.md`) and the b11 invoice shape.
|
||||||
|
*
|
||||||
|
* Load-bearing rules (phase §5):
|
||||||
|
* - **`settlementIban` is never returned in plaintext** — only a masked last-4 (`"••••0001"`). On create
|
||||||
|
* it is **write-then-masked** (submit the full IBAN, only last-4 shows afterwards).
|
||||||
|
* - **Merchant-of-record drives the settlement view** — the invoice/settlement surface renders only when
|
||||||
|
* `isMerchantOfRecord === true`.
|
||||||
|
* - **VAT is on the commission line only**, config-driven — never hardcode 10%.
|
||||||
|
* - **Tenancy** — a center admin sees only their own center (server-enforced); never fetch a raw id they
|
||||||
|
* don't own.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A center's onboarding/verification lifecycle (derived from `isActive`/`verifiedAt`; the mock sets it). */
|
||||||
|
export type CenterOnboardingState = 'draft' | 'pending_verification' | 'verified' | 'suspended';
|
||||||
|
|
||||||
|
/** `PartnerCenter` detail (admin + portal). `settlementIbanMasked` is last-4 only, never the full IBAN. */
|
||||||
|
export interface PartnerCenter {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
legalEntityType: string;
|
||||||
|
mohEstablishmentPermitNo: string;
|
||||||
|
technicalDirectorNurseUserId: number | null;
|
||||||
|
technicalDirectorLicenseNo: string | null;
|
||||||
|
enamadCode: string | null;
|
||||||
|
settlementIbanMasked: string | null;
|
||||||
|
isMerchantOfRecord: boolean;
|
||||||
|
commissionRate: number;
|
||||||
|
adminUserId: number | null;
|
||||||
|
isActive: boolean;
|
||||||
|
verifiedAt: string | null;
|
||||||
|
sponsoredNurseCount: number;
|
||||||
|
onboardingState: CenterOnboardingState;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create/update body. `settlementIban` is the **full** IBAN, write-only — the server stores it masked and
|
||||||
|
* only ever returns the last-4. Required when `isMerchantOfRecord`. `commissionRate ∈ [0, 1)`.
|
||||||
|
*/
|
||||||
|
export interface PartnerCenterInput {
|
||||||
|
name: string;
|
||||||
|
legalEntityType: string;
|
||||||
|
mohEstablishmentPermitNo: string;
|
||||||
|
technicalDirectorNurseUserId?: number | null;
|
||||||
|
technicalDirectorLicenseNo?: string | null;
|
||||||
|
enamadCode?: string | null;
|
||||||
|
settlementIban?: string | null;
|
||||||
|
isMerchantOfRecord: boolean;
|
||||||
|
commissionRate: number;
|
||||||
|
adminUserId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A nurse sponsored by a center (roster + portal list). */
|
||||||
|
export interface SponsoredNurse {
|
||||||
|
nurseProfileId: number;
|
||||||
|
name: string;
|
||||||
|
isVerified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A booking the center legally covers (portal list; read-only summary, no extra PII). */
|
||||||
|
export interface SponsoredBooking {
|
||||||
|
bookingId: number;
|
||||||
|
patientName: string;
|
||||||
|
scheduledDate: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `invoices.moadian_status`. */
|
||||||
|
export type MoadianStatus = 'pending' | 'submitted' | 'registered' | 'failed';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A per-booking commission invoice (only meaningful when the center is merchant-of-record). The
|
||||||
|
* reconciling breakdown is **platform commission + BNPL commission + VAT = total**; `grossIrr` is shown
|
||||||
|
* as context, not part of the total (VAT is on the commission line, never the gross service fee). Money is
|
||||||
|
* IRR digit-strings.
|
||||||
|
*/
|
||||||
|
export interface CenterInvoice {
|
||||||
|
id: number;
|
||||||
|
bookingId: number;
|
||||||
|
invoiceNumber: string;
|
||||||
|
grossIrr: string;
|
||||||
|
platformCommissionIrr: string;
|
||||||
|
bnplCommissionIrr: string | null;
|
||||||
|
vatRate: number;
|
||||||
|
vatIrr: string;
|
||||||
|
/** commission + bnpl commission + vat (REQ-033 — the wire lacks a total; summed from served legs). */
|
||||||
|
totalIrr: string;
|
||||||
|
moadianReferenceNumber: string | null;
|
||||||
|
moadianStatus: MoadianStatus | null;
|
||||||
|
pdfUrl: string | null;
|
||||||
|
issuedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin list filters. */
|
||||||
|
export interface PartnerCenterFilters {
|
||||||
|
isMerchantOfRecord?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bookings list filter (portal). */
|
||||||
|
export interface SponsoredBookingFilters {
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The partner-center API seam — admin-side management + the center-scoped portal reads. The real client
|
||||||
|
* and the in-memory mock both implement it (selection by `USE_PARTNER_MOCK`).
|
||||||
|
*/
|
||||||
|
export interface PartnerCenterApi {
|
||||||
|
// admin-side
|
||||||
|
listCenters(filters: PartnerCenterFilters, params: PageParams): Promise<Paginated<PartnerCenter>>;
|
||||||
|
getCenter(id: number): Promise<PartnerCenter>;
|
||||||
|
createCenter(input: PartnerCenterInput): Promise<PartnerCenter>;
|
||||||
|
updateCenter(id: number, input: PartnerCenterInput): Promise<PartnerCenter>;
|
||||||
|
verifyCenter(id: number): Promise<void>;
|
||||||
|
setCenterActive(id: number, isActive: boolean): Promise<void>;
|
||||||
|
assignNurse(id: number, nurseProfileId: number, unlink: boolean): Promise<void>;
|
||||||
|
getCenterSponsoredNurses(id: number): Promise<SponsoredNurse[]>;
|
||||||
|
// portal (center-scoped)
|
||||||
|
getMyCenter(): Promise<PartnerCenter>;
|
||||||
|
listMySponsoredNurses(): Promise<SponsoredNurse[]>;
|
||||||
|
listMySponsoredBookings(filters: SponsoredBookingFilters, params: PageParams): Promise<Paginated<SponsoredBooking>>;
|
||||||
|
listMySettlement(params: PageParams): Promise<Paginated<CenterInvoice>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive the onboarding state from a center's `isActive`/`verifiedAt` (the real-path fallback). */
|
||||||
|
export function deriveCenterState(isActive: boolean, verifiedAt: string | null): CenterOnboardingState {
|
||||||
|
if (verifiedAt && isActive) return 'verified';
|
||||||
|
if (verifiedAt && !isActive) return 'suspended';
|
||||||
|
return 'pending_verification';
|
||||||
|
}
|
||||||
@@ -2,17 +2,27 @@ import { clientFetch } from '@/lib/api/client';
|
|||||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||||
import { PAYOUTS_PAGE_SIZE } from '../constants';
|
import { PAYOUTS_PAGE_SIZE } from '../constants';
|
||||||
import type {
|
import type {
|
||||||
|
AdminPayoutBatchDetail,
|
||||||
|
AdminPayoutRow,
|
||||||
EarningsListParams,
|
EarningsListParams,
|
||||||
|
EligibleNurseEarnings,
|
||||||
NurseEarningsItem,
|
NurseEarningsItem,
|
||||||
NurseEarningsSummary,
|
NurseEarningsSummary,
|
||||||
NursePayoutDetail,
|
NursePayoutDetail,
|
||||||
NursePayoutHistoryItem,
|
NursePayoutHistoryItem,
|
||||||
|
PayoutBatchFilters,
|
||||||
|
PayoutBatchStatus,
|
||||||
|
PayoutBatchSummary,
|
||||||
PayoutStatus,
|
PayoutStatus,
|
||||||
PayoutsApi,
|
PayoutsApi,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type { PageParams } from '@/lib/api/types';
|
import type { PageParams } from '@/lib/api/types';
|
||||||
|
|
||||||
const NURSE_PAYOUTS = '/api/v1/nurse_payouts';
|
const NURSE_PAYOUTS = '/api/v1/nurse_payouts';
|
||||||
|
const ADMIN_PAYOUTS = '/api/v1/admin_payouts';
|
||||||
|
|
||||||
|
/** The header b13's process/retry read the per-run idempotency key from (same convention as b10/b12). */
|
||||||
|
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The b13 nurse history payload (`GET nurse_payouts/history` → `NursePayoutHistoryDto`). Note it carries
|
* The b13 nurse history payload (`GET nurse_payouts/history` → `NursePayoutHistoryDto`). Note it carries
|
||||||
@@ -51,6 +61,91 @@ function toHistoryItem(wire: NursePayoutHistoryWire): NursePayoutHistoryItem {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Admin batch wire DTOs (`admin_payouts/*`) ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `PayoutBatchDto` — note it has `initiatedByAdminId` but **no** `holidayShifted` flag (REQ-036). */
|
||||||
|
interface PayoutBatchWire {
|
||||||
|
id: number;
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
processingDate: string;
|
||||||
|
totalAmount: string;
|
||||||
|
payoutCount: number;
|
||||||
|
status: PayoutBatchStatus;
|
||||||
|
processedAt: string | null;
|
||||||
|
failureNotes: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `PayoutDto` — note the transferred amount is `amount`, mapped to `amountIrr`. */
|
||||||
|
interface PayoutWire {
|
||||||
|
id: number;
|
||||||
|
nurseId: number;
|
||||||
|
nurseName: string | null;
|
||||||
|
maskedIban: string;
|
||||||
|
grossEarningsIrr: string;
|
||||||
|
clawbackAppliedIrr: string;
|
||||||
|
netAmountIrr: string;
|
||||||
|
amount: string;
|
||||||
|
status: PayoutStatus;
|
||||||
|
transferReference: string | null;
|
||||||
|
paidAt: string | null;
|
||||||
|
failureReason: string | null;
|
||||||
|
bookings: { bookingId: number; sessionId: number | null; payoutAmountIrr: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PayoutBatchDetailWire {
|
||||||
|
batch: PayoutBatchWire;
|
||||||
|
payouts: PayoutWire[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeneratePayoutBatchResultWire {
|
||||||
|
batch: PayoutBatchWire;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBatchSummary(wire: PayoutBatchWire): PayoutBatchSummary {
|
||||||
|
return {
|
||||||
|
id: wire.id,
|
||||||
|
periodStart: wire.periodStart,
|
||||||
|
periodEnd: wire.periodEnd,
|
||||||
|
processingDate: wire.processingDate,
|
||||||
|
totalAmount: wire.totalAmount,
|
||||||
|
payoutCount: wire.payoutCount,
|
||||||
|
status: wire.status,
|
||||||
|
processedAt: wire.processedAt,
|
||||||
|
failureNotes: wire.failureNotes,
|
||||||
|
createdAt: wire.createdAt,
|
||||||
|
// REQ-036: PayoutBatchDto exposes no holidayShifted flag — a single preview endpoint returning
|
||||||
|
// eligible+skipped+processingDate+holidayShifted would carry it. Defaults false until then.
|
||||||
|
holidayShifted: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPayoutRow(wire: PayoutWire): AdminPayoutRow {
|
||||||
|
return {
|
||||||
|
id: wire.id,
|
||||||
|
nurseId: wire.nurseId,
|
||||||
|
nurseName: wire.nurseName,
|
||||||
|
maskedIban: wire.maskedIban,
|
||||||
|
grossEarningsIrr: wire.grossEarningsIrr,
|
||||||
|
clawbackAppliedIrr: wire.clawbackAppliedIrr,
|
||||||
|
netAmountIrr: wire.netAmountIrr,
|
||||||
|
amountIrr: wire.amount,
|
||||||
|
status: wire.status,
|
||||||
|
transferReference: wire.transferReference,
|
||||||
|
paidAt: wire.paidAt,
|
||||||
|
failureReason: wire.failureReason,
|
||||||
|
bookings: wire.bookings.map((b) => ({
|
||||||
|
bookingId: b.bookingId,
|
||||||
|
sessionId: b.sessionId,
|
||||||
|
payoutAmountIrr: b.payoutAmountIrr,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real HTTP implementation of the `PayoutsApi` seam (b13 contract `dev/contracts/domains/payouts.md`,
|
* Real HTTP implementation of the `PayoutsApi` seam (b13 contract `dev/contracts/domains/payouts.md`,
|
||||||
* swagger `dev/contracts/openapi/swagger.v1.json`). Only `getNursePayoutHistory` maps a **published** nurse
|
* swagger `dev/contracts/openapi/swagger.v1.json`). Only `getNursePayoutHistory` maps a **published** nurse
|
||||||
@@ -90,4 +185,87 @@ export const payoutsClientApi: PayoutsApi = {
|
|||||||
|
|
||||||
getNursePayoutDetail: async (payoutId: number) =>
|
getNursePayoutDetail: async (payoutId: number) =>
|
||||||
unwrap(await clientFetch<ApiEnvelope<NursePayoutDetail>>(`${NURSE_PAYOUTS}/${payoutId}`)),
|
unwrap(await clientFetch<ApiEnvelope<NursePayoutDetail>>(`${NURSE_PAYOUTS}/${payoutId}`)),
|
||||||
|
|
||||||
|
// ── Admin batch actions (`admin_payouts/*`) ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
listPayoutBatches: async (
|
||||||
|
filters: PayoutBatchFilters,
|
||||||
|
params: PageParams,
|
||||||
|
): Promise<Paginated<PayoutBatchSummary>> => {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (filters.status) query.set('status', filters.status);
|
||||||
|
query.set('page', String(params.page ?? 1));
|
||||||
|
query.set('pageSize', String(params.pageSize ?? PAYOUTS_PAGE_SIZE));
|
||||||
|
const page = unwrap(
|
||||||
|
await clientFetch<ApiEnvelope<Paginated<PayoutBatchWire>>>(`${ADMIN_PAYOUTS}/batches?${query.toString()}`),
|
||||||
|
);
|
||||||
|
return { ...page, items: page.items.map(toBatchSummary) };
|
||||||
|
},
|
||||||
|
|
||||||
|
previewPayoutBatch: async (periodStart: string, periodEnd: string) => {
|
||||||
|
const query = new URLSearchParams({ periodStart, periodEnd });
|
||||||
|
const page = unwrap(
|
||||||
|
await clientFetch<ApiEnvelope<Paginated<EligibleNurseEarnings>>>(
|
||||||
|
`${ADMIN_PAYOUTS}/eligible?${query.toString()}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const eligible = page.items;
|
||||||
|
const totalNet = eligible.reduce((sum, e) => sum + BigInt(e.netAmountIrr), BigInt(0));
|
||||||
|
// REQ-036: a single preview endpoint returning eligible + skipped + processingDate + holidayShifted in
|
||||||
|
// one shot. The b13 `eligible` read is paged and returns only the eligible rows — no skipped list (that
|
||||||
|
// is materialized by the generate call) and no processingDate/holidayShifted — so carry what we can:
|
||||||
|
// processingDate falls back to periodEnd and skipped is empty until the preview route lands.
|
||||||
|
return {
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
processingDate: periodEnd,
|
||||||
|
holidayShifted: false,
|
||||||
|
eligible,
|
||||||
|
skipped: [],
|
||||||
|
totalNetIrr: String(totalNet),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
runPayoutBatch: async (periodStart: string, periodEnd: string, idempotencyKey: string) => {
|
||||||
|
const result = unwrap(
|
||||||
|
await clientFetch<ApiEnvelope<GeneratePayoutBatchResultWire>>(`${ADMIN_PAYOUTS}/batches`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { [IDEMPOTENCY_KEY_HEADER]: idempotencyKey },
|
||||||
|
body: JSON.stringify({ periodStart, periodEnd }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return toBatchSummary(result.batch);
|
||||||
|
},
|
||||||
|
|
||||||
|
getPayoutBatchDetail: async (batchId: number, page: number): Promise<AdminPayoutBatchDetail> => {
|
||||||
|
const query = new URLSearchParams({ page: String(page) });
|
||||||
|
const detail = unwrap(
|
||||||
|
await clientFetch<ApiEnvelope<PayoutBatchDetailWire>>(
|
||||||
|
`${ADMIN_PAYOUTS}/batches/${batchId}?${query.toString()}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
batch: toBatchSummary(detail.batch),
|
||||||
|
payouts: detail.payouts.map(toPayoutRow),
|
||||||
|
total: detail.total,
|
||||||
|
page: detail.page,
|
||||||
|
pageSize: detail.pageSize,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
retryPayout: async (payoutId: number, idempotencyKey: string) => {
|
||||||
|
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_PAYOUTS}/${payoutId}/retry`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { [IDEMPOTENCY_KEY_HEADER]: idempotencyKey },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
recordTransferReference: async (payoutId: number, reference: string) => {
|
||||||
|
// REQ-036: b13 has `mark_failed` but no record-transfer-reference route — a manually reconciled bank
|
||||||
|
// transfer reference has nowhere to land. Proposed action-style slug; 404s until the backend adds it.
|
||||||
|
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_PAYOUTS}/${payoutId}/transfer_reference`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ reference }),
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import type { Paginated } from '@/lib/api/types';
|
import type { Paginated } from '@/lib/api/types';
|
||||||
import type { PageParams } from '@/lib/api/types';
|
import type { PageParams } from '@/lib/api/types';
|
||||||
import { MOCK_SCENARIO } from '../constants';
|
import { ADMIN_BATCH_DETAIL_PAGE_SIZE, MOCK_SCENARIO } from '../constants';
|
||||||
import type {
|
import type {
|
||||||
|
AdminPayoutBatchDetail,
|
||||||
|
AdminPayoutRow,
|
||||||
EarningsListParams,
|
EarningsListParams,
|
||||||
EarningsState,
|
EarningsState,
|
||||||
|
EligibleNurseEarnings,
|
||||||
NurseEarningsItem,
|
NurseEarningsItem,
|
||||||
NurseEarningsSummary,
|
NurseEarningsSummary,
|
||||||
NursePayoutDetail,
|
NursePayoutDetail,
|
||||||
NursePayoutHistoryItem,
|
NursePayoutHistoryItem,
|
||||||
|
PayoutBatchFilters,
|
||||||
|
PayoutBatchPreview,
|
||||||
|
PayoutBatchSummary,
|
||||||
PayoutsApi,
|
PayoutsApi,
|
||||||
|
SkippedNurse,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -320,6 +327,262 @@ function delay<T>(value: T): Promise<T> {
|
|||||||
|
|
||||||
const STATE_ORDER: Record<EarningsState, number> = { pending: 0, eligible: 1, paid: 2, clawback_applied: 3 };
|
const STATE_ORDER: Record<EarningsState, number> = { pending: 0, eligible: 1, paid: 2, clawback_applied: 3 };
|
||||||
|
|
||||||
|
// ══ Admin batch actions (b13 admin_payouts/*) — mutable in-memory state ════════════════════════════════
|
||||||
|
//
|
||||||
|
// Engineered to exercise every admin UI state and stay money-correct: batches spanning completed /
|
||||||
|
// partially_failed / processing, at least one holiday-shifted; per-payout rows including a `failed` row
|
||||||
|
// (so retry is demonstrable) and `paid` rows with a last-4 masked IBAN + transfer reference. Every row
|
||||||
|
// reconciles `gross − clawback = net = amount`, its booking links sum to its gross, and each batch's
|
||||||
|
// `totalAmount = Σ its payouts' net`. Rows are **mutated in place** by retry / record-reference so state
|
||||||
|
// persists across calls within the session.
|
||||||
|
|
||||||
|
/** ISO `YYYY-MM-DD` shifted `days` off `isoDate` (holiday-shifted processing date). */
|
||||||
|
function isoDateShift(isoDate: string, days: number): string {
|
||||||
|
return new Date(new Date(isoDate).getTime() + days * DAY_MS).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IRR-string sum (integer-safe BigInt; never a float). */
|
||||||
|
function sumIrr(values: string[]): string {
|
||||||
|
return String(values.reduce((total, v) => total + BigInt(v), BigInt(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADMIN_MASKED_IBAN_A = 'IR••••••••••••••••••4821';
|
||||||
|
const ADMIN_MASKED_IBAN_B = 'IR••••••••••••••••••7734';
|
||||||
|
const ADMIN_MASKED_IBAN_C = 'IR••••••••••••••••••1092';
|
||||||
|
|
||||||
|
/** Per-nurse payout rows keyed by batch id. Mutated in place by `retryPayout` / `recordTransferReference`. */
|
||||||
|
const BATCH_DETAILS: Record<number, AdminPayoutRow[]> = {
|
||||||
|
// 7101 — partially_failed: one paid + one failed (retryable). Σ net = 4,250,000 + 3,400,000 = 7,650,000.
|
||||||
|
7101: [
|
||||||
|
{
|
||||||
|
id: 9201,
|
||||||
|
nurseId: 301,
|
||||||
|
nurseName: 'زهرا موسوی',
|
||||||
|
maskedIban: ADMIN_MASKED_IBAN_A,
|
||||||
|
grossEarningsIrr: '4250000',
|
||||||
|
clawbackAppliedIrr: '0',
|
||||||
|
netAmountIrr: '4250000',
|
||||||
|
amountIrr: '4250000',
|
||||||
|
status: 'paid',
|
||||||
|
transferReference: 'PAYA-14050412-9201',
|
||||||
|
paidAt: isoFromNowHours(-20),
|
||||||
|
failureReason: null,
|
||||||
|
bookings: [{ bookingId: 5003, sessionId: 1, payoutAmountIrr: '4250000' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9202,
|
||||||
|
nurseId: 302,
|
||||||
|
nurseName: 'مریم احمدی',
|
||||||
|
maskedIban: ADMIN_MASKED_IBAN_B,
|
||||||
|
grossEarningsIrr: '3400000',
|
||||||
|
clawbackAppliedIrr: '0',
|
||||||
|
netAmountIrr: '3400000',
|
||||||
|
amountIrr: '3400000',
|
||||||
|
status: 'failed',
|
||||||
|
transferReference: null,
|
||||||
|
paidAt: null,
|
||||||
|
failureReason: 'invalid_sheba',
|
||||||
|
bookings: [{ bookingId: 5002, sessionId: 1, payoutAmountIrr: '3400000' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// 7102 — completed: a clawback-netted paid + a clean paid. Σ net = 4,250,000 + 2,000,000 = 6,250,000.
|
||||||
|
7102: [
|
||||||
|
{
|
||||||
|
id: 9203,
|
||||||
|
nurseId: 303,
|
||||||
|
nurseName: 'فاطمه کریمی',
|
||||||
|
maskedIban: ADMIN_MASKED_IBAN_C,
|
||||||
|
grossEarningsIrr: '5000000',
|
||||||
|
clawbackAppliedIrr: '750000',
|
||||||
|
netAmountIrr: '4250000',
|
||||||
|
amountIrr: '4250000',
|
||||||
|
status: 'paid',
|
||||||
|
transferReference: 'SATNA-14050405-9203',
|
||||||
|
paidAt: isoFromNowHours(-96),
|
||||||
|
failureReason: null,
|
||||||
|
bookings: [
|
||||||
|
{ bookingId: 4990, sessionId: 1, payoutAmountIrr: '2750000' },
|
||||||
|
{ bookingId: 4991, sessionId: 1, payoutAmountIrr: '2250000' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9204,
|
||||||
|
nurseId: 304,
|
||||||
|
nurseName: 'سکینه رضایی',
|
||||||
|
maskedIban: ADMIN_MASKED_IBAN_A,
|
||||||
|
grossEarningsIrr: '2000000',
|
||||||
|
clawbackAppliedIrr: '0',
|
||||||
|
netAmountIrr: '2000000',
|
||||||
|
amountIrr: '2000000',
|
||||||
|
status: 'paid',
|
||||||
|
transferReference: 'PAYA-14050405-9204',
|
||||||
|
paidAt: isoFromNowHours(-100),
|
||||||
|
failureReason: null,
|
||||||
|
bookings: [{ bookingId: 5006, sessionId: 1, payoutAmountIrr: '2000000' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// 7103 — processing: submitted, awaiting settlement. Σ net = 3,000,000.
|
||||||
|
7103: [
|
||||||
|
{
|
||||||
|
id: 9205,
|
||||||
|
nurseId: 305,
|
||||||
|
nurseName: 'اکرم حسینی',
|
||||||
|
maskedIban: ADMIN_MASKED_IBAN_B,
|
||||||
|
grossEarningsIrr: '3000000',
|
||||||
|
clawbackAppliedIrr: '0',
|
||||||
|
netAmountIrr: '3000000',
|
||||||
|
amountIrr: '3000000',
|
||||||
|
status: 'submitted',
|
||||||
|
transferReference: null,
|
||||||
|
paidAt: null,
|
||||||
|
failureReason: null,
|
||||||
|
bookings: [{ bookingId: 5010, sessionId: 1, payoutAmountIrr: '3000000' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// 7104 — completed (holiday-shifted), older. Σ net = 4,250,000.
|
||||||
|
7104: [
|
||||||
|
{
|
||||||
|
id: 9206,
|
||||||
|
nurseId: 301,
|
||||||
|
nurseName: 'زهرا موسوی',
|
||||||
|
maskedIban: ADMIN_MASKED_IBAN_A,
|
||||||
|
grossEarningsIrr: '4250000',
|
||||||
|
clawbackAppliedIrr: '0',
|
||||||
|
netAmountIrr: '4250000',
|
||||||
|
amountIrr: '4250000',
|
||||||
|
status: 'paid',
|
||||||
|
transferReference: 'SATNA-14050328-9206',
|
||||||
|
paidAt: isoFromNowHours(-260),
|
||||||
|
failureReason: null,
|
||||||
|
bookings: [{ bookingId: 4980, sessionId: 1, payoutAmountIrr: '4250000' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Batch headers (newest-first by `createdAt` at read time). `totalAmount = Σ its detail rows' net`. */
|
||||||
|
const BATCHES: PayoutBatchSummary[] = [
|
||||||
|
{
|
||||||
|
id: 7103,
|
||||||
|
periodStart: isoDateDaysAgo(7),
|
||||||
|
periodEnd: isoDateDaysAgo(1),
|
||||||
|
processingDate: isoDateDaysAgo(0),
|
||||||
|
totalAmount: '3000000',
|
||||||
|
payoutCount: 1,
|
||||||
|
status: 'processing',
|
||||||
|
processedAt: null,
|
||||||
|
failureNotes: null,
|
||||||
|
createdAt: isoFromNowHours(-6),
|
||||||
|
holidayShifted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7101,
|
||||||
|
periodStart: isoDateDaysAgo(14),
|
||||||
|
periodEnd: isoDateDaysAgo(8),
|
||||||
|
processingDate: isoDateDaysAgo(6),
|
||||||
|
totalAmount: '7650000',
|
||||||
|
payoutCount: 2,
|
||||||
|
status: 'partially_failed',
|
||||||
|
processedAt: isoFromNowHours(-20),
|
||||||
|
failureNotes: '۱ انتقال توسط سامانه بانکی رد شد (invalid_sheba)',
|
||||||
|
createdAt: isoFromNowHours(-26),
|
||||||
|
holidayShifted: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7102,
|
||||||
|
periodStart: isoDateDaysAgo(14),
|
||||||
|
periodEnd: isoDateDaysAgo(8),
|
||||||
|
processingDate: isoDateDaysAgo(7),
|
||||||
|
totalAmount: '6250000',
|
||||||
|
payoutCount: 2,
|
||||||
|
status: 'completed',
|
||||||
|
processedAt: isoFromNowHours(-96),
|
||||||
|
failureNotes: null,
|
||||||
|
createdAt: isoFromNowHours(-120),
|
||||||
|
holidayShifted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7104,
|
||||||
|
periodStart: isoDateDaysAgo(21),
|
||||||
|
periodEnd: isoDateDaysAgo(15),
|
||||||
|
processingDate: isoDateDaysAgo(13),
|
||||||
|
totalAmount: '4250000',
|
||||||
|
payoutCount: 1,
|
||||||
|
status: 'completed',
|
||||||
|
processedAt: isoFromNowHours(-260),
|
||||||
|
failureNotes: null,
|
||||||
|
createdAt: isoFromNowHours(-264),
|
||||||
|
holidayShifted: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Idempotency ledgers: the SAME key returns the SAME result (never a double-run / double-pay). */
|
||||||
|
const RUN_BATCH_IDEMPOTENCY = new Map<string, PayoutBatchSummary>();
|
||||||
|
const RETRY_IDEMPOTENCY = new Set<string>();
|
||||||
|
let nextBatchId = 7200;
|
||||||
|
let nextAdminPayoutId = 9300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The eligibility dry-run: ≥3 eligible nurses (one with a netted clawback), one flagged with no verified
|
||||||
|
* IBAN (`hasVerifiedPrimaryIban:false` — shown, not dropped), one skipped (`no_verified_primary_iban`), and
|
||||||
|
* a holiday-shifted processing date. `totalNetIrr = Σ eligible.netAmountIrr`. The server owns eligibility +
|
||||||
|
* the shifted date; the client only renders this.
|
||||||
|
*/
|
||||||
|
function buildPreview(periodStart: string, periodEnd: string): PayoutBatchPreview {
|
||||||
|
const eligible: EligibleNurseEarnings[] = [
|
||||||
|
{
|
||||||
|
nurseId: 301,
|
||||||
|
nurseName: 'زهرا موسوی',
|
||||||
|
bookingCount: 2,
|
||||||
|
grossEarningsIrr: '5000000',
|
||||||
|
clawbackAppliedIrr: '0',
|
||||||
|
netAmountIrr: '5000000',
|
||||||
|
hasVerifiedPrimaryIban: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nurseId: 302,
|
||||||
|
nurseName: 'مریم احمدی',
|
||||||
|
bookingCount: 1,
|
||||||
|
grossEarningsIrr: '3400000',
|
||||||
|
clawbackAppliedIrr: '0',
|
||||||
|
netAmountIrr: '3400000',
|
||||||
|
hasVerifiedPrimaryIban: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// netted clawback: 6,000,000 gross − 1,500,000 clawback = 4,500,000 net
|
||||||
|
nurseId: 303,
|
||||||
|
nurseName: 'فاطمه کریمی',
|
||||||
|
bookingCount: 3,
|
||||||
|
grossEarningsIrr: '6000000',
|
||||||
|
clawbackAppliedIrr: '1500000',
|
||||||
|
netAmountIrr: '4500000',
|
||||||
|
hasVerifiedPrimaryIban: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// flagged (no verified primary IBAN) — surfaced with the flag, NOT dropped (contract semantics)
|
||||||
|
nurseId: 306,
|
||||||
|
nurseName: 'نرگس علوی',
|
||||||
|
bookingCount: 1,
|
||||||
|
grossEarningsIrr: '2000000',
|
||||||
|
clawbackAppliedIrr: '0',
|
||||||
|
netAmountIrr: '2000000',
|
||||||
|
hasVerifiedPrimaryIban: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const skipped: SkippedNurse[] = [
|
||||||
|
{ nurseId: 307, nurseName: 'طاهره یوسفی', grossEarningsIrr: '1200000', reason: 'no_verified_primary_iban' },
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
// holiday-shifted a couple days past periodEnd (the server owns the shift; the client renders it)
|
||||||
|
processingDate: isoDateShift(periodEnd, 2),
|
||||||
|
holidayShifted: true,
|
||||||
|
eligible,
|
||||||
|
skipped,
|
||||||
|
totalNetIrr: sumIrr(eligible.map((e) => e.netAmountIrr)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const payoutsMockApi: PayoutsApi = {
|
export const payoutsMockApi: PayoutsApi = {
|
||||||
getNurseEarningsBalance: async () => delay(buildSummary()),
|
getNurseEarningsBalance: async () => delay(buildSummary()),
|
||||||
|
|
||||||
@@ -336,4 +599,111 @@ export const payoutsMockApi: PayoutsApi = {
|
|||||||
if (!detail) throw new Error(`Mock payout ${payoutId} not found`);
|
if (!detail) throw new Error(`Mock payout ${payoutId} not found`);
|
||||||
return delay(detail);
|
return delay(detail);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Admin batch actions ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
listPayoutBatches: async (filters: PayoutBatchFilters, params: PageParams) => {
|
||||||
|
const sorted = [...BATCHES].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
||||||
|
const filtered = filters.status ? sorted.filter((b) => b.status === filters.status) : sorted;
|
||||||
|
return delay(paginate(filtered, params));
|
||||||
|
},
|
||||||
|
|
||||||
|
previewPayoutBatch: async (periodStart: string, periodEnd: string) =>
|
||||||
|
delay(buildPreview(periodStart, periodEnd)),
|
||||||
|
|
||||||
|
runPayoutBatch: async (periodStart: string, periodEnd: string, idempotencyKey: string) => {
|
||||||
|
// Idempotency: the same key returns the same batch — a retried run never opens a second batch.
|
||||||
|
const prior = RUN_BATCH_IDEMPOTENCY.get(idempotencyKey);
|
||||||
|
if (prior) return delay(prior);
|
||||||
|
|
||||||
|
const preview = buildPreview(periodStart, periodEnd);
|
||||||
|
// Only nurses with a verified primary IBAN are materialized into payouts (the flagged ones are skipped).
|
||||||
|
const payable = preview.eligible.filter((e) => e.hasVerifiedPrimaryIban);
|
||||||
|
const id = nextBatchId++;
|
||||||
|
const batch: PayoutBatchSummary = {
|
||||||
|
id,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
|
processingDate: preview.processingDate,
|
||||||
|
totalAmount: sumIrr(payable.map((e) => e.netAmountIrr)),
|
||||||
|
payoutCount: payable.length,
|
||||||
|
status: 'processing',
|
||||||
|
processedAt: null,
|
||||||
|
failureNotes: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
holidayShifted: preview.holidayShifted,
|
||||||
|
};
|
||||||
|
BATCHES.unshift(batch);
|
||||||
|
BATCH_DETAILS[id] = payable.map((e) => ({
|
||||||
|
id: nextAdminPayoutId++,
|
||||||
|
nurseId: e.nurseId,
|
||||||
|
nurseName: e.nurseName,
|
||||||
|
maskedIban: ADMIN_MASKED_IBAN_A,
|
||||||
|
grossEarningsIrr: e.grossEarningsIrr,
|
||||||
|
clawbackAppliedIrr: e.clawbackAppliedIrr,
|
||||||
|
netAmountIrr: e.netAmountIrr,
|
||||||
|
amountIrr: e.netAmountIrr,
|
||||||
|
status: 'submitted',
|
||||||
|
transferReference: null,
|
||||||
|
paidAt: null,
|
||||||
|
failureReason: null,
|
||||||
|
bookings: [{ bookingId: 5000 + e.nurseId, sessionId: 1, payoutAmountIrr: e.grossEarningsIrr }],
|
||||||
|
}));
|
||||||
|
RUN_BATCH_IDEMPOTENCY.set(idempotencyKey, batch);
|
||||||
|
return delay(batch);
|
||||||
|
},
|
||||||
|
|
||||||
|
getPayoutBatchDetail: async (batchId: number, page: number) => {
|
||||||
|
const batch = BATCHES.find((b) => b.id === batchId);
|
||||||
|
if (!batch) throw new Error(`Mock payout batch ${batchId} not found`);
|
||||||
|
const rows = BATCH_DETAILS[batchId] ?? [];
|
||||||
|
const pageSize = ADMIN_BATCH_DETAIL_PAGE_SIZE;
|
||||||
|
const p = Math.max(1, page);
|
||||||
|
const start = (p - 1) * pageSize;
|
||||||
|
const detail: AdminPayoutBatchDetail = {
|
||||||
|
batch,
|
||||||
|
payouts: rows.slice(start, start + pageSize),
|
||||||
|
total: rows.length,
|
||||||
|
page: p,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
return delay(detail);
|
||||||
|
},
|
||||||
|
|
||||||
|
retryPayout: async (payoutId: number, idempotencyKey: string) => {
|
||||||
|
// Idempotency: a re-fired retry with the same key never re-applies (no double-pay).
|
||||||
|
if (RETRY_IDEMPOTENCY.has(idempotencyKey)) return delay(undefined);
|
||||||
|
RETRY_IDEMPOTENCY.add(idempotencyKey);
|
||||||
|
for (const [key, rows] of Object.entries(BATCH_DETAILS)) {
|
||||||
|
const row = rows.find((r) => r.id === payoutId);
|
||||||
|
if (!row) continue;
|
||||||
|
if (row.status === 'failed') {
|
||||||
|
row.status = 'paid';
|
||||||
|
row.failureReason = null;
|
||||||
|
row.paidAt = new Date().toISOString();
|
||||||
|
row.transferReference = `PAYA-RETRY-${payoutId}`;
|
||||||
|
row.amountIrr = row.netAmountIrr;
|
||||||
|
const batch = BATCHES.find((b) => b.id === Number(key));
|
||||||
|
// if that was the last failure in the batch, it re-settles partially_failed → completed
|
||||||
|
if (batch && batch.status === 'partially_failed' && rows.every((r) => r.status !== 'failed')) {
|
||||||
|
batch.status = 'completed';
|
||||||
|
batch.processedAt = new Date().toISOString();
|
||||||
|
batch.failureNotes = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return delay(undefined);
|
||||||
|
},
|
||||||
|
|
||||||
|
recordTransferReference: async (payoutId: number, reference: string) => {
|
||||||
|
for (const rows of Object.values(BATCH_DETAILS)) {
|
||||||
|
const row = rows.find((r) => r.id === payoutId);
|
||||||
|
if (row) {
|
||||||
|
row.transferReference = reference;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return delay(undefined);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user