frontend phase 15

This commit is contained in:
hamid
2026-07-10 20:28:06 +03:30
parent bc51cf59b4
commit 70cf00ce4a
151 changed files with 10711 additions and 44 deletions
+47 -9
View File
@@ -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)
│ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx
│ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here
│ │ ── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell
│ │ ├── layout.tsx # 'use client' — wraps AdminLayout
│ │ ├── page.tsx # /admin (overview)
│ │ ├── users/page.tsx # /admin/users
│ │ ── notifications/page.tsx # /admin/notifications
│ │ ── 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 (capability-gated nav)
│ │ ├── page.tsx # /admin — f15 overview landing: a capability-gated grid of console cards
│ │ ├── verification/ # /admin/verification — f15 review queue (page.tsx: status-filtered nurse worklist) ↔ [nurseId]/page.tsx per-nurse case (DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry, Approve enabled only when all steps pass — client never writes is_verified)
│ │ ── tickets/ # /admin/tickets — f15 global ticket queue (page.tsx: filter status/category/referenceCode) ↔ [id]/page.tsx admin thread (AdminMessageBubble renders isInternal notes distinctly; internal-note composer; RefundPanel opens from a refund ticket)
│ │ │ ├── payouts/ # /admin/payouts — f15 batch dashboard (page.tsx: batches + preview-next-batch dialog → run, idempotency-keyed) ↔ [batchId]/page.tsx per-nurse rows + failed-payout retry + transfer-reference reconcile
│ │ │ ├── reviews/page.tsx # /admin/reviews — f15 moderation queue: publish/hide/reject (reason on hide/reject); low-rating flag; client never computes the aggregate
│ │ │ ├── config/page.tsx # /admin/config — f15 config editor: typed input by data_type + 01 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)/
│ ├── layout.tsx # 'use client' — wraps PublicLayout
│ └── 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
│ ├── 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
│ ├── 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}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
│ ├── 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`
- `'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)
- `'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
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.
@@ -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
does **not** verify the signature. The API is the only authority; never gate real authorization on the
middleware or `isTokenAlive`.
- **Role gating is coarse:** the shells pick chrome from `currentUser.roles`, but cross-actor route access
isn't hard-guarded client-side yet (the server authorizes each call). Add route guards when a phase needs
them.
- **Role gating is coarse for chrome, fine for the backoffice:** the shells pick chrome from the collapsed
`currentUser.roles` (`useActorRole`). **f15 adds `useAdminCapabilities()` (`@/hooks`)** — a memoized selector
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
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`.
+430 -2
View File
@@ -19,7 +19,20 @@
"notifications": "Notifications",
"support": "Support",
"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": {
"dark_mode": "Dark mode",
@@ -44,7 +57,8 @@
"customer_app": "Family app",
"nurse_app": "Nurse view",
"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": {
"greeting_named": "Hi, {name}",
@@ -1146,5 +1160,419 @@
"mark_all_read": "Mark all read",
"load_more": "Load more",
"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 dont 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": "Couldnt 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 cant 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 ~710 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 nurses 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 dont 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": "Couldnt open the invoice PDF.",
"invoice_number": "Invoice #{number}",
"settlement_iban": "Settlement IBAN"
}
}
+430 -2
View File
@@ -19,7 +19,20 @@
"notifications": "اعلان‌ها",
"support": "پشتیبانی",
"login": "ورود",
"logout": "خروج"
"logout": "خروج",
"payouts": "تسویه‌ها",
"reviews": "نظرات",
"config": "پیکربندی",
"holidays": "تعطیلات",
"alerts": "هشدارها",
"audit": "گزارش ممیزی",
"partners": "مراکز همکار",
"roles": "نقش‌ها",
"tickets": "تیکت‌ها",
"partner_home": "مرکز",
"partner_nurses": "پرستاران تحت پوشش",
"partner_bookings": "رزروها",
"partner_settlement": "تسویه"
},
"common": {
"dark_mode": "حالت تاریک",
@@ -44,7 +57,8 @@
"customer_app": "اپلیکیشن خانواده",
"nurse_app": "نمای پرستار",
"admin_console": "کنسول مدیریت",
"placeholder_body": "این بخش در فازهای بعدی تکمیل می‌شود."
"placeholder_body": "این بخش در فازهای بعدی تکمیل می‌شود.",
"partner_console": "پرتال همکار"
},
"home": {
"greeting_named": "سلام، {name}",
@@ -1146,5 +1160,419 @@
"mark_all_read": "علامت‌گذاری همه به‌عنوان خوانده‌شده",
"load_more": "نمایش بیشتر",
"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 01 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 01). 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';
import { PlaceholderScreen } from '@/components';
'use client';
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');
const tShell = await getTranslations('shell');
return <PlaceholderScreen icon="admin" title={t('overview')} description={tShell('placeholder_body')} />;
/**
* Admin overview landing (f15) — the backoffice home. Renders one **console card** per worklist the current
* principal may act on, derived from `useAdminCapabilities()` (a UI hint; the server still enforces every
* 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);
});
});
+88
View File
@@ -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();
});
});
+213
View File
@@ -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;
+32
View File
@@ -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
import SupportIcon from '@mui/icons-material/SupportAgentOutlined';
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
@@ -172,4 +185,16 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
family: FamilyIcon,
support: SupportIcon,
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,
};
+16
View File
@@ -13,3 +13,19 @@ export const APP_ROLES = {
export type AppRole = (typeof APP_ROLES)[keyof typeof APP_ROLES];
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);
}
+43 -1
View File
@@ -69,12 +69,54 @@ export const ROUTES = {
// Nurse in-app notification center (f14).
NURSE_NOTIFICATIONS: '/nurse/notifications',
// Admin / backoffice console
// Admin / backoffice console (f15) — desktop sidebar shell, role-gated worklists.
ADMIN: '/admin',
ADMIN_USERS: '/admin/users',
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;
/** 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. */
export const bookingInvoicePath = (bookingId: number | string): string =>
`${ROUTES.BOOKINGS}/${bookingId}/invoice`;
+7
View File
@@ -10,6 +10,13 @@ export interface SessionUser {
id?: number;
phone: string;
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 {
+71
View File
@@ -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
View File
@@ -1,3 +1,4 @@
export * from './auth';
export * from './capabilities';
export * from './event';
export * from './layout';
+23 -10
View File
@@ -2,26 +2,39 @@
import { FunctionComponent, PropsWithChildren, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { ROUTES } from '@/constants';
import { useAdminCapabilities } from '@/hooks';
import { LinkToPage } from '@/utils';
import TopBarAndSideBarLayout from './TopBarAndSideBarLayout';
/**
* Admin / backoffice shell desktop-oriented ops console (f15). Uses the shared
* TopBar + SideBar engine with a persistent sidebar on desktop.
* Admin / backoffice shell the desktop ops console (f15). The sidebar is **role-gated**: each console
* 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
*/
const AdminLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
const t = useTranslations('nav');
const tShell = useTranslations('shell');
const caps = useAdminCapabilities();
const sidebarItems: Array<LinkToPage> = useMemo(
() => [
{ title: t('overview'), path: ROUTES.ADMIN, icon: 'admin' },
{ title: t('users'), path: ROUTES.ADMIN_USERS, icon: 'users' },
{ title: t('notifications'), path: ROUTES.ADMIN_NOTIFICATIONS, icon: 'notifications' },
],
[t]
);
const sidebarItems: Array<LinkToPage> = useMemo(() => {
const items: Array<LinkToPage & { show: boolean }> = [
{ title: t('overview'), path: ROUTES.ADMIN, icon: 'dashboard', show: true },
{ title: t('verification'), path: ROUTES.ADMIN_VERIFICATION, icon: 'verification', show: caps.canVerify },
{ title: t('tickets'), path: ROUTES.ADMIN_TICKETS, icon: 'support', show: caps.canManageTickets },
{ title: t('payouts'), path: ROUTES.ADMIN_PAYOUTS, icon: 'earnings', show: caps.canPayout },
{ 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 (
<TopBarAndSideBarLayout
+40
View File
@@ -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;
+2 -1
View File
@@ -3,5 +3,6 @@ import PublicLayout from './PublicLayout';
import CustomerLayout from './CustomerLayout';
import NurseLayout from './NurseLayout';
import AdminLayout from './AdminLayout';
import PartnerLayout from './PartnerLayout';
export { PublicLayout, PrivateLayout, CustomerLayout, NurseLayout, AdminLayout };
export { PublicLayout, PrivateLayout, CustomerLayout, NurseLayout, AdminLayout, PartnerLayout };
+178
View File
@@ -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 }),
});
},
};
+10
View File
@@ -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;
+196
View File
@@ -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 01 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);
},
};
+39
View File
@@ -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() });
},
});
}
+17
View File
@@ -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';
+30
View File
@@ -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,
};
+183
View File
@@ -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 01) 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(() => {
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]);
}
@@ -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';
+25
View File
@@ -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,
};
+138
View File
@@ -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 { PAYOUTS_PAGE_SIZE } from '../constants';
import type {
AdminPayoutBatchDetail,
AdminPayoutRow,
EarningsListParams,
EligibleNurseEarnings,
NurseEarningsItem,
NurseEarningsSummary,
NursePayoutDetail,
NursePayoutHistoryItem,
PayoutBatchFilters,
PayoutBatchStatus,
PayoutBatchSummary,
PayoutStatus,
PayoutsApi,
} from '../types';
import type { PageParams } from '@/lib/api/types';
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
@@ -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`,
* 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) =>
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 }),
});
},
};
+371 -1
View File
@@ -1,14 +1,21 @@
import type { Paginated } 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 {
AdminPayoutBatchDetail,
AdminPayoutRow,
EarningsListParams,
EarningsState,
EligibleNurseEarnings,
NurseEarningsItem,
NurseEarningsSummary,
NursePayoutDetail,
NursePayoutHistoryItem,
PayoutBatchFilters,
PayoutBatchPreview,
PayoutBatchSummary,
PayoutsApi,
SkippedNurse,
} 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 };
// ══ 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 = {
getNurseEarningsBalance: async () => delay(buildSummary()),
@@ -336,4 +599,111 @@ export const payoutsMockApi: PayoutsApi = {
if (!detail) throw new Error(`Mock payout ${payoutId} not found`);
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