diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 09a0d53..12ca995 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -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 + 0–1 rate validation + audited-save dialog + change-history drawer + │ │ │ ├── holidays/page.tsx # /admin/holidays — f15 Iranian-holiday manager (is_bank_closed toggle; client never computes the payout shift) + │ │ │ ├── alerts/page.tsx # /admin/alerts — f15 internal support-alert worklist (assign/resolve); NEVER surfaced to a non-admin + │ │ │ ├── audit/page.tsx # /admin/audit — f15 append-only audit viewer (filtered, paginated, expandable changedFields diff; no edit/delete) + │ │ │ ├── partners/ # /admin/partners — f15 partner-center management (page.tsx: list + create) ↔ [id]/page.tsx detail (verify/activate/suspend + edit + sponsored-nurse roster + assign-nurse; IBAN write-then-masked) + │ │ │ ├── roles/page.tsx # /admin/roles — f15 RBAC grant/revoke grid (DEFERRED-IF-MISSING — mock-backed until the b15 role endpoints land) + │ │ │ ├── users/page.tsx # /admin/users + │ │ │ └── notifications/page.tsx # /admin/notifications + │ │ └── partner/ # Partner-center portal (/partner/…) — a SEPARATE authz scope (f15). A center admin is not a Balinyaar admin; each page resolves the caller's OWN center (useMyPartnerCenter → access-denied on 403/404). + │ │ ├── layout.tsx # 'use client' — wraps PartnerLayout (own partner nav) + │ │ ├── page.tsx # /partner — center home: onboarding/verification state banner + license fields + is_merchant_of_record indicator + │ │ ├── nurses/page.tsx # /partner/nurses — the center's sponsored nurses (verification badge) + │ │ ├── bookings/page.tsx # /partner/bookings — the bookings the center legally covers (read-only summaries) + │ │ └── settlement/page.tsx # /partner/settlement — rendered ONLY when is_merchant_of_record: per-booking commission invoices (commission/VAT decomposition via PartnerSettlementRow, signed-URL PDF, masked IBAN); non-MoR shows the "settlement via Balinyaar" state │ └── (public-routes)/ │ ├── 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`. diff --git a/client/messages/en.json b/client/messages/en.json index 770a546..ce364fc 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -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 don’t have access to this console.", + "role_gate_hint": "This action is limited to certain admin roles.", + "masked_iban_label": "IBAN", + "ver_title": "Verification queue", + "ver_subtitle": "Nurses awaiting document review.", + "ver_empty": "Queue clear — nothing to review.", + "ver_col_nurse": "Nurse", + "ver_col_step": "Step", + "ver_col_status": "Status", + "ver_col_submitted": "Submitted", + "ver_progress": "{done} of {total}", + "ver_next_step": "Next: {step}", + "ver_open_case": "Review", + "ver_case_title": "Verification case", + "ver_identity_name": "Identity on file", + "ver_steps_title": "Steps", + "ver_documents_title": "Documents", + "ver_credentials_title": "Credentials", + "ver_no_documents": "No documents for this step.", + "ver_automated_badge": "Automated check", + "ver_pass": "Pass", + "ver_reject": "Reject", + "ver_pass_step": "Pass step", + "ver_reject_step": "Reject step", + "ver_reject_reason_ph": "Why is this step rejected?", + "ver_credential_title": "Record credential", + "ver_credential_number": "Credential number", + "ver_holder_name": "Holder name", + "ver_holder_hint": "Must match the verified identity name, or the credential is rejected.", + "ver_issuing_authority": "Issuing authority", + "ver_issued_at": "Issued at", + "ver_expires_at": "Expires at", + "ver_expiry_required": "An expiry date is required for this credential.", + "ver_expiring_warning": "Expiring credential", + "ver_approve": "Approve verification", + "ver_reject_all": "Reject verification", + "ver_approve_hint": "Enabled only when every required step has passed.", + "ver_approve_confirm": "Approve this nurse? The server flips their verified status; this cannot be undone here.", + "ver_reject_confirm": "Reject this verification with the reason above?", + "ver_decided": "Decision recorded.", + "doc_loading": "Loading document…", + "doc_expired": "This secure link expired.", + "doc_reload": "Re-request link", + "doc_error": "Couldn’t load the document.", + "doc_open_new": "Open in a new tab", + "doc_file_meta": "{name} · {size}", + "payout_title": "Payout batches", + "payout_subtitle": "Weekly nurse payouts — preview, run, reconcile.", + "payout_empty": "No payout batches yet.", + "payout_col_period": "Period", + "payout_col_count": "Payouts", + "payout_col_total": "Total", + "payout_col_status": "Status", + "payout_col_processing": "Processing date", + "payout_holiday_shift": "Shifted off a bank holiday", + "payout_preview": "Preview next batch", + "payout_preview_title": "Eligibility preview", + "payout_period_start": "Period start", + "payout_period_end": "Period end", + "payout_eligible_nurses": "Eligible nurses", + "payout_col_gross": "Gross earnings", + "payout_col_clawback": "Clawback netted", + "payout_col_net": "Net", + "payout_no_iban": "No verified IBAN — will be skipped", + "payout_eligibility_note": "Only completed bookings past their dispute window appear here — computed server-side.", + "payout_run": "Run batch", + "payout_run_confirm_title": "Run this payout batch?", + "payout_run_confirm_body": "Money moves to nurses. This is protected by an idempotency key — a double-click can’t pay a booking twice.", + "payout_running": "Submitting to the bank rail…", + "payout_skipped": "Skipped nurses", + "payout_batch_title": "Batch #{id}", + "payout_rows_title": "Payouts in this batch", + "payout_row_nurse": "Nurse", + "payout_row_net": "Net", + "payout_row_status": "Status", + "payout_row_ref": "Transfer ref", + "payout_failure_reason": "Failure: {reason}", + "payout_retry": "Retry payout", + "payout_retry_confirm": "Retry this failed payout? (Idempotency-protected.)", + "payout_record_ref": "Record transfer reference", + "payout_record_ref_ph": "Bank transfer reference", + "payout_ref_saved": "Transfer reference recorded.", + "payout_decomp": "{gross} gross − {clawback} clawback = {net} net", + "payout_ran": "Batch submitted — now processing.", + "refund_title": "Refund", + "refund_open": "Open refund panel", + "refund_linked_booking": "Booking #{id}", + "refund_preview_title": "Refund preview", + "refund_percentage": "Refund rate", + "refund_row_fee": "Platform commission refunded", + "refund_row_payout": "Nurse payout refunded", + "refund_row_total": "Total refund", + "refund_channel": "Channel", + "refund_channel_hint": "How the money is returned — chosen by the server.", + "refund_eta": "Estimated to the customer: {date}", + "refund_eta_bnpl": "BNPL reverts take ~7–10 business days.", + "refund_clawback_notice": "The nurse was already paid — a clawback will be created automatically.", + "refund_initiate": "Initiate refund", + "refund_approve": "Approve", + "refund_reject": "Reject", + "refund_confirm": "Initiate this refund on booking #{id}?", + "refund_provider_failed": "The provider revert failed.", + "refund_done": "Refund processed.", + "refund_reason_category": "Reason", + "refund_notes_ph": "Notes (optional)", + "mod_title": "Review moderation", + "mod_subtitle": "Publish, hide, or reject submitted reviews.", + "mod_empty": "Nothing to moderate.", + "mod_low_rating": "Low rating", + "mod_col_rating": "Rating", + "mod_col_review": "Review", + "mod_col_context": "Nurse / booking", + "mod_publish": "Publish", + "mod_hide": "Hide", + "mod_reject": "Reject", + "mod_confirm_publish": "Publish this review? It becomes public and re-computes the nurse’s rating.", + "mod_confirm_hide": "Hide this review with the reason above?", + "mod_confirm_reject": "Reject this review with the reason above?", + "mod_done": "Review updated.", + "mod_nurse": "Nurse #{id}", + "mod_booking": "Booking #{id}", + "cfg_title": "Platform configuration", + "cfg_subtitle": "Typed, audited settings that drive fees, deadlines, and scheduling.", + "cfg_col_key": "Key", + "cfg_col_value": "Value", + "cfg_col_type": "Type", + "cfg_col_updated": "Last updated", + "cfg_edit": "Edit", + "cfg_updated_by": "by {actor}", + "cfg_range_error": "A rate must be between 0 and 1.", + "cfg_int_error": "This must be a whole number.", + "cfg_json_error": "Invalid JSON.", + "cfg_empty_error": "A value is required.", + "cfg_save_confirm_title": "Save this configuration change?", + "cfg_save_confirm_body": "This change is audited and takes effect immediately. It does NOT retroactively change already-computed bookings or ledger entries.", + "cfg_saved": "Configuration saved.", + "cfg_history": "Change history", + "cfg_history_title": "History — {key}", + "cfg_history_change": "{old} → {new}", + "cfg_history_empty": "No changes recorded yet.", + "cfg_group_fees": "Fees & VAT", + "cfg_group_deadlines": "Deadlines & windows", + "cfg_group_evv": "EVV", + "cfg_group_bnpl": "BNPL", + "cfg_group_cancellation": "Cancellation tiers", + "cfg_group_other": "Other", + "hol_title": "Holiday calendar", + "hol_subtitle": "Bank-closed days shift payout scheduling. The server computes the shift.", + "hol_empty": "No holidays in this range.", + "hol_col_date": "Date", + "hol_col_name": "Name", + "hol_col_type": "Type", + "hol_col_bank": "Bank closed", + "hol_add": "Add holiday", + "hol_edit": "Edit holiday", + "hol_name_fa": "Name (Persian)", + "hol_bank_hint": "When on, payouts falling on this day shift to the next business day.", + "hol_saved": "Holiday saved.", + "hol_year": "Year", + "alert_title": "Support alerts", + "alert_subtitle": "Internal triage — never shown to customers or nurses.", + "alert_empty": "No open alerts.", + "alert_col_type": "Type", + "alert_col_entity": "Linked to", + "alert_col_owner": "Owner", + "alert_col_status": "Status", + "alert_col_created": "Raised", + "alert_assign_me": "Assign to me", + "alert_assign": "Assign", + "alert_resolve": "Resolve", + "alert_resolve_title": "Resolve alert", + "alert_resolve_note_ph": "How was this resolved?", + "alert_assigned": "Alert assigned.", + "alert_resolved": "Alert resolved.", + "alert_link_booking": "Booking #{id}", + "alert_link_review": "Review #{id}", + "alert_link_entity": "{type} #{id}", + "alert_unassigned": "Unassigned", + "audit_title": "Audit log", + "audit_subtitle": "Append-only record of every admin state change. Read-only.", + "audit_empty": "No audit entries for this filter.", + "audit_col_entity": "Entity", + "audit_col_action": "Action", + "audit_col_actor": "Actor", + "audit_col_time": "Time", + "audit_entity_type_ph": "Entity type (e.g. PlatformConfig)", + "audit_entity_id_ph": "Entity id", + "audit_from": "From", + "audit_to": "To", + "audit_diff_title": "Changed fields", + "audit_diff_field": "Field", + "audit_diff_old": "Old", + "audit_diff_new": "New", + "audit_no_diff": "No field-level diff recorded.", + "audit_redacted": "", + "ticket_title": "Ticket queue", + "ticket_subtitle": "Every ticket across the platform. Internal notes are staff-only.", + "ticket_empty": "No tickets match this filter.", + "ticket_col_ref": "Reference", + "ticket_col_subject": "Subject", + "ticket_col_category": "Category", + "ticket_col_status": "Status", + "ticket_col_booking": "Booking", + "ticket_search_ref_ph": "Search by reference code", + "ticket_thread_title": "Ticket {ref}", + "ticket_internal_badge": "Internal note", + "ticket_public_reply": "Reply", + "ticket_internal_note": "Internal note", + "ticket_composer_public_ph": "Reply to the participants…", + "ticket_composer_internal_ph": "Add a staff-only internal note…", + "ticket_send": "Send", + "ticket_sent": "Message sent.", + "ticket_participants": "Participants", + "ticket_linked_refund": "Refund #{id}", + "partner_title": "Partner centers", + "partner_subtitle": "Licensed centers that sponsor nurses and may be merchant-of-record.", + "partner_empty": "No partner centers yet.", + "partner_col_name": "Name", + "partner_col_mor": "Merchant of record", + "partner_col_nurses": "Nurses", + "partner_col_state": "State", + "partner_create": "Create center", + "partner_edit": "Edit center", + "partner_detail_title": "Center detail", + "partner_name": "Center name", + "partner_legal_type": "Legal entity type", + "partner_permit": "پروانه تأسیس", + "partner_permit_en": "MoH establishment permit", + "partner_tech_director": "مسئول فنی", + "partner_tech_director_license": "Technical director license", + "partner_enamad": "نماد اعتماد الکترونیکی", + "partner_iban": "Settlement IBAN", + "partner_iban_write_hint": "Enter the full IBAN to set it; it is stored masked and only the last 4 digits are shown afterward.", + "partner_commission": "Commission rate", + "partner_is_mor": "Merchant of record", + "partner_is_mor_hint": "When on, this center issues invoices and is the settlement target.", + "partner_admin_user": "Center admin user id", + "partner_verify": "Verify & activate", + "partner_verify_confirm": "Record licensing approval and activate this center?", + "partner_activate": "Activate", + "partner_suspend": "Suspend", + "partner_saved": "Center saved.", + "partner_verified_toast": "Center verified and activated.", + "partner_roster_title": "Sponsored nurses", + "partner_assign_nurse": "Assign nurse", + "partner_assign_nurse_ph": "Nurse profile id", + "partner_unlink_nurse": "Remove", + "partner_nurse_assigned": "Nurse assignment updated.", + "role_title": "Roles & access", + "role_subtitle": "Grant or revoke admin roles. (Awaiting backend role endpoints.)", + "role_deferred": "This console is served by a client-side placeholder until the RBAC endpoints ship.", + "role_col_user": "User", + "role_col_role": "Role", + "role_col_granted": "Granted", + "role_grant": "Grant role", + "role_revoke": "Revoke", + "role_grant_confirm": "Grant {role} to user #{id}?", + "role_revoke_confirm": "Revoke {role} from user #{id}?", + "role_updated": "Role updated.", + "agg_not_started": "Not started", + "agg_pending": "Pending", + "agg_in_review": "In review", + "agg_approved": "Approved", + "agg_rejected": "Rejected", + "agg_suspended": "Suspended", + "step_not_started": "Not started", + "step_pending": "Pending", + "step_in_review": "In review", + "step_passed": "Passed", + "step_failed": "Failed", + "step_expired": "Expired", + "step_identity_kyc": "Identity (KYC)", + "step_shahkar_match": "Shahkar match", + "step_moh_competency_license": "MoH competency license", + "step_ino_membership": "INO membership", + "step_criminal_record": "Criminal record", + "step_bank_account_verification": "Bank account check", + "batch_status_draft": "Draft", + "batch_status_processing": "Processing", + "batch_status_partially_failed": "Partially failed", + "batch_status_completed": "Completed", + "batch_status_failed": "Failed", + "pstatus_pending": "Pending", + "pstatus_submitted": "Submitted", + "pstatus_paid": "Paid", + "pstatus_failed": "Failed", + "channel_psp_card": "Card (PSP)", + "channel_bnpl_revert": "BNPL revert", + "channel_manual": "Manual bank", + "rstatus_requested": "Requested", + "rstatus_approved": "Approved", + "rstatus_processing": "Processing", + "rstatus_succeeded": "Succeeded", + "rstatus_failed": "Failed", + "rstatus_rejected": "Rejected", + "mstatus_pending_moderation": "Pending", + "mstatus_published": "Published", + "mstatus_hidden": "Hidden", + "mstatus_rejected": "Rejected", + "dtype_string": "Text", + "dtype_int": "Integer", + "dtype_decimal": "Decimal", + "dtype_bool": "Boolean", + "dtype_json": "JSON", + "htype_official": "Official", + "htype_religious": "Religious", + "htype_national": "National", + "atype_low_rating": "Low rating", + "atype_evv_no_show": "No-show", + "atype_evv_location_mismatch": "EVV location mismatch", + "atype_verification_expired": "Verification expired", + "atype_shared_sim": "Shared SIM", + "atype_payment_anomaly": "Payment anomaly", + "atype_fraud_signal": "Fraud signal", + "atype_nurse_clawback": "Nurse clawback", + "atype_emergency": "Emergency", + "astatus_open": "Open", + "astatus_assigned": "Assigned", + "astatus_resolved": "Resolved", + "sev_low": "Low", + "sev_medium": "Medium", + "sev_high": "High", + "tstatus_open": "Open", + "tstatus_closed": "Closed", + "tcat_coordination": "Coordination", + "tcat_support": "Support", + "tcat_refund": "Refund", + "tcat_emergency": "Emergency", + "role_super_admin": "Super admin", + "role_admin": "Admin", + "role_support": "Support", + "role_finance": "Finance", + "role_moderation": "Moderation", + "center_state_draft": "Draft", + "center_state_pending_verification": "Pending verification", + "center_state_verified": "Verified", + "center_state_suspended": "Suspended", + "yes": "Yes", + "no": "No" + }, + "partner": { + "home_title": "Your center", + "home_subtitle": "Onboarding, sponsored nurses, and settlement at a glance.", + "state_banner_draft": "Your center is a draft — complete onboarding to go live.", + "state_banner_pending": "Your center is pending verification.", + "state_banner_suspended": "Your center is suspended. Contact Balinyaar.", + "license_title": "License details", + "permit": "پروانه تأسیس", + "tech_director": "مسئول فنی", + "enamad": "نماد اعتماد الکترونیکی", + "legal_type": "Legal entity type", + "is_mor_yes": "Merchant of record", + "is_mor_no": "Settlement runs through Balinyaar", + "access_denied": "You don’t have access to a partner center.", + "nurses_title": "Sponsored nurses", + "nurses_empty": "No nurses sponsored yet.", + "nurses_col_name": "Nurse", + "nurses_col_verified": "Verification", + "bookings_title": "Sponsored bookings", + "bookings_empty": "No bookings under this center yet.", + "bookings_col_id": "Booking", + "bookings_col_patient": "Patient", + "bookings_col_date": "Date", + "bookings_col_status": "Status", + "settlement_title": "Settlement & invoices", + "settlement_not_mor": "This center is not merchant-of-record — settlement runs through Balinyaar, and no commission invoices are issued here.", + "settlement_empty": "No invoices yet.", + "settlement_col_booking": "Booking", + "settlement_col_gross": "Gross", + "settlement_col_total": "Total", + "settlement_moadian": "سامانه مودیان", + "settlement_moadian_ref": "Moadian reference", + "settlement_moadian_pending": "Not yet submitted to سامانه مودیان", + "invoice_row_gross": "Gross service fee", + "invoice_row_commission": "Platform commission", + "invoice_row_bnpl_commission": "BNPL commission", + "invoice_row_vat": "VAT (on commission)", + "invoice_row_total": "Invoice total", + "invoice_download": "Download PDF", + "invoice_pdf_error": "Couldn’t open the invoice PDF.", + "invoice_number": "Invoice #{number}", + "settlement_iban": "Settlement IBAN" } } diff --git a/client/messages/fa.json b/client/messages/fa.json index aeff2c9..24cf5c8 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -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": "شبای تسویه" } } diff --git a/client/src/app/[locale]/(private-routes)/admin/alerts/page.tsx b/client/src/app/[locale]/(private-routes)/admin/alerts/page.tsx new file mode 100644 index 0000000..956267e --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/alerts/page.tsx @@ -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('open'); + const [type, setType] = useState(''); + const [page, setPage] = useState(1); + const [resolving, setResolving] = useState(null); + + const filters = { status: status || undefined, type: type || undefined }; + const alerts = useSupportAlerts(filters, page); + const assign = useAssignSupportAlert(); + const resolve = useResolveSupportAlert(); + + const items = alerts.data?.items ?? []; + const pageCount = Math.max(1, Math.ceil((alerts.data?.total ?? 0) / ADMIN_PAGE_SIZE)); + + const 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 ( + + + { + setStatus(e.target.value as SupportAlertStatus | ''); + setPage(1); + }} + sx={{ minWidth: 140 }} + > + {t('filter_all')} + {STATUSES.map((s) => ( + + {t(`astatus_${s}`)} + + ))} + + { + setType(e.target.value as SupportAlertType | ''); + setPage(1); + }} + sx={{ minWidth: 180 }} + > + {t('filter_all')} + {TYPES.map((ty) => ( + + {t(`atype_${ty}`)} + + ))} + + + } + /> + + {alerts.isLoading ? ( + {[0, 1, 2].map((k) => )} + ) : alerts.isError ? ( + alerts.refetch()} /> + ) : items.length === 0 ? ( + + ) : ( + + {items.map((alert) => ( + + ))} + + )} + + 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 })} + /> + + setResolving(null)} + loading={resolve.isPending} + requireReason + reasonLabel={t('note_label')} + reasonPlaceholder={t('alert_resolve_note_ph')} + /> + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/audit/page.tsx b/client/src/app/[locale]/(private-routes)/admin/audit/page.tsx new file mode 100644 index 0000000..8fcf1a0 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/audit/page.tsx @@ -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(EMPTY); + const [applied, setApplied] = useState(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 ( + + + + + setDraft((d) => ({ ...d, entityType: e.target.value || undefined }))} + sx={{ minWidth: 200 }} + /> + setDraft((d) => ({ ...d, entityId: e.target.value || undefined }))} + sx={{ minWidth: 120 }} + /> + setDraft((d) => ({ ...d, from: e.target.value || undefined }))} + slotProps={{ inputLabel: { shrink: true } }} + /> + setDraft((d) => ({ ...d, to: e.target.value || undefined }))} + slotProps={{ inputLabel: { shrink: true } }} + /> + + {t('apply')} + + + {t('clear')} + + + + {audit.isLoading ? ( + {[0, 1, 2, 3].map((k) => )} + ) : audit.isError ? ( + audit.refetch()} /> + ) : items.length === 0 ? ( + + ) : ( + + {items.map((entry) => ( + + ))} + + )} + + 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 })} + /> + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/config/page.tsx b/client/src/app/[locale]/(private-routes)/admin/config/page.tsx new file mode 100644 index 0000000..b691764 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/config/page.tsx @@ -0,0 +1,218 @@ +'use client'; +import { useMemo, useState } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { useSnackbar } from 'notistack'; +import { + Box, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Drawer, + FormControlLabel, + Skeleton, + Stack, + Switch, + TextField, + Typography, +} from '@mui/material'; +import { AppButton, AppIcon } from '@/components'; +import { AdminEmptyState, AdminErrorState, AdminPageHeader, ConfigRow } from '@/components/admin'; +import { formatShamsiDateTime } from '@/utils'; +import { useAdminCapabilities } from '@/hooks'; +import { CONFIG_GROUPS, RATE_CONFIG_KEYS } from '@/services/admin/constants'; +import type { PlatformConfig } from '@/services/admin/types'; +import { usePlatformConfigs, useUpdatePlatformConfig, useConfigChangeHistory } from '@/services/admin'; + +const GROUP_ORDER = ['fees', 'deadlines', 'evv', 'bnpl', 'cancellation', 'other'] as const; +type GroupKey = (typeof GROUP_ORDER)[number]; + +/** Validate a candidate value against a config's `data_type` (+ the 0–1 rate rule). Returns an i18n key or null. */ +function validate(config: PlatformConfig, value: string): string | null { + const trimmed = value.trim(); + if (trimmed.length === 0) return 'cfg_empty_error'; + if (config.dataType === 'int') { + if (!/^-?\d+$/.test(trimmed)) return 'cfg_int_error'; + } + if (config.dataType === 'int' || config.dataType === 'decimal') { + const n = Number(trimmed); + if (Number.isNaN(n)) return 'cfg_int_error'; + if (RATE_CONFIG_KEYS.includes(config.key) && (n < 0 || n > 1)) return 'cfg_range_error'; + } + if (config.dataType === 'json') { + try { + JSON.parse(trimmed); + } catch { + return 'cfg_json_error'; + } + } + return null; +} + +/** + * Platform config editor (f15) — every `platform_configs` row grouped by concern, each with a typed input + * by `data_type` and boundary validation (a rate is 0–1). Saving is audited server-side and takes effect + * immediately without re-pricing already-computed rows — the save dialog says so. The change-history drawer + * proves the value in effect at any past moment. The client never re-parses config beyond rendering by + * `data_type` (phase §5). + */ +export default function AdminConfigPage() { + const t = useTranslations('admin'); + const caps = useAdminCapabilities(); + const configs = usePlatformConfigs(1); + const [editing, setEditing] = useState(null); + const [historyKey, setHistoryKey] = useState(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 = { fees: [], deadlines: [], evv: [], bnpl: [], cancellation: [], other: [] }; + for (const c of items) result[byKey.get(c.key) ?? 'other'].push(c); + return result; + }, [configs.data]); + + return ( + + + + {configs.isLoading ? ( + {[0, 1, 2].map((k) => )} + ) : configs.isError ? ( + configs.refetch()} /> + ) : (configs.data?.items.length ?? 0) === 0 ? ( + + ) : ( + GROUP_ORDER.filter((g) => grouped[g].length > 0).map((g) => ( + + + {t(`cfg_group_${g}`)} + + {grouped[g].map((config) => ( + setHistoryKey(c.key)} + /> + ))} + + )) + )} + + {editing ? setEditing(null)} /> : null} + setHistoryKey(null)} /> + + ); +} + +/** 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 ( + + {config.key} + + {config.description ? ( + {config.description} + ) : null} + + {isBool ? ( + setValue(e.target.checked ? 'true' : 'false')} />} + label={t(`dtype_bool`)} + /> + ) : ( + 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 } }} + /> + )} + + + {t('cfg_save_confirm_body')} + + + + + {t('cancel')} + + + {update.isPending ? t('saving') : t('save')} + + + + ); +} + +/** 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 ( + + + {configKey ? t('cfg_history_title', { key: configKey }) : ''} + + + + + + {history.isLoading ? ( + {[0, 1].map((k) => )} + ) : (history.data?.items.length ?? 0) === 0 ? ( + + {t('cfg_history_empty')} + + ) : ( + + {history.data?.items.map((change) => ( + + + {t('cfg_history_change', { old: change.oldValue ?? '—', new: change.newValue ?? '—' })} + + + {formatShamsiDateTime(change.occurredAt, locale)} + {change.actorUserId != null ? ` · #${change.actorUserId}` : ''} + + + ))} + + )} + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/holidays/page.tsx b/client/src/app/[locale]/(private-routes)/admin/holidays/page.tsx new file mode 100644 index 0000000..1ef081a --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/holidays/page.tsx @@ -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(null); + + const columns: AdminTableColumn[] = [ + { 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) => }, + { + key: 'bank', + header: t('hol_col_bank'), + render: (h) => ( + + ), + }, + ...(caps.canConfig + ? [ + { + key: 'actions', + header: '', + render: (h: Holiday) => ( + setEditing(h)} sx={{ m: 0 }}> + {t('cfg_edit')} + + ), + } as AdminTableColumn, + ] + : []), + ]; + + return ( + + setEditing('new')} sx={{ m: 0 }}> + {t('hol_add')} + + ) : undefined + } + /> + + {holidays.isLoading ? ( + + ) : holidays.isError ? ( + holidays.refetch()} /> + ) : (holidays.data?.items.length ?? 0) === 0 ? ( + + ) : ( + h.id} ariaLabel={t('hol_title')} /> + )} + + {editing ? ( + setEditing(null)} /> + ) : null} + + ); +} + +const TODAY_ISO = ''; // seeded below via state default so no Date at module load + +function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose: () => void }) { + const t = useTranslations('admin'); + const { enqueueSnackbar } = useSnackbar(); + const upsert = useUpsertHoliday(); + const [form, setForm] = useState({ + holidayDate: holiday?.holidayDate?.slice(0, 10) ?? TODAY_ISO, + 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 ( + + {holiday ? t('hol_edit') : t('hol_add')} + + + setForm((f) => ({ ...f, holidayDate: e.target.value }))} + disabled={!!holiday} + slotProps={{ inputLabel: { shrink: true } }} + /> + setForm((f) => ({ ...f, nameFa: e.target.value }))} + /> + setForm((f) => ({ ...f, type: e.target.value as HolidayType }))} + > + {HOLIDAY_TYPES.map((ty) => ( + + {t(`htype_${ty}`)} + + ))} + + setForm((f) => ({ ...f, isBankClosed: e.target.checked }))} />} + label={t('hol_bank_hint')} + /> + + + + + {t('cancel')} + + + {upsert.isPending ? t('saving') : t('save')} + + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/page.tsx b/client/src/app/[locale]/(private-routes)/admin/page.tsx index 94c85fd..a20ceaf 100644 --- a/client/src/app/[locale]/(private-routes)/admin/page.tsx +++ b/client/src/app/[locale]/(private-routes)/admin/page.tsx @@ -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 ; +/** + * 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 ( + + + + + {consoles.map((c) => ( + + + + + {tNav(c.key)} + + + + ))} + + + ); } diff --git a/client/src/app/[locale]/(private-routes)/admin/partners/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/admin/partners/[id]/page.tsx new file mode 100644 index 0000000..05718f9 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/partners/[id]/page.tsx @@ -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 = ( + router.push(`/${locale}${ROUTES.ADMIN_PARTNERS}`)} + sx={{ m: 0, alignSelf: 'flex-start' }} + > + {t('back')} + + ); + + if (center.isLoading) { + return ( + + {back} + + + + + ); + } + + if (center.isError) { + return ( + + {back} + center.refetch()} /> + + ); + } + + if (!data) { + return ( + + {back} + + + ); + } + + const commissionPercent = new Intl.NumberFormat(locale, { style: 'percent', maximumFractionDigits: 2 }).format( + data.commissionRate, + ); + + return ( + + + {back} + + + {t('partner_detail_title')} + + + + + {data.name} + + + + + } sx={{ gap: 1.25 }}> + {data.legalEntityType || '—'} + {data.mohEstablishmentPermitNo || '—'} + {data.technicalDirectorLicenseNo ?? '—'} + {data.enamadCode ?? '—'} + {commissionPercent} + {t(data.isMerchantOfRecord ? 'yes' : 'no')} + + + {data.settlementIbanMasked ?? '—'} + + + + + + {caps.canManagePartners ? ( + + {data.verifiedAt == null ? ( + setConfirmVerify(true)} + disabled={verify.isPending} + sx={{ m: 0 }} + > + {t('partner_verify')} + + ) : null} + setActive.mutate(!data.isActive)} + disabled={setActive.isPending} + sx={{ m: 0 }} + > + {t(data.isActive ? 'partner_suspend' : 'partner_activate')} + + setEditing(true)} sx={{ m: 0 }}> + {t('partner_edit')} + + + ) : null} + + + + {t('partner_roster_title')} + + + {roster.isLoading ? ( + + ) : ( + + }> + {(roster.data ?? []).map((nurse) => ( + + + + {nurse.name} + + + + {caps.canManagePartners ? ( + onRemove(nurse.nurseProfileId)} + disabled={assignNurse.isPending} + sx={{ m: 0 }} + > + {t('partner_unlink_nurse')} + + ) : null} + + ))} + + + )} + + {caps.canManagePartners ? ( + + setAssignId(e.target.value)} + slotProps={{ htmlInput: { min: 1, step: 1 } }} + sx={{ minWidth: 200 }} + /> + + {t('partner_assign_nurse')} + + + ) : null} + + + setConfirmVerify(false)} + loading={verify.isPending} + /> + + {editing ? setEditing(false)} /> : null} + + ); +} + +/** One label/value line in the license/settlement block. */ +function DetailRow({ label, children }: { label: string; children: ReactNode }) { + return ( + + + {label} + + + {children} + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/partners/page.tsx b/client/src/app/[locale]/(private-routes)/admin/partners/page.tsx new file mode 100644 index 0000000..f81d33a --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/partners/page.tsx @@ -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 = { + 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[] = [ + { 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) => , + }, + ]; + + return ( + + setCreating(true)} sx={{ m: 0 }}> + {t('partner_create')} + + ) : undefined + } + /> + + {centers.isLoading ? ( + + ) : centers.isError ? ( + centers.refetch()} /> + ) : items.length === 0 ? ( + + ) : ( + c.id} + ariaLabel={t('partner_title')} + onRowClick={(c) => router.push(`/${locale}${adminPartnerCenterPath(c.id)}`)} + /> + )} + + 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 ? setCreating(false)} /> : null} + + ); +} + +/** 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(() => initialForm(center)); + + const set = (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 ( + + {isEdit ? t('partner_edit') : t('partner_create')} + + + set('name', e.target.value)} /> + set('legalEntityType', e.target.value)} + /> + set('mohEstablishmentPermitNo', e.target.value)} + /> + set('technicalDirectorLicenseNo', e.target.value)} + /> + set('enamadCode', e.target.value)} /> + set('settlementIban', e.target.value)} + helperText={t('partner_iban_write_hint')} + placeholder={center?.settlementIbanMasked ?? undefined} + slotProps={{ htmlInput: { dir: 'ltr' } }} + /> + + set('isMerchantOfRecord', e.target.checked)} />} + label={t('partner_is_mor')} + /> + + {t('partner_is_mor_hint')} + + + set('commissionRate', e.target.value)} + slotProps={{ htmlInput: { min: 0, max: 0.999, step: 0.01 } }} + /> + set('adminUserId', e.target.value)} + slotProps={{ htmlInput: { min: 1, step: 1 } }} + /> + + + + + {t('cancel')} + + + {mutation.isPending ? t('saving') : t('save')} + + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/payouts/[batchId]/page.tsx b/client/src/app/[locale]/(private-routes)/admin/payouts/[batchId]/page.tsx new file mode 100644 index 0000000..4d46415 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/payouts/[batchId]/page.tsx @@ -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 = { + draft: 'neutral', + processing: 'info', + partially_failed: 'pending', + completed: 'verified', + failed: 'rejected', +}; + +const PAYOUT_STATUS_KIND: Record = { + 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 ( + + + router.push(`/${locale}${ROUTES.ADMIN_PAYOUTS}`)} + sx={{ m: 0, alignSelf: 'flex-start' }} + > + {t('back')} + + + {t('payout_batch_title', { id: batchId })} + + + + {detail.isLoading ? ( + + + + + + ) : detail.isError ? ( + detail.refetch()} /> + ) : !data ? ( + + ) : ( + <> + + + + + {formatShamsiDate(data.batch.periodStart, locale)} – {formatShamsiDate(data.batch.periodEnd, locale)} + + + + {formatShamsiDate(data.batch.processingDate, locale)} + {data.batch.holidayShifted ? ( + + ) : null} + + + + + + + + {t('payout_rows_title')} + + {data.payouts.length === 0 ? ( + + ) : ( + data.payouts.map((row) => ( + + )) + )} + + + 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 })} + /> + + )} + + ); +} + +/** + * 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 ( + + + + + {t('payout_row_nurse')}: {row.nurseName ?? `#${row.nurseId}`} + + + + + + {t('payout_decomp', { + gross: formatIrrToToman(row.grossEarningsIrr, locale), + clawback: formatIrrToToman(row.clawbackAppliedIrr, locale), + net: formatIrrToToman(row.netAmountIrr, locale), + })} + + + + + + {row.maskedIban} + + + + + {row.transferReference ?? '—'} + + + + + {row.status === 'failed' ? ( + + + {t('payout_failure_reason', { reason: row.failureReason ?? '—' })} + + {canPayout ? ( + + setRetryOpen(true)} + disabled={retry.isPending} + sx={{ m: 0 }} + > + {t('payout_retry')} + + + ) : null} + + ) : null} + + {canPayout ? ( + + setReference(e.target.value)} + slotProps={{ htmlInput: { dir: 'ltr' } }} + sx={{ minWidth: 220 }} + /> + + {record.isPending ? t('saving') : t('save')} + + + ) : null} + + + setRetryOpen(false)} + loading={retry.isPending} + confirmColor="error" + /> + + ); +}; + +const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => ( + + + {label} + + {children} + +); + +const Field: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => ( + + + {label} + + + {children} + + +); diff --git a/client/src/app/[locale]/(private-routes)/admin/payouts/page.tsx b/client/src/app/[locale]/(private-routes)/admin/payouts/page.tsx new file mode 100644 index 0000000..505388c --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/payouts/page.tsx @@ -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 = { + 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(''); + 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[] = [ + { + 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) => , + }, + { + key: 'processing', + header: t('payout_col_processing'), + render: (b) => ( + + {formatShamsiDate(b.processingDate, locale)} + {b.holidayShifted ? ( + + ) : null} + + ), + }, + ]; + + return ( + + + { + setStatus(e.target.value as PayoutBatchStatus | ''); + setPage(1); + }} + sx={{ minWidth: 160 }} + > + {t('filter_all')} + {BATCH_STATUSES.map((s) => ( + + {t(`batch_status_${s}`)} + + ))} + + {caps.canPayout ? ( + setPreviewOpen(true)} sx={{ m: 0 }}> + {t('payout_preview')} + + ) : null} + + } + /> + + {batches.isLoading ? ( + {[0, 1, 2].map((k) => )} + ) : batches.isError ? ( + batches.refetch()} /> + ) : items.length === 0 ? ( + + ) : ( + b.id} + onRowClick={(b) => router.push(`/${locale}${adminPayoutBatchPath(b.id)}`)} + ariaLabel={t('payout_title')} + /> + )} + + 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 ? setPreviewOpen(false)} /> : null} + + ); +} + +/** + * 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 ( + + {t('payout_preview_title')} + + + setPeriodStart(e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + setPeriodEnd(e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + + {t('payout_preview')} + + + + {result ? ( + + + + {t('payout_col_processing')} + + + {formatShamsiDate(result.processingDate, locale)} + + {result.holidayShifted ? ( + + ) : null} + + + + + {t('payout_eligible_nurses')} + + {result.eligible.length === 0 ? ( + + — + + ) : ( + } sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}> + {result.eligible.map((n) => ( + + + + {n.nurseName ?? `#${n.nurseId}`} + + {!n.hasVerifiedPrimaryIban ? ( + + ) : null} + + + {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')} + + + ))} + + )} + + + {result.skipped.length > 0 ? ( + + + {t('payout_skipped')} + + } sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2 }}> + {result.skipped.map((n) => ( + + + {n.nurseName ?? `#${n.nurseId}`} + + + {n.reason} + + + ))} + + + ) : null} + + + {t('payout_eligibility_note')} + + + ) : null} + + + + {t('cancel')} + + {canPayout ? ( + setRunConfirmOpen(true)} + disabled={!result || run.isPending} + sx={{ m: 0 }} + > + {t('payout_run')} + + ) : null} + + + setRunConfirmOpen(false)} + loading={run.isPending} + /> + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/reviews/page.tsx b/client/src/app/[locale]/(private-routes)/admin/reviews/page.tsx new file mode 100644 index 0000000..9da8d1f --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/reviews/page.tsx @@ -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 = { + 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('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 ( + + { + setStatus(e.target.value as ModerationStatus); + setPage(1); + }} + sx={{ minWidth: 180 }} + > + {MODERATION_STATUSES.map((s) => ( + + {t(`mstatus_${s}`)} + + ))} + + } + /> + + {queue.isLoading ? ( + {[0, 1, 2].map((k) => )} + ) : queue.isError ? ( + queue.refetch()} /> + ) : items.length === 0 ? ( + + ) : ( + + {items.map((item) => ( + setPending({ item, action })} + /> + ))} + + )} + + 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 })} + /> + + setPending(null)} + /> + + ); +} + +/** 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 ( + + + + + {item.lowRatingAlertId != null ? ( + + ) : null} + + + {item.body ? ( + + {item.body} + + ) : null} + + {item.tagCodes.length > 0 ? ( + + {item.tagCodes.map((code) => ( + + ))} + + ) : null} + + + + {t('mod_nurse', { id: item.nurseProfileId })} + + + {t('mod_booking', { id: item.bookingId })} + + + + {canModerate ? ( + + onAct('publish')} sx={{ m: 0 }}> + {t('mod_publish')} + + onAct('hide')} sx={{ m: 0 }}> + {t('mod_hide')} + + onAct('reject')} sx={{ m: 0 }}> + {t('mod_reject')} + + + ) : null} + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/roles/page.tsx b/client/src/app/[locale]/(private-routes)/admin/roles/page.tsx new file mode 100644 index 0000000..8a3be1b --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/roles/page.tsx @@ -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(null); + + // Only active grants — a revoked grant leaves the grid. + const active = (roles.data ?? []).filter((g) => g.revokedAt == null); + + const columns: AdminTableColumn[] = [ + { key: 'user', header: t('role_col_user'), render: (g) => `#${g.userId}` }, + { + key: 'role', + header: t('role_col_role'), + render: (g) => , + }, + { key: 'granted', header: t('role_col_granted'), render: (g) => formatShamsiDate(g.grantedAt, locale) }, + ...(caps.canManageRoles + ? [ + { + key: 'actions', + header: '', + align: 'right', + render: (g: RoleGrant) => ( + setRevoking(g)} sx={{ m: 0 }}> + {t('role_revoke')} + + ), + } as AdminTableColumn, + ] + : []), + ]; + + const onRevokeConfirm = () => { + if (!revoking) return; + revoke.mutate( + { userId: revoking.userId, role: revoking.role }, + { + onSuccess: () => { + enqueueSnackbar(t('role_updated'), { variant: 'success' }); + setRevoking(null); + }, + }, + ); + }; + + return ( + + setGranting(true)} sx={{ m: 0 }}> + {t('role_grant')} + + ) : undefined + } + /> + + + {t('role_deferred')} + + + {roles.isLoading ? ( + + ) : roles.isError ? ( + roles.refetch()} /> + ) : active.length === 0 ? ( + + ) : ( + `${g.userId}:${g.role}`} + ariaLabel={t('role_title')} + /> + )} + + {granting ? setGranting(false)} /> : null} + + setRevoking(null)} + /> + + ); +} + +/** 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('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 ( + + {t('role_grant')} + + + setUserId(e.target.value)} + slotProps={{ htmlInput: { min: 1 } }} + /> + setRole(e.target.value as AdminRole)}> + {ROLES.map((r) => ( + + {t(`role_${r}`)} + + ))} + + {valid ? ( + {t('role_grant_confirm', { role: t(`role_${role}`), id: parsedId })} + ) : null} + + + + + {t('cancel')} + + + {grant.isPending ? t('saving') : t('confirm')} + + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx new file mode 100644 index 0000000..9bd4d2f --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx @@ -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 = { 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 ( + + router.push(`/${locale}${ROUTES.ADMIN_TICKETS}`)} + sx={{ m: 0, alignSelf: 'flex-start' }} + > + {t('back')} + + + {isLoading ? ( + + + + + + ) : isError ? ( + refetch()} /> + ) : !detail ? ( + + ) : ( + <> + + + + {t('ticket_thread_title', { ref: detail.referenceCode })} + + {detail.subject ? ( + + {detail.subject} + + ) : null} + + + + {detail.bookingId != null ? ( + + ) : null} + {detail.refundId != null ? ( + + ) : null} + + + {showRefund ? ( + + setRefundShown((v) => !v)} + sx={{ m: 0 }} + > + {t('refund_open')} + + + + + + + + ) : null} + + + + + {detail.messages.map((m) => ( + + ))} + + + {caps.canManageTickets ? ( + + + next && setMode(next)} + > + {t('ticket_public_reply')} + {t('ticket_internal_note')} + + setBody(e.target.value)} + /> + + {t('ticket_send')} + + + + ) : null} + + )} + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/tickets/page.tsx b/client/src/app/[locale]/(private-routes)/admin/tickets/page.tsx new file mode 100644 index 0000000..7e83122 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/tickets/page.tsx @@ -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 = { 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(EMPTY); + const [applied, setApplied] = useState(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[] = [ + { + key: 'ref', + header: t('ticket_col_ref'), + render: (row) => ( + + {row.referenceCode} + + ), + }, + { key: 'subject', header: t('ticket_col_subject'), render: (row) => row.subject ?? '—' }, + { + key: 'category', + header: t('ticket_col_category'), + render: (row) => , + }, + { + key: 'status', + header: t('ticket_col_status'), + render: (row) => , + }, + { key: 'booking', header: t('ticket_col_booking'), render: (row) => row.bookingId ?? '—' }, + ]; + + return ( + + + + + setDraft((d) => ({ ...d, status: (e.target.value || undefined) as TicketStatus | undefined }))} + sx={{ minWidth: 140 }} + > + {t('filter_all')} + {STATUSES.map((s) => ( + + {t(`tstatus_${s}`)} + + ))} + + setDraft((d) => ({ ...d, category: (e.target.value || undefined) as TicketCategory | undefined }))} + sx={{ minWidth: 160 }} + > + {t('filter_all')} + {CATEGORIES.map((c) => ( + + {t(`tcat_${c}`)} + + ))} + + setDraft((d) => ({ ...d, referenceCode: e.target.value || undefined }))} + sx={{ minWidth: 220 }} + /> + + {t('apply')} + + + {t('clear')} + + + + {tickets.isLoading ? ( + + {[0, 1, 2, 3].map((k) => ( + + ))} + + ) : tickets.isError ? ( + tickets.refetch()} /> + ) : items.length === 0 ? ( + + ) : ( + row.id} + ariaLabel={t('ticket_title')} + onRowClick={(row) => router.push(`/${locale}${adminTicketThreadPath(row.id)}`)} + /> + )} + + 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 })} + /> + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx b/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx new file mode 100644 index 0000000..54d5f1c --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/verification/[nurseId]/page.tsx @@ -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 = { + 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 ( + + + + {t('back')} + + + + + {isLoading ? ( + + + + + + ) : isError ? ( + refetch()} /> + ) : !data ? ( + + ) : ( + <> + + + {t('ver_identity_name')} + + + {data.identityName} + + + + + {t('ver_steps_title')} + {data.steps.map((step) => ( + + ))} + + + {data.credentials.length > 0 ? ( + + {t('ver_credentials_title')} + {data.credentials.map((cred) => ( + + + + {t(`step_${cred.credentialType}`)} + + {cred.expiresAt ? ( + + {t('ver_expires_at')}: {formatShamsiDate(cred.expiresAt, locale)} + + ) : null} + + {cred.holderNameSnapshot} + + {cred.issuingAuthority} + + + ))} + + ) : null} + + + + setApproveOpen(true)} + disabled={!allPassed || !caps.canVerify} + sx={{ m: 0 }} + > + {t('ver_approve')} + + setRejectOpen(true)} + disabled={!caps.canVerify} + sx={{ m: 0 }} + > + {t('ver_reject_all')} + + + + {t('ver_approve_hint')} + + + + )} + + setApproveOpen(false)} + loading={approve.isPending} + /> + setRejectOpen(false)} + loading={reject.isPending} + requireReason + reasonLabel={t('reason_label')} + reasonPlaceholder={t('ver_reject_reason_ph')} + confirmColor="error" + /> + + ); +} + +/** 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 ( + + + + {t(`step_${step.code}`)} + + + {step.isAutomated ? ( + + ) : null} + + + {step.documents.length > 0 ? ( + + {step.documents.map((doc) => ( + + ))} + + ) : isManual ? ( + + {t('ver_no_documents')} + + ) : null} + + {step.failureReason ? ( + + {step.failureReason} + + ) : null} + + {isDecidable && canVerify ? ( + + (isCredentialStep ? setCredentialOpen(true) : onPass())} + disabled={decide.isPending} + sx={{ m: 0 }} + > + {t('ver_pass')} + + setRejectOpen(true)} + disabled={decide.isPending} + sx={{ m: 0 }} + > + {t('ver_reject')} + + + ) : null} + + setRejectOpen(false)} + loading={decide.isPending} + requireReason + reasonLabel={t('reason_label')} + reasonPlaceholder={t('ver_reject_reason_ph')} + confirmColor="error" + /> + + {isCredentialStep && credentialOpen ? ( + setCredentialOpen(false)} /> + ) : null} + + ); +} + +/** 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 ( + + {t('ver_credential_title')} + + + setCredentialNumber(e.target.value)} + /> + setHolderName(e.target.value)} + /> + setIssuingAuthority(e.target.value)} + /> + setIssuedAt(e.target.value)} + slotProps={{ inputLabel: { shrink: true } }} + /> + setExpiresAt(e.target.value)} + required={expiryRequired} + error={expiryMissing} + helperText={expiryMissing ? t('ver_expiry_required') : undefined} + slotProps={{ inputLabel: { shrink: true } }} + /> + + + + + {t('cancel')} + + + {decide.isPending ? t('saving') : t('save')} + + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/admin/verification/page.tsx b/client/src/app/[locale]/(private-routes)/admin/verification/page.tsx new file mode 100644 index 0000000..d2573be --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/admin/verification/page.tsx @@ -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 = { + 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(''); + 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[] = [ + { + key: 'nurse', + header: t('ver_col_nurse'), + render: (item) => ( + + {item.nurseName} + {item.hasExpiringCredential ? ( + } + label={t('ver_expiring_warning')} + sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }} + /> + ) : null} + + ), + }, + { + key: 'status', + header: t('ver_col_status'), + render: (item) => , + }, + { + key: 'step', + header: t('ver_col_step'), + render: (item) => ( + + {t('ver_progress', { done: item.stepsPassed, total: item.stepsTotal })} + + {t('ver_next_step', { step: item.nextPendingStepCode ? t(`step_${item.nextPendingStepCode}`) : '—' })} + + + ), + }, + { + key: 'submitted', + header: t('ver_col_submitted'), + render: (item) => (item.submittedAt ? formatShamsiDate(item.submittedAt, locale) : '—'), + }, + ]; + + return ( + + { + setStatus(e.target.value as QueueStatusFilter); + setPage(1); + }} + sx={{ minWidth: 160 }} + > + {t('filter_all')} + {t('agg_pending')} + {t('agg_in_review')} + + } + /> + + {queue.isLoading ? ( + {[0, 1, 2, 3].map((k) => )} + ) : queue.isError ? ( + queue.refetch()} /> + ) : items.length === 0 ? ( + + ) : ( + item.nurseVerificationId} + ariaLabel={t('ver_title')} + onRowClick={(item) => router.push(`/${locale}${adminVerificationCasePath(item.nurseVerificationId)}`)} + /> + )} + + 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 })} + /> + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/partner/bookings/page.tsx b/client/src/app/[locale]/(private-routes)/partner/bookings/page.tsx new file mode 100644 index 0000000..0e369e8 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/partner/bookings/page.tsx @@ -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(''); + 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[] = [ + { 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) => , + }, + ]; + + return ( + + { + setStatus(e.target.value); + setPage(1); + }} + sx={{ minWidth: 180 }} + > + {ta('filter_all')} + {BOOKING_STATUS_OPTIONS.map((s) => ( + + {s} + + ))} + + } + /> + + {bookings.isLoading ? ( + + {[0, 1, 2].map((k) => ( + + ))} + + ) : bookings.isError ? ( + bookings.refetch()} /> + ) : items.length === 0 ? ( + + ) : ( + b.bookingId} + ariaLabel={t('bookings_title')} + /> + )} + + 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 })} + /> + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/partner/layout.tsx b/client/src/app/[locale]/(private-routes)/partner/layout.tsx new file mode 100644 index 0000000..91e5425 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/partner/layout.tsx @@ -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 {children}; +} diff --git a/client/src/app/[locale]/(private-routes)/partner/nurses/page.tsx b/client/src/app/[locale]/(private-routes)/partner/nurses/page.tsx new file mode 100644 index 0000000..2cb4010 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/partner/nurses/page.tsx @@ -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[] = [ + { + key: 'name', + header: t('nurses_col_name'), + render: (n) => n.name, + }, + { + key: 'verified', + header: t('nurses_col_verified'), + render: (n) => ( + + ), + }, + ]; + + return ( + + + + {nurses.isLoading ? ( + + {[0, 1, 2].map((k) => ( + + ))} + + ) : nurses.isError ? ( + nurses.refetch()} /> + ) : items.length === 0 ? ( + + ) : ( + n.nurseProfileId} + ariaLabel={t('nurses_title')} + /> + )} + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/partner/page.tsx b/client/src/app/[locale]/(private-routes)/partner/page.tsx new file mode 100644 index 0000000..7dc4c4e --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/partner/page.tsx @@ -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 ( + + + + + + ); + } + + // The center query is the portal's access gate — a 403/404 means the caller owns no center. + if (center.isError || !center.data) { + return ; + } + + const c = center.data; + + return ( + + + } + /> + + + + + + ); +} + +/** + * 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 ( + + + + ); + } + + const banner: Record, { 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 ( + + {t(key)} + + ); +} + +/** 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 ( + + + {t('license_title')} + + + + + + + {center.isMerchantOfRecord ? ( + + ) : null} + + + ); +} + +/** 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 ( + + + {label} + + {ltr ? ( + + {value ?? '—'} + + ) : ( + + {value ?? '—'} + + )} + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/partner/settlement/page.tsx b/client/src/app/[locale]/(private-routes)/partner/settlement/page.tsx new file mode 100644 index 0000000..1b08c8e --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/partner/settlement/page.tsx @@ -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 ( + + + + + ); + } + + if (center.isError || !center.data) { + return center.refetch()} />; + } + + const c = center.data; + + if (!c.isMerchantOfRecord) { + return ( + + + + {t('settlement_not_mor')} + + + ); + } + + const invoices = settlement.data?.items ?? []; + const pageCount = Math.max(1, Math.ceil((settlement.data?.total ?? 0) / PARTNER_PAGE_SIZE)); + + return ( + + + + + + + {t('settlement_iban')} + + + {c.settlementIbanMasked ?? '—'} + + + + + {settlement.isLoading ? ( + + {[0, 1].map((k) => ( + + ))} + + ) : settlement.isError ? ( + settlement.refetch()} /> + ) : invoices.length === 0 ? ( + + ) : ( + + {invoices.map((inv) => ( + { + if (i.pdfUrl) window.open(i.pdfUrl, '_blank', 'noopener'); + }} + /> + ))} + + )} + + 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 })} + /> + + ); +} diff --git a/client/src/components/admin/AdminDataTable.test.tsx b/client/src/components/admin/AdminDataTable.test.tsx new file mode 100644 index 0000000..f4f880b --- /dev/null +++ b/client/src/components/admin/AdminDataTable.test.tsx @@ -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[] = [ + { key: 'id', header: 'ID', render: (r) => r.id }, + { key: 'name', header: 'Name', render: (r) => r.name }, +]; + +describe('', () => { + const wrap = (ui: React.ReactNode) => render({ui}); + + it('renders headers and every row cell', () => { + wrap( 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( r.id} />); + expect(container.querySelectorAll('[data-col="name"]').length).toBe(2); + }); + + it('calls onRowClick with the clicked row', () => { + const onRowClick = jest.fn(); + wrap( r.id} onRowClick={onRowClick} />); + fireEvent.click(screen.getByText('Beta')); + expect(onRowClick).toHaveBeenCalledWith(ROWS[1]); + }); +}); diff --git a/client/src/components/admin/AdminDataTable.tsx b/client/src/components/admin/AdminDataTable.tsx new file mode 100644 index 0000000..f73c032 --- /dev/null +++ b/client/src/components/admin/AdminDataTable.tsx @@ -0,0 +1,75 @@ +import { ReactNode } from 'react'; +import { + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@mui/material'; + +export interface AdminTableColumn { + /** 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 { + columns: AdminTableColumn[]; + 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({ columns, rows, getRowKey, onRowClick, dense = true, ariaLabel }: AdminDataTableProps) { + return ( + + + + + {columns.map((col) => ( + + {col.header} + + ))} + + + + {rows.map((row) => ( + onRowClick(row) : undefined} + sx={{ cursor: onRowClick ? 'pointer' : 'default', '&:last-child td': { border: 0 } }} + > + {columns.map((col) => ( + + {col.render(row)} + + ))} + + ))} + +
+
+ ); +} + +export default AdminDataTable; diff --git a/client/src/components/admin/AdminEmptyState.test.tsx b/client/src/components/admin/AdminEmptyState.test.tsx new file mode 100644 index 0000000..41e3d9b --- /dev/null +++ b/client/src/components/admin/AdminEmptyState.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import AdminEmptyState from './AdminEmptyState'; + +describe('', () => { + const wrap = (ui: React.ReactNode) => render({ui}); + + it('renders the title and body', () => { + wrap(); + expect(screen.getByText('Queue clear')).toBeInTheDocument(); + expect(screen.getByText('Nothing to review')).toBeInTheDocument(); + }); + + it('renders without a body', () => { + wrap(); + expect(screen.getByText('Empty')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/admin/AdminEmptyState.tsx b/client/src/components/admin/AdminEmptyState.tsx new file mode 100644 index 0000000..52f7653 --- /dev/null +++ b/client/src/components/admin/AdminEmptyState.tsx @@ -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 = ({ icon = 'info', title, body }) => ( + + + + {title} + + {body ? ( + + {body} + + ) : null} + +); + +export default AdminEmptyState; diff --git a/client/src/components/admin/AdminErrorState.test.tsx b/client/src/components/admin/AdminErrorState.test.tsx new file mode 100644 index 0000000..8a58ac8 --- /dev/null +++ b/client/src/components/admin/AdminErrorState.test.tsx @@ -0,0 +1,15 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import AdminErrorState from './AdminErrorState'; + +describe('', () => { + const wrap = (ui: React.ReactNode) => render({ui}); + + it('renders the message and calls onRetry on click', () => { + const onRetry = jest.fn(); + wrap(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Retry/ })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/admin/AdminErrorState.tsx b/client/src/components/admin/AdminErrorState.tsx new file mode 100644 index 0000000..8a878a7 --- /dev/null +++ b/client/src/components/admin/AdminErrorState.tsx @@ -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 = ({ message, retryLabel, onRetry }) => ( + + + {message} + + + {retryLabel} + + +); + +export default AdminErrorState; diff --git a/client/src/components/admin/AdminMessageBubble.test.tsx b/client/src/components/admin/AdminMessageBubble.test.tsx new file mode 100644 index 0000000..fd9a6c5 --- /dev/null +++ b/client/src/components/admin/AdminMessageBubble.test.tsx @@ -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('', () => { + const wrap = (ui: React.ReactNode) => render({ui}); + + it('renders the body and author label', () => { + const { container } = wrap(); + 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( + , + ); + expect(container.querySelector('[data-internal="true"]')).toBeInTheDocument(); + expect(screen.getByText('ticket_internal_badge')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/admin/AdminMessageBubble.tsx b/client/src/components/admin/AdminMessageBubble.tsx new file mode 100644 index 0000000..cddf8f3 --- /dev/null +++ b/client/src/components/admin/AdminMessageBubble.tsx @@ -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 = ({ message, authorLabel }) => { + const t = useTranslations('admin'); + const locale = useLocale(); + + const internal = message.isInternal; + const mine = message.isMine; + + return ( + + + + + {authorLabel} + + {internal ? ( + + ) : null} + + + {message.body} + + + {formatShamsiDateTime(message.createdAt, locale)} + {message.sendStatus === 'failed' ? ` · ${t('error_generic')}` : ''} + + + + ); +}; + +export default AdminMessageBubble; diff --git a/client/src/components/admin/AdminPageHeader.test.tsx b/client/src/components/admin/AdminPageHeader.test.tsx new file mode 100644 index 0000000..1486ca3 --- /dev/null +++ b/client/src/components/admin/AdminPageHeader.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import AdminPageHeader from './AdminPageHeader'; + +describe('', () => { + const wrap = (ui: React.ReactNode) => render({ui}); + + it('renders the title and subtitle', () => { + wrap(); + expect(screen.getByRole('heading', { name: 'Backoffice' })).toBeInTheDocument(); + expect(screen.getByText('Run the marketplace')).toBeInTheDocument(); + }); + + it('renders the actions slot when provided', () => { + wrap(Do} />); + expect(screen.getByRole('button', { name: 'Do' })).toBeInTheDocument(); + }); + + it('omits the subtitle when not given', () => { + wrap(); + expect(screen.getByRole('heading', { name: 'T' })).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/admin/AdminPageHeader.tsx b/client/src/components/admin/AdminPageHeader.tsx new file mode 100644 index 0000000..76b0279 --- /dev/null +++ b/client/src/components/admin/AdminPageHeader.tsx @@ -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 = ({ title, subtitle, actions }) => ( + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + {actions ? {actions} : null} + +); + +export default AdminPageHeader; diff --git a/client/src/components/admin/AdminPager.test.tsx b/client/src/components/admin/AdminPager.test.tsx new file mode 100644 index 0000000..7e9ae89 --- /dev/null +++ b/client/src/components/admin/AdminPager.test.tsx @@ -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('', () => { + const wrap = (ui: React.ReactNode) => render({ui}); + const base = { prevLabel: 'Prev', nextLabel: 'Next', indicator: 'Page 2' }; + + it('renders nothing when there is a single page', () => { + const { container } = wrap( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('fires onPrev/onNext and disables at the ends', () => { + const onPrev = jest.fn(); + const onNext = jest.fn(); + wrap(); + 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(); + expect(screen.getByRole('button', { name: 'Prev' })).toBeDisabled(); + }); +}); diff --git a/client/src/components/admin/AdminPager.tsx b/client/src/components/admin/AdminPager.tsx new file mode 100644 index 0000000..b899ca1 --- /dev/null +++ b/client/src/components/admin/AdminPager.tsx @@ -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 = ({ + page, + pageCount, + onPrev, + onNext, + prevLabel, + nextLabel, + indicator, +}) => { + useLocale(); + if (pageCount <= 1) return null; + return ( + + + {prevLabel} + + + {indicator} + + = pageCount} sx={{ m: 0 }}> + {nextLabel} + + + ); +}; + +export default AdminPager; diff --git a/client/src/components/admin/AuditLogRow.test.tsx b/client/src/components/admin/AuditLogRow.test.tsx new file mode 100644 index 0000000..57326e1 --- /dev/null +++ b/client/src/components/admin/AuditLogRow.test.tsx @@ -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('', () => { + const wrap = (ui: React.ReactNode) => render({ui}); + + it('renders the entity and action', () => { + wrap(); + expect(screen.getByText('PlatformConfig #vat_rate')).toBeInTheDocument(); + expect(screen.getByText('updated')).toBeInTheDocument(); + }); + + it('reveals the changed-fields diff on expand', () => { + wrap(); + // 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(); + }); +}); diff --git a/client/src/components/admin/AuditLogRow.tsx b/client/src/components/admin/AuditLogRow.tsx new file mode 100644 index 0000000..e0e3e60 --- /dev/null +++ b/client/src/components/admin/AuditLogRow.tsx @@ -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 ``). Read-only by design — there is **no** edit/delete affordance + * (phase §5). Presentational; the caller passes the paged entries. + * @component AuditLogRow + */ +const AuditLogRow: FunctionComponent = ({ entry }) => { + const t = useTranslations('admin'); + const locale = useLocale(); + const [open, setOpen] = useState(false); + const fields = entry.changedFields ? Object.entries(entry.changedFields) : []; + + return ( + + setOpen((v) => !v) : undefined} + > + + + {entry.action} + + + {entry.actorUserId != null ? `#${entry.actorUserId}` : '—'} + + + {formatShamsiDateTime(entry.occurredAt, locale)} + + {fields.length ? : null} + + + 0}> + + + {t('audit_diff_title')} + + + + + {t('audit_diff_field')} + {t('audit_diff_old')} + {t('audit_diff_new')} + + + + {fields.map(([field, delta]) => ( + + {field} + {displayValue(delta.old)} + {displayValue(delta.new)} + + ))} + +
+
+
+
+ ); +}; + +export default AuditLogRow; diff --git a/client/src/components/admin/ConfigRow.test.tsx b/client/src/components/admin/ConfigRow.test.tsx new file mode 100644 index 0000000..5ed0f9a --- /dev/null +++ b/client/src/components/admin/ConfigRow.test.tsx @@ -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('', () => { + const wrap = (ui: React.ReactNode) => render({ui}); + + it('renders the key, value and description', () => { + wrap(); + 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(); + expect(screen.queryByText('cfg_edit')).not.toBeInTheDocument(); + rerender( + + + , + ); + fireEvent.click(screen.getByText('cfg_edit')); + expect(onEdit).toHaveBeenCalledWith(CONFIG); + }); + + it('fires onHistory', () => { + const onHistory = jest.fn(); + wrap(); + fireEvent.click(screen.getByText('cfg_history')); + expect(onHistory).toHaveBeenCalledWith(CONFIG); + }); +}); diff --git a/client/src/components/admin/ConfigRow.tsx b/client/src/components/admin/ConfigRow.tsx new file mode 100644 index 0000000..d3a7960 --- /dev/null +++ b/client/src/components/admin/ConfigRow.tsx @@ -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 = ({ config, canEdit = false, onEdit, onHistory }) => { + const t = useTranslations('admin'); + const locale = useLocale(); + + return ( + + + + + + {config.key} + + + + {config.description ? ( + + {config.description} + + ) : null} + + + + onHistory?.(config)} sx={{ m: 0 }}> + {t('cfg_history')} + + {canEdit ? ( + onEdit?.(config)} sx={{ m: 0 }}> + {t('cfg_edit')} + + ) : null} + + + + + {config.dataType === 'bool' ? ( + + ) : ( + + {config.value} + + )} + {config.updatedAt ? ( + + {formatShamsiDateTime(config.updatedAt, locale)} + {config.updatedBy ? ` · ${t('cfg_updated_by', { actor: config.updatedBy })}` : ''} + + ) : null} + + + ); +}; + +export default ConfigRow; diff --git a/client/src/components/admin/ConfirmDialog.test.tsx b/client/src/components/admin/ConfirmDialog.test.tsx new file mode 100644 index 0000000..81b2c9d --- /dev/null +++ b/client/src/components/admin/ConfirmDialog.test.tsx @@ -0,0 +1,44 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; +import ConfirmDialog from './ConfirmDialog'; + +describe('', () => { + const wrap = (ui: React.ReactNode) => render({ui}); + const base = { + title: 'Run batch?', + confirmLabel: 'Run', + cancelLabel: 'Cancel', + onClose: jest.fn(), + }; + + it('renders the title and body when open', () => { + wrap(); + 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(); + 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(); + 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(); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/components/admin/ConfirmDialog.tsx b/client/src/components/admin/ConfirmDialog.tsx new file mode 100644 index 0000000..72435c3 --- /dev/null +++ b/client/src/components/admin/ConfirmDialog.tsx @@ -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 = ({ + 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 ( + + {title} + + {body ? ( + typeof body === 'string' ? ( + {body} + ) : ( + body + ) + ) : null} + {requireReason ? ( + setReason(e.target.value)} + label={reasonLabel} + placeholder={reasonPlaceholder} + sx={{ mt: 2 }} + /> + ) : null} + + + + {cancelLabel} + + : undefined} + sx={{ m: 0 }} + > + {confirmLabel} + + + + ); +}; + +export default ConfirmDialog; diff --git a/client/src/components/admin/DocumentViewer.test.tsx b/client/src/components/admin/DocumentViewer.test.tsx new file mode 100644 index 0000000..7446be3 --- /dev/null +++ b/client/src/components/admin/DocumentViewer.test.tsx @@ -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('', () => { + const wrap = (ui: React.ReactNode) => render({ui}); + 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(); + 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(); + 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(); + expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/admin/DocumentViewer.tsx b/client/src/components/admin/DocumentViewer.tsx new file mode 100644 index 0000000..b3792ce --- /dev/null +++ b/client/src/components/admin/DocumentViewer.tsx @@ -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 = ({ document }) => { + const t = useTranslations('admin'); + const signed = useVerificationDocumentUrl(document.id); + const isImage = document.contentType.startsWith('image/'); + + return ( + + + + + {t('doc_file_meta', { name: document.originalFileName ?? `#${document.id}`, size: formatSize(document.fileSizeBytes) })} + + signed.refetch()} + disabled={signed.isFetching} + sx={{ m: 0, minWidth: 0 }} + > + {t('doc_reload')} + + + + {signed.isLoading || signed.isFetching ? ( + + ) : signed.isError ? ( + + + {t('doc_error')} + + signed.refetch()} sx={{ m: 0 }}> + {t('doc_reload')} + + + ) : signed.data?.url ? ( + isImage ? ( + // Signed, short-lived, cross-host URL (not a static asset) — a plain via Box, not next/image. + + ) : ( + + {t('doc_open_new')} + + ) + ) : null} + + ); +}; + +export default DocumentViewer; diff --git a/client/src/components/admin/PartnerSettlementRow.test.tsx b/client/src/components/admin/PartnerSettlementRow.test.tsx new file mode 100644 index 0000000..2785a18 --- /dev/null +++ b/client/src/components/admin/PartnerSettlementRow.test.tsx @@ -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('', () => { + const wrap = (ui: React.ReactNode) => render({ui}); + + it('renders a reconciling commission breakdown without a console error', () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + wrap(); + 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(); + expect(screen.getByText(/1234567890123456789012/)).toBeInTheDocument(); + }); + + it('fires onDownloadPdf', () => { + const onDownloadPdf = jest.fn(); + wrap(); + fireEvent.click(screen.getByText('invoice_download')); + expect(onDownloadPdf).toHaveBeenCalledWith(INVOICE); + }); +}); diff --git a/client/src/components/admin/PartnerSettlementRow.tsx b/client/src/components/admin/PartnerSettlementRow.tsx new file mode 100644 index 0000000..eceb934 --- /dev/null +++ b/client/src/components/admin/PartnerSettlementRow.tsx @@ -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 = ({ 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 ( + + + + + {t('invoice_number', { number: invoice.invoiceNumber })} + + + {t('settlement_col_booking')} #{invoice.bookingId} · {formatShamsiDate(invoice.issuedAt, locale)} + + + + + {t('invoice_row_gross')} + + + {formatIrrToToman(invoice.grossIrr, locale)} {tc('currency_toman')} + + + + + + + + + {invoice.pdfUrl ? ( + downloadError ? ( + onRetryDownload?.(invoice)} sx={{ m: 0 }}> + {t('invoice_pdf_error')} + + ) : ( + onDownloadPdf?.(invoice)} sx={{ m: 0 }}> + {t('invoice_download')} + + ) + ) : null} + + + ); +}; + +export default PartnerSettlementRow; diff --git a/client/src/components/admin/RefundPanel.test.tsx b/client/src/components/admin/RefundPanel.test.tsx new file mode 100644 index 0000000..8ebe9fc --- /dev/null +++ b/client/src/components/admin/RefundPanel.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from '@testing-library/react'; +import { ThemeProvider } from '../../theme'; + +jest.mock('next-intl', () => ({ useTranslations: () => (k: string, v?: Record) => (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('', () => { + const wrap = (ui: React.ReactNode) => render({ui}); + 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(); + 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(); + 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(); + expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/admin/RefundPanel.tsx b/client/src/components/admin/RefundPanel.tsx new file mode 100644 index 0000000..90c9a88 --- /dev/null +++ b/client/src/components/admin/RefundPanel.tsx @@ -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 = ({ 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(''); + const [notes, setNotes] = useState(''); + const [result, setResult] = useState(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 ; + if (preview.isError || !p) { + return ( + + {t('error_generic')} + + ); + } + + 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 ( + + + + {t('refund_preview_title')} + + + + + + + + {!activeResult ? ( + <> + setChannel(e.target.value as RefundChannel)} + sx={{ maxWidth: 260 }} + > + {CHANNELS.map((c) => ( + + {t(`channel_${c}`)} + + ))} + + + {p.expectedCustomerRefundEta ? ( + + {t('refund_eta', { date: formatShamsiDate(p.expectedCustomerRefundEta, locale) })} + {effectiveChannel === 'bnpl_revert' ? ` — ${t('refund_eta_bnpl')}` : ''} + + ) : null} + + {p.willCreateClawback ? ( + + {t('refund_clawback_notice')} + + ) : null} + + setNotes(e.target.value)} + multiline + minRows={2} + /> + + setConfirmOpen(true)} disabled={busy} sx={{ m: 0, alignSelf: 'flex-start' }}> + {t('refund_initiate')} + + + ) : failed ? ( + <> + + {t('refund_provider_failed')} + + + + {t('refund_approve')} + + setRejectOpen(true)} disabled={busy} sx={{ m: 0 }}> + {t('refund_reject')} + + + + ) : finalDone ? ( + + {t(`rstatus_${activeResult!.status}`)} + + ) : null} + + setConfirmOpen(false)} + loading={initiate.isPending} + /> + setRejectOpen(false)} + loading={reject.isPending} + requireReason + reasonLabel={t('reason_label')} + /> + + ); +}; + +export default RefundPanel; diff --git a/client/src/components/admin/SupportAlertCard.test.tsx b/client/src/components/admin/SupportAlertCard.test.tsx new file mode 100644 index 0000000..a215850 --- /dev/null +++ b/client/src/components/admin/SupportAlertCard.test.tsx @@ -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('', () => { + const wrap = (ui: React.ReactNode) => render({ui}); + + it('renders the alert type and status labels', () => { + wrap(); + expect(screen.getByText('atype_low_rating')).toBeInTheDocument(); + expect(screen.getByText('astatus_open')).toBeInTheDocument(); + }); + + it('hides actions unless canAct', () => { + wrap(); + expect(screen.queryByText('alert_resolve')).not.toBeInTheDocument(); + }); + + it('fires assign/resolve callbacks when actionable', () => { + const onAssignSelf = jest.fn(); + const onResolve = jest.fn(); + wrap(); + 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(); + expect(screen.queryByText('alert_resolve')).not.toBeInTheDocument(); + expect(screen.getByText('done')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/admin/SupportAlertCard.tsx b/client/src/components/admin/SupportAlertCard.tsx new file mode 100644 index 0000000..d4e7924 --- /dev/null +++ b/client/src/components/admin/SupportAlertCard.tsx @@ -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 = { + open: 'pending', + assigned: 'info', + resolved: 'verified', +}; + +/** Severity → the inline-start accent token. */ +const SEVERITY_ACCENT: Record = { + 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 = ({ 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 ( + + + + {t(`atype_${alert.type}`)} + + + + + + + {entityLabel} + + {alert.ownerUserId != null ? `#${alert.ownerUserId}` : t('alert_unassigned')} + + {formatShamsiDateTime(alert.createdAt, locale)} + + + {alert.status === 'resolved' && alert.resolutionNote ? ( + + {alert.resolutionNote} + + ) : null} + + {canAct && alert.status !== 'resolved' ? ( + + {alert.status === 'open' ? ( + onAssignSelf?.(alert)} sx={{ m: 0 }}> + {t('alert_assign_me')} + + ) : null} + onResolve?.(alert)} sx={{ m: 0 }}> + {t('alert_resolve')} + + + ) : null} + + ); +}; + +export default SupportAlertCard; diff --git a/client/src/components/admin/index.ts b/client/src/components/admin/index.ts new file mode 100644 index 0000000..fbcc797 --- /dev/null +++ b/client/src/components/admin/index.ts @@ -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'; diff --git a/client/src/components/common/AppIcon/config.ts b/client/src/components/common/AppIcon/config.ts index 3f0bafd..a9d398d 100644 --- a/client/src/components/common/AppIcon/config.ts +++ b/client/src/components/common/AppIcon/config.ts @@ -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, }; diff --git a/client/src/constants/roles.ts b/client/src/constants/roles.ts index 2152388..7640792 100644 --- a/client/src/constants/roles.ts +++ b/client/src/constants/roles.ts @@ -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); +} diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index 5c77b0d..7f26eb3 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -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`; diff --git a/client/src/context/auth/types.ts b/client/src/context/auth/types.ts index 68856d0..ecc7557 100644 --- a/client/src/context/auth/types.ts +++ b/client/src/context/auth/types.ts @@ -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 { diff --git a/client/src/hooks/capabilities.ts b/client/src/hooks/capabilities.ts new file mode 100644 index 0000000..30058e5 --- /dev/null +++ b/client/src/hooks/capabilities.ts @@ -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(() => { + 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 }; diff --git a/client/src/hooks/index.ts b/client/src/hooks/index.ts index 13ede56..120ddf7 100644 --- a/client/src/hooks/index.ts +++ b/client/src/hooks/index.ts @@ -1,3 +1,4 @@ export * from './auth'; +export * from './capabilities'; export * from './event'; export * from './layout'; diff --git a/client/src/layout/AdminLayout.tsx b/client/src/layout/AdminLayout.tsx index 6aa6fc1..c060f67 100644 --- a/client/src/layout/AdminLayout.tsx +++ b/client/src/layout/AdminLayout.tsx @@ -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 = ({ children }) => { const t = useTranslations('nav'); const tShell = useTranslations('shell'); + const caps = useAdminCapabilities(); - const sidebarItems: Array = 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 = useMemo(() => { + const items: Array = [ + { 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 ( = ({ children }) => { + const t = useTranslations('nav'); + const tShell = useTranslations('shell'); + + const sidebarItems: Array = 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 ( + + {children} + + ); +}; + +export default PartnerLayout; diff --git a/client/src/layout/index.tsx b/client/src/layout/index.tsx index c999b8b..6e669a4 100644 --- a/client/src/layout/index.tsx +++ b/client/src/layout/index.tsx @@ -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 }; diff --git a/client/src/services/admin/apis/clientApi.ts b/client/src/services/admin/apis/clientApi.ts new file mode 100644 index 0000000..cc3b93a --- /dev/null +++ b/client/src/services/admin/apis/clientApi.ts @@ -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 { + 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>>(`${API}/platform_config/get_platform_configs?${pageQuery(params)}`)), + + updateConfig: async (key, value) => { + await clientFetch>(`${API}/platform_config/update_platform_config`, { + method: 'POST', + body: JSON.stringify({ key, value }), + }); + }, + + getConfigHistory: async (key, params) => { + const wire = unwrap( + await clientFetch>>( + `${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>>( + `${API}/holidays/get_holidays?${pageQuery(params, { from: filters.from, to: filters.to })}`, + ), + ), + + upsertHoliday: async (input: HolidayInput) => { + await clientFetch>(`${API}/holidays/upsert_holiday`, { + method: 'POST', + body: JSON.stringify(input), + }); + }, + + listAuditLogs: async (filters: AuditFilters, params) => { + const wire = unwrap( + await clientFetch>>( + `${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>>( + `${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>(`${API}/support_alerts/assign_support_alert`, { + method: 'POST', + body: JSON.stringify({ alertId, ownerUserId }), + }); + }, + + resolveSupportAlert: async (alertId, note) => { + await clientFetch>(`${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>( + `${API}/admin_roles/list_roles${userId != null ? `?user_id=${userId}` : ''}`, + ), + ), + grantRole: async (userId, role: AdminRole) => { + await clientFetch>(`${API}/admin_roles/grant_role`, { + method: 'POST', + body: JSON.stringify({ userId, role }), + }); + }, + revokeRole: async (userId, role: AdminRole) => { + await clientFetch>(`${API}/admin_roles/revoke_role`, { + method: 'POST', + body: JSON.stringify({ userId, role }), + }); + }, +}; diff --git a/client/src/services/admin/apis/index.ts b/client/src/services/admin/apis/index.ts new file mode 100644 index 0000000..17abf02 --- /dev/null +++ b/client/src/services/admin/apis/index.ts @@ -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; diff --git a/client/src/services/admin/apis/mockApi.ts b/client/src/services/admin/apis/mockApi.ts new file mode 100644 index 0000000..56ebda6 --- /dev/null +++ b/client/src/services/admin/apis/mockApi.ts @@ -0,0 +1,196 @@ +import type { PageParams, Paginated } from '@/lib/api/types'; +import type { + AdminApi, + AdminRole, + AuditFilters, + AuditLogEntry, + ConfigChange, + Holiday, + HolidayFilters, + HolidayInput, + PlatformConfig, + RoleGrant, + SupportAlert, + SupportAlertFilters, +} from '../types'; + +/** + * In-memory `AdminApi` — **the primary implementation this phase** (REQ-029/030/031: config audit + * columns, rich audit filters, and the whole RBAC surface are gaps). The fixtures are engineered to + * exercise every console state: one config **per `dataType`** (so all typed inputs + the 0–1 rate + * validation are reachable), a config change-history trail, holidays with **bank-closed** days, a paged + * audit log with `changedFields` diffs, a support-alert list spanning **every** alert type/status (so the + * worklist filters are testable), and RBAC grants. Mutations mutate the in-memory arrays so a save shows + * on the next read. Timestamps are relative to now so Shamsi rendering always reads sensibly. + */ + +const DAY_MS = 24 * 60 * 60 * 1000; +const isoDaysAgo = (d: number): string => new Date(Date.now() - d * DAY_MS).toISOString(); +const dateDaysAgo = (d: number): string => isoDaysAgo(d).slice(0, 10); +const dateDaysAhead = (d: number): string => new Date(Date.now() + d * DAY_MS).toISOString().slice(0, 10); + +const LATENCY_MS = 200; +const delay = (v: T): Promise => new Promise((r) => setTimeout(() => r(v), LATENCY_MS)); + +function paginate(all: T[], params: PageParams): Paginated { + 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 = { + 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: '', new: '' }, 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); + }, +}; diff --git a/client/src/services/admin/constants.ts b/client/src/services/admin/constants.ts new file mode 100644 index 0000000..df2139f --- /dev/null +++ b/client/src/services/admin/constants.ts @@ -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 = { + 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'], +}; diff --git a/client/src/services/admin/hooks/useAdminRoles.ts b/client/src/services/admin/hooks/useAdminRoles.ts new file mode 100644 index 0000000..8781c33 --- /dev/null +++ b/client/src/services/admin/hooks/useAdminRoles.ts @@ -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, + }); +} diff --git a/client/src/services/admin/hooks/useAssignSupportAlert.ts b/client/src/services/admin/hooks/useAssignSupportAlert.ts new file mode 100644 index 0000000..b67bb8f --- /dev/null +++ b/client/src/services/admin/hooks/useAssignSupportAlert.ts @@ -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({ + mutationFn: ({ alertId, ownerUserId }) => adminApi.assignSupportAlert(alertId, ownerUserId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: adminKeys.supportAlerts() }); + }, + }); +} diff --git a/client/src/services/admin/hooks/useAuditLogs.ts b/client/src/services/admin/hooks/useAuditLogs.ts new file mode 100644 index 0000000..ac675ba --- /dev/null +++ b/client/src/services/admin/hooks/useAuditLogs.ts @@ -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, + }); +} diff --git a/client/src/services/admin/hooks/useConfigChangeHistory.ts b/client/src/services/admin/hooks/useConfigChangeHistory.ts new file mode 100644 index 0000000..fc03c08 --- /dev/null +++ b/client/src/services/admin/hooks/useConfigChangeHistory.ts @@ -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, + }); +} diff --git a/client/src/services/admin/hooks/useGrantRole.ts b/client/src/services/admin/hooks/useGrantRole.ts new file mode 100644 index 0000000..25daff1 --- /dev/null +++ b/client/src/services/admin/hooks/useGrantRole.ts @@ -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({ + mutationFn: ({ userId, role }) => adminApi.grantRole(userId, role), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: adminKeys.roles() }); + }, + }); +} diff --git a/client/src/services/admin/hooks/useHolidays.ts b/client/src/services/admin/hooks/useHolidays.ts new file mode 100644 index 0000000..23c73a2 --- /dev/null +++ b/client/src/services/admin/hooks/useHolidays.ts @@ -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, + }); +} diff --git a/client/src/services/admin/hooks/usePlatformConfigs.ts b/client/src/services/admin/hooks/usePlatformConfigs.ts new file mode 100644 index 0000000..a5064af --- /dev/null +++ b/client/src/services/admin/hooks/usePlatformConfigs.ts @@ -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, + }); +} diff --git a/client/src/services/admin/hooks/useResolveSupportAlert.ts b/client/src/services/admin/hooks/useResolveSupportAlert.ts new file mode 100644 index 0000000..008ddb5 --- /dev/null +++ b/client/src/services/admin/hooks/useResolveSupportAlert.ts @@ -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({ + mutationFn: ({ alertId, note }) => adminApi.resolveSupportAlert(alertId, note), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: adminKeys.supportAlerts() }); + }, + }); +} diff --git a/client/src/services/admin/hooks/useRevokeRole.ts b/client/src/services/admin/hooks/useRevokeRole.ts new file mode 100644 index 0000000..1545230 --- /dev/null +++ b/client/src/services/admin/hooks/useRevokeRole.ts @@ -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({ + mutationFn: ({ userId, role }) => adminApi.revokeRole(userId, role), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: adminKeys.roles() }); + }, + }); +} diff --git a/client/src/services/admin/hooks/useSupportAlerts.ts b/client/src/services/admin/hooks/useSupportAlerts.ts new file mode 100644 index 0000000..44c88f1 --- /dev/null +++ b/client/src/services/admin/hooks/useSupportAlerts.ts @@ -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, + }); +} diff --git a/client/src/services/admin/hooks/useUpdatePlatformConfig.ts b/client/src/services/admin/hooks/useUpdatePlatformConfig.ts new file mode 100644 index 0000000..6135a62 --- /dev/null +++ b/client/src/services/admin/hooks/useUpdatePlatformConfig.ts @@ -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({ + mutationFn: ({ key, value }) => adminApi.updateConfig(key, value), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: adminKeys.config() }); + }, + }); +} diff --git a/client/src/services/admin/hooks/useUpsertHoliday.ts b/client/src/services/admin/hooks/useUpsertHoliday.ts new file mode 100644 index 0000000..307131f --- /dev/null +++ b/client/src/services/admin/hooks/useUpsertHoliday.ts @@ -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({ + mutationFn: (input) => adminApi.upsertHoliday(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: adminKeys.holidays() }); + }, + }); +} diff --git a/client/src/services/admin/index.ts b/client/src/services/admin/index.ts new file mode 100644 index 0000000..e7a5506 --- /dev/null +++ b/client/src/services/admin/index.ts @@ -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'; diff --git a/client/src/services/admin/keys.ts b/client/src/services/admin/keys.ts new file mode 100644 index 0000000..ba363d6 --- /dev/null +++ b/client/src/services/admin/keys.ts @@ -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, +}; diff --git a/client/src/services/admin/types.ts b/client/src/services/admin/types.ts new file mode 100644 index 0000000..c071b9c --- /dev/null +++ b/client/src/services/admin/types.ts @@ -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` envelope, so these are the post-`unwrap()` payloads. + * + * Load-bearing rules (phase §5): + * - **Internal-only:** `SupportAlert`s are staff-only and must never reach a non-admin surface. + * - **Server is the authority:** config parsing is *rendering by `dataType`* only; the client validates at + * the boundary (a rate is 0–1) but never re-derives money, holiday shifts, or eligibility. + * - **Append-only audit:** there is no edit/delete — the viewer is read-only. + * + * Enums cross the wire as stable string codes, mirrored here as string-literal unions. + */ + +// ── Config ──────────────────────────────────────────────────────────────────────────────────────────── +/** `platform_configs.data_type` — how to parse (and which typed input to render for) a config `value`. */ +export type ConfigDataType = 'string' | 'int' | 'decimal' | 'bool' | 'json'; + +/** + * `PlatformConfigDto` (b1). `value` is the **raw string** — parse per `dataType`. `updatedAt`/`updatedBy` + * are **not** on the b1 wire (REQ-029) — optional and mock-supplied until delivered; the row degrades + * gracefully without them. + */ +export interface PlatformConfig { + key: string; + value: string; + dataType: ConfigDataType; + description: string | null; + updatedAt?: string | null; + updatedBy?: string | null; +} + +/** `action` on an audit/config-change row. */ +export type AuditAction = 'created' | 'updated' | 'deleted'; + +/** + * A config change-history row, derived from `ConfigChangeDto` (b1). The wire carries `changedFieldsJson` + * (`{ "Value": { "old": …, "new": … } }`); the client parses the value delta into `oldValue`/`newValue` + * for the drawer so finance can prove the rate in effect at any past moment. + */ +export interface ConfigChange { + id: number; + action: AuditAction; + actorUserId: number | null; + occurredAt: string; + oldValue: string | null; + newValue: string | null; +} + +// ── Holidays ────────────────────────────────────────────────────────────────────────────────────────── +/** `iranian_holidays.type`. */ +export type HolidayType = 'official' | 'religious' | 'national'; + +/** `HolidayDto` (b1). `isBankClosed` is what shifts payout scheduling (server-computed). */ +export interface Holiday { + id: number; + holidayDate: string; + nameFa: string; + type: HolidayType; + isBankClosed: boolean; +} + +/** Upsert body (`upsert_holiday`, keyed on `holidayDate`). */ +export interface HolidayInput { + holidayDate: string; + nameFa: string; + type: HolidayType; + isBankClosed: boolean; +} + +/** Holiday list range. */ +export interface HolidayFilters { + from?: string; + to?: string; +} + +// ── Audit ───────────────────────────────────────────────────────────────────────────────────────────── +/** `AuditLogDto` (b1). `changedFields` is the parsed `changedFieldsJson` (field → {old,new}); PII redacted. */ +export interface AuditLogEntry { + id: number; + entityType: string; + entityId: string; + action: string; + actorUserId: number | null; + occurredAt: string; + changedFields: Record | 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>; + updateConfig(key: string, value: string): Promise; + getConfigHistory(key: string, params: PageParams): Promise>; + // holidays + listHolidays(filters: HolidayFilters, params: PageParams): Promise>; + upsertHoliday(input: HolidayInput): Promise; + // audit + listAuditLogs(filters: AuditFilters, params: PageParams): Promise>; + // support alerts + listSupportAlerts(filters: SupportAlertFilters, params: PageParams): Promise>; + assignSupportAlert(alertId: number, ownerUserId: number): Promise; + resolveSupportAlert(alertId: number, note: string): Promise; + // rbac (deferred-if-missing) + listRoles(userId?: number): Promise; + grantRole(userId: number, role: AdminRole): Promise; + revokeRole(userId: number, role: AdminRole): Promise; +} diff --git a/client/src/services/auth/hooks/useSessionRoleSync.ts b/client/src/services/auth/hooks/useSessionRoleSync.ts index c18bd84..5038167 100644 --- a/client/src/services/auth/hooks/useSessionRoleSync.ts +++ b/client/src/services/auth/hooks/useSessionRoleSync.ts @@ -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]); } diff --git a/client/src/services/partnerCenter/apis/clientApi.ts b/client/src/services/partnerCenter/apis/clientApi.ts new file mode 100644 index 0000000..5d864d3 --- /dev/null +++ b/client/src/services/partnerCenter/apis/clientApi.ts @@ -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>>(`${API}/admin/partner-centers?${q}`)); + return { ...wire, items: wire.items.map(mapCenter) }; + }, + + getCenter: async (id) => mapCenter(unwrap(await clientFetch>(`${API}/admin/partner-centers/${id}`))), + + createCenter: async (input: PartnerCenterInput) => + mapCenter( + unwrap( + await clientFetch>(`${API}/admin/partner-centers`, { + method: 'POST', + body: JSON.stringify(input), + }), + ), + ), + + updateCenter: async (id, input: PartnerCenterInput) => + mapCenter( + unwrap( + await clientFetch>(`${API}/admin/partner-centers/${id}`, { + method: 'PATCH', + body: JSON.stringify(input), + }), + ), + ), + + verifyCenter: async (id) => { + await clientFetch>(`${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>(`${API}/admin/partner-centers/${id}/set-active`, { + method: 'POST', + body: JSON.stringify({ isActive }), + }); + }, + + assignNurse: async (id, nurseProfileId, unlink) => { + await clientFetch>(`${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>(`${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>(`${API}/centers/me`))), + listMySponsoredNurses: async () => + unwrap(await clientFetch>(`${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>>(`${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>>(`${API}/centers/me/settlement?${q}`)); + }, +}; diff --git a/client/src/services/partnerCenter/apis/index.ts b/client/src/services/partnerCenter/apis/index.ts new file mode 100644 index 0000000..fa43457 --- /dev/null +++ b/client/src/services/partnerCenter/apis/index.ts @@ -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; diff --git a/client/src/services/partnerCenter/apis/mockApi.ts b/client/src/services/partnerCenter/apis/mockApi.ts new file mode 100644 index 0000000..b683f0d --- /dev/null +++ b/client/src/services/partnerCenter/apis/mockApi.ts @@ -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 = (v: T): Promise => new Promise((r) => setTimeout(() => r(v), LATENCY_MS)); + +function paginate(all: T[], params: PageParams): Paginated { + 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 = { + 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 = { + 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)); + }, +}; diff --git a/client/src/services/partnerCenter/constants.ts b/client/src/services/partnerCenter/constants.ts new file mode 100644 index 0000000..80eff78 --- /dev/null +++ b/client/src/services/partnerCenter/constants.ts @@ -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; diff --git a/client/src/services/partnerCenter/hooks/useAssignNurseToPartnerCenter.ts b/client/src/services/partnerCenter/hooks/useAssignNurseToPartnerCenter.ts new file mode 100644 index 0000000..cfbdc97 --- /dev/null +++ b/client/src/services/partnerCenter/hooks/useAssignNurseToPartnerCenter.ts @@ -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({ + 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() }); + }, + }); +} diff --git a/client/src/services/partnerCenter/hooks/useCenterSponsoredNurses.ts b/client/src/services/partnerCenter/hooks/useCenterSponsoredNurses.ts new file mode 100644 index 0000000..86a36ac --- /dev/null +++ b/client/src/services/partnerCenter/hooks/useCenterSponsoredNurses.ts @@ -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, + }); +} diff --git a/client/src/services/partnerCenter/hooks/useCreatePartnerCenter.ts b/client/src/services/partnerCenter/hooks/useCreatePartnerCenter.ts new file mode 100644 index 0000000..4e65e8d --- /dev/null +++ b/client/src/services/partnerCenter/hooks/useCreatePartnerCenter.ts @@ -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({ + mutationFn: (input) => partnerCenterApi.createCenter(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: centerKeys.lists() }); + }, + }); +} diff --git a/client/src/services/partnerCenter/hooks/useMyPartnerCenter.ts b/client/src/services/partnerCenter/hooks/useMyPartnerCenter.ts new file mode 100644 index 0000000..189dbe1 --- /dev/null +++ b/client/src/services/partnerCenter/hooks/useMyPartnerCenter.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; +import { partnerCenterApi } from '../apis'; +import { centerKeys } from '../keys'; +import { PARTNER_DETAIL_STALE_TIME, PARTNER_GC_TIME } from '../constants'; + +/** + * The 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, + }); +} diff --git a/client/src/services/partnerCenter/hooks/useMySettlement.ts b/client/src/services/partnerCenter/hooks/useMySettlement.ts new file mode 100644 index 0000000..8fdf8e2 --- /dev/null +++ b/client/src/services/partnerCenter/hooks/useMySettlement.ts @@ -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, + }); +} diff --git a/client/src/services/partnerCenter/hooks/useMySponsoredBookings.ts b/client/src/services/partnerCenter/hooks/useMySponsoredBookings.ts new file mode 100644 index 0000000..5d50b3c --- /dev/null +++ b/client/src/services/partnerCenter/hooks/useMySponsoredBookings.ts @@ -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, + }); +} diff --git a/client/src/services/partnerCenter/hooks/useMySponsoredNurses.ts b/client/src/services/partnerCenter/hooks/useMySponsoredNurses.ts new file mode 100644 index 0000000..3a76e6c --- /dev/null +++ b/client/src/services/partnerCenter/hooks/useMySponsoredNurses.ts @@ -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, + }); +} diff --git a/client/src/services/partnerCenter/hooks/usePartnerCenter.ts b/client/src/services/partnerCenter/hooks/usePartnerCenter.ts new file mode 100644 index 0000000..3cfbbd9 --- /dev/null +++ b/client/src/services/partnerCenter/hooks/usePartnerCenter.ts @@ -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, + }); +} diff --git a/client/src/services/partnerCenter/hooks/usePartnerCenters.ts b/client/src/services/partnerCenter/hooks/usePartnerCenters.ts new file mode 100644 index 0000000..adf03f5 --- /dev/null +++ b/client/src/services/partnerCenter/hooks/usePartnerCenters.ts @@ -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, + }); +} diff --git a/client/src/services/partnerCenter/hooks/useSetPartnerCenterActive.ts b/client/src/services/partnerCenter/hooks/useSetPartnerCenterActive.ts new file mode 100644 index 0000000..e47c2e9 --- /dev/null +++ b/client/src/services/partnerCenter/hooks/useSetPartnerCenterActive.ts @@ -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({ + mutationFn: (isActive) => partnerCenterApi.setCenterActive(id, isActive), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: centerKeys.lists() }); + queryClient.invalidateQueries({ queryKey: centerKeys.detail(id) }); + }, + }); +} diff --git a/client/src/services/partnerCenter/hooks/useUpdatePartnerCenter.ts b/client/src/services/partnerCenter/hooks/useUpdatePartnerCenter.ts new file mode 100644 index 0000000..3ad7af8 --- /dev/null +++ b/client/src/services/partnerCenter/hooks/useUpdatePartnerCenter.ts @@ -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({ + mutationFn: (input) => partnerCenterApi.updateCenter(id, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: centerKeys.lists() }); + queryClient.invalidateQueries({ queryKey: centerKeys.detail(id) }); + }, + }); +} diff --git a/client/src/services/partnerCenter/hooks/useVerifyPartnerCenter.ts b/client/src/services/partnerCenter/hooks/useVerifyPartnerCenter.ts new file mode 100644 index 0000000..d099ff8 --- /dev/null +++ b/client/src/services/partnerCenter/hooks/useVerifyPartnerCenter.ts @@ -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({ + mutationFn: () => partnerCenterApi.verifyCenter(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: centerKeys.lists() }); + queryClient.invalidateQueries({ queryKey: centerKeys.detail(id) }); + }, + }); +} diff --git a/client/src/services/partnerCenter/index.ts b/client/src/services/partnerCenter/index.ts new file mode 100644 index 0000000..5275656 --- /dev/null +++ b/client/src/services/partnerCenter/index.ts @@ -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'; diff --git a/client/src/services/partnerCenter/keys.ts b/client/src/services/partnerCenter/keys.ts new file mode 100644 index 0000000..42df829 --- /dev/null +++ b/client/src/services/partnerCenter/keys.ts @@ -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, +}; diff --git a/client/src/services/partnerCenter/types.ts b/client/src/services/partnerCenter/types.ts new file mode 100644 index 0000000..3ff5756 --- /dev/null +++ b/client/src/services/partnerCenter/types.ts @@ -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>; + getCenter(id: number): Promise; + createCenter(input: PartnerCenterInput): Promise; + updateCenter(id: number, input: PartnerCenterInput): Promise; + verifyCenter(id: number): Promise; + setCenterActive(id: number, isActive: boolean): Promise; + assignNurse(id: number, nurseProfileId: number, unlink: boolean): Promise; + getCenterSponsoredNurses(id: number): Promise; + // portal (center-scoped) + getMyCenter(): Promise; + listMySponsoredNurses(): Promise; + listMySponsoredBookings(filters: SponsoredBookingFilters, params: PageParams): Promise>; + listMySettlement(params: PageParams): Promise>; +} + +/** 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'; +} diff --git a/client/src/services/payouts/apis/clientApi.ts b/client/src/services/payouts/apis/clientApi.ts index 006af85..a796711 100644 --- a/client/src/services/payouts/apis/clientApi.ts +++ b/client/src/services/payouts/apis/clientApi.ts @@ -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>(`${NURSE_PAYOUTS}/${payoutId}`)), + + // ── Admin batch actions (`admin_payouts/*`) ───────────────────────────────────────────────────────── + + listPayoutBatches: async ( + filters: PayoutBatchFilters, + params: PageParams, + ): Promise> => { + 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>>(`${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>>( + `${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>(`${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 => { + const query = new URLSearchParams({ page: String(page) }); + const detail = unwrap( + await clientFetch>( + `${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>(`${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>(`${ADMIN_PAYOUTS}/${payoutId}/transfer_reference`, { + method: 'POST', + body: JSON.stringify({ reference }), + }); + }, }; diff --git a/client/src/services/payouts/apis/mockApi.ts b/client/src/services/payouts/apis/mockApi.ts index 633090c..f6ca0a0 100644 --- a/client/src/services/payouts/apis/mockApi.ts +++ b/client/src/services/payouts/apis/mockApi.ts @@ -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(value: T): Promise { const STATE_ORDER: Record = { 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 = { + // 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(); +const RETRY_IDEMPOTENCY = new Set(); +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); + }, }; diff --git a/client/src/services/payouts/constants.ts b/client/src/services/payouts/constants.ts index 5919bb9..f2b23d5 100644 --- a/client/src/services/payouts/constants.ts +++ b/client/src/services/payouts/constants.ts @@ -34,5 +34,15 @@ export const PAYOUT_HISTORY_STALE_TIME = 5 * 60 * 1000; export const PAYOUT_DETAIL_STALE_TIME = 10 * 60 * 1000; export const PAYOUTS_GC_TIME = 15 * 60 * 1000; +/** + * The **admin** reconciliation surfaces are more volatile than the nurse read — within one session an admin + * opens a draft, runs it, retries a failed payout, records a reference. A short `staleTime`, backed by + * explicit invalidation on every mutation (`useRunPayoutBatch`/`useRetryPayout`/`useRecordTransferReference`). + */ +export const ADMIN_BATCHES_STALE_TIME = 30 * 1000; +export const ADMIN_BATCH_DETAIL_STALE_TIME = 30 * 1000; +/** Page size for the admin batch-detail payout rows (contract default 50). */ +export const ADMIN_BATCH_DETAIL_PAGE_SIZE = 50; + /** Page size for the earnings + payout-history lists (api-conventions `pageSize`). */ export const PAYOUTS_PAGE_SIZE = 10; diff --git a/client/src/services/payouts/hooks/usePayoutBatchDetail.ts b/client/src/services/payouts/hooks/usePayoutBatchDetail.ts new file mode 100644 index 0000000..a9f9938 --- /dev/null +++ b/client/src/services/payouts/hooks/usePayoutBatchDetail.ts @@ -0,0 +1,20 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { payoutsApi } from '../apis'; +import { payoutKeys } from '../keys'; +import { ADMIN_BATCH_DETAIL_STALE_TIME, PAYOUTS_GC_TIME } from '../constants'; + +/** + * One batch expanded — its header + paginated per-payout rows (status, net, masked IBAN, transfer reference, + * booking links). Disabled until a batch is selected (`batchId` present); each page keys separately and + * `keepPreviousData` holds the prior page while the next loads. Invalidated by retry / record-reference. + */ +export function usePayoutBatchDetail(batchId: number | null, page: number) { + return useQuery({ + queryKey: payoutKeys.adminBatchDetail(batchId ?? -1, page), + queryFn: () => payoutsApi.getPayoutBatchDetail(batchId as number, page), + enabled: batchId != null && batchId > 0, + staleTime: ADMIN_BATCH_DETAIL_STALE_TIME, + gcTime: PAYOUTS_GC_TIME, + placeholderData: keepPreviousData, + }); +} diff --git a/client/src/services/payouts/hooks/usePayoutBatches.ts b/client/src/services/payouts/hooks/usePayoutBatches.ts new file mode 100644 index 0000000..b0ee56b --- /dev/null +++ b/client/src/services/payouts/hooks/usePayoutBatches.ts @@ -0,0 +1,22 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { payoutsApi } from '../apis'; +import { payoutKeys } from '../keys'; +import { ADMIN_BATCHES_STALE_TIME, PAYOUTS_GC_TIME, PAYOUTS_PAGE_SIZE } from '../constants'; +import type { PayoutBatchFilters } from '../types'; + +/** + * The admin reconciliation list of payout batches (newest first). The **filter + page params are part of the + * query key** so each status filter / page caches independently; `keepPreviousData` avoids an empty flash + * while paging or switching filters. Volatile relative to the nurse read — a short `staleTime`, refreshed by + * explicit invalidation from the batch mutations. + */ +export function usePayoutBatches(filters: PayoutBatchFilters, page: number) { + const params = { page, pageSize: PAYOUTS_PAGE_SIZE }; + return useQuery({ + queryKey: payoutKeys.adminBatches(filters, params), + queryFn: () => payoutsApi.listPayoutBatches(filters, params), + staleTime: ADMIN_BATCHES_STALE_TIME, + gcTime: PAYOUTS_GC_TIME, + placeholderData: keepPreviousData, + }); +} diff --git a/client/src/services/payouts/hooks/usePreviewPayoutBatch.ts b/client/src/services/payouts/hooks/usePreviewPayoutBatch.ts new file mode 100644 index 0000000..ee31070 --- /dev/null +++ b/client/src/services/payouts/hooks/usePreviewPayoutBatch.ts @@ -0,0 +1,15 @@ +import { useMutation } from '@tanstack/react-query'; +import { payoutsApi } from '../apis'; +import type { PayoutBatchPreview } from '../types'; + +/** + * The eligibility dry-run for a window — a **mutation**, not an auto-fetching query: it runs only when the + * admin explicitly asks to preview (never on mount), and its result (the eligible/skipped breakdown + the + * server's holiday-shifted processing date) is read from the mutation's `data`. The client renders it; it + * never computes eligibility or the shifted date. + */ +export function usePreviewPayoutBatch() { + return useMutation({ + mutationFn: ({ periodStart, periodEnd }) => payoutsApi.previewPayoutBatch(periodStart, periodEnd), + }); +} diff --git a/client/src/services/payouts/hooks/useRecordTransferReference.ts b/client/src/services/payouts/hooks/useRecordTransferReference.ts new file mode 100644 index 0000000..eecb6ea --- /dev/null +++ b/client/src/services/payouts/hooks/useRecordTransferReference.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { payoutsApi } from '../apis'; +import { payoutKeys } from '../keys'; + +/** + * Record a manually reconciled bank transfer reference on a payout (REQ-036 — b13 has `mark_failed` but no + * record-reference route). On success we invalidate the owning batch's detail so the reference renders on the + * row without a manual refresh. + */ +export function useRecordTransferReference() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ payoutId, reference }) => payoutsApi.recordTransferReference(payoutId, reference), + onSuccess: (_data, { batchId }) => { + queryClient.invalidateQueries({ queryKey: [...payoutKeys.adminBatchDetails(), batchId] }); + }, + }); +} diff --git a/client/src/services/payouts/hooks/useRetryPayout.ts b/client/src/services/payouts/hooks/useRetryPayout.ts new file mode 100644 index 0000000..933eb37 --- /dev/null +++ b/client/src/services/payouts/hooks/useRetryPayout.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { payoutsApi } from '../apis'; +import { payoutKeys } from '../keys'; + +/** + * Re-submit a single `failed` payout to the bank rail. **Idempotency-keyed**: a re-fired retry with the same + * key never re-pays. On success we invalidate the owning batch's detail (the row flips `failed → paid`, and + * if it was the batch's last failure the batch re-settles `partially_failed → completed`) and the batches + * list (its status may have changed). + */ +export function useRetryPayout() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ payoutId, idempotencyKey }) => payoutsApi.retryPayout(payoutId, idempotencyKey), + onSuccess: (_data, { batchId }) => { + queryClient.invalidateQueries({ queryKey: [...payoutKeys.adminBatchDetails(), batchId] }); + queryClient.invalidateQueries({ queryKey: payoutKeys.adminBatchLists() }); + }, + }); +} diff --git a/client/src/services/payouts/hooks/useRunPayoutBatch.ts b/client/src/services/payouts/hooks/useRunPayoutBatch.ts new file mode 100644 index 0000000..6076f57 --- /dev/null +++ b/client/src/services/payouts/hooks/useRunPayoutBatch.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { payoutsApi } from '../apis'; +import { payoutKeys } from '../keys'; +import type { PayoutBatchSummary } from '../types'; + +/** + * Open + run a payout batch for a window. **Idempotency-keyed**: the caller passes a stable `idempotencyKey` + * per run so a retried submit converges on the same batch (never a double-run). On success we invalidate the + * batches list so the new batch appears at the top without a manual refresh. Domain 4xx (e.g. no eligible + * bookings) surface to the caller's `onError`. + */ +export function useRunPayoutBatch() { + const queryClient = useQueryClient(); + return useMutation< + PayoutBatchSummary, + unknown, + { periodStart: string; periodEnd: string; idempotencyKey: string } + >({ + mutationFn: ({ periodStart, periodEnd, idempotencyKey }) => + payoutsApi.runPayoutBatch(periodStart, periodEnd, idempotencyKey), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: payoutKeys.adminBatchLists() }); + }, + }); +} diff --git a/client/src/services/payouts/index.ts b/client/src/services/payouts/index.ts index 87dab43..ec7f14f 100644 --- a/client/src/services/payouts/index.ts +++ b/client/src/services/payouts/index.ts @@ -6,3 +6,11 @@ export { useNurseEarningsBalance } from './hooks/useNurseEarningsBalance'; export { useNurseEarnings } from './hooks/useNurseEarnings'; export { useNursePayoutHistory } from './hooks/useNursePayoutHistory'; export { useNursePayoutDetail } from './hooks/useNursePayoutDetail'; + +// Admin batch actions (b13 admin_payouts/*). +export { usePayoutBatches } from './hooks/usePayoutBatches'; +export { usePayoutBatchDetail } from './hooks/usePayoutBatchDetail'; +export { usePreviewPayoutBatch } from './hooks/usePreviewPayoutBatch'; +export { useRunPayoutBatch } from './hooks/useRunPayoutBatch'; +export { useRetryPayout } from './hooks/useRetryPayout'; +export { useRecordTransferReference } from './hooks/useRecordTransferReference'; diff --git a/client/src/services/payouts/keys.ts b/client/src/services/payouts/keys.ts index 559b915..7d7c72c 100644 --- a/client/src/services/payouts/keys.ts +++ b/client/src/services/payouts/keys.ts @@ -1,4 +1,5 @@ -import type { EarningsState } from './types'; +import type { PageParams } from '@/lib/api/types'; +import type { EarningsState, PayoutBatchFilters } from './types'; /** * React Query key factory for the payouts domain (hierarchical, per the `services/{domain}` pattern). @@ -22,4 +23,14 @@ export const payoutKeys = { details: () => [...payoutKeys.all, 'detail'] as const, detail: (payoutId: number) => [...payoutKeys.details(), payoutId] as const, + + // Admin batch actions. The filter object + page params are part of the key (so each filter/page caches + // separately); mutations invalidate the `adminBatchLists` / `adminBatchDetails` prefixes. + adminBatchLists: () => [...payoutKeys.all, 'admin_batches'] as const, + adminBatches: (filters: PayoutBatchFilters, params: PageParams) => + [...payoutKeys.adminBatchLists(), filters, params] as const, + + adminBatchDetails: () => [...payoutKeys.all, 'admin_batch_detail'] as const, + adminBatchDetail: (batchId: number, page: number) => + [...payoutKeys.adminBatchDetails(), batchId, page] as const, }; diff --git a/client/src/services/payouts/types.ts b/client/src/services/payouts/types.ts index 9774981..e081952 100644 --- a/client/src/services/payouts/types.ts +++ b/client/src/services/payouts/types.ts @@ -173,14 +173,123 @@ export interface EarningsListParams extends PageParams { state?: EarningsState; } +// ── Admin payout-batch actions (b13 `admin_payouts/*`) ──────────────────────────────────────────────── +// +// The admin side of the same weekly engine: preview eligible earnings, open/run a batch, read batches + +// per-payout rows, retry a failed payout, record a reconciled transfer reference. These map published b13 +// routes 1:1 (`clientApi.ts`) but the domain stays **mock-primary** this phase (see `constants.ts`). +// Money is IRR digit-strings; the client renders the **server's** eligibility + holiday-shifted date — it +// never computes them. Invariants: per eligible nurse / payout `gross − clawback = net`; a batch's +// `totalAmount = Σ its payouts' net`; a payout's booking links sum to its `grossEarningsIrr`. + +/** One nurse's payout-eligible, unpaid earnings for a window (`EligibleNurseEarningsDto`). A nurse missing a + * verified primary IBAN is **flagged** (`hasVerifiedPrimaryIban:false`) here, not dropped. */ +export interface EligibleNurseEarnings { + nurseId: number; + nurseName: string | null; + bookingCount: number; + grossEarningsIrr: string; + clawbackAppliedIrr: string; + /** `= grossEarningsIrr − clawbackAppliedIrr` (clawbacks netted into the preview). */ + netAmountIrr: string; + hasVerifiedPrimaryIban: boolean; +} + +/** A nurse excluded from a generated batch, with the reason (`SkippedNurseDto`; e.g. `no_verified_primary_iban`). */ +export interface SkippedNurse { + nurseId: number; + nurseName: string | null; + grossEarningsIrr: string; + reason: string; +} + +/** A `nurse_payout_batches` header for the admin reconciliation list (`PayoutBatchDto` + `holidayShifted`). */ +export interface PayoutBatchSummary { + id: number; + /** Holiday-shifted server-side; ISO dates `YYYY-MM-DD`. */ + periodStart: string; + periodEnd: string; + processingDate: string; + /** `= Σ its payouts' net_amount_irr`. IRR digit-string. */ + totalAmount: string; + payoutCount: number; + status: PayoutBatchStatus; + processedAt: string | null; + failureNotes: string | null; + createdAt: string; + /** Whether `processingDate` was shifted off a bank-closed day (server truth; the client only renders it). */ + holidayShifted: boolean; +} + +/** The dry-run before a batch: the eligible nurses, the ones that would be skipped, and the shifted date. */ +export interface PayoutBatchPreview { + periodStart: string; + periodEnd: string; + processingDate: string; + holidayShifted: boolean; + eligible: EligibleNurseEarnings[]; + skipped: SkippedNurse[]; + /** `= Σ eligible.netAmountIrr`. IRR digit-string. */ + totalNetIrr: string; +} + +/** One `nurse_payouts` row expanded for the admin batch detail (`PayoutDto`). Every row reconciles: + * `grossEarningsIrr − clawbackAppliedIrr = netAmountIrr`; on a clean payout `amountIrr = netAmountIrr`. */ +export interface AdminPayoutRow { + id: number; + nurseId: number; + nurseName: string | null; + /** Masked, **last-4 only** — an encrypted field; never a full IBAN. */ + maskedIban: string; + grossEarningsIrr: string; + clawbackAppliedIrr: string; + netAmountIrr: string; + /** What was actually transferred (`PayoutDto.amount`); `= netAmountIrr` on a clean payout. */ + amountIrr: string; + status: PayoutStatus; + transferReference: string | null; + paidAt: string | null; + /** `failed` only. Empty otherwise. */ + failureReason: string | null; + /** The bookings this payout covered; `Σ payoutAmountIrr = grossEarningsIrr`. */ + bookings: { bookingId: number; sessionId: number | null; payoutAmountIrr: string }[]; +} + +/** A batch header + its paginated payout rows (`PayoutBatchDetailDto`). */ +export interface AdminPayoutBatchDetail { + batch: PayoutBatchSummary; + payouts: AdminPayoutRow[]; + total: number; + page: number; + pageSize: number; +} + +/** `listPayoutBatches` filter — the optional status is part of the query key so each filter caches separately. */ +export interface PayoutBatchFilters { + status?: PayoutBatchStatus; +} + /** * The payouts API seam — the real HTTP client and the in-memory mock both implement this interface; - * selection is by config (`USE_PAYOUTS_MOCK`), never scattered `if (mock)` checks. **All reads; no - * mutations** (a nurse never writes payout state). + * selection is by config (`USE_PAYOUTS_MOCK`), never scattered `if (mock)` checks. + * + * The **nurse read** methods are all reads (a nurse never writes payout state). The **admin** methods + * (`admin_payouts/*`) add the batch actions: previewing eligibility, running a batch, retrying a failed + * payout, and recording a transfer reference. `runPayoutBatch`/`retryPayout` are **idempotency-keyed** — + * the same key returns the same result (never a double-pay). */ export interface PayoutsApi { + // Nurse read side. getNurseEarningsBalance(): Promise; getNurseEarnings(params: EarningsListParams): Promise>; getNursePayoutHistory(params: PageParams): Promise>; getNursePayoutDetail(payoutId: number): Promise; + + // Admin batch actions. + listPayoutBatches(filters: PayoutBatchFilters, params: PageParams): Promise>; + previewPayoutBatch(periodStart: string, periodEnd: string): Promise; + runPayoutBatch(periodStart: string, periodEnd: string, idempotencyKey: string): Promise; + getPayoutBatchDetail(batchId: number, page: number): Promise; + retryPayout(payoutId: number, idempotencyKey: string): Promise; + recordTransferReference(payoutId: number, reference: string): Promise; } diff --git a/client/src/services/refunds/apis/clientApi.ts b/client/src/services/refunds/apis/clientApi.ts index 4e148a4..1078985 100644 --- a/client/src/services/refunds/apis/clientApi.ts +++ b/client/src/services/refunds/apis/clientApi.ts @@ -2,9 +2,12 @@ import { clientFetch } from '@/lib/api/client'; import { unwrap, type ApiEnvelope } from '@/lib/api/types'; import { ApiError } from '@/lib/api/errors'; import type { + AdminRefundResult, CancelBookingInput, CancellationPolicyPreview, + InitiateRefundInput, RefundChannel, + RefundPreview, RefundStatus, RefundSummary, RefundsApi, @@ -12,6 +15,7 @@ import type { const BOOKINGS = '/api/v1/bookings'; const REFUNDS = '/api/v1/refunds'; +const ADMIN_REFUNDS = '/api/v1/admin_refunds'; /** * The thin b11 customer refund payload (`GET refunds/{id}/status`) — the only refund shape the contract @@ -93,4 +97,52 @@ export const refundsClientApi: RefundsApi = { getRefund: async (refundId: number) => toSummary(unwrap(await clientFetch>(`${REFUNDS}/${refundId}/status`))), + + // REQ-035: refund preview endpoint. b11 computes the fee-leg decomposition only *on create* (there is no + // read-only preview route), yet the admin console must disclose the split before initiating. Filed as a + // proposed `GET api/v1/admin_refunds/preview?booking_id=&ticket_id=`; the mock serves it today. + getRefundPreview: async (bookingId: number, ticketId: number | null) => { + const params = new URLSearchParams({ booking_id: String(bookingId) }); + if (ticketId != null) params.set('ticket_id', String(ticketId)); + return unwrap( + await clientFetch>(`${ADMIN_REFUNDS}/preview?${params.toString()}`), + ); + }, + + // Create + immediately execute a ticket-linked refund (b11 `POST api/v1/admin_refunds`). The response is + // already `AdminRefundResult`-shaped (refundId/status/channel/decomposed legs/eta/clawbackId). + initiateRefund: async (input: InitiateRefundInput) => + unwrap( + await clientFetch>(ADMIN_REFUNDS, { + method: 'POST', + body: JSON.stringify({ + bookingId: input.bookingId, + ticketId: input.ticketId, + refundPercentage: input.refundPercentage, + refundChannel: input.refundChannel, + reasonCategory: input.reasonCategory, + reasonNotes: input.reasonNotes, + }), + }), + ), + + // REQ-035: approve/reject a refund. b11 has no separate approve/reject step — `POST admin_refunds` both + // creates and executes — so a failed-channel retry and an explicit rejection are proposed as + // `POST api/v1/admin_refunds/{id}/approve` and `.../{id}/reject`; the mock serves both today. + approveRefund: async (refundId: number) => + unwrap( + await clientFetch>(`${ADMIN_REFUNDS}/${refundId}/approve`, { + method: 'POST', + }), + ), + + // REQ-035: see approveRefund. Reject records a reason and moves the refund to `rejected`. + rejectRefund: async (refundId: number, reason: string) => { + unwrap( + await clientFetch>(`${ADMIN_REFUNDS}/${refundId}/reject`, { + method: 'POST', + body: JSON.stringify({ reason }), + }), + ); + }, }; diff --git a/client/src/services/refunds/apis/mockApi.ts b/client/src/services/refunds/apis/mockApi.ts index 2400d23..5414125 100644 --- a/client/src/services/refunds/apis/mockApi.ts +++ b/client/src/services/refunds/apis/mockApi.ts @@ -5,11 +5,15 @@ import { BNPL_REFUND_ETA_BUSINESS_DAYS, MOCK_POLICY_TIERS } from '../constants'; import { isBookingCancellable, isTerminalRefundStatus, + type AdminRefundResult, type CancelBookingInput, type CancellationPolicyCode, type CancellationPolicyPreview, type CancellationSessionPreview, + type InitiateRefundInput, type RefundChannel, + type RefundPreview, + type RefundStatus, type RefundSummary, type RefundsApi, } from '../types'; @@ -184,6 +188,99 @@ function advanceRefund(refund: MockRefund): void { } } +/* -------------------------------------------------------------------------------------------------- + * Admin refund tooling mock (b11 `admin_refunds`). Self-contained fixtures keyed by a **sentinel booking + * id** — one per console panel state — kept separate from the customer booking store above so every admin + * state is deterministic. Each fixture reconciles `platformFeeRefunded + nursePayoutRefunded === amount` + * (BigInt). The states: + * 6001 — normal card: `psp_card`, `succeeded` immediately, no ETA, no clawback. + * 6002 — BNPL: `bnpl_revert`, `processing` on initiate, a ~9-business-day `expectedCustomerRefundEta`. + * 6003 — post-payout: `willCreateClawback` (nurse already paid), initiate returns a `clawbackId`. + * 6004 — provider decline: the FIRST `initiateRefund` returns `failed` (channel refused); a subsequent + * `approveRefund` (retry) succeeds — so the retry path is demonstrable. + * ------------------------------------------------------------------------------------------------ */ + +/** Sentinel booking whose first refund attempt the channel declines (`failed`), then a retry succeeds. */ +const PROVIDER_DECLINE_BOOKING_ID = 6004; + +/** The admin decomposition preview per fixture booking (the BNPL ETA is filled dynamically at read time). */ +const ADMIN_PREVIEW_FIXTURES: Record> = { + 6001: { + bookingId: 6001, + refundPercentageApplied: 1, + amountIrr: '10000000', + platformFeeRefundedIrr: '1500000', + nursePayoutRefundedIrr: '8500000', + refundChannel: 'psp_card', + willCreateClawback: false, + cancellationPolicyCode: 'free_24h', + }, + 6002: { + bookingId: 6002, + refundPercentageApplied: 1, + amountIrr: '6000000', + platformFeeRefundedIrr: '900000', + nursePayoutRefundedIrr: '5100000', + refundChannel: 'bnpl_revert', + willCreateClawback: false, + cancellationPolicyCode: 'free_24h', + }, + 6003: { + bookingId: 6003, + refundPercentageApplied: 0.5, + amountIrr: '5000000', + platformFeeRefundedIrr: '750000', + nursePayoutRefundedIrr: '4250000', + refundChannel: 'psp_card', + willCreateClawback: true, + cancellationPolicyCode: 'partial_under_24h', + }, + [PROVIDER_DECLINE_BOOKING_ID]: { + bookingId: PROVIDER_DECLINE_BOOKING_ID, + refundPercentageApplied: 1, + amountIrr: '8000000', + platformFeeRefundedIrr: '1200000', + nursePayoutRefundedIrr: '6800000', + refundChannel: 'psp_card', + willCreateClawback: false, + cancellationPolicyCode: 'free_24h', + }, +}; + +/** The BNPL admin ETA — ~9 business days out (Fridays skipped), within the product's ~7–10-day window. */ +const ADMIN_BNPL_ETA_BUSINESS_DAYS = 9; + +/** Assert the fee legs sum to the total (BigInt) — a preview must reconcile to the rial before it ships. */ +function assertReconciles(preview: Omit): void { + const sum = parseIrr(preview.platformFeeRefundedIrr) + parseIrr(preview.nursePayoutRefundedIrr); + if (sum !== parseIrr(preview.amountIrr)) { + throw new Error(`Admin refund preview ${preview.bookingId} does not reconcile`); + } +} + +/** Resolve the full preview for a fixture booking (filling the BNPL ETA); `404` on an unknown booking. */ +function adminPreviewFor(bookingId: number): RefundPreview { + const base = ADMIN_PREVIEW_FIXTURES[bookingId]; + if (!base) throw new ApiError(404, 'No captured payment for this booking', 'not_found'); + assertReconciles(base); + return { + ...base, + expectedCustomerRefundEta: + base.refundChannel === 'bnpl_revert' ? businessDaysFromNow(ADMIN_BNPL_ETA_BUSINESS_DAYS) : null, + }; +} + +/** The executed status for a channel: a card refund succeeds immediately; BNPL/manual go to `processing`. */ +function executedStatusFor(channel: RefundChannel): RefundStatus { + return channel === 'psp_card' ? 'succeeded' : 'processing'; +} + +let nextAdminRefundId = 9001; +let nextClawbackId = 4001; +const adminRefundsById: Record = {}; +// Per-booking initiate counter — drives the provider-decline sentinel's first-attempt failure. +const adminInitiateAttempts: Record = {}; + /** * In-memory mock behind the `RefundsApi` seam — the whole customer cancel + refund surface b11 doesn't * serve (admin-only refunds; no cancel command / policy preview / refund-by-booking / decomposition on the @@ -258,4 +355,70 @@ export const refundsMockApi: RefundsApi = { advanceRefund(refund); return toRefundSummary(refund); }, + + // --- Admin refund tooling (ticket-linked; the mock serves the whole console this phase). --- + + getRefundPreview: async (bookingId, _ticketId) => { + await sleep(MOCK_LATENCY_MS); + return adminPreviewFor(bookingId); + }, + + initiateRefund: async (input: InitiateRefundInput) => { + await sleep(MOCK_LATENCY_MS); + const preview = adminPreviewFor(input.bookingId); // 404 if unknown + const channel = input.refundChannel ?? preview.refundChannel; + + const attempt = (adminInitiateAttempts[input.bookingId] ?? 0) + 1; + adminInitiateAttempts[input.bookingId] = attempt; + + // The provider-decline fixture fails its first attempt (channel refused); a later approve/retry recovers. + const declined = input.bookingId === PROVIDER_DECLINE_BOOKING_ID && attempt === 1; + const status: RefundStatus = declined ? 'failed' : executedStatusFor(channel); + + const result: AdminRefundResult = { + refundId: nextAdminRefundId++, + bookingId: input.bookingId, + status, + refundChannel: channel, + // The client renders the server's decomposition verbatim; it never recomputes the legs. + amount: preview.amountIrr, + platformFeeRefundedIrr: preview.platformFeeRefundedIrr, + nursePayoutRefundedIrr: preview.nursePayoutRefundedIrr, + expectedCustomerRefundEta: status === 'failed' ? null : preview.expectedCustomerRefundEta, + // Post-payout: the nurse was already paid, so executing opens a pending clawback — never on a decline. + clawbackId: preview.willCreateClawback && !declined ? nextClawbackId++ : null, + }; + adminRefundsById[result.refundId] = result; + return result; + }, + + approveRefund: async (refundId) => { + await sleep(MOCK_LATENCY_MS); + const existing = adminRefundsById[refundId]; + if (!existing) throw new ApiError(404, 'Refund not found', 'not_found'); + // Approve/retry only applies to a refund awaiting execution or one the channel declined. + if (existing.status !== 'failed' && existing.status !== 'requested') { + throw new ApiError(409, 'Refund is not awaiting approval', 'not_approvable'); + } + const status = executedStatusFor(existing.refundChannel); + const updated: AdminRefundResult = { + ...existing, + status, + expectedCustomerRefundEta: + existing.refundChannel === 'bnpl_revert' ? businessDaysFromNow(ADMIN_BNPL_ETA_BUSINESS_DAYS) : null, + }; + adminRefundsById[refundId] = updated; + return updated; + }, + + rejectRefund: async (refundId, reason) => { + await sleep(MOCK_LATENCY_MS); + if (!reason?.trim()) throw new ApiError(400, 'A rejection reason is required', 'reason_required'); + const existing = adminRefundsById[refundId]; + if (!existing) throw new ApiError(404, 'Refund not found', 'not_found'); + if (isTerminalRefundStatus(existing.status)) { + throw new ApiError(409, 'Refund is already terminal', 'not_rejectable'); + } + adminRefundsById[refundId] = { ...existing, status: 'rejected' }; + }, }; diff --git a/client/src/services/refunds/constants.ts b/client/src/services/refunds/constants.ts index 5d2e3e3..e37a66c 100644 --- a/client/src/services/refunds/constants.ts +++ b/client/src/services/refunds/constants.ts @@ -50,6 +50,12 @@ export const MOCK_POLICY_TIERS: Record< customer_no_show: { refundFraction: 0, leadTimeLabel: 'started' }, }; +/** + * The admin decomposition preview depends on the booking's current payout/dispute state (a post-payout + * refund forks to a clawback), so keep it short-lived — the console must never initiate off a stale split. + */ +export const ADMIN_REFUND_PREVIEW_STALE_TIME = 10 * 1000; + /** * The BNPL customer cash-back window the mock projects onto `expectedCustomerRefundEta` — the product's * ~7–10 business-day truth, Fridays skipped (see `cancellation-and-payout.md`). Surface it honestly; diff --git a/client/src/services/refunds/hooks/useApproveRefund.ts b/client/src/services/refunds/hooks/useApproveRefund.ts new file mode 100644 index 0000000..8b53be5 --- /dev/null +++ b/client/src/services/refunds/hooks/useApproveRefund.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { refundsApi } from '../apis'; +import { invalidateAfterAdminRefund } from '../invalidations'; +import type { AdminRefundResult } from '../types'; + +/** + * Approve / retry a refund (the console's action on a channel-declined `failed` refund, or an + * approval-gated `requested` one). The result carries the booking id, so on success we invalidate that + * booking's refund + previews via the shared helper. Domain 4xx (`404` not found, `409` not awaiting + * approval) surface via `mutation.error`. + */ +export function useApproveRefund() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (refundId) => refundsApi.approveRefund(refundId), + onSuccess: (result) => invalidateAfterAdminRefund(queryClient, result.bookingId), + }); +} diff --git a/client/src/services/refunds/hooks/useInitiateRefund.ts b/client/src/services/refunds/hooks/useInitiateRefund.ts new file mode 100644 index 0000000..5bf1805 --- /dev/null +++ b/client/src/services/refunds/hooks/useInitiateRefund.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { refundsApi } from '../apis'; +import { invalidateAfterAdminRefund } from '../invalidations'; +import type { AdminRefundResult, InitiateRefundInput } from '../types'; + +/** + * Initiate (create + immediately execute) a ticket-linked admin refund. On success the customer-facing refund + * read, the booking, every admin preview, and the linked support ticket are invalidated so the console and the + * customer's status both reflect the outcome without a manual refetch. Domain 4xx (`400` invalid amount, `404` + * no captured payment, `409` over-refund, `400` channel refused) surface via `mutation.error` for the panel to + * render inline; the fetch layer already toasts 401/403/5xx, so this hook never double-toasts. A `failed` + * result (channel declined) is a **success** here (the request completed) — the panel offers the retry. + */ +export function useInitiateRefund() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input) => refundsApi.initiateRefund(input), + onSuccess: (_result, input) => invalidateAfterAdminRefund(queryClient, input.bookingId, input.ticketId), + }); +} diff --git a/client/src/services/refunds/hooks/useRefundPreview.ts b/client/src/services/refunds/hooks/useRefundPreview.ts new file mode 100644 index 0000000..1a44c50 --- /dev/null +++ b/client/src/services/refunds/hooks/useRefundPreview.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import { refundsApi } from '../apis'; +import { refundKeys } from '../keys'; +import { ADMIN_REFUND_PREVIEW_STALE_TIME } from '../constants'; + +/** + * The admin refund decomposition preview for a booking — the server's fee/payout split, applied percentage, + * channel, ETA, and the `willCreateClawback` warning — disclosed **before** the admin initiates. Keyed by the + * booking **and** the linking ticket (a refund is always previewed in a ticket's context). Short `staleTime` + * (the split forks on the booking's live payout state); enabled only when a booking id is present. The + * client renders the served numbers verbatim — it never recomputes the split. + */ +export function useRefundPreview(bookingId: number | null, ticketId: number | null = null) { + return useQuery({ + queryKey: refundKeys.adminPreview(bookingId ?? -1, ticketId), + queryFn: () => refundsApi.getRefundPreview(bookingId as number, ticketId), + enabled: bookingId != null && bookingId > 0, + staleTime: ADMIN_REFUND_PREVIEW_STALE_TIME, + }); +} diff --git a/client/src/services/refunds/hooks/useRejectRefund.ts b/client/src/services/refunds/hooks/useRejectRefund.ts new file mode 100644 index 0000000..dda2b10 --- /dev/null +++ b/client/src/services/refunds/hooks/useRejectRefund.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { refundsApi } from '../apis'; +import { invalidateAfterRefundRejection } from '../invalidations'; + +/** + * Reject a refund with a required reason (moves it to terminal `rejected`). Returns `void`, so on success we + * invalidate the by-refund read + the admin previews via the shared helper. Domain 4xx (`400` missing reason, + * `404` not found, `409` already terminal) surface via `mutation.error`. + */ +export function useRejectRefund() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ refundId, reason }) => refundsApi.rejectRefund(refundId, reason), + onSuccess: (_void, { refundId }) => invalidateAfterRefundRejection(queryClient, refundId), + }); +} diff --git a/client/src/services/refunds/index.ts b/client/src/services/refunds/index.ts index 2dbf35e..939bca5 100644 --- a/client/src/services/refunds/index.ts +++ b/client/src/services/refunds/index.ts @@ -5,3 +5,9 @@ export { useCancellationPolicyPreview } from './hooks/useCancellationPolicyPreview'; export { useCancelBooking } from './hooks/useCancelBooking'; export { useRefundStatus } from './hooks/useRefundStatus'; + +// Admin refund tooling (b11 admin_refunds; ticket-linked). +export { useRefundPreview } from './hooks/useRefundPreview'; +export { useInitiateRefund } from './hooks/useInitiateRefund'; +export { useApproveRefund } from './hooks/useApproveRefund'; +export { useRejectRefund } from './hooks/useRejectRefund'; diff --git a/client/src/services/refunds/invalidations.ts b/client/src/services/refunds/invalidations.ts index 9b3b4cf..5b2c50c 100644 --- a/client/src/services/refunds/invalidations.ts +++ b/client/src/services/refunds/invalidations.ts @@ -1,5 +1,6 @@ import type { QueryClient } from '@tanstack/react-query'; import { bookingKeys } from '@/services/bookings/keys'; +import { ticketKeys } from '@/services/tickets/keys'; import { refundKeys } from './keys'; import type { RefundSummary } from './types'; @@ -20,3 +21,34 @@ export function invalidateAfterCancellation( queryClient.invalidateQueries({ queryKey: bookingKeys.lists() }); queryClient.invalidateQueries({ queryKey: refundKeys.policyPreview(bookingId) }); } + +/** + * After an admin **initiates** a refund (or approves a failed one): the booking's refund now exists / moved, + * so refresh the customer-facing refund read (`byBooking`), the booking detail + lists (status/refund + * changed), every admin decomposition preview (the booking is no longer previewable the same way), and the + * linked support ticket if one was passed (the console posts the outcome onto the ticket). Never a blanket + * refetch. Called from `useInitiateRefund` / `useApproveRefund`. + */ +export function invalidateAfterAdminRefund( + queryClient: QueryClient, + bookingId: number, + ticketId?: number | null, +): void { + queryClient.invalidateQueries({ queryKey: refundKeys.byBooking(bookingId) }); + queryClient.invalidateQueries({ queryKey: refundKeys.details() }); + queryClient.invalidateQueries({ queryKey: refundKeys.adminPreviews() }); + queryClient.invalidateQueries({ queryKey: bookingKeys.bookingDetail(bookingId) }); + queryClient.invalidateQueries({ queryKey: bookingKeys.lists() }); + if (ticketId != null) { + queryClient.invalidateQueries({ queryKey: ticketKeys.detail(ticketId) }); + } +} + +/** + * After an admin **rejects** a refund. Reject returns no booking id, so we refresh the by-refund read and + * the admin previews (the refund is now terminal `rejected`); the worklist re-reads it on next fetch. + */ +export function invalidateAfterRefundRejection(queryClient: QueryClient, refundId: number): void { + queryClient.invalidateQueries({ queryKey: refundKeys.detail(refundId) }); + queryClient.invalidateQueries({ queryKey: refundKeys.adminPreviews() }); +} diff --git a/client/src/services/refunds/keys.ts b/client/src/services/refunds/keys.ts index 78f39b0..117a99c 100644 --- a/client/src/services/refunds/keys.ts +++ b/client/src/services/refunds/keys.ts @@ -15,4 +15,11 @@ export const refundKeys = { details: () => [...refundKeys.all, 'detail'] as const, detail: (refundId: number) => [...refundKeys.details(), refundId] as const, + + // Admin refund tooling: the decomposition preview is keyed by the booking **and** the linking ticket + // (a refund is always initiated in a ticket's context), so re-previewing under a different ticket caches + // independently. + adminPreviews: () => [...refundKeys.all, 'admin_preview'] as const, + adminPreview: (bookingId: number, ticketId: number | null) => + [...refundKeys.adminPreviews(), bookingId, ticketId] as const, }; diff --git a/client/src/services/refunds/types.ts b/client/src/services/refunds/types.ts index f3ffcbb..afe92a1 100644 --- a/client/src/services/refunds/types.ts +++ b/client/src/services/refunds/types.ts @@ -182,6 +182,64 @@ export function isBookingCancellable(status: BookingStatus): boolean { return status === 'confirmed' || status === 'in_progress'; } +/* ---------------------------------------------------------------------------------------------------- + * Admin refund tooling (b11 `POST`/`GET api/v1/admin_refunds`). The console half of the story: an admin + * previews the fee-leg decomposition, then initiates (create + immediately execute) a **ticket-linked** + * refund — every initiate carries a `ticketId`. The server computes the split; the client only renders it. + * Money stays an IRR digit-string; the client NEVER recomputes the percentage or the fee/payout legs — + * `platformFeeRefundedIrr + nursePayoutRefundedIrr === amount` is a server invariant, rendered as served. + * -------------------------------------------------------------------------------------------------- */ + +/** + * The server's refund decomposition preview for a booking (admin console, before initiate). Reconciles by + * construction: `platformFeeRefundedIrr + nursePayoutRefundedIrr === amountIrr`. `willCreateClawback` warns + * that the nurse was already paid (a post-payout refund opens a `pending` clawback + support alert); the + * `bnpl_revert` channel carries the ~7–10-business-day `expectedCustomerRefundEta` (a `YYYY-MM-DD` date). + */ +export interface RefundPreview { + bookingId: number; + refundPercentageApplied: number; + amountIrr: string; + platformFeeRefundedIrr: string; + nursePayoutRefundedIrr: string; + refundChannel: RefundChannel; // 'psp_card' | 'bnpl_revert' | 'manual' + expectedCustomerRefundEta: string | null; + willCreateClawback: boolean; + cancellationPolicyCode: string | null; +} + +/** + * `POST api/v1/admin_refunds` input. Supply **either** `refundPercentage` (0–1) **or** neither (the server + * falls back to the booking's b9 cancellation-snapshot percentage). `ticketId` links the refund to its + * support ticket — nullable only until `refund_ticket_required` config is enforced (b15). + */ +export interface InitiateRefundInput { + bookingId: number; + ticketId: number | null; + refundPercentage?: number; + refundChannel?: RefundChannel; + reasonCategory?: string; + reasonNotes?: string; +} + +/** + * The `POST api/v1/admin_refunds` result — the refund was created **and executed**. A card refund returns + * `succeeded` immediately (no ETA); a BNPL/manual refund returns `processing` with an + * `expectedCustomerRefundEta`; a post-payout refund sets `clawbackId`. `status` reuses the domain's + * `RefundStatus` (a channel refusal returns `failed`, retryable via approve). + */ +export interface AdminRefundResult { + refundId: number; + bookingId: number; + status: RefundStatus; + refundChannel: RefundChannel; + amount: string; + platformFeeRefundedIrr: string; + nursePayoutRefundedIrr: string; + expectedCustomerRefundEta: string | null; + clawbackId: number | null; +} + /** * The refunds API seam — the real HTTP client and the in-memory mock both implement this interface; * selection is by config (`USE_REFUNDS_MOCK`), never scattered `if (mock)` checks. @@ -192,4 +250,11 @@ export interface RefundsApi { /** `null` when the booking has no refund (e.g. not cancelled) — a clean empty state, not an error. */ getRefundByBooking(bookingId: number): Promise; getRefund(refundId: number): Promise; + + /* --- Admin refund tooling (b11 admin_refunds; every initiate is ticket-linked). --- */ + /** The server's fee-leg decomposition preview for a booking (`ticketId` = the linking ticket, or null). */ + getRefundPreview(bookingId: number, ticketId: number | null): Promise; + initiateRefund(input: InitiateRefundInput): Promise; + approveRefund(refundId: number): Promise; + rejectRefund(refundId: number, reason: string): Promise; } diff --git a/client/src/services/reviews/apis/clientApi.ts b/client/src/services/reviews/apis/clientApi.ts index ef0e872..05e1d0e 100644 --- a/client/src/services/reviews/apis/clientApi.ts +++ b/client/src/services/reviews/apis/clientApi.ts @@ -4,6 +4,10 @@ import type { PageParams } from '@/lib/api/types'; import { REVIEWS_PAGE_SIZE } from '../constants'; import type { CreateReviewRequest, + ModerateReviewResult, + ModerationAction, + ModerationQueueFilters, + ModerationQueueItem, MyReviewState, NurseReviews, ReviewEligibility, @@ -20,6 +24,12 @@ interface NurseReviewsWire { reviews: Paginated; } +/** + * Wire `ModerationQueueItemDto`. Per the b14 contract it does **not** carry `tagCodes` (REQ-037 — the admin + * card can't show the review's tags); the client defaults it to `[]` on map. + */ +type ModerationQueueItemWire = Omit; + /** * Real HTTP implementation of the `ReviewsApi` seam (b14 contract `dev/contracts/domains/reviews-records.md`, * swagger `dev/contracts/openapi/swagger.v1.json`). Two of the four methods map **published** b14 routes: @@ -63,4 +73,30 @@ export const reviewsClientApi: ReviewsApi = { body: JSON.stringify({ rating: body.rating, body: body.body ?? null, tagCodes: body.tagCodes ?? [] }), }), ), + + listModerationQueue: async (filters, params): Promise> => { + const query = new URLSearchParams(); + query.set('status', filters.status ?? 'pending_moderation'); + query.set('page', String(params.page ?? 1)); + query.set('pageSize', String(params.pageSize ?? REVIEWS_PAGE_SIZE)); + const wire = unwrap( + await clientFetch>>( + `${API}/admin/reviews/moderation_queue?${query.toString()}`, + ), + ); + // REQ-037: the wire dto omits tagCodes — default to [] so the admin card renders without them. + return { ...wire, items: wire.items.map((item) => ({ ...item, tagCodes: [] })) }; + }, + + moderateReview: async ( + reviewId: number, + action: ModerationAction, + reason?: string, + ): Promise => + unwrap( + await clientFetch>(`${API}/reviews/${reviewId}/status`, { + method: 'PATCH', + body: JSON.stringify({ action, reason: reason ?? null }), + }), + ), }; diff --git a/client/src/services/reviews/apis/mockApi.ts b/client/src/services/reviews/apis/mockApi.ts index 7908837..0c63b8d 100644 --- a/client/src/services/reviews/apis/mockApi.ts +++ b/client/src/services/reviews/apis/mockApi.ts @@ -5,6 +5,10 @@ import { mockGetBookingForReview } from '@/services/bookings/apis/mockApi'; import { MIN_RATING_FOR_SUPPORT_ALERT } from '../constants'; import type { CreateReviewRequest, + ModerateReviewResult, + ModerationAction, + ModerationQueueFilters, + ModerationQueueItem, ModerationStatus, MyReviewState, NurseReviews, @@ -28,9 +32,13 @@ import type { * - **Submission tracking** — `createReview` records the customer's review as `pending_moderation` (it does * **not** enter any public list), so eligibility flips to `already_reviewed` and `getMyReviewForBooking` * returns the persistent "under review" state. - * - **`__mockPublishSubmittedReview(bookingId)`** — dev-only stand-in for the deferred (f15) admin - * moderation queue, so a human can watch a submitted review move to `published` and appear on the nurse - * profile (the aggregate + count updating on the next fetch). Never wired into a customer/nurse screen. + * - **Admin moderation queue** — `listModerationQueue`/`moderateReview` back the admin worklist: a seeded set + * of `pending_moderation` rows (incl. low-rating reviews with a linked `lowRatingAlertId`) filtered by + * status, transitioned statefully in place. A transition returns a **plausible recomputed** nurse aggregate + * (the numbers are server-authoritative — the client renders, never computes them). + * - **`__mockPublishSubmittedReview(bookingId)`** — dev-only stand-in that moves a *customer-submitted* review + * to `published` and onto the nurse profile so a human can watch it appear (aggregate + count updating on the + * next fetch). Distinct from the seeded moderation queue above; never wired into a customer/nurse screen. * * Booking/nurse ids align with the f8 bookings seeds (nurse 1; completed booking 5005) so a submit from a * completed booking deep-links correctly. @@ -82,6 +90,30 @@ const PUBLISHED: Record = { const submissions = new Map(); +// ── Admin moderation queue (the f13 admin worklist reads this) ──────────────────────────────────────────── +/** + * A seeded moderation worklist: mostly `pending_moderation` rows (the default view) with a couple already + * transitioned so a status filter has something to show. It **includes low-rating reviews** (rating 1/2 with a + * linked `lowRatingAlertId`) alongside normal ones. Rows are mutated in place by `moderateReview`, so once a + * row leaves `pending_moderation` it drops out of the pending view (filtered by status) and appears under its + * new status — statefully, for the session. + */ +const MODERATION_QUEUE: ModerationQueueItem[] = [ + { id: 9301, bookingId: 5011, nurseProfileId: 1, customerProfileId: 7001, rating: 2, body: 'کمی دیر رسید و ارتباط ضعیفی داشت.', tagCodes: ['communicative'], moderationStatus: 'pending_moderation', moderationReason: null, lowRatingAlertId: 4501, createdAt: isoDaysAgo(1) }, + { id: 9302, bookingId: 5012, nurseProfileId: 2, customerProfileId: 7002, rating: 5, body: 'مراقبت عالی و حرفه‌ای؛ کاملاً راضی بودیم.', tagCodes: ['professional', 'kind'], moderationStatus: 'pending_moderation', moderationReason: null, lowRatingAlertId: null, createdAt: isoDaysAgo(1) }, + { id: 9303, bookingId: 5013, nurseProfileId: 1, customerProfileId: 7003, rating: 1, body: 'اصلاً سر وقت نیامد.', tagCodes: [], moderationStatus: 'pending_moderation', moderationReason: null, lowRatingAlertId: 4502, createdAt: isoDaysAgo(2) }, + { id: 9304, bookingId: 5014, nurseProfileId: 3, customerProfileId: 7004, rating: 4, body: 'تمیز و منظم بود.', tagCodes: ['clean', 'punctual'], moderationStatus: 'pending_moderation', moderationReason: null, lowRatingAlertId: null, createdAt: isoDaysAgo(3) }, + { id: 9305, bookingId: 5015, nurseProfileId: 4, customerProfileId: 7005, rating: 5, body: null, tagCodes: ['professional'], moderationStatus: 'published', moderationReason: null, lowRatingAlertId: null, createdAt: isoDaysAgo(6) }, +]; + +/** action → resulting moderation status (the human decision; always overrides the AI pre-screen). */ +const ACTION_TO_STATUS: Record = { + publish: 'published', + hide: 'hidden', + reject: 'rejected', + unpublish: 'pending_moderation', +}; + /** 2-dp average over a nurse's currently-published reviews (server-recomputed; mirrored here). */ function aggregateFor(nurseProfileId: number): { averageRating: number; publishedCount: number } { const list = PUBLISHED[nurseProfileId] ?? []; @@ -102,6 +134,24 @@ function isReviewableStatus(status: string): boolean { return status === 'completed' || status === 'closed'; } +/** + * A **plausible recomputed-looking** nurse aggregate returned after a transition. The real server recomputes + * `averageRating`/`totalReviews` from source in the same transaction — the numbers are server-authoritative; the + * mock just returns believable values. A `publish` folds the moderated review's rating into the nurse's + * published rollup; the other actions leave the published set unchanged here. + */ +function recomputedAggregateFor( + nurseProfileId: number, + action: ModerationAction, + rating: number, +): { averageRating: number; totalReviews: number } { + const base = aggregateFor(nurseProfileId); + const totalReviews = base.publishedCount + (action === 'publish' ? 1 : 0); + if (totalReviews === 0) return { averageRating: 0, totalReviews: 0 }; + const foldedSum = base.averageRating * base.publishedCount + (action === 'publish' ? rating : 0); + return { averageRating: Math.round((foldedSum / totalReviews) * 100) / 100, totalReviews }; +} + export const reviewsMockApi: ReviewsApi = { getNurseReviews: async (nurseProfileId: number, params: PageParams): Promise => { await sleep(MOCK_LATENCY_MS); @@ -164,6 +214,40 @@ export const reviewsMockApi: ReviewsApi = { lowRatingAlertRaised: body.rating <= MIN_RATING_FOR_SUPPORT_ALERT, }; }, + + listModerationQueue: async ( + filters: ModerationQueueFilters, + params: PageParams, + ): Promise> => { + await sleep(MOCK_LATENCY_MS); + const status = filters.status ?? 'pending_moderation'; + const list = MODERATION_QUEUE.filter((q) => q.moderationStatus === status).sort( + (a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt), + ); + return paginate(list, params); + }, + + moderateReview: async ( + reviewId: number, + action: ModerationAction, + reason?: string, + ): Promise => { + await sleep(MOCK_LATENCY_MS); + // hide/reject demand a reason (contract) — surface a 400 so the caller's onError keeps the dialog open. + if ((action === 'hide' || action === 'reject') && !reason?.trim()) { + throw new ApiError(400, 'A reason is required to hide or reject a review', 'reason_required'); + } + const item = MODERATION_QUEUE.find((q) => q.id === reviewId); + if (!item) throw new ApiError(404, 'Review not found', 'review_not_found'); + + const nextStatus = ACTION_TO_STATUS[action]; + // Mutate in place: once it leaves pending_moderation it drops out of the pending view (filtered by status). + item.moderationStatus = nextStatus; + item.moderationReason = action === 'hide' || action === 'reject' ? reason?.trim() ?? null : null; + + const agg = recomputedAggregateFor(item.nurseProfileId, action, item.rating); + return { id: reviewId, moderationStatus: nextStatus, averageRating: agg.averageRating, totalReviews: agg.totalReviews }; + }, }; /** diff --git a/client/src/services/reviews/constants.ts b/client/src/services/reviews/constants.ts index e2516e5..1cead9a 100644 --- a/client/src/services/reviews/constants.ts +++ b/client/src/services/reviews/constants.ts @@ -25,6 +25,13 @@ export const NURSE_REVIEWS_STALE_TIME = 2 * 60 * 1000; export const REVIEW_ELIGIBILITY_STALE_TIME = 30 * 1000; export const REVIEWS_GC_TIME = 10 * 60 * 1000; +/** + * The admin moderation worklist is an actively-worked queue — a short `staleTime` keeps it fresh as items are + * moderated by this (or another) admin, while still serving from cache during quick filter/page switches. A + * moderation mutation invalidates it immediately, so this only governs background freshness. + */ +export const MODERATION_QUEUE_STALE_TIME = 20 * 1000; + /** * The low-rating support-alert threshold (server config, default ≤ 2). The mock echoes it on * `SubmitReviewResult.lowRatingAlertRaised`; the **UI never surfaces it** — it exists only so the mock's diff --git a/client/src/services/reviews/hooks/useModerateReview.ts b/client/src/services/reviews/hooks/useModerateReview.ts new file mode 100644 index 0000000..2c0d288 --- /dev/null +++ b/client/src/services/reviews/hooks/useModerateReview.ts @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { reviewsApi } from '../apis'; +import { reviewKeys } from '../keys'; +import type { ModerateReviewResult, ModerationAction } from '../types'; + +interface ModerateReviewVars { + reviewId: number; + action: ModerationAction; + /** Required for `hide`/`reject` (≤ 500); a missing reason is a domain `400` surfaced to `onError`. */ + reason?: string; +} + +/** + * Transition a review (admin/moderator). On success we invalidate the whole moderation queue subtree (the row + * has moved out of / into a status view) **and** every nurse public list: the server recomputed the affected + * nurse's `averageRating`/`totalReviews` from source, but that nurse's id isn't reachable from the mutation + * vars or the result, so we drop all nurse lists rather than compute anything client-side (the client **never** + * computes the aggregate). Domain 4xx (a missing `reason` on hide/reject) surface to the caller's `onError`, so + * the moderation dialog keeps its draft. + */ +export function useModerateReview() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ reviewId, action, reason }) => reviewsApi.moderateReview(reviewId, action, reason), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: reviewKeys.moderationQueues() }); + queryClient.invalidateQueries({ queryKey: reviewKeys.nurseLists() }); + }, + }); +} diff --git a/client/src/services/reviews/hooks/useModerationQueue.ts b/client/src/services/reviews/hooks/useModerationQueue.ts new file mode 100644 index 0000000..048af54 --- /dev/null +++ b/client/src/services/reviews/hooks/useModerationQueue.ts @@ -0,0 +1,22 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { reviewsApi } from '../apis'; +import { reviewKeys } from '../keys'; +import { MODERATION_QUEUE_STALE_TIME, REVIEWS_GC_TIME, REVIEWS_PAGE_SIZE } from '../constants'; +import type { ModerationQueueFilters } from '../types'; + +/** + * The admin moderation worklist — reviews awaiting a decision, filtered by `status` (default + * `pending_moderation`) + paginated. **Admin-scoped**: these rows carry moderation internals (`moderationReason`, + * `lowRatingAlertId`) and are never rendered on a customer/nurse surface. Filters + page key the cache so + * switching a status tab or paging never refetches a page already held; `keepPreviousData` avoids an empty flash + * while the next page/tab loads. A `useModerateReview` mutation invalidates this queue on success. + */ +export function useModerationQueue(filters: ModerationQueueFilters, page = 1) { + return useQuery({ + queryKey: reviewKeys.moderationQueue(filters, { page, pageSize: REVIEWS_PAGE_SIZE }), + queryFn: () => reviewsApi.listModerationQueue(filters, { page, pageSize: REVIEWS_PAGE_SIZE }), + staleTime: MODERATION_QUEUE_STALE_TIME, + gcTime: REVIEWS_GC_TIME, + placeholderData: keepPreviousData, + }); +} diff --git a/client/src/services/reviews/index.ts b/client/src/services/reviews/index.ts index 4aaf9be..0173af0 100644 --- a/client/src/services/reviews/index.ts +++ b/client/src/services/reviews/index.ts @@ -6,3 +6,5 @@ export { useNurseReviews } from './hooks/useNurseReviews'; export { useReviewEligibility } from './hooks/useReviewEligibility'; export { useMyReviewForBooking } from './hooks/useMyReviewForBooking'; export { useCreateReview } from './hooks/useCreateReview'; +export { useModerationQueue } from './hooks/useModerationQueue'; +export { useModerateReview } from './hooks/useModerateReview'; diff --git a/client/src/services/reviews/keys.ts b/client/src/services/reviews/keys.ts index b610cee..e154293 100644 --- a/client/src/services/reviews/keys.ts +++ b/client/src/services/reviews/keys.ts @@ -1,3 +1,6 @@ +import type { PageParams } from '@/lib/api/types'; +import type { ModerationQueueFilters } from './types'; + /** * React Query key factory for the reviews domain (hierarchical, per the `services/{domain}` pattern). * @@ -5,6 +8,8 @@ * cache; `eligibility` and `myReviewForBooking` key per booking so the leave-a-review CTA reads its own state * independently. A `createReview` mutation invalidates **only** `eligibility` + `myReviewForBooking` for the * booking — the public list/aggregate is **never** touched (the new review is `pending_moderation`, not public). + * A moderation transition (admin) invalidates `moderationQueues()` **and** `nurseLists()` (the affected nurse's + * public list — its id isn't reachable from the mutation, so all nurse lists are dropped). */ export const reviewKeys = { all: ['reviews'] as const, @@ -15,4 +20,9 @@ export const reviewKeys = { eligibility: (bookingId: number) => [...reviewKeys.all, 'eligibility', bookingId] as const, myReviewForBooking: (bookingId: number) => [...reviewKeys.all, 'my_review', bookingId] as const, + + moderationQueues: () => [...reviewKeys.all, 'moderation_queue'] as const, + /** The admin worklist. Filters + page key the cache so switching a status filter or paging never refetches. */ + moderationQueue: (filters: ModerationQueueFilters, params: PageParams) => + [...reviewKeys.moderationQueues(), filters, params] as const, }; diff --git a/client/src/services/reviews/types.ts b/client/src/services/reviews/types.ts index 7b197ec..f5a1c98 100644 --- a/client/src/services/reviews/types.ts +++ b/client/src/services/reviews/types.ts @@ -103,6 +103,51 @@ export interface MyReviewState { createdAt: string | null; } +// ── Admin moderation queue (b14 admin routes; admin/moderator-only) ───────────────────────────────────────── + +/** + * Moderation **action** (the `PATCH .../status` body). `hide`/`reject` require a non-empty `reason`; + * `unpublish` returns a `published` review to `pending_moderation`. The human decision always overrides the AI. + */ +export type ModerationAction = 'publish' | 'hide' | 'reject' | 'unpublish'; + +/** + * A row in the admin moderation worklist (`ModerationQueueItemDto`). Unlike the public `ReviewListItem` this + * **does** carry moderation internals — the linked `lowRatingAlertId` (id only; support alerts stay internal), + * the `moderationReason`, and the customer/nurse profile ids the moderator needs. Never rendered on a user + * surface. Note: the wire dto does **not** carry `tagCodes` (REQ-037) — the client defaults it to `[]`. + */ +export interface ModerationQueueItem { + id: number; + bookingId: number; + nurseProfileId: number; + customerProfileId: number; + rating: number; + body: string | null; + tagCodes: string[]; + moderationStatus: ModerationStatus; + moderationReason: string | null; + lowRatingAlertId: number | null; + createdAt: string; +} + +/** Moderation-queue filter. `status` defaults to `pending_moderation` (the worklist) when omitted. */ +export interface ModerationQueueFilters { + status?: ModerationStatus; +} + +/** + * `ModerateReviewResult` — the outcome of a transition plus the **server-recomputed-from-source** nurse + * aggregate (`averageRating`/`totalReviews`). The client never computes these; it renders them and invalidates + * the affected nurse's public list so the new average/count show up. + */ +export interface ModerateReviewResult { + id: number; + moderationStatus: ModerationStatus; + averageRating: number; + totalReviews: number; +} + /** * The reviews API seam — the real HTTP client and the in-memory mock both implement this; selection is by * config (`USE_REVIEWS_MOCK`), never scattered `if (mock)` checks. @@ -116,4 +161,8 @@ export interface ReviewsApi { getMyReviewForBooking(bookingId: number): Promise; /** Submit the one review for a completed booking (`409` if already reviewed). */ createReview(bookingId: number, body: CreateReviewRequest): Promise; + /** Admin — the moderation worklist, filtered by status + paginated. */ + listModerationQueue(filters: ModerationQueueFilters, params: PageParams): Promise>; + /** Admin — transition a review; returns the recomputed nurse aggregate. `hide`/`reject` need a `reason`. */ + moderateReview(reviewId: number, action: ModerationAction, reason?: string): Promise; } diff --git a/client/src/services/tickets/apis/clientApi.ts b/client/src/services/tickets/apis/clientApi.ts index a749a68..182d407 100644 --- a/client/src/services/tickets/apis/clientApi.ts +++ b/client/src/services/tickets/apis/clientApi.ts @@ -1,9 +1,14 @@ import { clientFetch } from '@/lib/api/client'; -import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; +import { unwrap, type ApiEnvelope, type PageParams, type Paginated } from '@/lib/api/types'; import { TICKETS_PAGE_SIZE } from '../constants'; import type { + AdminTicketDetail, + AdminTicketFilters, + AdminTicketMessage, + AdminTicketSummary, OpenTicketRequest, OpenTicketResult, + PostAdminMessageRequest, PostMessageRequest, PostMessageResult, TicketAuthorRole, @@ -100,6 +105,56 @@ function mapThread(w: TicketThreadWire, viewerUserId?: number): TicketDetail { }; } +/** Admin summary shares the wire shape of the user summary (no `unread`/`lastMessageAt`). */ +function mapAdminSummary(w: TicketSummaryWire): AdminTicketSummary { + return { + id: w.id, + referenceCode: w.referenceCode, + subject: w.subject, + status: w.status as AdminTicketSummary['status'], + category: w.category as AdminTicketSummary['category'], + bookingId: w.bookingId, + refundId: w.refundId, + createdAt: w.createdAt, + }; +} + +/** + * Admin thread mapper. Unlike `mapThread`, it **keeps internal messages** and carries `isInternal` through — + * this IS the admin view, whose whole purpose is that staff see internal notes (contract §"Critical rules"). + */ +function mapAdminThread(w: TicketThreadWire, viewerUserId?: number): AdminTicketDetail { + const roleBySender = new Map( + w.participants.map((p) => [p.userId, (p.roleOnTicket ?? 'system') as TicketAuthorRole]), + ); + const messages: AdminTicketMessage[] = w.messages.map((m) => ({ + id: m.id, + ticketId: w.id, + body: m.body, + authorRole: roleBySender.get(m.senderId) ?? 'system', + createdAt: m.sentAt, + isMine: viewerUserId != null && m.senderId === viewerUserId, + isInternal: m.isInternal, + sendStatus: 'sent' as const, + })); + return { + id: w.id, + referenceCode: w.referenceCode, + subject: w.subject, + status: w.status as AdminTicketDetail['status'], + category: w.category as AdminTicketDetail['category'], + bookingId: w.bookingId, + refundId: w.refundId, + openedById: w.openedById, + closedAt: w.closedAt, + participants: w.participants.map((p) => ({ + userId: p.userId, + roleOnTicket: (p.roleOnTicket ?? 'system') as TicketAuthorRole, + })), + messages, + }; +} + /** * Real HTTP implementation of the `TicketsApi` seam (b15 contract). All four methods map published routes: * - `listMyTickets` → `GET /tickets` (own, paginated; `Status`/`ReferenceCode`/`Page`/`PageSize`). @@ -150,4 +205,40 @@ export const ticketsClientApi: TicketsApi = { body: JSON.stringify({ body: body.body }), }), ), + + // ── Admin lens (b15). Global queue + admin thread (internal INCLUDED) + staff message post. ── + listAdminTickets: async ( + filters: AdminTicketFilters, + params: PageParams, + ): Promise> => { + const query = new URLSearchParams(); + if (filters.status) query.set('Status', filters.status); + if (filters.category) query.set('Category', filters.category); + if (filters.referenceCode) query.set('ReferenceCode', filters.referenceCode); + if (filters.bookingId != null) query.set('BookingId', String(filters.bookingId)); + if (filters.refundId != null) query.set('RefundId', String(filters.refundId)); + query.set('Page', String(params.page ?? 1)); + query.set('PageSize', String(params.pageSize ?? TICKETS_PAGE_SIZE)); + const wire = unwrap( + await clientFetch>>( + `${API}/admin/tickets?${query.toString()}`, + ), + ); + return { ...wire, items: wire.items.map(mapAdminSummary) }; + }, + + getAdminTicket: async (ticketId: number, viewerUserId?: number): Promise => { + // The admin view keeps internal notes — do NOT filter them here; that is the point of this endpoint. + const wire = unwrap(await clientFetch>(`${API}/admin/tickets/${ticketId}`)); + return mapAdminThread(wire, viewerUserId); + }, + + // Staff post — may set `isInternal` (the one caller allowed to). `clientMessageId` stays client-only. + postAdminMessage: async (ticketId: number, body: PostAdminMessageRequest): Promise => + unwrap( + await clientFetch>(`${API}/tickets/${ticketId}/messages`, { + method: 'POST', + body: JSON.stringify({ body: body.body, isInternal: body.isInternal }), + }), + ), }; diff --git a/client/src/services/tickets/apis/mockApi.ts b/client/src/services/tickets/apis/mockApi.ts index 148b0b2..58cf2d4 100644 --- a/client/src/services/tickets/apis/mockApi.ts +++ b/client/src/services/tickets/apis/mockApi.ts @@ -1,10 +1,15 @@ import { sleep } from '@/utils'; import { ApiError } from '@/lib/api/errors'; -import type { Paginated } from '@/lib/api/types'; +import type { PageParams, Paginated } from '@/lib/api/types'; import { MOCK_SEND_FAIL_SENTINEL, MOCK_VIEWER_USER_ID } from '../constants'; import type { + AdminTicketDetail, + AdminTicketFilters, + AdminTicketMessage, + AdminTicketSummary, OpenTicketRequest, OpenTicketResult, + PostAdminMessageRequest, PostMessageRequest, PostMessageResult, TicketAuthorRole, @@ -93,6 +98,12 @@ let nextMessageId = 50_000; */ let lastViewerUserId = CUSTOMER; +/** + * The viewer of the most recent `getAdminTicket` — tracked SEPARATELY from `lastViewerUserId` so an admin + * reading a thread never re-attributes a user's `postMessage`. `postAdminMessage` appends as this id. + */ +let lastAdminViewerUserId = ADMIN; + const tickets: StoredTicket[] = [ { id: 1201, @@ -206,6 +217,52 @@ function toDetail(t: StoredTicket, viewerUserId: number): TicketDetail { }; } +/** Admin summary — the queue row (no unread/last-activity; that's a user-inbox concern). */ +function toAdminSummary(t: StoredTicket): AdminTicketSummary { + return { + id: t.id, + referenceCode: t.referenceCode, + subject: t.subject, + status: t.status, + category: t.category, + bookingId: t.bookingId, + refundId: t.refundId, + createdAt: t.messages[0]?.sentAt ?? new Date().toISOString(), + }; +} + +/** + * Admin detail — the INVERSE of `toDetail`: it KEEPS internal messages and carries `isInternal`, so the + * admin thread shows the internal-styled bubble the user view drops (the no-leak test is demonstrable both + * ways against the same store). + */ +function toAdminDetail(t: StoredTicket, viewerUserId: number): AdminTicketDetail { + const roleBySender = new Map(t.participants.map((p) => [p.userId, p.roleOnTicket])); + const messages: AdminTicketMessage[] = t.messages.map((m) => ({ + id: m.id, + ticketId: t.id, + body: m.body, + authorRole: roleBySender.get(m.senderId) ?? 'system', + createdAt: m.sentAt, + isMine: m.senderId === viewerUserId, + isInternal: m.internal, + sendStatus: 'sent' as const, + })); + return { + id: t.id, + referenceCode: t.referenceCode, + subject: t.subject, + status: t.status, + category: t.category, + bookingId: t.bookingId, + refundId: t.refundId, + openedById: t.openedById, + closedAt: t.closedAt, + participants: t.participants, + messages, + }; +} + /** `TKT-XXXXXXXX` — a stable, unique-looking reference (base36 of the id, not a real random code). */ function makeReferenceCode(id: number): string { return `TKT-${(id * 2_654_435_761 % 0xffffffff).toString(36).toUpperCase().padStart(8, '0').slice(-8)}`; @@ -288,4 +345,56 @@ export const ticketsMockApi: TicketsApi = { t.messages.push({ id, senderId: lastViewerUserId, body: body.body, internal: false, sentAt }); return { messageId: id, ticketId, sentAt }; }, + + // ── Admin lens ────────────────────────────────────────────────────────────────────────────────── + // The global queue over EVERY ticket (own-scoping is a user-view rule), filterable the way the b15 + // admin console filters it. + listAdminTickets: async ( + filters: AdminTicketFilters, + params: PageParams, + ): Promise> => { + await sleep(MOCK_LATENCY_MS); + let all = [...tickets]; + if (filters.status) all = all.filter((t) => t.status === filters.status); + if (filters.category) all = all.filter((t) => t.category === filters.category); + if (filters.referenceCode) { + const needle = filters.referenceCode.trim().toLowerCase(); + all = all.filter((t) => t.referenceCode.toLowerCase().includes(needle)); + } + if (filters.bookingId != null) all = all.filter((t) => t.bookingId === filters.bookingId); + if (filters.refundId != null) all = all.filter((t) => t.refundId === filters.refundId); + all.sort((a, b) => Date.parse(lastMessageAt(b)) - Date.parse(lastMessageAt(a))); + const page = Math.max(1, params.page ?? 1); + const pageSize = Math.max(1, params.pageSize ?? all.length); + const start = (page - 1) * pageSize; + return { + items: all.slice(start, start + pageSize).map(toAdminSummary), + total: all.length, + page, + pageSize, + }; + }, + + // The admin thread — the FULL store INCLUDING the internal note (`isInternal: true`), so the admin + // no-leak-inverse is demonstrable. Opening it does NOT clear a user's unread indicator. + getAdminTicket: async (ticketId: number, viewerUserId?: number): Promise => { + await sleep(MOCK_LATENCY_MS); + const t = findTicket(ticketId); + lastAdminViewerUserId = viewerUserId ?? ADMIN; + return toAdminDetail(t, lastAdminViewerUserId); + }, + + // Staff post — appends with the given `internal` flag (the one caller allowed to). A staff caller may + // also post to a closed ticket (contract: only NON-staff get a 403 there), so no closed-guard here. + postAdminMessage: async (ticketId: number, body: PostAdminMessageRequest): Promise => { + await sleep(MOCK_LATENCY_MS); + const t = findTicket(ticketId); + if (body.body.trim() === MOCK_SEND_FAIL_SENTINEL) { + throw new ApiError(500, 'Simulated send failure', 'mock_send_failed'); + } + const id = nextMessageId++; + const sentAt = new Date().toISOString(); + t.messages.push({ id, senderId: lastAdminViewerUserId, body: body.body, internal: body.isInternal, sentAt }); + return { messageId: id, ticketId, sentAt }; + }, }; diff --git a/client/src/services/tickets/constants.ts b/client/src/services/tickets/constants.ts index e6e9718..ad72556 100644 --- a/client/src/services/tickets/constants.ts +++ b/client/src/services/tickets/constants.ts @@ -26,6 +26,9 @@ export const TICKETS_LIST_STALE_TIME = 30 * 1000; export const TICKET_THREAD_STALE_TIME = 15 * 1000; export const TICKETS_GC_TIME = 5 * 60 * 1000; +/** The admin global queue is a live worklist — a short stale window keeps it fresh without hammering. */ +export const ADMIN_TICKETS_LIST_STALE_TIME = 20 * 1000; + /** * DEV-ONLY trigger for the optimistic-send **failure** path (phase §7 step 2): posting this exact message * body makes the mock throw a `500` so a human can watch the bubble roll back, the draft stay in the diff --git a/client/src/services/tickets/hooks/useAdminTicket.ts b/client/src/services/tickets/hooks/useAdminTicket.ts new file mode 100644 index 0000000..0b6dac2 --- /dev/null +++ b/client/src/services/tickets/hooks/useAdminTicket.ts @@ -0,0 +1,23 @@ +import { useQuery } from '@tanstack/react-query'; +import { ticketsApi } from '../apis'; +import { ticketKeys } from '../keys'; +import { TICKETS_GC_TIME, TICKET_THREAD_STALE_TIME } from '../constants'; +import { useTicketViewer } from './useTicketViewer'; + +/** + * The full admin ticket thread (b15 `GET /admin/tickets/{id}`) — header + participants + messages, + * **internal notes INCLUDED**. A single cached `adminDetail(id)` entry (the contract returns the whole + * thread in one call). The viewer id drives which bubbles are "mine"; `useTicketViewer` yields the admin + * "me" under the admin console (real path uses the authenticated id, mock falls back to the admin id), and + * the mock defaults to an admin viewer if none is passed. `usePostAdminMessage` mutates this same entry. + */ +export function useAdminTicket(ticketId: number | null) { + const { userId } = useTicketViewer(); + return useQuery({ + queryKey: ticketKeys.adminDetail(ticketId ?? -1), + queryFn: () => ticketsApi.getAdminTicket(ticketId as number, userId), + enabled: ticketId != null && ticketId > 0, + staleTime: TICKET_THREAD_STALE_TIME, + gcTime: TICKETS_GC_TIME, + }); +} diff --git a/client/src/services/tickets/hooks/useAdminTicketThread.ts b/client/src/services/tickets/hooks/useAdminTicketThread.ts new file mode 100644 index 0000000..8ee960a --- /dev/null +++ b/client/src/services/tickets/hooks/useAdminTicketThread.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; +import { ticketsApi } from '../apis'; +import { ticketKeys } from '../keys'; +import { TICKETS_GC_TIME, TICKET_THREAD_STALE_TIME } from '../constants'; +import type { AdminTicketMessage } from '../types'; +import { useTicketViewer } from './useTicketViewer'; + +/** + * Just the messages of an admin thread — a `select` over the same `adminDetail(id)` cache the admin header + * reads (mirrors `useTicketThread`). One network fetch feeds both; the message list re-renders on a new + * message without re-rendering the thread header. Because it is the admin view, the returned messages CARRY + * `isInternal` (internal-styled bubbles render here — they never do in the user thread). Optimistic admin + * sends mutate `adminDetail(id)`, so the list updates instantly. + */ +export function useAdminTicketThread(ticketId: number | null) { + const { userId } = useTicketViewer(); + return useQuery({ + queryKey: ticketKeys.adminDetail(ticketId ?? -1), + queryFn: () => ticketsApi.getAdminTicket(ticketId as number, userId), + enabled: ticketId != null && ticketId > 0, + staleTime: TICKET_THREAD_STALE_TIME, + gcTime: TICKETS_GC_TIME, + select: (detail): AdminTicketMessage[] => detail.messages, + }); +} diff --git a/client/src/services/tickets/hooks/useAdminTickets.ts b/client/src/services/tickets/hooks/useAdminTickets.ts new file mode 100644 index 0000000..4e9faf5 --- /dev/null +++ b/client/src/services/tickets/hooks/useAdminTickets.ts @@ -0,0 +1,24 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import type { PageParams } from '@/lib/api/types'; +import { ticketsApi } from '../apis'; +import { ticketKeys } from '../keys'; +import { ADMIN_TICKETS_LIST_STALE_TIME, TICKETS_GC_TIME, TICKETS_PAGE_SIZE } from '../constants'; +import type { AdminTicketFilters } from '../types'; + +/** + * The admin global ticket queue (b15 `GET /admin/tickets`) — EVERY ticket, not one viewer's, filterable by + * status/category/referenceCode/bookingId/refundId. The **filter object + page key the cache**, so each + * filter/page combination caches independently and revisiting serves from cache; `keepPreviousData` avoids a + * flash while a filter changes. Posting an admin message invalidates `adminLists()`, so new activity shows + * without a manual refresh. + */ +export function useAdminTickets(filters: AdminTicketFilters = {}, page = 1) { + const params: PageParams = { page, pageSize: TICKETS_PAGE_SIZE }; + return useQuery({ + queryKey: ticketKeys.adminList(filters, params), + queryFn: () => ticketsApi.listAdminTickets(filters, params), + placeholderData: keepPreviousData, + staleTime: ADMIN_TICKETS_LIST_STALE_TIME, + gcTime: TICKETS_GC_TIME, + }); +} diff --git a/client/src/services/tickets/hooks/usePostAdminMessage.ts b/client/src/services/tickets/hooks/usePostAdminMessage.ts new file mode 100644 index 0000000..6c18c1f --- /dev/null +++ b/client/src/services/tickets/hooks/usePostAdminMessage.ts @@ -0,0 +1,81 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { ticketKeys } from '../keys'; +import { ticketsApi } from '../apis'; +import type { AdminTicketDetail, AdminTicketMessage, PostMessageResult } from '../types'; +import { useTicketViewer } from './useTicketViewer'; + +interface PostAdminMessageVars { + body: string; + /** Staff-only: a `true` posts an internal note (never reaches the user view). */ + isInternal: boolean; + /** Client-generated id; the reconcile key so the optimistic bubble is never double-rendered (§3.5). */ + clientMessageId: string; +} + +interface PostAdminMessageContext { + previous?: AdminTicketDetail; +} + +/** + * The admin optimistic message send — the same pattern as `usePostMessage`, but over the `adminDetail(id)` + * cache and carrying `isInternal` so a pending internal note shows its internal styling immediately. + * + * `onMutate` appends a **pending** `AdminTicketMessage` (with its `isInternal`) after `cancelQueries` + a + * snapshot. `onError` rolls the thread back (composer keeps the draft + offers retry). `onSuccess` reconciles + * the pending bubble **by `clientMessageId`** with the server message (never double-rendered). `onSettled` + * invalidates the admin thread + the admin queues (a new note moves the queue's activity). + */ +export function usePostAdminMessage(ticketId: number) { + const queryClient = useQueryClient(); + const { role } = useTicketViewer(); + + return useMutation({ + mutationFn: ({ body, isInternal, clientMessageId }) => + ticketsApi.postAdminMessage(ticketId, { body, isInternal, clientMessageId }), + + onMutate: async ({ body, isInternal, clientMessageId }) => { + const key = ticketKeys.adminDetail(ticketId); + await queryClient.cancelQueries({ queryKey: key }); + const previous = queryClient.getQueryData(key); + if (previous) { + const pending: AdminTicketMessage = { + id: null, + clientMessageId, + ticketId, + body, + authorRole: role, + createdAt: new Date().toISOString(), + isMine: true, + isInternal, + sendStatus: 'sending', + }; + queryClient.setQueryData(key, { ...previous, messages: [...previous.messages, pending] }); + } + return { previous }; + }, + + onError: (_err, _vars, context) => { + if (context?.previous) queryClient.setQueryData(ticketKeys.adminDetail(ticketId), context.previous); + }, + + onSuccess: (result, { clientMessageId }) => { + const key = ticketKeys.adminDetail(ticketId); + const current = queryClient.getQueryData(key); + if (current) { + queryClient.setQueryData(key, { + ...current, + messages: current.messages.map((m) => + m.clientMessageId === clientMessageId + ? { ...m, id: result.messageId, createdAt: result.sentAt, sendStatus: 'sent' } + : m, + ), + }); + } + }, + + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ticketKeys.adminDetail(ticketId) }); + queryClient.invalidateQueries({ queryKey: ticketKeys.adminLists() }); + }, + }); +} diff --git a/client/src/services/tickets/index.ts b/client/src/services/tickets/index.ts index 3efd553..de1c652 100644 --- a/client/src/services/tickets/index.ts +++ b/client/src/services/tickets/index.ts @@ -7,3 +7,9 @@ export { useTicket } from './hooks/useTicket'; export { useTicketThread } from './hooks/useTicketThread'; export { useOpenTicket } from './hooks/useOpenTicket'; export { usePostMessage } from './hooks/usePostMessage'; + +// Admin ticket lens (b15) — the global queue + admin thread (internal INCLUDED) + staff internal-note post. +export { useAdminTickets } from './hooks/useAdminTickets'; +export { useAdminTicket } from './hooks/useAdminTicket'; +export { useAdminTicketThread } from './hooks/useAdminTicketThread'; +export { usePostAdminMessage } from './hooks/usePostAdminMessage'; diff --git a/client/src/services/tickets/keys.ts b/client/src/services/tickets/keys.ts index 90534cc..dc1054b 100644 --- a/client/src/services/tickets/keys.ts +++ b/client/src/services/tickets/keys.ts @@ -1,4 +1,5 @@ -import type { TicketListParams } from './types'; +import type { PageParams } from '@/lib/api/types'; +import type { AdminTicketFilters, TicketListParams } from './types'; /** * React Query key factory for the tickets domain (hierarchical, per the `services/{domain}` pattern). @@ -18,4 +19,13 @@ export const ticketKeys = { details: () => [...ticketKeys.all, 'detail'] as const, detail: (ticketId: number) => [...ticketKeys.details(), ticketId] as const, + + // Admin lens — a separate subtree so the internal-carrying admin caches never collide with the user + // caches above (and invalidating one never touches the other). Filters + page key the global queue. + adminLists: () => [...ticketKeys.all, 'admin', 'list'] as const, + adminList: (filters: AdminTicketFilters, params: PageParams) => + [...ticketKeys.adminLists(), filters, params] as const, + + adminDetails: () => [...ticketKeys.all, 'admin', 'detail'] as const, + adminDetail: (ticketId: number) => [...ticketKeys.adminDetails(), ticketId] as const, }; diff --git a/client/src/services/tickets/types.ts b/client/src/services/tickets/types.ts index 3c82e51..eff63de 100644 --- a/client/src/services/tickets/types.ts +++ b/client/src/services/tickets/types.ts @@ -141,6 +141,62 @@ export interface PostMessageResult { sentAt: string; } +/* ── Admin ticket lens (b15 `GET /admin/tickets…`) ─────────────────────────────────────────────────── + * The **admin** surface is a DELIBERATELY SEPARATE model from the user types above. The admin view + * INCLUDES internal notes (`GET /admin/tickets/{id}`), so its message/detail shapes carry `isInternal` — + * a flag the user types (`TicketMessage`/`TicketDetail`) must NEVER gain (an internal note can't bleed + * into the user app; contract "Critical rules" + phase §5). Keeping the two surfaces distinct is the + * enforcement: there is no `isInternal` on the user side to accidentally read/render. + */ + +/** Admin thread message — INCLUDES the internal-note flag (user types never do). */ +export interface AdminTicketMessage { + id: number | null; + clientMessageId?: string; + ticketId: number; + body: string; + authorRole: TicketAuthorRole; + createdAt: string; + isMine: boolean; + isInternal: boolean; + sendStatus: MessageSendStatus; +} +export interface AdminTicketDetail { + id: number; + referenceCode: string; + subject: string | null; + status: TicketStatus; + category: TicketCategory; + bookingId: number | null; + refundId: number | null; + openedById: number; + closedAt: string | null; + participants: TicketParticipant[]; + messages: AdminTicketMessage[]; +} +export interface AdminTicketSummary { + id: number; + referenceCode: string; + subject: string | null; + status: TicketStatus; + category: TicketCategory; + bookingId: number | null; + refundId: number | null; + createdAt: string; +} +export interface AdminTicketFilters { + status?: TicketStatus; + category?: TicketCategory; + referenceCode?: string; + bookingId?: number; + refundId?: number; +} +export interface PostAdminMessageRequest { + body: string; + isInternal: boolean; + clientMessageId: string; +} + /** * The tickets API seam — the real HTTP client and the in-memory mock both implement this; selection is by * config (`USE_TICKETS_MOCK`), never scattered `if (mock)` checks. `getTicket` takes the viewer's user id @@ -157,4 +213,11 @@ export interface TicketsApi { */ openTicket(body: OpenTicketRequest, viewerUserId?: number): Promise; postMessage(ticketId: number, body: PostMessageRequest): Promise; + + /* Admin lens (b15). Distinct methods so the internal-carrying admin view can never be reached through a + * user-view call. `listAdminTickets` is the global queue (all tickets, filterable); `getAdminTicket` + * returns the thread WITH internal notes; `postAdminMessage` may set `isInternal` (staff only). */ + listAdminTickets(filters: AdminTicketFilters, params: PageParams): Promise>; + getAdminTicket(ticketId: number, viewerUserId?: number): Promise; + postAdminMessage(ticketId: number, body: PostAdminMessageRequest): Promise; } diff --git a/client/src/services/verification/apis/clientApi.ts b/client/src/services/verification/apis/clientApi.ts index 9fefd4e..fb19fbf 100644 --- a/client/src/services/verification/apis/clientApi.ts +++ b/client/src/services/verification/apis/clientApi.ts @@ -1,20 +1,112 @@ import { clientFetch } from '@/lib/api/client'; import { ApiError } from '@/lib/api/errors'; -import { unwrap, type ApiEnvelope } from '@/lib/api/types'; +import { unwrap, type ApiEnvelope, type Paginated, type PageParams } from '@/lib/api/types'; +import { ADMIN_QUEUE_PAGE_SIZE } from '../constants'; import type { + AdminVerificationCase, + AdminVerificationQueueFilters, + AdminVerificationQueueItem, + AdminVerificationStepDetail, CredentialDetailsInput, + DecideStepInput, + DecideStepResult, DocumentConfirmedResult, IdentityKycInput, + NurseCredential, RunStepResult, + SignedDocumentUrl, TrustBadge, UploadUrlResult, + VerificationAggregateStatus, VerificationApi, VerificationDocument, VerificationStatus, + VerificationStepStatus, } from '../types'; const BASE = '/api/v1/nurse_verification'; const NURSES_BASE = '/api/v1/nurses'; +const ADMIN_BASE = '/api/v1/admin_verifications'; + +/** `AdminPendingStepDto` — one **row per step** awaiting attention (not per nurse); documents carry signed GET URLs. */ +interface AdminPendingStepWire { + nurseVerificationId: number; + nurseId: number; + nurseName: string; + stepId: number; + stepCode: string; + stepDisplayName: string; + status: VerificationStepStatus; + submittedAt: string | null; + documents: VerificationDocument[]; +} + +/** `AdminStepDetailDto` — note the id lives under `stepId` on the wire (mapped to `id`). */ +interface AdminStepDetailWire { + stepId: number; + code: string; + displayName: string; + status: VerificationStepStatus; + isAutomated: boolean; + expiresAt: string | null; + failureReason: string | null; + documents: VerificationDocument[]; +} + +/** `AdminVerificationDetailDto`. */ +interface AdminVerificationDetailWire { + nurseVerificationId: number; + nurseId: number; + identityName: string; + status: VerificationAggregateStatus; + steps: AdminStepDetailWire[]; + credentials: NurseCredential[]; +} + +/** + * REQ-034: the queue DTO is **per step**, so we fold rows to one item per nurse for the queue UI. This is + * lossy — the per-step page carries no whole-nurse aggregate (`stepsPassed`/`stepsTotal`/expiry), and a + * nurse's steps can straddle page boundaries — which is why a nurse-level queue endpoint is filed. We map + * what the row gives (nurse identity, the step as `nextPendingStepCode`, `submittedAt`) and leave the + * unavailable aggregate fields at neutral defaults. + */ +function foldQueueRows(rows: AdminPendingStepWire[]): AdminVerificationQueueItem[] { + const byNurse = new Map(); + for (const row of rows) { + const existing = byNurse.get(row.nurseVerificationId); + if (existing) { + if (row.submittedAt && (existing.submittedAt == null || row.submittedAt < existing.submittedAt)) { + existing.submittedAt = row.submittedAt; + } + continue; + } + byNurse.set(row.nurseVerificationId, { + nurseVerificationId: row.nurseVerificationId, + nurseId: row.nurseId, + nurseName: row.nurseName, + status: 'in_review', + stepsPassed: 0, + stepsTotal: 0, + nextPendingStepCode: row.stepCode, + submittedAt: row.submittedAt, + hasExpiringCredential: false, + }); + } + return Array.from(byNurse.values()); +} + +function toStepDetail(wire: AdminStepDetailWire): AdminVerificationStepDetail { + return { + id: wire.stepId, + code: wire.code, + displayName: wire.displayName, + status: wire.status, + isAutomated: wire.isAutomated, + expiresAt: wire.expiresAt, + failureReason: wire.failureReason, + documents: wire.documents, + }; +} /** * Computes the browser-side integrity hash the confirm endpoint records against the uploaded bytes @@ -120,4 +212,62 @@ export const verificationClientApi: VerificationApi = { getTrustBadge: async (nurseId) => unwrap(await clientFetch>(`${NURSES_BASE}/${nurseId}/trust_badge`)), + + listVerificationQueue: async ( + filters: AdminVerificationQueueFilters, + params: PageParams, + ): Promise> => { + const query = new URLSearchParams(); + if (filters.status) query.set('status', filters.status); + query.set('page', String(params.page ?? 1)); + query.set('page_size', String(params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE)); + const page = unwrap( + await clientFetch>>(`${ADMIN_BASE}?${query.toString()}`), + ); + // REQ-034: `total`/`page`/`pageSize` stay the wire (per-step) values until a nurse-level queue endpoint + // exists — folding to one item per nurse (see foldQueueRows) makes the count nominal, not exact. + return { items: foldQueueRows(page.items), total: page.total, page: page.page, pageSize: page.pageSize }; + }, + + getVerificationCase: async (nurseVerificationId: number): Promise => { + const wire = unwrap( + await clientFetch>(`${ADMIN_BASE}/${nurseVerificationId}`), + ); + return { + nurseVerificationId: wire.nurseVerificationId, + nurseId: wire.nurseId, + identityName: wire.identityName, + status: wire.status, + steps: wire.steps.map(toStepDetail), + credentials: wire.credentials, + }; + }, + + // REQ-034: b6 has no per-document signed-URL route (documents already carry a short-lived signed `url` on + // the case detail). This targets a proposed `GET admin_verifications/documents/{documentId}/url` for an + // on-demand re-sign; until it ships, callers can re-fetch the case to get a fresh document `url`. + getDocumentSignedUrl: async (documentId: number): Promise => + unwrap(await clientFetch>(`${ADMIN_BASE}/documents/${documentId}/url`)), + + decideStep: async (stepId: number, input: DecideStepInput): Promise => + unwrap( + await clientFetch>(`${ADMIN_BASE}/steps/${stepId}/decide`, { + method: 'POST', + body: JSON.stringify(input), + }), + ), + + // REQ-034: b6 has no whole-verification approve/reject route — approval emerges from the final step + // `decide` re-aggregating `is_verified`. These target proposed `POST admin_verifications/{id}/approve` and + // `/reject` for an explicit admin action (until they ship, approve by deciding the last pending step). + approveVerification: async (nurseVerificationId: number): Promise => { + await clientFetch>(`${ADMIN_BASE}/${nurseVerificationId}/approve`, { method: 'POST' }); + }, + + rejectVerification: async (nurseVerificationId: number, reason: string): Promise => { + await clientFetch>(`${ADMIN_BASE}/${nurseVerificationId}/reject`, { + method: 'POST', + body: JSON.stringify({ reason }), + }); + }, }; diff --git a/client/src/services/verification/apis/mockApi.ts b/client/src/services/verification/apis/mockApi.ts index 493a441..10e27d8 100644 --- a/client/src/services/verification/apis/mockApi.ts +++ b/client/src/services/verification/apis/mockApi.ts @@ -1,7 +1,12 @@ import { sleep } from '@/utils'; import { ApiError } from '@/lib/api/errors'; import type { + AdminVerificationCase, + AdminVerificationQueueItem, + AdminVerificationStepDetail, + CredentialType, IdentityKycInput, + NurseCredential, StepTypeCode, TrustBadge, VerificationApi, @@ -10,7 +15,7 @@ import type { VerificationStep, VerificationStepStatus, } from '../types'; -import { NATIONAL_ID_LENGTH } from '../constants'; +import { ADMIN_QUEUE_PAGE_SIZE, NATIONAL_ID_LENGTH } from '../constants'; const MOCK_LATENCY_MS = 300; @@ -73,6 +78,194 @@ function seedSteps(): void { })); } +/* --- Admin review queue fixtures + state --------------------------------------------------------- + * A small in-memory review desk: three nurses spanning `pending` / `in_review`, each a full case with + * ordered steps (automated steps `passed`, a manual credential-bearing step `in_review` with a document), + * one nurse carrying an expiring credential. Decisions mutate this state so a human can watch a case move + * through decide → approve/reject and drop off the queue. Timestamps are relative to module-load `Date.now()`. + */ + +const CREDENTIAL_BEARING: ReadonlySet = new Set([ + 'moh_competency_license', + 'ino_membership', + 'criminal_record', +]); + +/** Internal record: the admin case + the queue-only metadata (`nurseName`, `submittedAt`, expiry flag). */ +interface AdminCaseRecord extends AdminVerificationCase { + nurseName: string; + submittedAt: string | null; + hasExpiringCredential: boolean; +} + +let nextAdminStepId = 1; +let nextCredentialId = 8001; + +const adminNowMs = Date.now(); +const daysAgo = (n: number): string => new Date(adminNowMs - n * 24 * 60 * 60 * 1000).toISOString(); +const daysFromNow = (n: number): string => new Date(adminNowMs + n * 24 * 60 * 60 * 1000).toISOString(); + +function mkStep( + code: StepTypeCode, + status: VerificationStepStatus, + isAutomated: boolean, + extra: Partial> = {}, +): AdminVerificationStepDetail { + return { + id: nextAdminStepId++, + code, + displayName: code, + status, + isAutomated, + expiresAt: extra.expiresAt ?? null, + failureReason: extra.failureReason ?? null, + documents: extra.documents ?? [], + }; +} + +function mkDoc(id: number, originalFileName: string): VerificationDocument { + return { + id, + contentType: 'application/pdf', + fileSizeBytes: 482_000, + originalFileName, + // A short-lived signed GET URL; the on-demand `getDocumentSignedUrl` re-signs it fresh each open. + url: `https://mock.balinyaar.local/docs/${id}`, + }; +} + +function mkCredential( + credentialType: CredentialType, + holderNameSnapshot: string, + issuingAuthority: string, + opts: { issuedAt?: string | null; expiresAt?: string | null } = {}, +): NurseCredential { + return { + id: nextCredentialId++, + credentialType, + holderNameSnapshot, + issuingAuthority, + issuedAt: opts.issuedAt ?? null, + expiresAt: opts.expiresAt ?? null, + verificationMethod: 'manual', + }; +} + +const adminCases: AdminCaseRecord[] = [ + { + nurseVerificationId: 501, + nurseId: 101, + identityName: 'مریم رضایی', + nurseName: 'مریم رضایی', + status: 'in_review', + submittedAt: daysAgo(2), + hasExpiringCredential: false, + steps: [ + mkStep('identity_kyc', 'passed', true), + mkStep('shahkar_match', 'passed', true), + mkStep('moh_competency_license', 'in_review', false, { documents: [mkDoc(9001, 'moh-license.pdf')] }), + mkStep('ino_membership', 'pending', false), + mkStep('criminal_record', 'pending', false), + mkStep('bank_account_verification', 'passed', true), + ], + credentials: [], + }, + { + nurseVerificationId: 502, + nurseId: 102, + identityName: 'زهرا محمدی', + nurseName: 'زهرا محمدی', + status: 'pending', + submittedAt: daysAgo(5), + hasExpiringCredential: true, + steps: [ + mkStep('identity_kyc', 'passed', true), + mkStep('shahkar_match', 'passed', true), + mkStep('moh_competency_license', 'pending', false), + mkStep('ino_membership', 'pending', false), + mkStep('criminal_record', 'passed', false, { expiresAt: daysFromNow(18) }), + mkStep('bank_account_verification', 'passed', true), + ], + // A recorded criminal-record credential lapsing soon — drives the `hasExpiringCredential` queue chip. + credentials: [mkCredential('criminal_record', 'زهرا محمدی', 'ناجا', { issuedAt: daysAgo(347), expiresAt: daysFromNow(18) })], + }, + { + nurseVerificationId: 503, + nurseId: 103, + identityName: 'علی کریمی', + nurseName: 'علی کریمی', + status: 'in_review', + submittedAt: daysAgo(1), + hasExpiringCredential: false, + steps: [ + mkStep('identity_kyc', 'passed', true), + mkStep('shahkar_match', 'passed', true), + mkStep('moh_competency_license', 'passed', false), + mkStep('ino_membership', 'in_review', false, { documents: [mkDoc(9002, 'ino-membership.pdf')] }), + mkStep('criminal_record', 'passed', false, { expiresAt: daysFromNow(300) }), + mkStep('bank_account_verification', 'passed', true), + ], + credentials: [ + mkCredential('moh_competency_license', 'علی کریمی', 'وزارت بهداشت', { issuedAt: daysAgo(120) }), + mkCredential('criminal_record', 'علی کریمی', 'ناجا', { issuedAt: daysAgo(65), expiresAt: daysFromNow(300) }), + ], + }, +]; + +function findCaseById(nurseVerificationId: number): AdminCaseRecord | undefined { + return adminCases.find((record) => record.nurseVerificationId === nurseVerificationId); +} + +function findCaseByStepId(stepId: number): { record: AdminCaseRecord; step: AdminVerificationStepDetail } | undefined { + for (const record of adminCases) { + const step = record.steps.find((candidate) => candidate.id === stepId); + if (step) return { record, step }; + } + return undefined; +} + +/** The next step needing an admin's eyes: an `in_review` (uploaded, awaiting decision) step first, else a `pending` one. */ +function nextPendingCode(steps: AdminVerificationStepDetail[]): string | null { + return ( + steps.find((step) => step.status === 'in_review')?.code ?? + steps.find((step) => step.status === 'pending')?.code ?? + null + ); +} + +/** Re-aggregate the case status exactly as the server would after a step decision. */ +function reaggregateCase(record: AdminCaseRecord): void { + const allPassed = record.steps.every((step) => step.status === 'passed'); + const anyInReview = record.steps.some((step) => step.status === 'in_review'); + record.status = allPassed ? 'approved' : anyInReview ? 'in_review' : 'pending'; +} + +function toQueueItem(record: AdminCaseRecord): AdminVerificationQueueItem { + return { + nurseVerificationId: record.nurseVerificationId, + nurseId: record.nurseId, + nurseName: record.nurseName, + status: record.status, + stepsPassed: record.steps.filter((step) => step.status === 'passed').length, + stepsTotal: record.steps.length, + nextPendingStepCode: nextPendingCode(record.steps), + submittedAt: record.submittedAt, + hasExpiringCredential: record.hasExpiringCredential, + }; +} + +/** Return the admin-case view (drops the queue-only metadata; deep-copies so callers can't mutate the store). */ +function toCaseView(record: AdminCaseRecord): AdminVerificationCase { + return { + nurseVerificationId: record.nurseVerificationId, + nurseId: record.nurseId, + identityName: record.identityName, + status: record.status, + steps: record.steps.map((step) => ({ ...step, documents: step.documents.map((doc) => ({ ...doc })) })), + credentials: record.credentials.map((credential) => ({ ...credential })), + }; +} + /** * In-memory mock behind the VerificationApi seam. Drives the whole nurse journey end-to-end — the * automated runs (identity/shahkar/bank), the manual document uploads (→ in_review), the structured @@ -170,6 +363,88 @@ export const verificationMockApi: VerificationApi = { credentialTypes: agg.status === 'approved' ? ['moh_competency_license', 'ino_membership'] : [], } satisfies TrustBadge; }, + + listVerificationQueue: async (filters, params) => { + await sleep(MOCK_LATENCY_MS); + // Default (no status filter) shows the whole desk — both `pending` and `in_review`. + const wanted: ReadonlyArray = filters.status ? [filters.status] : ['pending', 'in_review']; + const matched = adminCases.filter((record) => wanted.includes(record.status)).map(toQueueItem); + const page = params.page ?? 1; + const pageSize = params.pageSize ?? ADMIN_QUEUE_PAGE_SIZE; + const start = (page - 1) * pageSize; + return { items: matched.slice(start, start + pageSize), total: matched.length, page, pageSize }; + }, + + getVerificationCase: async (nurseVerificationId) => { + await sleep(MOCK_LATENCY_MS); + const record = findCaseById(nurseVerificationId); + if (!record) throw new ApiError(404, 'Verification not found', 'not_found'); + return toCaseView(record); + }, + + getDocumentSignedUrl: async (documentId) => { + await sleep(MOCK_LATENCY_MS); + // Sentinel for the viewer's error/re-request path: this document can never be signed. + if (documentId === 9999) { + throw new ApiError(404, 'Document not found', 'document_not_found'); + } + // A FRESH short-lived URL each call — the signature + timestamp differ so it is never re-used from cache. + const sig = Math.random().toString(36).slice(2, 12); + return { + url: `https://mock.balinyaar.local/docs/${documentId}?sig=${sig}&t=${Date.now()}`, + expiresInSeconds: 60, + }; + }, + + decideStep: async (stepId, input) => { + await sleep(MOCK_LATENCY_MS); + const found = findCaseByStepId(stepId); + if (!found) throw new ApiError(404, 'Step not found', 'not_found'); + const { record, step } = found; + if (input.approve) { + step.status = 'passed'; + step.failureReason = null; + let credentialId: number | null = null; + // On approving a credential-bearing step with a credential number, record the (encrypted-at-rest, + // never re-serialized) credential — mirroring the server's `nurse_credentials` write. + if (CREDENTIAL_BEARING.has(step.code) && input.credentialNumber) { + const credential = mkCredential( + step.code as CredentialType, + input.holderName ?? record.identityName, + input.issuingAuthority ?? '', + { issuedAt: input.issuedAt ?? null, expiresAt: input.expiresAt ?? null }, + ); + record.credentials.push(credential); + credentialId = credential.id; + } + reaggregateCase(record); + return { stepId, stepStatus: step.status, credentialId }; + } + const reason = input.rejectionReason?.trim(); + if (!reason) throw new ApiError(400, 'Rejection reason is required', 'rejection_reason_required'); + step.status = 'failed'; + step.failureReason = reason; + reaggregateCase(record); + return { stepId, stepStatus: step.status, credentialId: null }; + }, + + approveVerification: async (nurseVerificationId) => { + await sleep(MOCK_LATENCY_MS); + const record = findCaseById(nurseVerificationId); + if (!record) throw new ApiError(404, 'Verification not found', 'not_found'); + record.steps = record.steps.map((step) => ({ ...step, status: 'passed', failureReason: null })); + // Aggregate → `approved`, which drops it out of the queue's `pending`/`in_review` filter. + record.status = 'approved'; + }, + + rejectVerification: async (nurseVerificationId, reason) => { + await sleep(MOCK_LATENCY_MS); + const record = findCaseById(nurseVerificationId); + if (!record) throw new ApiError(404, 'Verification not found', 'not_found'); + if (reason.trim().length === 0) throw new ApiError(400, 'Rejection reason is required', 'rejection_reason_required'); + // Aggregate → `rejected`, dropping it out of the queue. + record.status = 'rejected'; + }, }; /** diff --git a/client/src/services/verification/constants.ts b/client/src/services/verification/constants.ts index 20c3bee..e288208 100644 --- a/client/src/services/verification/constants.ts +++ b/client/src/services/verification/constants.ts @@ -25,3 +25,21 @@ export const MAX_DOCUMENT_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB /** The national-ID is a 10-digit code with an official checksum — validated before the KYC run. */ export const NATIONAL_ID_LENGTH = 10; + +/** + * Admin review queue — moderately fresh; a decision invalidates it. `keepPreviousData` + a per-page key + * make paging flicker-free, so a short `staleTime` is enough. + */ +export const ADMIN_QUEUE_STALE_TIME = 20_000; +export const ADMIN_QUEUE_PAGE_SIZE = 20; + +/** A single admin case — same freshness as the queue; invalidated on every decide / approve / reject. */ +export const ADMIN_CASE_STALE_TIME = 20_000; + +/** + * A document's **signed GET URL is short-lived** (server issues ~60 s URLs). Fetch it on demand and keep it + * out of long-term cache: a short `staleTime` re-fetches a fresh URL on reopen; a short `gcTime` drops the + * stale URL soon after the viewer closes (never retry — a failed/expired sign is surfaced, not re-hammered). + */ +export const SIGNED_DOCUMENT_URL_STALE_TIME = 30_000; +export const SIGNED_DOCUMENT_URL_GC_TIME = 60_000; diff --git a/client/src/services/verification/hooks/useApproveVerification.ts b/client/src/services/verification/hooks/useApproveVerification.ts new file mode 100644 index 0000000..131d4a1 --- /dev/null +++ b/client/src/services/verification/hooks/useApproveVerification.ts @@ -0,0 +1,18 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { verificationApi } from '../apis'; +import { verificationKeys } from '../keys'; + +/** + * Approve the whole verification (all required steps pass → aggregate `approved`), removing it from the + * queue. Takes the `nurseVerificationId`; invalidates the queue and that case so both reflect the flip. + */ +export function useApproveVerification() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (nurseVerificationId) => verificationApi.approveVerification(nurseVerificationId), + onSuccess: (_void, nurseVerificationId) => { + queryClient.invalidateQueries({ queryKey: verificationKeys.adminQueues() }); + queryClient.invalidateQueries({ queryKey: verificationKeys.adminCase(nurseVerificationId) }); + }, + }); +} diff --git a/client/src/services/verification/hooks/useDecideStep.ts b/client/src/services/verification/hooks/useDecideStep.ts new file mode 100644 index 0000000..b9218a4 --- /dev/null +++ b/client/src/services/verification/hooks/useDecideStep.ts @@ -0,0 +1,29 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { verificationApi } from '../apis'; +import { verificationKeys } from '../keys'; +import type { DecideStepInput, DecideStepResult } from '../types'; + +export interface DecideStepVars { + stepId: number; + /** The case this step belongs to — used to invalidate exactly that case on success. */ + nurseVerificationId: number; + input: DecideStepInput; +} + +/** + * Approve or reject a manual step. On approving a credential-bearing step the (encrypted) credential is + * recorded server-side and its `credentialId` comes back; a reject requires `input.rejectionReason`. On + * success we invalidate the case (its steps re-render) and the queue (the aggregate/counts may have moved, + * or the case may have dropped off). A domain 4xx (missing reason, holder-name mismatch) surfaces to the + * caller's `onError`. + */ +export function useDecideStep() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ stepId, input }) => verificationApi.decideStep(stepId, input), + onSuccess: (_result, { nurseVerificationId }) => { + queryClient.invalidateQueries({ queryKey: verificationKeys.adminCase(nurseVerificationId) }); + queryClient.invalidateQueries({ queryKey: verificationKeys.adminQueues() }); + }, + }); +} diff --git a/client/src/services/verification/hooks/useRejectVerification.ts b/client/src/services/verification/hooks/useRejectVerification.ts new file mode 100644 index 0000000..1ef348d --- /dev/null +++ b/client/src/services/verification/hooks/useRejectVerification.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { verificationApi } from '../apis'; +import { verificationKeys } from '../keys'; + +export interface RejectVerificationVars { + nurseVerificationId: number; + reason: string; +} + +/** + * Reject the whole verification (aggregate `rejected`), removing it from the queue. Takes the + * `nurseVerificationId` + a `reason`; invalidates the queue and that case so both reflect the change. + */ +export function useRejectVerification() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ nurseVerificationId, reason }) => verificationApi.rejectVerification(nurseVerificationId, reason), + onSuccess: (_void, { nurseVerificationId }) => { + queryClient.invalidateQueries({ queryKey: verificationKeys.adminQueues() }); + queryClient.invalidateQueries({ queryKey: verificationKeys.adminCase(nurseVerificationId) }); + }, + }); +} diff --git a/client/src/services/verification/hooks/useVerificationCase.ts b/client/src/services/verification/hooks/useVerificationCase.ts new file mode 100644 index 0000000..d244b68 --- /dev/null +++ b/client/src/services/verification/hooks/useVerificationCase.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query'; +import { verificationApi } from '../apis'; +import { verificationKeys } from '../keys'; +import { ADMIN_CASE_STALE_TIME } from '../constants'; + +/** + * The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check. + * Keyed per `nurseVerificationId` and only enabled once one is selected (pass `null` from the queue until a + * row is opened). Invalidated on every decide / approve / reject so the case reflects the new step states. + */ +export function useVerificationCase(nurseVerificationId: number | null) { + return useQuery({ + queryKey: verificationKeys.adminCase(nurseVerificationId ?? -1), + queryFn: () => verificationApi.getVerificationCase(nurseVerificationId as number), + enabled: nurseVerificationId != null, + staleTime: ADMIN_CASE_STALE_TIME, + }); +} diff --git a/client/src/services/verification/hooks/useVerificationDocumentUrl.ts b/client/src/services/verification/hooks/useVerificationDocumentUrl.ts new file mode 100644 index 0000000..f0d0a9e --- /dev/null +++ b/client/src/services/verification/hooks/useVerificationDocumentUrl.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { verificationApi } from '../apis'; +import { verificationKeys } from '../keys'; +import { SIGNED_DOCUMENT_URL_GC_TIME, SIGNED_DOCUMENT_URL_STALE_TIME } from '../constants'; + +/** + * A document's **short-lived signed GET URL**, fetched on demand when the viewer opens a document (pass + * `null` while none is open). Short `staleTime` + short `gcTime` keep the URL out of long-term cache — a + * reopen re-signs a fresh URL rather than reusing an expired one. `retry: false`: a failed/expired sign is + * surfaced to the viewer's error/re-request path, not silently re-hammered. + */ +export function useVerificationDocumentUrl(documentId: number | null) { + return useQuery({ + queryKey: verificationKeys.adminDocumentUrl(documentId ?? -1), + queryFn: () => verificationApi.getDocumentSignedUrl(documentId as number), + enabled: documentId != null, + staleTime: SIGNED_DOCUMENT_URL_STALE_TIME, + gcTime: SIGNED_DOCUMENT_URL_GC_TIME, + retry: false, + }); +} diff --git a/client/src/services/verification/hooks/useVerificationQueue.ts b/client/src/services/verification/hooks/useVerificationQueue.ts new file mode 100644 index 0000000..e1eaab4 --- /dev/null +++ b/client/src/services/verification/hooks/useVerificationQueue.ts @@ -0,0 +1,21 @@ +import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { verificationApi } from '../apis'; +import { verificationKeys } from '../keys'; +import { ADMIN_QUEUE_PAGE_SIZE, ADMIN_QUEUE_STALE_TIME } from '../constants'; +import type { AdminVerificationQueueFilters } from '../types'; + +/** + * The admin review queue (one item per nurse), filtered by `status` and paginated. `filters` + `params` + * are part of the query key, so switching the status filter or paging reuses cached pages; `keepPreviousData` + * avoids an empty flash while the next page loads. A decision (`useDecideStep` / approve / reject) invalidates + * the queue so the desk re-renders without a manual refetch. + */ +export function useVerificationQueue(filters: AdminVerificationQueueFilters, page: number) { + const params = { page, pageSize: ADMIN_QUEUE_PAGE_SIZE }; + return useQuery({ + queryKey: verificationKeys.adminQueue(filters, params), + queryFn: () => verificationApi.listVerificationQueue(filters, params), + staleTime: ADMIN_QUEUE_STALE_TIME, + placeholderData: keepPreviousData, + }); +} diff --git a/client/src/services/verification/index.ts b/client/src/services/verification/index.ts index 7ca29e3..742e500 100644 --- a/client/src/services/verification/index.ts +++ b/client/src/services/verification/index.ts @@ -5,3 +5,11 @@ export { useRunBankVerification } from './hooks/useRunBankVerification'; export { useUploadVerificationDocument } from './hooks/useUploadVerificationDocument'; export { useSubmitCredentials } from './hooks/useSubmitCredentials'; export { useNurseTrustBadge } from './hooks/useNurseTrustBadge'; + +// Admin review queue (b6 AdminVerificationsController) +export { useVerificationQueue } from './hooks/useVerificationQueue'; +export { useVerificationCase } from './hooks/useVerificationCase'; +export { useVerificationDocumentUrl } from './hooks/useVerificationDocumentUrl'; +export { useDecideStep } from './hooks/useDecideStep'; +export { useApproveVerification } from './hooks/useApproveVerification'; +export { useRejectVerification } from './hooks/useRejectVerification'; diff --git a/client/src/services/verification/keys.ts b/client/src/services/verification/keys.ts index b491539..d88bd31 100644 --- a/client/src/services/verification/keys.ts +++ b/client/src/services/verification/keys.ts @@ -1,8 +1,15 @@ +import type { AdminVerificationQueueFilters } from './types'; +import type { PageParams } from '@/lib/api/types'; + /** * React Query key factory for the verification domain. The nurse's own `status()` is the **single * cached source** that both B3 (checklist) and B6 (under-review) read — one query, two views. Every * submit/upload/run mutation invalidates `status()` so the checklist re-renders from cache with no * manual refetch. The public `badge(nurseId)` is longer-lived and reused by search/f6. + * + * The admin subtree (`admin()` → queue / case / document-url) mirrors the same hierarchy: each queue + * variant (filters+params) and each case keys independently, and the `adminQueues()` / `adminCases()` + * prefixes let a decision invalidate every queue page and a single case in one call. */ export const verificationKeys = { all: ['verification'] as const, @@ -15,4 +22,21 @@ export const verificationKeys = { // The public trust badge — keyed per nurse; reused by the own-profile view and f6 search/profile. badge: (nurseId: number) => [...verificationKeys.all, 'badge', nurseId] as const, + + // --- Admin review queue (b6 AdminVerificationsController) --- + admin: () => [...verificationKeys.all, 'admin'] as const, + + // The review queue — `filters` + `params` are part of the key, so paging / changing the status filter + // never refetches a page already in cache (React Query hashes keys deterministically). + adminQueues: () => [...verificationKeys.admin(), 'queue'] as const, + adminQueue: (filters: AdminVerificationQueueFilters, params: PageParams) => + [...verificationKeys.adminQueues(), filters, params] as const, + + // A single nurse's full case — invalidated on every decide / approve / reject. + adminCases: () => [...verificationKeys.admin(), 'case'] as const, + adminCase: (nurseVerificationId: number) => [...verificationKeys.adminCases(), nurseVerificationId] as const, + + // A document's short-lived signed URL — keyed per document; kept out of long-term cache (fetched on demand). + adminDocumentUrls: () => [...verificationKeys.admin(), 'document_url'] as const, + adminDocumentUrl: (documentId: number) => [...verificationKeys.adminDocumentUrls(), documentId] as const, }; diff --git a/client/src/services/verification/types.ts b/client/src/services/verification/types.ts index 2370084..0bc02ef 100644 --- a/client/src/services/verification/types.ts +++ b/client/src/services/verification/types.ts @@ -15,6 +15,8 @@ * credential **types** only. */ +import type { PageParams, Paginated } from '@/lib/api/types'; + /** The aggregate `nurse_verifications.status` — the single source of verification truth. */ export type VerificationAggregateStatus = | 'not_started' @@ -146,6 +148,87 @@ export interface CredentialDetailsInput { expiresAt?: string | null; } +/* --- Admin review queue (b6 `AdminVerificationsController`) --------------------------------------- + * The admin-side surface of the same trust engine. The nurse builds the checklist above; an admin + * reviews it here — works the queue, opens a case, decides each manual step, and (via a re-aggregate) + * flips `is_verified`. `credentialNumber` is accepted only as **input** on a decide; it is NEVER on any + * response DTO (encrypted at rest). Signed document URLs are short-lived — fetched on demand, not cached. + */ + +/** + * `AdminPendingStepDto`, folded to **one row per nurse** — the review queue item. The b6 endpoint returns + * one row per *step* awaiting attention; the nurse-level aggregate (`stepsPassed`/`stepsTotal`/ + * `nextPendingStepCode`/`hasExpiringCredential`) is the shape the queue UI needs (a nurse-level queue + * endpoint is filed as REQ-034; the client maps what it can). + */ +export interface AdminVerificationQueueItem { + nurseVerificationId: number; + nurseId: number; + nurseName: string; + status: VerificationAggregateStatus; + stepsPassed: number; + stepsTotal: number; + nextPendingStepCode: string | null; + submittedAt: string | null; + hasExpiringCredential: boolean; +} + +/** Queue filter — `status` defaults to `in_review` server-side when omitted. */ +export interface AdminVerificationQueueFilters { + status?: 'pending' | 'in_review'; +} + +/** `AdminStepDetailDto` — one step of the admin case view, carrying its documents (signed GET URLs). */ +export interface AdminVerificationStepDetail { + id: number; + code: string; + displayName: string; + status: VerificationStepStatus; + isAutomated: boolean; + expiresAt: string | null; + failureReason: string | null; + documents: VerificationDocument[]; +} + +/** `AdminVerificationDetailDto` — the full case: ordered steps, recorded credentials, + the identity name for cross-check. */ +export interface AdminVerificationCase { + nurseVerificationId: number; + nurseId: number; + identityName: string; + status: VerificationAggregateStatus; + steps: AdminVerificationStepDetail[]; + credentials: NurseCredential[]; +} + +/** + * Body for `POST admin_verifications/steps/{stepId}/decide`. `rejectionReason` is required when + * `approve=false`; the credential fields (recorded only on approving a credential-bearing step) include + * `credentialNumber` — accepted as **input** here, but never echoed back on any response. + */ +export interface DecideStepInput { + approve: boolean; + rejectionReason?: string; + credentialNumber?: string; + holderName?: string; + issuingAuthority?: string; + issuedAt?: string | null; + expiresAt?: string | null; + verificationSource?: string; +} + +/** `ReviewStepResult` — the step's new status after a decision; `credentialId` is set only when one was recorded. */ +export interface DecideStepResult { + stepId: number; + stepStatus: VerificationStepStatus; + credentialId: number | null; +} + +/** A freshly-signed, short-lived GET URL for a document — fetched on demand (never long-cached). */ +export interface SignedDocumentUrl { + url: string; + expiresInSeconds: number; +} + /** * The verification domain's API seam — the real HTTP client and the in-memory mock both implement * this interface; selection is by config (`USE_VERIFICATION_MOCK`), never scattered `if (mock)` checks. @@ -170,6 +253,20 @@ export interface VerificationApi { submitCredentialDetails(input: CredentialDetailsInput): Promise; /** The public trust badge for a nurse (types only). */ getTrustBadge(nurseId: number): Promise; + + // --- Admin review queue (b6 AdminVerificationsController) --- + /** The review queue, folded to one item per nurse. `status` filters (default `in_review`); paginated. */ + listVerificationQueue(filters: AdminVerificationQueueFilters, params: PageParams): Promise>; + /** The full admin case for one nurse — steps + documents + credentials + the identity name for cross-check. */ + getVerificationCase(nurseVerificationId: number): Promise; + /** A freshly-signed, short-lived GET URL for a document — fetched on demand (URLs expire; never long-cached). */ + getDocumentSignedUrl(documentId: number): Promise; + /** Approve or reject a manual step; on a credential-bearing step, records the (encrypted) credential. Re-aggregates. */ + decideStep(stepId: number, input: DecideStepInput): Promise; + /** Approve the whole verification (all required steps pass → `approved`), removing it from the queue. */ + approveVerification(nurseVerificationId: number): Promise; + /** Reject the whole verification (`rejected`), removing it from the queue. */ + rejectVerification(nurseVerificationId: number, reason: string): Promise; } /** The specialties offered as ready-made chips in B5 (nurse can add their own). Stable codes → i18n labels. */