frontend phase 12
This commit is contained in:
+7
-1
@@ -165,7 +165,8 @@ client/
|
||||
│ │ │ │ ├── review/page.tsx # B6 — under-review (same status query, condensed mini-checklist)
|
||||
│ │ │ │ ├── VerificationChecklist.tsx # B3 body: meter + step rows (co-located, page-only)
|
||||
│ │ │ │ └── verificationSteps.ts # step→label/chip/route helpers + synthetic mobile step (keeps rendering data-driven)
|
||||
│ │ │ └── visits/ # /nurse/visits — f8 EVV: page.tsx = ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": EVV controls + gated care card)
|
||||
│ │ │ ├── visits/ # /nurse/visits — f8 EVV: page.tsx = ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": EVV controls + gated care card)
|
||||
│ │ │ └── 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)
|
||||
│ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell
|
||||
│ │ ├── layout.tsx # 'use client' — wraps AdminLayout
|
||||
│ │ ├── page.tsx # /admin (overview)
|
||||
@@ -203,6 +204,9 @@ client/
|
||||
│ ├── RefundEtaBanner/ # f10 per-channel refund ETA — bnpl_revert surfaces the ~7–10 business-day window honestly (never instant), psp_card/manual wording; one branch on refund_channel (tested)
|
||||
│ ├── BnplPlanCard/ # f11 D2 installment-plan option card (terracotta): term/installments + interest-free/fee sub-label + served monthly amount + down-payment indicator; single-select (tested)
|
||||
│ ├── InstallmentScheduleRow/ # f11 repayment row: down-payment(«امروز»)/installment + Shamsi due date + served amount + optional provider-reported status chip; reused by D4 schedule + D5 wallet due list (tested)
|
||||
│ ├── EarningsBalanceHeader/ # f12 nurse net payable balance + 4-bucket breakdown (pending/eligible/paid/clawback off --bal-{warning,info,success,error}); renders a negative net as an explicit "owed back" state (magnitude only, never a bare minus) (tested)
|
||||
│ ├── EarningsRow/ # f12 one earnings item: three-amount «gross − commission = your payout» breakdown via PriceBreakdown + one of four visually-distinct state chips + state affordance (pending→display-only dispute-window CountdownTimer, eligible→awaiting-batch, paid→paid_at+ref+payout link, clawback_applied→net explanation); deep-links to /nurse/visits/[id] (tested)
|
||||
│ ├── PayoutHistoryRow/ # f12 one nurse_payouts row: net transferred + payout-status chip (pending/submitted/paid/failed) + period + masked IBAN (last-4, dir=ltr) + transfer ref + read-only failure banner (no nurse retry) (tested)
|
||||
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
|
||||
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
|
||||
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
|
||||
@@ -260,6 +264,7 @@ client/
|
||||
│ ├── payment/ # F9 checkout & card capture (b10) + customer invoice read (b11). useCheckoutSummary/useInitiatePayment(caller owns the per-ATTEMPT Idempotency-Key)/useConfirmGatewayReturn/usePaymentOutcome(backoff poll, stops on terminal + bounded attempts)/useInvoice(immutable, long staleTime, 404=not-issued not error); invalidations.ts = the one post-capture cache transition (request detail/lists + bookings lists/detail + summary/outcome — never a blanket refetch); seam+mock(PRIMARY — the conversion trigger bridging the f7↔f8 mock stores: capture converts the request, inserts a confirmed booking, issues the b11-shaped invoice)+client (initiate/invoice = real b10/b11 contract; summary = REQ-016 proposed route; outcome = mapped booking_requests/get, REQ-017). Money = served IRR digit-strings; rows reconcile by construction; a 409 on the money path is benign convergence, never a toast
|
||||
│ ├── refunds/ # F10 customer cancellation + refund status (b11). resolveCancellationPolicy/cancelBooking/getRefundByBooking/getRefund. useCancellationPolicyPreview/useCancelBooking/useRefundStatus(polls only while non-terminal); invalidations.ts primes the fresh refund + invalidates booking detail/lists on cancel; seam+mock(PRIMARY — reads the f8 bookings store to resolve tier+per-session refundability, flips the booking cancelled, drives card-immediate/BNPL-processing refunds)+client. Contract is admin-only (REQ-019/020/021 fill the customer cancel command, policy preview, refund-by-booking + decomposition). Money = IRR digit-strings, BigInt; refund %+fee disclosed before confirm; refunds never self-issued
|
||||
│ ├── bnpl/ # F11 BNPL installment checkout (b12) — the alternate branch off C6. useBnplOptions/useCheckEligibility/useBnplSchedule/useIssueBnplToken/useAcceptBnplSchedule(invalidates booking+checkout+wallet)/useBnplOrder(bounded backoff poll)/useWalletInstallments; invalidations.ts reuses f9 invalidateAfterPaymentSuccess + the wallet key; seam+mock(PRIMARY)+client. Mock = the settle bridge: reuses the f9 conversion (mockInsertConvertedBooking + mockMarkBookingRequestConverted) — a settled BNPL order is a card payment net-of-fee — and seeds a provider-reported Wallet plan (D5). Contract serves only eligibility/initiate/status; options/schedule/wallet-installments/D3-KYC/customer-bookingId are REQ-022/023/024 gaps mocked behind the seam. Money = served IRR digit-strings (the mock computes plan/schedule with BigInt; components only format). D5 is provider-reported status, NOT a Balinyaar ledger; early-pay hands off to the provider
|
||||
│ ├── payouts/ # F12 nurse earnings & payout history (b13) — read-only, no mutations. useNurseEarningsBalance/useNurseEarnings(state,page)/useNursePayoutHistory(page)/useNursePayoutDetail(id); the state-filter + page are part of the query key (tabs/pages cache separately, keepPreviousData); seam+mock(PRIMARY)+client. b13 serves only GET nurse_payouts/history; the four-bucket earnings summary, per-booking earnings list + money-state, and nurse-readable payout detail (batch context + booking links + failureReason) are REQ-025 gaps mocked behind the seam. EarningsState (pending|eligible|paid|clawback_applied) is a client display model derived server-side; PayoutStatus is the contract's pending|submitted|paid|failed. Money = IRR digit-strings (gross=commission+payout; net=gross−clawback; Σ booking-links=grossEarnings); the net payable balance is SIGNED (may be negative "owed back", never clamped); eligibility/dates/amounts are server truth (never computed client-side); the BNPL provider commission never appears (payment-method-invariant). MOCK_SCENARIO toggles the negative-balance demo
|
||||
│ └── {domain}/
|
||||
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
|
||||
│ ├── keys.ts # React Query key factory (hierarchical)
|
||||
@@ -358,6 +363,7 @@ async function MyServerComponent() {
|
||||
- `'payment'` — the f9 checkout & invoice surface: C6 labels (breakdown rows هزینه خدمت/کارمزد بالینیار/مالیات/مبلغ کل, the **verbatim escrow copy** `escrow_notice`, «ادامه پرداخت ←», the BNPL seam), the card-flow states (initiating/redirecting/pending/failed/expired/already-paid), the confirmation + invoice screens (VAT-on-commission line, مودیان `moadian_*` states), `pstatus_*` transaction-status labels, and the dev mock-gateway harness copy; consumed by the checkout pages, the invoice page, `EscrowNotice`, and `PaymentStatusBadge`
|
||||
- `'refunds'` — the f10 customer cancellation + refund-status surface: policy-tier labels keyed off `cancellation_policy_code` (`policy_*`), the lead-time + refund %/fee % disclosure, the refund-vs-fee breakdown rows, the multi-session refundable/locked reasons (`reason_*`), the admin-approval explainer, the three refund-status step + chip labels (`step_*`/`rstatus_*`), the per-channel ETA copy (`eta_*` — `bnpl_revert` 7–10-business-day window / `psp_card` / `manual`), and the failed/contact-support copy; consumed by the cancel + refund-status pages and `CancellationPolicyDisclosure`/`RefundStatusCard`/`RefundEtaBanner`
|
||||
- `'bnpl'` — the f11 BNPL installment checkout (D1–D5): the ownership-truth copy (`ownership_note`/`contract_note`/`provider_owned_note`/`paid_via_installments` — the agreement is customer↔provider, provider-financed, Balinyaar paid in full), provider names/taglines keyed off `provider_{code}`, the method/plan/eligibility/schedule labels, ICU-`number` plan params (`plan_term_months`/`plan_installments`/`plan_fee`/`down_payment_percent`/`installment_n` — Persian digits on `fa`), the declined/error copy + card fall-back, the D5 wallet outstanding-balance/due-list/`status_*` labels, and the handoff/settle states; consumed by the D1–D4 wizard + gateway/return pages, `WalletInstallments`, the reused confirmation, and `BnplPlanCard`/`InstallmentScheduleRow`
|
||||
- `'payouts'` — the f12 nurse earnings & payout-history surface: the balance header (`balance_net_*`/`balance_owed_*` — the negative "owed back" state + hint) + four buckets (`bucket_*`), the cadence/dispute-window explainer (`explainer_*` — weekly batches, EVV+72h gate, method-invariant), the state tabs + earnings-state chip labels (`tab_*`/`estate_*` for pending/eligible/paid/clawback_applied), the nurse-framed three-amount breakdown (`amount_gross`/`amount_commission`/`amount_your_payout`) + clawback net explanation (`clawback_*`), the per-state affordances (`pending_affordance`/`dispute_window_*`/`eligible_affordance`/`paid_on`), the payout-status labels (`pstatus_*` for pending/submitted/paid/failed) + batch-status labels (`bstatus_*`), the read-only failure banner (`failure_*`), and the detail money decomposition + booking-links copy (`detail_*`/`gross_earnings_label`/`net_amount_label`); consumed by the `/nurse/earnings` pages and `EarningsBalanceHeader`/`EarningsRow`/`PayoutHistoryRow`
|
||||
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
|
||||
|
||||
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"requests": "Requests",
|
||||
"verification": "Verification",
|
||||
"visits": "Visits",
|
||||
"earnings": "Earnings",
|
||||
"admin": "Admin",
|
||||
"overview": "Overview",
|
||||
"users": "Users",
|
||||
@@ -928,5 +929,89 @@
|
||||
"step_plan": "Plan",
|
||||
"step_eligibility": "Eligibility",
|
||||
"step_schedule": "Schedule"
|
||||
},
|
||||
"payouts": {
|
||||
"title": "Earnings",
|
||||
"subtitle": "What you've earned, and when it arrives.",
|
||||
"balance_net_label": "Payable balance",
|
||||
"balance_net_hint": "Amounts you've earned that are on their way to your account.",
|
||||
"balance_owed_label": "Owed back to Balinyaar",
|
||||
"balance_owed_hint": "Past clawbacks exceed your current earnings. This is deducted from your future payouts — nothing is charged to you directly.",
|
||||
"balance_error": "Couldn't load your balance.",
|
||||
"bucket_pending": "Pending",
|
||||
"bucket_eligible": "Eligible",
|
||||
"bucket_paid": "Paid (lifetime)",
|
||||
"bucket_clawback": "Clawback outstanding",
|
||||
"explainer_title": "How payouts work",
|
||||
"explainer_point_1": "You're paid in weekly batches to your verified primary account.",
|
||||
"explainer_point_2": "An amount becomes eligible only after the visit is verified (check-out) and the 72-hour dispute window closes.",
|
||||
"explainer_point_3": "You receive the same amount whether the family paid by card or in installments — the installment provider's fee is never deducted from you.",
|
||||
"tab_all": "All",
|
||||
"tab_pending": "Pending",
|
||||
"tab_eligible": "Eligible",
|
||||
"tab_paid": "Paid",
|
||||
"tab_clawback_applied": "Clawed back",
|
||||
"estate_pending": "Pending",
|
||||
"estate_eligible": "Eligible",
|
||||
"estate_paid": "Paid",
|
||||
"estate_clawback_applied": "Clawed back",
|
||||
"booking_ref": "Booking #{id}",
|
||||
"view_booking": "View booking",
|
||||
"view_payout": "View payout",
|
||||
"view_payout_detail": "View details",
|
||||
"amount_gross": "Service price",
|
||||
"amount_commission": "Balinyaar commission",
|
||||
"amount_your_payout": "Your payout",
|
||||
"clawback_explainer": "This booking was refunded after you were paid, so the amount was netted out:",
|
||||
"clawback_original": "Originally earned",
|
||||
"clawback_amount": "Clawed back",
|
||||
"clawback_net": "Net",
|
||||
"pending_affordance": "In escrow · dispute window open",
|
||||
"dispute_window_label": "Time to eligible",
|
||||
"dispute_window_elapsed": "Dispute window closed",
|
||||
"eligible_affordance": "Cleared · awaiting the next weekly batch",
|
||||
"paid_on": "Paid on {date}",
|
||||
"transfer_reference_label": "Transfer reference",
|
||||
"list_error": "Couldn't load your earnings.",
|
||||
"list_empty_title": "No earnings yet",
|
||||
"list_empty_body": "Completed and verified visits will appear here.",
|
||||
"retry": "Retry",
|
||||
"page_prev": "Previous",
|
||||
"page_next": "Next",
|
||||
"page_indicator": "Page {page} of {total}",
|
||||
"back_to_earnings": "Earnings",
|
||||
"history_title": "Payout history",
|
||||
"history_subtitle": "Every transfer to your account.",
|
||||
"history_error": "Couldn't load your payout history.",
|
||||
"history_empty_title": "No payouts yet",
|
||||
"history_empty_body": "Your weekly transfers will appear here once the first batch runs.",
|
||||
"payout_net_amount": "Amount transferred",
|
||||
"period_label": "Period",
|
||||
"paid_at_label": "Paid on",
|
||||
"masked_iban_label": "Account",
|
||||
"pstatus_pending": "Pending",
|
||||
"pstatus_submitted": "In transfer",
|
||||
"pstatus_paid": "Paid",
|
||||
"pstatus_failed": "Failed",
|
||||
"failure_title": "This transfer failed",
|
||||
"failure_reason_label": "Reason",
|
||||
"failure_hint": "Our team will retry it — you don't need to do anything. Check your bank details are correct in your bank settings.",
|
||||
"back_to_history": "Payout history",
|
||||
"detail_title": "Payout detail",
|
||||
"detail_batch": "Weekly batch",
|
||||
"detail_not_found": "Payout not found.",
|
||||
"bstatus_draft": "Draft",
|
||||
"bstatus_processing": "Processing",
|
||||
"bstatus_partially_failed": "Partially failed",
|
||||
"bstatus_completed": "Completed",
|
||||
"bstatus_failed": "Failed",
|
||||
"processed_on_label": "Processed on",
|
||||
"detail_money_title": "Amount breakdown",
|
||||
"gross_earnings_label": "Gross earnings",
|
||||
"clawback_applied_label": "Clawback applied",
|
||||
"net_amount_label": "Net amount",
|
||||
"amount_transferred_label": "Transferred",
|
||||
"detail_bookings_title": "Bookings covered",
|
||||
"detail_bookings_hint": "This transfer paid for these visits."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"requests": "درخواستها",
|
||||
"verification": "احراز هویت",
|
||||
"visits": "ویزیتها",
|
||||
"earnings": "درآمدها",
|
||||
"admin": "مدیریت",
|
||||
"overview": "نمای کلی",
|
||||
"users": "کاربران",
|
||||
@@ -928,5 +929,89 @@
|
||||
"step_plan": "طرح",
|
||||
"step_eligibility": "اعتبارسنجی",
|
||||
"step_schedule": "جدول"
|
||||
},
|
||||
"payouts": {
|
||||
"title": "درآمدها",
|
||||
"subtitle": "آنچه بهدست آوردهاید و زمان واریز آن.",
|
||||
"balance_net_label": "موجودی قابل پرداخت",
|
||||
"balance_net_hint": "مبالغی که بهدست آوردهاید و در مسیر واریز به حساب شماست.",
|
||||
"balance_owed_label": "بدهی به بالینیار",
|
||||
"balance_owed_hint": "بازپسگیریهای گذشته از درآمد فعلی شما بیشتر است. این مبلغ از پرداختهای آینده کسر میشود و چیزی مستقیماً از شما دریافت نمیشود.",
|
||||
"balance_error": "بارگذاری موجودی ممکن نشد.",
|
||||
"bucket_pending": "در انتظار",
|
||||
"bucket_eligible": "آماده پرداخت",
|
||||
"bucket_paid": "پرداختشده (کل)",
|
||||
"bucket_clawback": "بازپسگیری معوق",
|
||||
"explainer_title": "پرداختها چگونه کار میکنند",
|
||||
"explainer_point_1": "پرداختها بهصورت هفتگی و به حساب تاییدشده اصلی شما واریز میشود.",
|
||||
"explainer_point_2": "هر مبلغ تنها پس از تایید ویزیت (خروج) و بستهشدن پنجره ۷۲ ساعته اعتراض، آماده پرداخت میشود.",
|
||||
"explainer_point_3": "چه خانواده با کارت پرداخت کند چه اقساطی، مبلغ شما یکسان است — کارمزد ارائهدهنده اقساط هرگز از شما کسر نمیشود.",
|
||||
"tab_all": "همه",
|
||||
"tab_pending": "در انتظار",
|
||||
"tab_eligible": "آماده",
|
||||
"tab_paid": "پرداختشده",
|
||||
"tab_clawback_applied": "بازپسگرفته",
|
||||
"estate_pending": "در انتظار",
|
||||
"estate_eligible": "آماده پرداخت",
|
||||
"estate_paid": "پرداختشده",
|
||||
"estate_clawback_applied": "بازپسگرفته",
|
||||
"booking_ref": "رزرو #{id}",
|
||||
"view_booking": "مشاهده رزرو",
|
||||
"view_payout": "مشاهده پرداخت",
|
||||
"view_payout_detail": "مشاهده جزئیات",
|
||||
"amount_gross": "مبلغ خدمت",
|
||||
"amount_commission": "کارمزد بالینیار",
|
||||
"amount_your_payout": "سهم شما",
|
||||
"clawback_explainer": "این رزرو پس از پرداخت به شما، مسترد شد؛ بنابراین مبلغ آن کسر شد:",
|
||||
"clawback_original": "درآمد اولیه",
|
||||
"clawback_amount": "بازپسگرفتهشده",
|
||||
"clawback_net": "خالص",
|
||||
"pending_affordance": "در حساب امانی · پنجره اعتراض باز است",
|
||||
"dispute_window_label": "تا آمادهشدن",
|
||||
"dispute_window_elapsed": "پنجره اعتراض بسته شد",
|
||||
"eligible_affordance": "تسویهشده · در انتظار دسته پرداخت هفتگی",
|
||||
"paid_on": "پرداخت در {date}",
|
||||
"transfer_reference_label": "کد پیگیری واریز",
|
||||
"list_error": "بارگذاری درآمدها ممکن نشد.",
|
||||
"list_empty_title": "هنوز درآمدی ندارید",
|
||||
"list_empty_body": "ویزیتهای تکمیل و تاییدشده اینجا نمایش داده میشوند.",
|
||||
"retry": "تلاش دوباره",
|
||||
"page_prev": "قبلی",
|
||||
"page_next": "بعدی",
|
||||
"page_indicator": "صفحه {page} از {total}",
|
||||
"back_to_earnings": "درآمدها",
|
||||
"history_title": "تاریخچه پرداختها",
|
||||
"history_subtitle": "همه واریزها به حساب شما.",
|
||||
"history_error": "بارگذاری تاریخچه پرداختها ممکن نشد.",
|
||||
"history_empty_title": "هنوز پرداختی ندارید",
|
||||
"history_empty_body": "واریزهای هفتگی شما پس از اجرای اولین دسته اینجا نمایش داده میشوند.",
|
||||
"payout_net_amount": "مبلغ واریزشده",
|
||||
"period_label": "دوره",
|
||||
"paid_at_label": "تاریخ واریز",
|
||||
"masked_iban_label": "حساب",
|
||||
"pstatus_pending": "در انتظار",
|
||||
"pstatus_submitted": "در حال انتقال",
|
||||
"pstatus_paid": "پرداختشده",
|
||||
"pstatus_failed": "ناموفق",
|
||||
"failure_title": "این واریز ناموفق بود",
|
||||
"failure_reason_label": "علت",
|
||||
"failure_hint": "تیم ما دوباره تلاش میکند و نیازی به اقدام شما نیست. از درستبودن اطلاعات بانکیتان در تنظیمات حساب مطمئن شوید.",
|
||||
"back_to_history": "تاریخچه پرداختها",
|
||||
"detail_title": "جزئیات پرداخت",
|
||||
"detail_batch": "دسته هفتگی",
|
||||
"detail_not_found": "پرداخت یافت نشد.",
|
||||
"bstatus_draft": "پیشنویس",
|
||||
"bstatus_processing": "در حال پردازش",
|
||||
"bstatus_partially_failed": "پرداخت ناقص",
|
||||
"bstatus_completed": "تکمیلشده",
|
||||
"bstatus_failed": "ناموفق",
|
||||
"processed_on_label": "تاریخ پردازش",
|
||||
"detail_money_title": "ریز مبلغ",
|
||||
"gross_earnings_label": "درآمد ناخالص",
|
||||
"clawback_applied_label": "بازپسگیری اعمالشده",
|
||||
"net_amount_label": "مبلغ خالص",
|
||||
"amount_transferred_label": "واریزشده",
|
||||
"detail_bookings_title": "رزروهای دربرگرفته",
|
||||
"detail_bookings_hint": "این واریز بابت این ویزیتها پرداخت شده است."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
'use client';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, EarningsBalanceHeader, EarningsRow } from '@/components';
|
||||
import { nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
|
||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||
import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types';
|
||||
import { useNurseEarnings, useNurseEarningsBalance } from '@/services/payouts';
|
||||
|
||||
type EarningsTab = 'all' | EarningsState;
|
||||
const TABS: readonly EarningsTab[] = ['all', ...EARNINGS_STATES];
|
||||
|
||||
/**
|
||||
* Nurse earnings home (f12) — the read-only "where is my money?" surface. Shows the ledger-derived net
|
||||
* payable balance + four-bucket breakdown (`EarningsBalanceHeader`), a plain-Persian explainer of the
|
||||
* weekly cadence + dispute-window gate, and a state-segmented earnings list. Each row deep-links to the f8
|
||||
* booking detail (never rebuilt here) and, when paid, to the payout detail. The server owns eligibility and
|
||||
* amounts; this screen only renders them.
|
||||
*/
|
||||
export default function NurseEarningsPage() {
|
||||
const t = useTranslations('payouts');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const [tab, setTab] = useState<EarningsTab>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [explainerOpen, setExplainerOpen] = useState(false);
|
||||
|
||||
const stateFilter = tab === 'all' ? undefined : tab;
|
||||
const balance = useNurseEarningsBalance();
|
||||
const earnings = useNurseEarnings(stateFilter, page);
|
||||
|
||||
const items = earnings.data?.items ?? [];
|
||||
const total = earnings.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
|
||||
|
||||
const openBooking = (bookingId: number) => router.push(`/${locale}${nurseBookingDetailPath(bookingId)}`);
|
||||
const openPayout = (payoutId: number) => router.push(`/${locale}${nursePayoutDetailPath(payoutId)}`);
|
||||
|
||||
const onTabChange = (_: React.SyntheticEvent, next: EarningsTab) => {
|
||||
setTab(next);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{balance.isLoading ? (
|
||||
<Skeleton variant="rounded" height={200} />
|
||||
) : balance.isError ? (
|
||||
<ErrorPanel message={t('balance_error')} onRetry={() => balance.refetch()} retryLabel={t('retry')} />
|
||||
) : balance.data ? (
|
||||
<EarningsBalanceHeader summary={balance.data} />
|
||||
) : null}
|
||||
|
||||
<ExplainerCard open={explainerOpen} onToggle={() => setExplainerOpen((v) => !v)} />
|
||||
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={onTabChange}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
allowScrollButtonsMobile
|
||||
>
|
||||
{TABS.map((value) => (
|
||||
<Tab key={value} value={value} label={t(`tab_${value}`)} sx={{ textTransform: 'none' }} />
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
{earnings.isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{[0, 1].map((k) => (
|
||||
<Skeleton key={k} variant="rounded" height={200} />
|
||||
))}
|
||||
</Stack>
|
||||
) : earnings.isError ? (
|
||||
<ErrorPanel message={t('list_error')} onRetry={() => earnings.refetch()} retryLabel={t('retry')} />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyPanel title={t('list_empty_title')} body={t('list_empty_body')} />
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
<EarningsRow key={item.bookingId} item={item} onViewBooking={openBooking} onViewPayout={openPayout} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Pager
|
||||
page={page}
|
||||
pageCount={pageCount}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Collapsible "how payouts work" — the cadence + dispute-window + method-invariant copy (both locales). */
|
||||
function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void }) {
|
||||
const t = useTranslations('payouts');
|
||||
const points = useMemo(() => ['explainer_point_1', 'explainer_point_2', 'explainer_point_3'] as const, []);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '3px solid',
|
||||
borderInlineStartColor: 'var(--bal-info)',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
|
||||
<AppIcon icon="info" size={18} color="var(--bal-info)" />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{t('explainer_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppIcon icon={open ? 'visibilityoff' : 'visibilityon'} size={18} color="var(--bal-text-secondary)" />
|
||||
</Stack>
|
||||
<Collapse in={open}>
|
||||
<Stack component="ul" sx={{ gap: 0.75, mt: 1.5, mb: 0, pl: 2.5 }}>
|
||||
{points.map((key) => (
|
||||
<Typography key={key} component="li" variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t(key)}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyPanel({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<AppIcon icon="earnings" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{body}
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorPanel({ message, onRetry, retryLabel }: { message: string; onRetry: () => void; retryLabel: string }) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
|
||||
{message}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={onRetry} sx={{ m: 0 }}>
|
||||
{retryLabel}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** Prev/next pager — rendered only when there is more than one page. */
|
||||
function Pager({
|
||||
page,
|
||||
pageCount,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const t = useTranslations('payouts');
|
||||
const locale = useLocale();
|
||||
if (pageCount <= 1) return null;
|
||||
const fmt = (n: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(n);
|
||||
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<AppButton variant="text" color="primary" onClick={onPrev} disabled={page <= 1} sx={{ m: 0 }}>
|
||||
{t('page_prev')}
|
||||
</AppButton>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
|
||||
</Typography>
|
||||
<AppButton variant="text" color="primary" onClick={onNext} disabled={page >= pageCount} sx={{ m: 0 }}>
|
||||
{t('page_next')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, PriceBreakdown, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import { nurseBookingDetailPath, ROUTES } from '@/constants';
|
||||
import { formatIrrToToman, formatShamsiDate, parseIrr } from '@/utils';
|
||||
import { useNursePayoutDetail } from '@/services/payouts';
|
||||
import type { PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
|
||||
|
||||
const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
|
||||
pending: 'pending',
|
||||
submitted: 'info',
|
||||
paid: 'verified',
|
||||
failed: 'rejected',
|
||||
};
|
||||
|
||||
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
|
||||
draft: 'neutral',
|
||||
processing: 'info',
|
||||
partially_failed: 'pending',
|
||||
completed: 'verified',
|
||||
failed: 'rejected',
|
||||
};
|
||||
|
||||
/**
|
||||
* Nurse payout/batch reconciliation detail (f12) — one payout expanded: the batch window (holiday-shifted
|
||||
* server-side), the money decomposition (`gross_earnings − clawback_applied = net_amount`, plus the amount
|
||||
* actually transferred), the masked IBAN + transfer reference, a failed reason (read-only), and the exact
|
||||
* bookings the payout covered (each deep-linking to its f8 booking detail — "this transfer paid for these
|
||||
* visits"). Money is display-only; the client never recomputes eligibility, dates, or amounts.
|
||||
*/
|
||||
export default function NursePayoutDetailPage() {
|
||||
const t = useTranslations('payouts');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string }>();
|
||||
const payoutId = Number(params?.id);
|
||||
|
||||
const { data, isLoading, isError } = useNursePayoutDetail(Number.isFinite(payoutId) ? payoutId : undefined);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
startIcon="wallet"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS_PAYOUTS}`)}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('back_to_history')}
|
||||
</AppButton>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('detail_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Skeleton variant="rounded" height={160} />
|
||||
<Skeleton variant="rounded" height={200} />
|
||||
</Stack>
|
||||
) : isError || !data ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('detail_not_found')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('detail_batch')}
|
||||
</Typography>
|
||||
<Stack direction="row" sx={{ gap: 0.75, flexWrap: 'wrap' }}>
|
||||
<StatusChip status={BATCH_STATUS_KIND[data.batch.status]} label={t(`bstatus_${data.batch.status}`)} />
|
||||
<StatusChip status={PAYOUT_STATUS_KIND[data.status]} label={t(`pstatus_${data.status}`)} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
<MetaLine label={t('period_label')}>
|
||||
{formatShamsiDate(data.batch.periodStart, locale)} – {formatShamsiDate(data.batch.periodEnd, locale)}
|
||||
</MetaLine>
|
||||
{data.batch.processedAt ? (
|
||||
<MetaLine label={t('processed_on_label')}>{formatShamsiDate(data.batch.processedAt, locale)}</MetaLine>
|
||||
) : null}
|
||||
<MetaLine label={t('masked_iban_label')}>
|
||||
<Box component="span" dir="ltr">
|
||||
{data.maskedIban}
|
||||
</Box>
|
||||
</MetaLine>
|
||||
{data.transferReference ? (
|
||||
<MetaLine label={t('transfer_reference_label')}>
|
||||
<Box component="span" dir="ltr">
|
||||
{data.transferReference}
|
||||
</Box>
|
||||
</MetaLine>
|
||||
) : null}
|
||||
{data.paidAt ? (
|
||||
<MetaLine label={t('paid_at_label')}>{formatShamsiDate(data.paidAt, locale)}</MetaLine>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{data.status === 'failed' ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.75,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '3px solid',
|
||||
borderInlineStartColor: 'var(--bal-error)',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', mb: 0.5 }}>
|
||||
<AppIcon icon="warning" size={18} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('failure_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{data.failureReason ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
|
||||
{t('failure_reason_label')}: {data.failureReason}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('failure_hint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('detail_money_title')}
|
||||
</Typography>
|
||||
<PriceBreakdown
|
||||
rows={
|
||||
parseIrr(data.clawbackAppliedIrr) > BigInt(0)
|
||||
? [
|
||||
{ key: 'gross_earnings', label: t('gross_earnings_label'), amountIrr: data.grossEarningsIrr },
|
||||
{
|
||||
key: 'clawback',
|
||||
label: t('clawback_applied_label'),
|
||||
amountIrr: String(-parseIrr(data.clawbackAppliedIrr)),
|
||||
},
|
||||
]
|
||||
: [{ key: 'gross_earnings', label: t('gross_earnings_label'), amountIrr: data.grossEarningsIrr }]
|
||||
}
|
||||
totalLabel={t('net_amount_label')}
|
||||
totalAmountIrr={data.netAmountIrr}
|
||||
/>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', px: 0.5 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('amount_transferred_label')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{formatIrrToToman(data.amountIrr, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('detail_bookings_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('detail_bookings_hint')}
|
||||
</Typography>
|
||||
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
|
||||
<Stack divider={<Divider />}>
|
||||
{data.bookings.map((link) => (
|
||||
<Stack
|
||||
key={link.bookingId}
|
||||
direction="row"
|
||||
sx={{ p: 1.75, gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('booking_ref', { id: link.bookingId })}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{formatIrrToToman(link.payoutAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
endIcon="visits"
|
||||
onClick={() => router.push(`/${locale}${nurseBookingDetailPath(link.bookingId)}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => (
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, PayoutHistoryRow } from '@/components';
|
||||
import { nursePayoutDetailPath, ROUTES } from '@/constants';
|
||||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||||
import { useNursePayoutHistory } from '@/services/payouts';
|
||||
|
||||
/**
|
||||
* Nurse payout history (f12) — a paginated, read-only list of the nurse's `nurse_payouts`, newest first.
|
||||
* Each row (`PayoutHistoryRow`) shows the net amount transferred, the status chip, the batch window, the
|
||||
* masked IBAN + transfer reference, and — when failed — the reason (no retry; that's an admin action). A row
|
||||
* opens the payout/batch reconciliation detail.
|
||||
*/
|
||||
export default function NursePayoutHistoryPage() {
|
||||
const t = useTranslations('payouts');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const history = useNursePayoutHistory(page);
|
||||
|
||||
const items = history.data?.items ?? [];
|
||||
const total = history.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
|
||||
const fmt = (n: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(n);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
startIcon="earnings"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS}`)}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('back_to_earnings')}
|
||||
</AppButton>
|
||||
<Typography variant="h5" component="h1">
|
||||
{t('history_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('history_subtitle')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{history.isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{[0, 1].map((k) => (
|
||||
<Skeleton key={k} variant="rounded" height={180} />
|
||||
))}
|
||||
</Stack>
|
||||
) : history.isError ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
|
||||
{t('history_error')}
|
||||
</Typography>
|
||||
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => history.refetch()} sx={{ m: 0 }}>
|
||||
{t('retry')}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
) : items.length === 0 ? (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon="earnings" size={40} color="var(--bal-text-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
|
||||
{t('history_empty_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('history_empty_body')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{items.map((item) => (
|
||||
<PayoutHistoryRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onOpen={(id) => router.push(`/${locale}${nursePayoutDetailPath(id)}`)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{pageCount > 1 ? (
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1} sx={{ m: 0 }}>
|
||||
{t('page_prev')}
|
||||
</AppButton>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
|
||||
</Typography>
|
||||
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount} sx={{ m: 0 }}>
|
||||
{t('page_next')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import type { NurseEarningsSummary } from '@/services/payouts/types';
|
||||
|
||||
// next-intl mocked to echo keys; locale 'en' so the money util renders ASCII digits for assertions.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import EarningsBalanceHeader from './EarningsBalanceHeader';
|
||||
|
||||
const BASE: NurseEarningsSummary = {
|
||||
pendingTotalIrr: '4250000',
|
||||
eligibleTotalIrr: '3400000',
|
||||
paidTotalIrr: '8500000',
|
||||
clawbackOutstandingIrr: '1700000',
|
||||
netPayableBalanceIrr: '5950000',
|
||||
};
|
||||
|
||||
function renderHeader(summary: NurseEarningsSummary) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<EarningsBalanceHeader summary={summary} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<EarningsBalanceHeader/> component', () => {
|
||||
it('renders a positive net balance as the payable state with the Toman magnitude', () => {
|
||||
const { container } = renderHeader(BASE);
|
||||
expect(container.querySelector('[data-balance-state="payable"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('balance_net_label')).toBeInTheDocument();
|
||||
// 5,950,000 IRR ÷ 10 = 595,000 Toman
|
||||
expect(screen.getByText('595,000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a negative net balance as "owed back" — magnitude only, never a bare minus', () => {
|
||||
const { container } = renderHeader({ ...BASE, netPayableBalanceIrr: '-4350000' });
|
||||
expect(container.querySelector('[data-balance-state="owed"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('balance_owed_label')).toBeInTheDocument();
|
||||
// The magnitude 435,000 shows; the raw "-435,000" must not.
|
||||
expect(screen.getByText('435,000')).toBeInTheDocument();
|
||||
expect(screen.queryByText('-435,000')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all four buckets with their formatted Toman amounts', () => {
|
||||
const { container } = renderHeader(BASE);
|
||||
for (const bucket of ['pending', 'eligible', 'paid', 'clawback']) {
|
||||
expect(container.querySelector(`[data-bucket="${bucket}"]`)).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getByText('425,000')).toBeInTheDocument(); // pending 4,250,000
|
||||
expect(screen.getByText('340,000')).toBeInTheDocument(); // eligible 3,400,000
|
||||
expect(screen.getByText('850,000')).toBeInTheDocument(); // paid lifetime 8,500,000
|
||||
expect(screen.getByText('170,000')).toBeInTheDocument(); // clawback outstanding 1,700,000
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useMemo } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '../common/AppIcon';
|
||||
import { formatIrrToToman, parseIrr } from '@/utils';
|
||||
import type { NurseEarningsSummary } from '@/services/payouts/types';
|
||||
|
||||
export interface EarningsBalanceHeaderProps {
|
||||
summary: NurseEarningsSummary;
|
||||
}
|
||||
|
||||
/** The four roll-up buckets — each keyed to a semantic token + icon so the states read at a glance. */
|
||||
const BUCKETS: ReadonlyArray<{
|
||||
key: 'pending' | 'eligible' | 'paid' | 'clawback';
|
||||
amountKey: keyof NurseEarningsSummary;
|
||||
token: string;
|
||||
icon: string;
|
||||
}> = [
|
||||
{ key: 'pending', amountKey: 'pendingTotalIrr', token: 'var(--bal-warning)', icon: 'pending' },
|
||||
{ key: 'eligible', amountKey: 'eligibleTotalIrr', token: 'var(--bal-info)', icon: 'schedule' },
|
||||
{ key: 'paid', amountKey: 'paidTotalIrr', token: 'var(--bal-success)', icon: 'verified' },
|
||||
{ key: 'clawback', amountKey: 'clawbackOutstandingIrr', token: 'var(--bal-error)', icon: 'refresh' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The nurse's money home header: the **net payable balance** prominently, with the four-bucket breakdown
|
||||
* (pending / eligible / paid-lifetime / clawback-outstanding) beneath. The net balance is **ledger-derived
|
||||
* and may be negative** — when it is, this renders an explicit **"owed back"** state (a distinct error-toned
|
||||
* card + the magnitude, never a bare minus sign as if it were a positive amount). Every amount is formatted
|
||||
* Toman via the money util (integer-safe BigInt); this component only formats, never computes a figure.
|
||||
* @component EarningsBalanceHeader
|
||||
*/
|
||||
const EarningsBalanceHeader: FunctionComponent<EarningsBalanceHeaderProps> = ({ summary }) => {
|
||||
const t = useTranslations('payouts');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const net = useMemo(() => parseIrr(summary.netPayableBalanceIrr), [summary.netPayableBalanceIrr]);
|
||||
const isOwed = net < BigInt(0);
|
||||
// Render the magnitude; the "owed back" framing carries the sign in words, never a bare minus.
|
||||
const magnitude = isOwed ? -net : net;
|
||||
const accent = isOwed ? 'var(--bal-error)' : 'var(--bal-primary)';
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-balance-state={isOwed ? 'owed' : 'payable'}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '4px solid',
|
||||
borderInlineStartColor: accent,
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon={isOwed ? 'warning' : 'wallet'} size={20} color={accent} />
|
||||
<Typography variant="subtitle2" sx={{ color: 'text.secondary', fontWeight: 600 }}>
|
||||
{isOwed ? t('balance_owed_label') : t('balance_net_label')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<Typography component="span" sx={{ fontWeight: 800, fontSize: '2rem', color: accent }}>
|
||||
{formatIrrToToman(magnitude, locale)}
|
||||
</Typography>
|
||||
<Typography component="span" variant="subtitle1" sx={{ color: 'text.secondary', fontWeight: 600 }}>
|
||||
{tc('currency_toman')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{isOwed ? t('balance_owed_hint') : t('balance_net_hint')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gap: 1.5,
|
||||
gridTemplateColumns: { xs: 'repeat(2, 1fr)', md: 'repeat(4, 1fr)' },
|
||||
}}
|
||||
>
|
||||
{BUCKETS.map((bucket) => (
|
||||
<Paper
|
||||
key={bucket.key}
|
||||
elevation={0}
|
||||
data-bucket={bucket.key}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '3px solid',
|
||||
borderInlineStartColor: bucket.token,
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
|
||||
<AppIcon icon={bucket.icon} size={16} color={bucket.token} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
|
||||
{t(`bucket_${bucket.key}`)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{formatIrrToToman(summary[bucket.amountKey], locale)}{' '}
|
||||
<Typography component="span" variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{tc('currency_toman')}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EarningsBalanceHeader;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './EarningsBalanceHeader';
|
||||
export type { EarningsBalanceHeaderProps } from './EarningsBalanceHeader';
|
||||
@@ -0,0 +1,101 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import type { EarningsState, NurseEarningsItem } from '@/services/payouts/types';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import EarningsRow from './EarningsRow';
|
||||
|
||||
function makeItem(state: EarningsState, overrides: Partial<NurseEarningsItem> = {}): NurseEarningsItem {
|
||||
return {
|
||||
bookingId: 5001,
|
||||
patientName: 'Test Patient',
|
||||
scheduledDate: '2026-06-01',
|
||||
grossPriceIrr: '5000000',
|
||||
balinyaarCommissionIrr: '750000',
|
||||
nursePayoutAmount: '4250000',
|
||||
state,
|
||||
disputeWindowEndsAt: null,
|
||||
payoutEligibleAt: null,
|
||||
paidAt: null,
|
||||
transferReference: null,
|
||||
nursePayoutId: null,
|
||||
batchId: null,
|
||||
clawbackAppliedIrr: null,
|
||||
netAmountIrr: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderRow(item: NurseEarningsItem, handlers?: { onViewBooking?: jest.Mock; onViewPayout?: jest.Mock }) {
|
||||
const onViewBooking = handlers?.onViewBooking ?? jest.fn();
|
||||
const onViewPayout = handlers?.onViewPayout ?? jest.fn();
|
||||
const utils = render(
|
||||
<ThemeProvider>
|
||||
<EarningsRow item={item} onViewBooking={onViewBooking} onViewPayout={onViewPayout} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { ...utils, onViewBooking, onViewPayout };
|
||||
}
|
||||
|
||||
const STATE_KIND: Array<{ state: EarningsState; kind: string }> = [
|
||||
{ state: 'pending', kind: 'pending' },
|
||||
{ state: 'eligible', kind: 'info' },
|
||||
{ state: 'paid', kind: 'verified' },
|
||||
{ state: 'clawback_applied', kind: 'rejected' },
|
||||
];
|
||||
|
||||
describe('<EarningsRow/> component', () => {
|
||||
it.each(STATE_KIND)('maps the $state state to the $kind chip kind', ({ state, kind }) => {
|
||||
const { container } = renderRow(makeItem(state));
|
||||
expect(container.querySelector(`[data-earnings-state="${state}"]`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`[data-status="${kind}"]`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the reconciled payout as the breakdown total (gross − commission = payout)', () => {
|
||||
const { container } = renderRow(makeItem('eligible'));
|
||||
const total = container.querySelector('[data-row="total"]');
|
||||
// 4,250,000 IRR ÷ 10 = 425,000 Toman
|
||||
expect(total?.textContent).toContain('425,000');
|
||||
});
|
||||
|
||||
it('shows the pending dispute-window affordance', () => {
|
||||
renderRow(makeItem('pending', { disputeWindowEndsAt: '2999-01-01T00:00:00Z' }));
|
||||
expect(screen.getByText('pending_affordance')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the clawback net-explanation block for a clawed-back earning', () => {
|
||||
// Reconciling fixture: original payout 1,700,000 − clawback 1,700,000 = net 0 (PriceBreakdown guard stays silent).
|
||||
renderRow(
|
||||
makeItem('clawback_applied', {
|
||||
grossPriceIrr: '2000000',
|
||||
balinyaarCommissionIrr: '300000',
|
||||
nursePayoutAmount: '1700000',
|
||||
clawbackAppliedIrr: '1700000',
|
||||
netAmountIrr: '0',
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText('clawback_explainer')).toBeInTheDocument();
|
||||
expect(screen.getByText('clawback_net')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links a paid earning to its payout detail', () => {
|
||||
const onViewPayout = jest.fn();
|
||||
renderRow(
|
||||
makeItem('paid', { paidAt: '2026-06-10T00:00:00Z', transferReference: 'PAYA-1', nursePayoutId: 9001 }),
|
||||
{ onViewPayout },
|
||||
);
|
||||
fireEvent.click(screen.getByText('view_payout'));
|
||||
expect(onViewPayout).toHaveBeenCalledWith(9001);
|
||||
});
|
||||
|
||||
it('deep-links every row to its booking detail', () => {
|
||||
const onViewBooking = jest.fn();
|
||||
renderRow(makeItem('eligible', { bookingId: 5002 }), { onViewBooking });
|
||||
fireEvent.click(screen.getByText('view_booking'));
|
||||
expect(onViewBooking).toHaveBeenCalledWith(5002);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useMemo } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import StatusChip, { StatusKind } from '@/components/StatusChip';
|
||||
import PriceBreakdown from '@/components/PriceBreakdown';
|
||||
import CountdownTimer from '@/components/CountdownTimer';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import { formatShamsiDate, parseIrr } from '@/utils';
|
||||
import type { EarningsState, NurseEarningsItem } from '@/services/payouts/types';
|
||||
|
||||
export interface EarningsRowProps {
|
||||
item: NurseEarningsItem;
|
||||
/** Deep-link to the f8 nurse booking detail (`/nurse/visits/{bookingId}`) — never rebuilt here. */
|
||||
onViewBooking: (bookingId: number) => void;
|
||||
/** Deep-link to this earning's payout detail (paid rows only). */
|
||||
onViewPayout: (payoutId: number) => void;
|
||||
}
|
||||
|
||||
/** Each earnings state maps to a distinct semantic chip so the four states read instantly (§5). */
|
||||
const EARNINGS_STATE_KIND: Record<EarningsState, StatusKind> = {
|
||||
pending: 'pending',
|
||||
eligible: 'info',
|
||||
paid: 'verified',
|
||||
clawback_applied: 'rejected',
|
||||
};
|
||||
|
||||
/**
|
||||
* One earnings row — a completed booking's contribution to the nurse's pay. Shows the booking reference,
|
||||
* the **three-amount breakdown** framed for the nurse (`gross − commission = your payout`, reconciled by
|
||||
* `PriceBreakdown`), the **earnings-state chip**, and the state-specific affordance:
|
||||
* - `pending` → "in escrow · dispute window open" + a **display-only** countdown off `disputeWindowEndsAt`
|
||||
* (the server owns eligibility — this never gates anything, only renders time remaining);
|
||||
* - `eligible` → "cleared · awaiting the next weekly batch";
|
||||
* - `paid` → `paidAt` (Shamsi) + `transferReference`, links to the payout detail;
|
||||
* - `clawback_applied` → the net explanation (`original − clawback = net`) so a lower paid total is explained.
|
||||
*
|
||||
* Money is display-only IRR digit-strings through the money util (no float, no client computation). Every
|
||||
* row deep-links to the booking detail. Read-only: no transfer/retry/eligibility action lives here.
|
||||
* @component EarningsRow
|
||||
*/
|
||||
const EarningsRow: FunctionComponent<EarningsRowProps> = ({ item, onViewBooking, onViewPayout }) => {
|
||||
const t = useTranslations('payouts');
|
||||
const locale = useLocale();
|
||||
|
||||
// gross + (−commission) = payout — the invariant PriceBreakdown reconciles against.
|
||||
const negativeCommissionIrr = useMemo(
|
||||
() => String(-parseIrr(item.balinyaarCommissionIrr)),
|
||||
[item.balinyaarCommissionIrr],
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-earnings-state={item.state}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<Stack sx={{ gap: 1.75 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}
|
||||
>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('booking_ref', { id: item.bookingId })}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{item.patientName} · {formatShamsiDate(item.scheduledDate, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<StatusChip status={EARNINGS_STATE_KIND[item.state]} label={t(`estate_${item.state}`)} />
|
||||
</Stack>
|
||||
|
||||
<PriceBreakdown
|
||||
rows={[
|
||||
{ key: 'gross', label: t('amount_gross'), amountIrr: item.grossPriceIrr },
|
||||
{ key: 'commission', label: t('amount_commission'), amountIrr: negativeCommissionIrr },
|
||||
]}
|
||||
totalLabel={t('amount_your_payout')}
|
||||
totalAmountIrr={item.nursePayoutAmount}
|
||||
/>
|
||||
|
||||
{item.state === 'clawback_applied' && item.clawbackAppliedIrr != null && item.netAmountIrr != null ? (
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('clawback_explainer')}
|
||||
</Typography>
|
||||
<PriceBreakdown
|
||||
rows={[
|
||||
{ key: 'original', label: t('clawback_original'), amountIrr: item.nursePayoutAmount },
|
||||
{ key: 'clawback', label: t('clawback_amount'), amountIrr: String(-parseIrr(item.clawbackAppliedIrr)) },
|
||||
]}
|
||||
totalLabel={t('clawback_net')}
|
||||
totalAmountIrr={item.netAmountIrr}
|
||||
/>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<StateAffordance item={item} onViewPayout={onViewPayout} />
|
||||
|
||||
<Stack direction="row" sx={{ justifyContent: 'flex-end' }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
endIcon="visits"
|
||||
onClick={() => onViewBooking(item.bookingId)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
/** The state-specific explanation block below the money breakdown. */
|
||||
const StateAffordance: FunctionComponent<{
|
||||
item: NurseEarningsItem;
|
||||
onViewPayout: (payoutId: number) => void;
|
||||
}> = ({ item, onViewPayout }) => {
|
||||
const t = useTranslations('payouts');
|
||||
const locale = useLocale();
|
||||
|
||||
if (item.state === 'pending') {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
|
||||
<AppIcon icon="lock" size={18} color="var(--bal-warning)" />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('pending_affordance')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{item.disputeWindowEndsAt ? (
|
||||
<CountdownTimer
|
||||
deadlineIso={item.disputeWindowEndsAt}
|
||||
label={t('dispute_window_label')}
|
||||
elapsedText={t('dispute_window_elapsed')}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.state === 'eligible') {
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
|
||||
<AppIcon icon="schedule" size={18} color="var(--bal-info)" />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('eligible_affordance')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.state === 'paid') {
|
||||
return (
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<AppIcon icon="verified" size={18} color="var(--bal-success)" />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{item.paidAt ? t('paid_on', { date: formatShamsiDate(item.paidAt, locale) }) : t('estate_paid')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{item.transferReference ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
|
||||
{t('transfer_reference_label')}: {item.transferReference}
|
||||
</Typography>
|
||||
) : null}
|
||||
{item.nursePayoutId != null ? (
|
||||
<Stack direction="row" sx={{ justifyContent: 'flex-start' }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
endIcon="wallet"
|
||||
onClick={() => onViewPayout(item.nursePayoutId as number)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('view_payout')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// clawback_applied — the net explanation block above already carries the "why"; nothing more here.
|
||||
return null;
|
||||
};
|
||||
|
||||
export default EarningsRow;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './EarningsRow';
|
||||
export type { EarningsRowProps } from './EarningsRow';
|
||||
@@ -0,0 +1,73 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import type { NursePayoutHistoryItem, PayoutStatus } from '@/services/payouts/types';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import PayoutHistoryRow from './PayoutHistoryRow';
|
||||
|
||||
function makeItem(status: PayoutStatus, overrides: Partial<NursePayoutHistoryItem> = {}): NursePayoutHistoryItem {
|
||||
return {
|
||||
id: 9001,
|
||||
batchId: 7001,
|
||||
status,
|
||||
grossEarningsIrr: '4250000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '4250000',
|
||||
maskedIban: 'IR••••4821',
|
||||
transferReference: status === 'paid' ? 'PAYA-1' : null,
|
||||
paidAt: status === 'paid' ? '2026-06-10T00:00:00Z' : null,
|
||||
periodStart: '2026-06-01',
|
||||
periodEnd: '2026-06-07',
|
||||
failureReason: status === 'failed' ? 'invalid_sheba' : null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderRow(item: NursePayoutHistoryItem, onOpen = jest.fn()) {
|
||||
const utils = render(
|
||||
<ThemeProvider>
|
||||
<PayoutHistoryRow item={item} onOpen={onOpen} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { ...utils, onOpen };
|
||||
}
|
||||
|
||||
const STATUS_KIND: Array<{ status: PayoutStatus; kind: string }> = [
|
||||
{ status: 'pending', kind: 'pending' },
|
||||
{ status: 'submitted', kind: 'info' },
|
||||
{ status: 'paid', kind: 'verified' },
|
||||
{ status: 'failed', kind: 'rejected' },
|
||||
];
|
||||
|
||||
describe('<PayoutHistoryRow/> component', () => {
|
||||
it.each(STATUS_KIND)('maps the $status payout status to the $kind chip kind', ({ status, kind }) => {
|
||||
const { container } = renderRow(makeItem(status));
|
||||
expect(container.querySelector(`[data-payout-status="${status}"]`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`[data-status="${kind}"]`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the net amount transferred in Toman and the masked IBAN', () => {
|
||||
renderRow(makeItem('paid'));
|
||||
expect(screen.getByText('425,000')).toBeInTheDocument(); // 4,250,000 ÷ 10
|
||||
expect(screen.getByText('IR••••4821')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a failed payout reason as a read-only banner with no retry control', () => {
|
||||
renderRow(makeItem('failed'));
|
||||
expect(screen.getByText('failure_title')).toBeInTheDocument();
|
||||
expect(screen.getByText(/invalid_sheba/)).toBeInTheDocument();
|
||||
// No retry affordance for the nurse — only the "view detail" link exists.
|
||||
expect(screen.queryByText('retry')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the payout detail on click', () => {
|
||||
const onOpen = jest.fn();
|
||||
renderRow(makeItem('paid', { id: 9002 }), onOpen);
|
||||
fireEvent.click(screen.getByText('view_payout_detail'));
|
||||
expect(onOpen).toHaveBeenCalledWith(9002);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import StatusChip, { StatusKind } from '@/components/StatusChip';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import type { NursePayoutHistoryItem, PayoutStatus } from '@/services/payouts/types';
|
||||
|
||||
export interface PayoutHistoryRowProps {
|
||||
item: NursePayoutHistoryItem;
|
||||
/** Open this payout's reconciliation detail. */
|
||||
onOpen: (payoutId: number) => void;
|
||||
}
|
||||
|
||||
/** Each payout status maps to a distinct semantic chip. `submitted` = handed to the bank rail (in transit). */
|
||||
const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
|
||||
pending: 'pending',
|
||||
submitted: 'info',
|
||||
paid: 'verified',
|
||||
failed: 'rejected',
|
||||
};
|
||||
|
||||
/**
|
||||
* One payout-history row: the **net amount transferred**, the payout-status chip, the batch window (Shamsi),
|
||||
* `paidAt` when settled, the **masked IBAN** (last-4, LTR), and the `transferReference` for reconciliation.
|
||||
* A `failed` payout surfaces its `failureReason` as a **read-only** banner — the nurse cannot retry (retry
|
||||
* is an admin action). Money is display-only through the money util. Deep-links to the payout detail.
|
||||
* @component PayoutHistoryRow
|
||||
*/
|
||||
const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOpen }) => {
|
||||
const t = useTranslations('payouts');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const isFailed = item.status === 'failed';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-payout-status={item.status}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}
|
||||
>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
|
||||
{t('payout_net_amount')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
|
||||
{formatIrrToToman(item.netAmountIrr, locale)}{' '}
|
||||
<Typography component="span" variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{tc('currency_toman')}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<StatusChip status={PAYOUT_STATUS_KIND[item.status]} label={t(`pstatus_${item.status}`)} />
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<MetaLine label={t('period_label')}>
|
||||
{formatShamsiDate(item.periodStart, locale)} – {formatShamsiDate(item.periodEnd, locale)}
|
||||
</MetaLine>
|
||||
{item.paidAt ? (
|
||||
<MetaLine label={t('paid_at_label')}>{formatShamsiDate(item.paidAt, locale)}</MetaLine>
|
||||
) : null}
|
||||
<MetaLine label={t('masked_iban_label')}>
|
||||
<Box component="span" dir="ltr">
|
||||
{item.maskedIban}
|
||||
</Box>
|
||||
</MetaLine>
|
||||
{item.transferReference ? (
|
||||
<MetaLine label={t('transfer_reference_label')}>
|
||||
<Box component="span" dir="ltr">
|
||||
{item.transferReference}
|
||||
</Box>
|
||||
</MetaLine>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{isFailed ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '3px solid',
|
||||
borderInlineStartColor: 'var(--bal-error)',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', mb: 0.5 }}>
|
||||
<AppIcon icon="warning" size={18} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('failure_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{item.failureReason ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
|
||||
{t('failure_reason_label')}: {item.failureReason}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('failure_hint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ justifyContent: 'flex-end' }}>
|
||||
<AppButton variant="text" color="primary" endIcon="wallet" onClick={() => onOpen(item.id)} sx={{ m: 0 }}>
|
||||
{t('view_payout_detail')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => (
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default PayoutHistoryRow;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './PayoutHistoryRow';
|
||||
export type { PayoutHistoryRowProps } from './PayoutHistoryRow';
|
||||
@@ -72,6 +72,8 @@ import EmergencyIcon from '@mui/icons-material/LocalPhoneOutlined';
|
||||
import LockIcon from '@mui/icons-material/LockOutlined';
|
||||
// BNPL — the installment-checkout surface (f11/b12): installment plans + repayment schedule
|
||||
import InstallmentsIcon from '@mui/icons-material/PaymentsOutlined';
|
||||
// Payouts — the nurse earnings & payout-history surface (f12/b13)
|
||||
import EarningsIcon from '@mui/icons-material/PaidOutlined';
|
||||
|
||||
/**
|
||||
* List of all available Icon names
|
||||
@@ -153,4 +155,5 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
|
||||
emergency: EmergencyIcon,
|
||||
lock: LockIcon,
|
||||
installments: InstallmentsIcon,
|
||||
earnings: EarningsIcon,
|
||||
};
|
||||
|
||||
@@ -26,6 +26,9 @@ import EscrowNotice from './EscrowNotice';
|
||||
import PaymentStatusBadge from './PaymentStatusBadge';
|
||||
import BnplPlanCard from './BnplPlanCard';
|
||||
import InstallmentScheduleRow from './InstallmentScheduleRow';
|
||||
import EarningsBalanceHeader from './EarningsBalanceHeader';
|
||||
import EarningsRow from './EarningsRow';
|
||||
import PayoutHistoryRow from './PayoutHistoryRow';
|
||||
|
||||
export {
|
||||
UserInfo,
|
||||
@@ -54,6 +57,9 @@ export {
|
||||
PaymentStatusBadge,
|
||||
BnplPlanCard,
|
||||
InstallmentScheduleRow,
|
||||
EarningsBalanceHeader,
|
||||
EarningsRow,
|
||||
PayoutHistoryRow,
|
||||
};
|
||||
export type { PlaceholderScreenProps } from './PlaceholderScreen';
|
||||
export type { OtpInputProps } from './OtpInput';
|
||||
@@ -79,3 +85,6 @@ export type { PriceBreakdownProps, PriceBreakdownRow } from './PriceBreakdown';
|
||||
export type { PaymentStatusBadgeProps } from './PaymentStatusBadge';
|
||||
export type { BnplPlanCardProps } from './BnplPlanCard';
|
||||
export type { InstallmentScheduleRowProps } from './InstallmentScheduleRow';
|
||||
export type { EarningsBalanceHeaderProps } from './EarningsBalanceHeader';
|
||||
export type { EarningsRowProps } from './EarningsRow';
|
||||
export type { PayoutHistoryRowProps } from './PayoutHistoryRow';
|
||||
|
||||
@@ -56,6 +56,10 @@ export const ROUTES = {
|
||||
NURSE_VERIFICATION_CREDENTIALS: '/nurse/verification/credentials',
|
||||
NURSE_VERIFICATION_REVIEW: '/nurse/verification/review',
|
||||
NURSE_VISITS: '/nurse/visits',
|
||||
// Earnings & payout history (f12) — the nurse's money home; append `/payouts` for the history list.
|
||||
NURSE_EARNINGS: '/nurse/earnings',
|
||||
// Payout history list base — append `/{payoutId}` for the payout/batch reconciliation detail.
|
||||
NURSE_EARNINGS_PAYOUTS: '/nurse/earnings/payouts',
|
||||
|
||||
// Admin / backoffice console
|
||||
ADMIN: '/admin',
|
||||
@@ -75,5 +79,13 @@ export const bookingCancelPath = (bookingId: number | string): string =>
|
||||
export const bookingRefundStatusPath = (bookingId: number | string): string =>
|
||||
`${ROUTES.BOOKINGS}/${bookingId}/refund_status`;
|
||||
|
||||
/** The nurse's payout/batch reconciliation detail (f12) — keyed by the payout id. */
|
||||
export const nursePayoutDetailPath = (payoutId: number | string): string =>
|
||||
`${ROUTES.NURSE_EARNINGS_PAYOUTS}/${payoutId}`;
|
||||
|
||||
/** The nurse's booking-detail view (f8) — earnings rows + payout booking-links deep-link here. */
|
||||
export const nurseBookingDetailPath = (bookingId: number | string): string =>
|
||||
`${ROUTES.NURSE_VISITS}/${bookingId}`;
|
||||
|
||||
/** Paths (without locale prefix) that bypass auth in middleware. */
|
||||
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN];
|
||||
|
||||
@@ -25,6 +25,7 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
|
||||
{ title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank' },
|
||||
{ title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification' },
|
||||
{ title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits' },
|
||||
{ title: t('earnings'), path: ROUTES.NURSE_EARNINGS, icon: 'earnings' },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
import { PAYOUTS_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
EarningsListParams,
|
||||
NurseEarningsItem,
|
||||
NurseEarningsSummary,
|
||||
NursePayoutDetail,
|
||||
NursePayoutHistoryItem,
|
||||
PayoutStatus,
|
||||
PayoutsApi,
|
||||
} from '../types';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
|
||||
const NURSE_PAYOUTS = '/api/v1/nurse_payouts';
|
||||
|
||||
/**
|
||||
* The b13 nurse history payload (`GET nurse_payouts/history` → `NursePayoutHistoryDto`). Note it carries
|
||||
* **no** `failureReason` — that field lives on the admin-only `PayoutDto`, so it maps to `null` until
|
||||
* REQ-025 adds it to the nurse read (a `failed` payout still renders; its reason is just absent).
|
||||
*/
|
||||
interface NursePayoutHistoryWire {
|
||||
id: number;
|
||||
batchId: number;
|
||||
status: PayoutStatus;
|
||||
grossEarningsIrr: string;
|
||||
clawbackAppliedIrr: string;
|
||||
netAmountIrr: string;
|
||||
maskedIban: string;
|
||||
transferReference: string | null;
|
||||
paidAt: string | null;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
}
|
||||
|
||||
function toHistoryItem(wire: NursePayoutHistoryWire): NursePayoutHistoryItem {
|
||||
return {
|
||||
id: wire.id,
|
||||
batchId: wire.batchId,
|
||||
status: wire.status,
|
||||
grossEarningsIrr: wire.grossEarningsIrr,
|
||||
clawbackAppliedIrr: wire.clawbackAppliedIrr,
|
||||
netAmountIrr: wire.netAmountIrr,
|
||||
maskedIban: wire.maskedIban,
|
||||
transferReference: wire.transferReference,
|
||||
paidAt: wire.paidAt,
|
||||
periodStart: wire.periodStart,
|
||||
periodEnd: wire.periodEnd,
|
||||
// REQ-025: the nurse history DTO carries no failure reason yet (it is on the admin PayoutDto).
|
||||
failureReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* route; the other three target contract gaps the frontend filed (**REQ-025**), which is why the domain
|
||||
* stays mock-primary (see `constants.ts`):
|
||||
* - `getNurseEarningsBalance` → the four-bucket summary + signed net balance (no nurse endpoint today).
|
||||
* - `getNurseEarnings` → the per-booking earnings list + money-state (no nurse endpoint today).
|
||||
* - `getNursePayoutDetail` → a nurse-scoped payout detail with batch context + booking links (the b13
|
||||
* `batches/{id}` detail is **admin-only**).
|
||||
*
|
||||
* NOT the primary implementation this phase (`USE_PAYOUTS_MOCK = true`). Routes are `snake_case`; ids come
|
||||
* from the route; `clientFetch` returns the raw envelope so we `unwrap()`.
|
||||
*/
|
||||
export const payoutsClientApi: PayoutsApi = {
|
||||
getNurseEarningsBalance: async () =>
|
||||
unwrap(await clientFetch<ApiEnvelope<NurseEarningsSummary>>(`${NURSE_PAYOUTS}/earnings_balance`)),
|
||||
|
||||
getNurseEarnings: async (params: EarningsListParams): Promise<Paginated<NurseEarningsItem>> => {
|
||||
const query = new URLSearchParams();
|
||||
if (params.state) query.set('state', params.state);
|
||||
query.set('page', String(params.page ?? 1));
|
||||
query.set('pageSize', String(params.pageSize ?? PAYOUTS_PAGE_SIZE));
|
||||
return unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<NurseEarningsItem>>>(`${NURSE_PAYOUTS}/earnings?${query.toString()}`),
|
||||
);
|
||||
},
|
||||
|
||||
getNursePayoutHistory: async (params: PageParams): Promise<Paginated<NursePayoutHistoryItem>> => {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page ?? 1));
|
||||
query.set('pageSize', String(params.pageSize ?? PAYOUTS_PAGE_SIZE));
|
||||
const page = unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<NursePayoutHistoryWire>>>(`${NURSE_PAYOUTS}/history?${query.toString()}`),
|
||||
);
|
||||
return { ...page, items: page.items.map(toHistoryItem) };
|
||||
},
|
||||
|
||||
getNursePayoutDetail: async (payoutId: number) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<NursePayoutDetail>>(`${NURSE_PAYOUTS}/${payoutId}`)),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { USE_PAYOUTS_MOCK } from '../constants';
|
||||
import type { PayoutsApi } from '../types';
|
||||
import { payoutsClientApi } from './clientApi';
|
||||
import { payoutsMockApi } from './mockApi';
|
||||
|
||||
/**
|
||||
* The selected `PayoutsApi` implementation — the single seam the hooks import. Selection is by config
|
||||
* (`USE_PAYOUTS_MOCK`), never by scattered `if (mock)` checks. Mock-primary this phase (REQ-025 gaps).
|
||||
*/
|
||||
export const payoutsApi: PayoutsApi = USE_PAYOUTS_MOCK ? payoutsMockApi : payoutsClientApi;
|
||||
@@ -0,0 +1,339 @@
|
||||
import type { Paginated } from '@/lib/api/types';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import { MOCK_SCENARIO } from '../constants';
|
||||
import type {
|
||||
EarningsListParams,
|
||||
EarningsState,
|
||||
NurseEarningsItem,
|
||||
NurseEarningsSummary,
|
||||
NursePayoutDetail,
|
||||
NursePayoutHistoryItem,
|
||||
PayoutsApi,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* In-memory `PayoutsApi` — **the primary implementation this phase** (b13 serves only the nurse history
|
||||
* endpoint; the summary, earnings list, and nurse payout detail are REQ-025 gaps — see `constants.ts`).
|
||||
*
|
||||
* The fixtures are engineered to exercise **every** UI state and to be **money-correct**:
|
||||
* - all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`);
|
||||
* - `gross = commission + payout` on every earnings row (the sacred three-amount invariant);
|
||||
* - a **negative net balance** ("owed back") under the `clawback_heavy` scenario;
|
||||
* - a `failed` payout (with a `failureReason`) plus `paid` and `submitted` payouts;
|
||||
* - payout-detail booking links whose `payoutAmountIrr` sum to the payout's `grossEarningsIrr`, and
|
||||
* `gross − clawback = net = amount` — the reconciliation the nurse checks.
|
||||
*
|
||||
* Booking ids align with the f8 bookings mock seeds (5001–5004) so an earnings row's "view booking"
|
||||
* deep-link lands on a real mock detail screen. Timestamps are computed **relative to now** so the pending
|
||||
* dispute-window countdown always ticks; all money stays an IRR digit-string end-to-end.
|
||||
*/
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
|
||||
/** An ISO instant `hours` in the future (+) / past (−) from now — for dispute-window / paid-at fixtures. */
|
||||
function isoFromNowHours(hours: number): string {
|
||||
return new Date(Date.now() + hours * HOUR_MS).toISOString();
|
||||
}
|
||||
|
||||
/** An ISO `YYYY-MM-DD` date `days` ago (batch window / scheduled-date fixtures). */
|
||||
function isoDateDaysAgo(days: number): string {
|
||||
return new Date(Date.now() - days * DAY_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const MASKED_IBAN = 'IR••••••••••••••••••4821';
|
||||
|
||||
// ── Earnings items (one per state; ids match the f8 bookings mock seeds) ──────────────────────────────
|
||||
const EARNINGS: NurseEarningsItem[] = [
|
||||
{
|
||||
// pending — in escrow, dispute window still open (~40h left, always ticking)
|
||||
bookingId: 5001,
|
||||
patientName: 'حاجآقا موسوی',
|
||||
scheduledDate: isoDateDaysAgo(1),
|
||||
grossPriceIrr: '5000000',
|
||||
balinyaarCommissionIrr: '750000',
|
||||
nursePayoutAmount: '4250000',
|
||||
state: 'pending',
|
||||
disputeWindowEndsAt: isoFromNowHours(40),
|
||||
payoutEligibleAt: null,
|
||||
paidAt: null,
|
||||
transferReference: null,
|
||||
nursePayoutId: null,
|
||||
batchId: null,
|
||||
clawbackAppliedIrr: null,
|
||||
netAmountIrr: null,
|
||||
},
|
||||
{
|
||||
// eligible — cleared (dispute window closed), awaiting the next weekly batch
|
||||
bookingId: 5002,
|
||||
patientName: 'خانم احمدی',
|
||||
scheduledDate: isoDateDaysAgo(4),
|
||||
grossPriceIrr: '4000000',
|
||||
balinyaarCommissionIrr: '600000',
|
||||
nursePayoutAmount: '3400000',
|
||||
state: 'eligible',
|
||||
disputeWindowEndsAt: null,
|
||||
payoutEligibleAt: isoFromNowHours(-12),
|
||||
paidAt: null,
|
||||
transferReference: null,
|
||||
nursePayoutId: null,
|
||||
batchId: null,
|
||||
clawbackAppliedIrr: null,
|
||||
netAmountIrr: null,
|
||||
},
|
||||
{
|
||||
// paid — transferred, links to its payout detail
|
||||
bookingId: 5003,
|
||||
patientName: 'آقای کریمی',
|
||||
scheduledDate: isoDateDaysAgo(9),
|
||||
grossPriceIrr: '5000000',
|
||||
balinyaarCommissionIrr: '750000',
|
||||
nursePayoutAmount: '4250000',
|
||||
state: 'paid',
|
||||
disputeWindowEndsAt: null,
|
||||
payoutEligibleAt: isoFromNowHours(-96),
|
||||
paidAt: isoFromNowHours(-72),
|
||||
transferReference: 'PAYA-14050412-9001',
|
||||
nursePayoutId: 9001,
|
||||
batchId: 7001,
|
||||
clawbackAppliedIrr: null,
|
||||
netAmountIrr: null,
|
||||
},
|
||||
{
|
||||
// clawback_applied — booking refunded after payout: original 1,700,000 − clawback 1,700,000 = net 0
|
||||
bookingId: 5004,
|
||||
patientName: 'خانم صادقی',
|
||||
scheduledDate: isoDateDaysAgo(14),
|
||||
grossPriceIrr: '2000000',
|
||||
balinyaarCommissionIrr: '300000',
|
||||
nursePayoutAmount: '1700000',
|
||||
state: 'clawback_applied',
|
||||
disputeWindowEndsAt: null,
|
||||
payoutEligibleAt: isoFromNowHours(-240),
|
||||
paidAt: isoFromNowHours(-216),
|
||||
transferReference: 'SATNA-14050405-9002',
|
||||
nursePayoutId: 9002,
|
||||
batchId: 7000,
|
||||
clawbackAppliedIrr: '1700000',
|
||||
netAmountIrr: '0',
|
||||
},
|
||||
];
|
||||
|
||||
// ── Payout history (newest first; exercises all four PayoutStatus values) ─────────────────────────────
|
||||
const HISTORY: NursePayoutHistoryItem[] = [
|
||||
{
|
||||
id: 9004,
|
||||
batchId: 7003,
|
||||
status: 'submitted',
|
||||
grossEarningsIrr: '2000000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '2000000',
|
||||
maskedIban: MASKED_IBAN,
|
||||
transferReference: null,
|
||||
paidAt: null,
|
||||
periodStart: isoDateDaysAgo(7),
|
||||
periodEnd: isoDateDaysAgo(1),
|
||||
failureReason: null,
|
||||
},
|
||||
{
|
||||
id: 9003,
|
||||
batchId: 7002,
|
||||
status: 'failed',
|
||||
grossEarningsIrr: '3400000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '3400000',
|
||||
maskedIban: MASKED_IBAN,
|
||||
transferReference: null,
|
||||
paidAt: null,
|
||||
periodStart: isoDateDaysAgo(14),
|
||||
periodEnd: isoDateDaysAgo(8),
|
||||
failureReason: 'invalid_sheba',
|
||||
},
|
||||
{
|
||||
id: 9001,
|
||||
batchId: 7001,
|
||||
status: 'paid',
|
||||
grossEarningsIrr: '4250000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '4250000',
|
||||
maskedIban: MASKED_IBAN,
|
||||
transferReference: 'PAYA-14050412-9001',
|
||||
paidAt: isoFromNowHours(-72),
|
||||
periodStart: isoDateDaysAgo(14),
|
||||
periodEnd: isoDateDaysAgo(8),
|
||||
failureReason: null,
|
||||
},
|
||||
{
|
||||
// demonstrates clawback netting in a paid payout: 5,000,000 gross − 750,000 clawback = 4,250,000 net
|
||||
id: 9002,
|
||||
batchId: 7000,
|
||||
status: 'paid',
|
||||
grossEarningsIrr: '5000000',
|
||||
clawbackAppliedIrr: '750000',
|
||||
netAmountIrr: '4250000',
|
||||
maskedIban: MASKED_IBAN,
|
||||
transferReference: 'SATNA-14050405-9002',
|
||||
paidAt: isoFromNowHours(-240),
|
||||
periodStart: isoDateDaysAgo(21),
|
||||
periodEnd: isoDateDaysAgo(15),
|
||||
failureReason: null,
|
||||
},
|
||||
];
|
||||
|
||||
// ── Payout details (batch context + booking links; every one reconciles) ──────────────────────────────
|
||||
const DETAILS: Record<number, NursePayoutDetail> = {
|
||||
9001: {
|
||||
id: 9001,
|
||||
batchId: 7001,
|
||||
status: 'paid',
|
||||
grossEarningsIrr: '4250000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '4250000',
|
||||
amountIrr: '4250000',
|
||||
maskedIban: MASKED_IBAN,
|
||||
transferReference: 'PAYA-14050412-9001',
|
||||
paidAt: isoFromNowHours(-72),
|
||||
failureReason: null,
|
||||
batch: {
|
||||
id: 7001,
|
||||
periodStart: isoDateDaysAgo(14),
|
||||
periodEnd: isoDateDaysAgo(8),
|
||||
processingDate: isoDateDaysAgo(7),
|
||||
status: 'completed',
|
||||
totalAmount: '4250000',
|
||||
payoutCount: 1,
|
||||
processedAt: isoFromNowHours(-72),
|
||||
},
|
||||
bookings: [{ bookingId: 5003, sessionId: 1, payoutAmountIrr: '4250000' }],
|
||||
},
|
||||
9002: {
|
||||
// Σ booking links (2,750,000 + 2,250,000) = 5,000,000 gross − 750,000 clawback = 4,250,000 net/amount
|
||||
id: 9002,
|
||||
batchId: 7000,
|
||||
status: 'paid',
|
||||
grossEarningsIrr: '5000000',
|
||||
clawbackAppliedIrr: '750000',
|
||||
netAmountIrr: '4250000',
|
||||
amountIrr: '4250000',
|
||||
maskedIban: MASKED_IBAN,
|
||||
transferReference: 'SATNA-14050405-9002',
|
||||
paidAt: isoFromNowHours(-240),
|
||||
failureReason: null,
|
||||
batch: {
|
||||
id: 7000,
|
||||
periodStart: isoDateDaysAgo(21),
|
||||
periodEnd: isoDateDaysAgo(15),
|
||||
processingDate: isoDateDaysAgo(14),
|
||||
status: 'completed',
|
||||
totalAmount: '4250000',
|
||||
payoutCount: 1,
|
||||
processedAt: isoFromNowHours(-240),
|
||||
},
|
||||
bookings: [
|
||||
{ bookingId: 4990, sessionId: 1, payoutAmountIrr: '2750000' },
|
||||
{ bookingId: 4991, sessionId: 1, payoutAmountIrr: '2250000' },
|
||||
],
|
||||
},
|
||||
9003: {
|
||||
id: 9003,
|
||||
batchId: 7002,
|
||||
status: 'failed',
|
||||
grossEarningsIrr: '3400000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '3400000',
|
||||
amountIrr: '3400000',
|
||||
maskedIban: MASKED_IBAN,
|
||||
transferReference: null,
|
||||
paidAt: null,
|
||||
failureReason: 'invalid_sheba',
|
||||
batch: {
|
||||
id: 7002,
|
||||
periodStart: isoDateDaysAgo(14),
|
||||
periodEnd: isoDateDaysAgo(8),
|
||||
processingDate: isoDateDaysAgo(7),
|
||||
status: 'partially_failed',
|
||||
totalAmount: '3400000',
|
||||
payoutCount: 1,
|
||||
processedAt: isoFromNowHours(-168),
|
||||
},
|
||||
bookings: [{ bookingId: 5005, sessionId: 1, payoutAmountIrr: '3400000' }],
|
||||
},
|
||||
9004: {
|
||||
id: 9004,
|
||||
batchId: 7003,
|
||||
status: 'submitted',
|
||||
grossEarningsIrr: '2000000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '2000000',
|
||||
amountIrr: '2000000',
|
||||
maskedIban: MASKED_IBAN,
|
||||
transferReference: null,
|
||||
paidAt: null,
|
||||
failureReason: null,
|
||||
batch: {
|
||||
id: 7003,
|
||||
periodStart: isoDateDaysAgo(7),
|
||||
periodEnd: isoDateDaysAgo(1),
|
||||
processingDate: isoDateDaysAgo(0),
|
||||
status: 'processing',
|
||||
totalAmount: '2000000',
|
||||
payoutCount: 1,
|
||||
processedAt: null,
|
||||
},
|
||||
bookings: [{ bookingId: 5010, sessionId: 1, payoutAmountIrr: '2000000' }],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The ledger-derived summary. `paid` is a **lifetime** total; the **net payable balance** is
|
||||
* `pending + eligible − clawbackOutstanding` (accrued-unpaid earnings minus receivables), computed with
|
||||
* BigInt and **not clamped** — under `clawback_heavy` it goes negative ("owed back"). `paid` never enters
|
||||
* the net balance (it already left the ledger).
|
||||
*/
|
||||
function buildSummary(): NurseEarningsSummary {
|
||||
const pending = BigInt(4_250_000);
|
||||
const eligible = BigInt(3_400_000);
|
||||
const paid = BigInt(8_500_000); // 9001 (4,250,000) + 9002 net (4,250,000)
|
||||
const clawbackOutstanding = MOCK_SCENARIO === 'clawback_heavy' ? BigInt(12_000_000) : BigInt(1_700_000);
|
||||
const net = pending + eligible - clawbackOutstanding;
|
||||
return {
|
||||
pendingTotalIrr: String(pending),
|
||||
eligibleTotalIrr: String(eligible),
|
||||
paidTotalIrr: String(paid),
|
||||
clawbackOutstandingIrr: String(clawbackOutstanding),
|
||||
netPayableBalanceIrr: String(net),
|
||||
};
|
||||
}
|
||||
|
||||
function paginate<T>(all: T[], params: PageParams): Paginated<T> {
|
||||
const page = Math.max(1, params.page ?? 1);
|
||||
const pageSize = Math.max(1, params.pageSize ?? all.length);
|
||||
const start = (page - 1) * pageSize;
|
||||
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
|
||||
}
|
||||
|
||||
/** Small artificial latency so loading skeletons are observable in dev. */
|
||||
const LATENCY_MS = 250;
|
||||
function delay<T>(value: T): Promise<T> {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(value), LATENCY_MS));
|
||||
}
|
||||
|
||||
const STATE_ORDER: Record<EarningsState, number> = { pending: 0, eligible: 1, paid: 2, clawback_applied: 3 };
|
||||
|
||||
export const payoutsMockApi: PayoutsApi = {
|
||||
getNurseEarningsBalance: async () => delay(buildSummary()),
|
||||
|
||||
getNurseEarnings: async (params: EarningsListParams) => {
|
||||
const filtered = params.state ? EARNINGS.filter((e) => e.state === params.state) : [...EARNINGS];
|
||||
filtered.sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state] || b.bookingId - a.bookingId);
|
||||
return delay(paginate(filtered, params));
|
||||
},
|
||||
|
||||
getNursePayoutHistory: async (params: PageParams) => delay(paginate([...HISTORY], params)),
|
||||
|
||||
getNursePayoutDetail: async (payoutId: number) => {
|
||||
const detail = DETAILS[payoutId];
|
||||
if (!detail) throw new Error(`Mock payout ${payoutId} not found`);
|
||||
return delay(detail);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* When true, the payouts domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
|
||||
* `PayoutsApi` seam.
|
||||
*
|
||||
* **Mock is primary this phase.** b13 shipped the nurse read as a **single** endpoint
|
||||
* (`GET api/v1/nurse_payouts/history` → `NursePayoutHistoryDto`). The **four-bucket earnings summary**,
|
||||
* the **per-booking earnings list + money-state**, and a **nurse-readable payout detail** (batch context +
|
||||
* booking links + `failureReason`) are contract gaps filed as **REQ-025**. So the whole earnings surface is
|
||||
* mocked behind this seam with real-shaped fixtures covering **all four earnings states + a negative net
|
||||
* balance (clawback > earnings) + a `failed` payout** (every UI state exercisable). Flip to `false` once
|
||||
* REQ-025 lands — no hook/component change (only `clientApi.ts`'s three gap methods start returning real data).
|
||||
*/
|
||||
export const USE_PAYOUTS_MOCK = true;
|
||||
|
||||
/**
|
||||
* Which mock ledger picture to serve. `standard` = a healthy positive net balance with one of each earnings
|
||||
* state; `clawback_heavy` = outstanding clawbacks exceed accrued-unpaid earnings so the **net balance goes
|
||||
* negative** ("owed back") — the explicit owed-back UI state (phase §7 step 3). The four earnings rows are
|
||||
* identical across scenarios; only the ledger-derived summary differs (the summary spans the whole ledger,
|
||||
* not just the visible page). Flip to demo the negative-balance treatment.
|
||||
*/
|
||||
export type PayoutsMockScenario = 'standard' | 'clawback_heavy';
|
||||
export const MOCK_SCENARIO: PayoutsMockScenario = 'standard';
|
||||
|
||||
/**
|
||||
* Earnings move on a **weekly cadence**, not per second — a generous `staleTime` means revisiting the
|
||||
* screen or switching a tab never needlessly refetches. There are no mutations this phase, so nothing
|
||||
* invalidates these; the natural staleness is the only refresh trigger.
|
||||
*/
|
||||
export const EARNINGS_SUMMARY_STALE_TIME = 5 * 60 * 1000;
|
||||
export const EARNINGS_LIST_STALE_TIME = 5 * 60 * 1000;
|
||||
export const PAYOUT_HISTORY_STALE_TIME = 5 * 60 * 1000;
|
||||
/** A settled payout is immutable — its detail is effectively permanent; keep it warm longer. */
|
||||
export const PAYOUT_DETAIL_STALE_TIME = 10 * 60 * 1000;
|
||||
export const PAYOUTS_GC_TIME = 15 * 60 * 1000;
|
||||
|
||||
/** Page size for the earnings + payout-history lists (api-conventions `pageSize`). */
|
||||
export const PAYOUTS_PAGE_SIZE = 10;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
import { EARNINGS_LIST_STALE_TIME, PAYOUTS_GC_TIME, PAYOUTS_PAGE_SIZE } from '../constants';
|
||||
import type { EarningsState } from '../types';
|
||||
|
||||
/**
|
||||
* The state-segmented earnings list. The **state filter + page are part of the query key** so each tab and
|
||||
* each page caches independently — switching back to a viewed tab is a cache hit with no network. `state`
|
||||
* omitted = the unfiltered ("all") tab. `keepPreviousData` holds the prior page/tab visible while the next
|
||||
* loads, so paging/tabbing never flashes an empty list.
|
||||
*/
|
||||
export function useNurseEarnings(state: EarningsState | undefined, page: number) {
|
||||
return useQuery({
|
||||
queryKey: payoutKeys.earningsList(state ?? 'all', page),
|
||||
queryFn: () => payoutsApi.getNurseEarnings({ state, page, pageSize: PAYOUTS_PAGE_SIZE }),
|
||||
staleTime: EARNINGS_LIST_STALE_TIME,
|
||||
gcTime: PAYOUTS_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
import { EARNINGS_SUMMARY_STALE_TIME, PAYOUTS_GC_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The nurse's four-bucket earnings roll-up + the **signed** net payable balance. Read-only; a generous
|
||||
* `staleTime` (earnings move weekly, not per second) so revisiting the screen serves from cache. The net
|
||||
* balance may be negative ("owed back") — the header renders the sign; this hook never touches the value.
|
||||
*/
|
||||
export function useNurseEarningsBalance() {
|
||||
return useQuery({
|
||||
queryKey: payoutKeys.earningsSummary(),
|
||||
queryFn: () => payoutsApi.getNurseEarningsBalance(),
|
||||
staleTime: EARNINGS_SUMMARY_STALE_TIME,
|
||||
gcTime: PAYOUTS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
import { PAYOUT_DETAIL_STALE_TIME, PAYOUTS_GC_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* One payout expanded — the nurse's reconciliation view (money decomposition + batch window + the exact
|
||||
* bookings covered). A settled payout is immutable, so a long `staleTime`; disabled until a valid id.
|
||||
*/
|
||||
export function useNursePayoutDetail(payoutId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: payoutKeys.detail(payoutId ?? -1),
|
||||
queryFn: () => payoutsApi.getNursePayoutDetail(payoutId as number),
|
||||
enabled: payoutId != null && payoutId > 0,
|
||||
staleTime: PAYOUT_DETAIL_STALE_TIME,
|
||||
gcTime: PAYOUTS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
import { PAYOUT_HISTORY_STALE_TIME, PAYOUTS_GC_TIME, PAYOUTS_PAGE_SIZE } from '../constants';
|
||||
|
||||
/**
|
||||
* The nurse's paginated payout history (`nurse_payouts`, newest first). Each page keys separately;
|
||||
* `keepPreviousData` avoids an empty flash while paging. Read-only, generous `staleTime`.
|
||||
*/
|
||||
export function useNursePayoutHistory(page: number) {
|
||||
return useQuery({
|
||||
queryKey: payoutKeys.history(page),
|
||||
queryFn: () => payoutsApi.getNursePayoutHistory({ page, pageSize: PAYOUTS_PAGE_SIZE }),
|
||||
staleTime: PAYOUT_HISTORY_STALE_TIME,
|
||||
gcTime: PAYOUTS_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Payouts domain barrel — re-exports **hooks only** (per the `services/{domain}` convention).
|
||||
* Import types/keys/apis directly from their files when needed.
|
||||
*/
|
||||
export { useNurseEarningsBalance } from './hooks/useNurseEarningsBalance';
|
||||
export { useNurseEarnings } from './hooks/useNurseEarnings';
|
||||
export { useNursePayoutHistory } from './hooks/useNursePayoutHistory';
|
||||
export { useNursePayoutDetail } from './hooks/useNursePayoutDetail';
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { EarningsState } from './types';
|
||||
|
||||
/**
|
||||
* React Query key factory for the payouts domain (hierarchical, per the `services/{domain}` pattern).
|
||||
*
|
||||
* The **state filter and page are part of the key** so switching the earnings tab or paging never refetches
|
||||
* data already in cache (reverting to a viewed tab is a cache hit); the summary, each earnings tab, each
|
||||
* history page, and each payout detail all key independently.
|
||||
*/
|
||||
export const payoutKeys = {
|
||||
all: ['payouts'] as const,
|
||||
|
||||
earningsSummary: () => [...payoutKeys.all, 'earnings_summary'] as const,
|
||||
|
||||
earningsLists: () => [...payoutKeys.all, 'earnings'] as const,
|
||||
/** `state` is `'all'` for the unfiltered tab, else the `EarningsState`; `page` keeps pages separate. */
|
||||
earningsList: (state: EarningsState | 'all', page: number) =>
|
||||
[...payoutKeys.earningsLists(), state, page] as const,
|
||||
|
||||
historyLists: () => [...payoutKeys.all, 'history'] as const,
|
||||
history: (page: number) => [...payoutKeys.historyLists(), page] as const,
|
||||
|
||||
details: () => [...payoutKeys.all, 'detail'] as const,
|
||||
detail: (payoutId: number) => [...payoutKeys.details(), payoutId] as const,
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
|
||||
/**
|
||||
* Payouts domain — the **nurse read** side of the b13 weekly-payout engine ("I did the work, where is
|
||||
* my money?"). Shapes are derived from the payouts contract
|
||||
* (`dev/contracts/domains/payouts.md` + `dev/contracts/openapi/swagger.v1.json`).
|
||||
*
|
||||
* **The contract only serves a nurse ONE endpoint** (`GET api/v1/nurse_payouts/history` →
|
||||
* `NursePayoutHistoryDto`). The **four-bucket earnings summary**, the **per-booking earnings list with a
|
||||
* money-state**, and a **nurse-readable payout detail** (batch context + booking links) are contract gaps
|
||||
* filed as **REQ-025** and mocked behind the `PayoutsApi` seam this phase — so the domain is mock-primary
|
||||
* (see `constants.ts`). When REQ-025 lands, only `apis/clientApi.ts` flips; hooks/screens are unchanged.
|
||||
*
|
||||
* Load-bearing money/authority semantics (contract + phase §5):
|
||||
* - Money is **IRR digit-strings**, integer-safe via the money util; **never** `Number()`/float math.
|
||||
* Toman is **display-only**. The three booking amounts satisfy `gross = commission + payout`.
|
||||
* - The nurse **payable balance is derived from the ledger and MAY be negative** ("owed back") — model it
|
||||
* as a **signed** string; **never clamp to zero**. A clawback **nets**, it does not auto-reverse.
|
||||
* - **Eligibility is server truth** (EVV complete AND `dispute_window_ends_at < now`). The client only ever
|
||||
* *renders* a cosmetic countdown off `disputeWindowEndsAt`; it **never computes eligibility** client-side.
|
||||
* - **Read-only:** a nurse never triggers a transfer, retries a payout, or runs a batch (admin actions, f15).
|
||||
* - The **BNPL provider commission is NEVER a nurse deduction** — it does not appear anywhere here; the nurse
|
||||
* amount is payment-method-invariant (`gross − balinyaar_commission`, identical for card vs BNPL).
|
||||
*
|
||||
* Enums cross the wire as stable string codes — mirrored here as string-literal unions; labels are i18n keys.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The four money states a nurse cares about, per completed booking. This is a **client display model**
|
||||
* (there is no single wire enum for it — REQ-025); it is derived server-side from the ledger + dispute
|
||||
* window + payout link:
|
||||
* - `pending` — still in escrow, the dispute window is open (not yet payout-eligible).
|
||||
* - `eligible` — cleared (dispute window closed), awaiting the next weekly batch.
|
||||
* - `paid` — transferred (carries `paidAt` + `transferReference` + the owning `nursePayoutId`).
|
||||
* - `clawback_applied` — a refund-after-payout netted the original earning out of the total.
|
||||
*/
|
||||
export type EarningsState = 'pending' | 'eligible' | 'paid' | 'clawback_applied';
|
||||
|
||||
/** Stable render/filter order for the state-segmented list. */
|
||||
export const EARNINGS_STATES: readonly EarningsState[] = [
|
||||
'pending',
|
||||
'eligible',
|
||||
'paid',
|
||||
'clawback_applied',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* `PayoutStatus` (contract enum) — the per-payout lifecycle, **forward-only**. `paid` is an irreversible
|
||||
* transfer with no outgoing edge; `failed` re-submits on an **admin** retry. NB the contract uses
|
||||
* `submitted` (not "processing"): a payout handed to the bank rail, awaiting settlement.
|
||||
*/
|
||||
export type PayoutStatus = 'pending' | 'submitted' | 'paid' | 'failed';
|
||||
|
||||
/** `PayoutBatchStatus` (contract enum) — the batch lifecycle a payout's batch context reports. */
|
||||
export type PayoutBatchStatus = 'draft' | 'processing' | 'partially_failed' | 'completed' | 'failed';
|
||||
|
||||
/** A payout is settled (no further movement the nurse can affect) once `paid` or `failed`. */
|
||||
export function isTerminalPayoutStatus(status: PayoutStatus): boolean {
|
||||
return status === 'paid' || status === 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* The four-bucket earnings roll-up + the derived net payable balance (REQ-025 — the summary shape the
|
||||
* contract does not yet serve). Every amount is an IRR digit-string.
|
||||
*/
|
||||
export interface NurseEarningsSummary {
|
||||
/** Still in escrow, dispute window open. */
|
||||
pendingTotalIrr: string;
|
||||
/** Cleared, awaiting the next weekly batch. */
|
||||
eligibleTotalIrr: string;
|
||||
/** Lifetime transferred. */
|
||||
paidTotalIrr: string;
|
||||
/** Clawback receivable not yet recovered (netted from a future batch). */
|
||||
clawbackOutstandingIrr: string;
|
||||
/**
|
||||
* **Signed** IRR digit-string — what Balinyaar currently owes the nurse (ledger-derived). **MAY be
|
||||
* negative** when outstanding clawbacks exceed accrued-unpaid earnings ("owed back"). Never clamp.
|
||||
*/
|
||||
netPayableBalanceIrr: string;
|
||||
}
|
||||
|
||||
/** One completed booking contributing to earnings (REQ-025). Enough fields to deep-link + explain each state. */
|
||||
export interface NurseEarningsItem {
|
||||
/** The booking this earning is for — deep-links to the f8 nurse booking detail. */
|
||||
bookingId: number;
|
||||
patientName: string;
|
||||
/** ISO date `YYYY-MM-DD`. */
|
||||
scheduledDate: string;
|
||||
/** The three amounts — IRR digit-strings; `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`. */
|
||||
grossPriceIrr: string;
|
||||
balinyaarCommissionIrr: string;
|
||||
nursePayoutAmount: string;
|
||||
state: EarningsState;
|
||||
/** Drives the **display-only** pending countdown; `null` once past. The server owns eligibility. */
|
||||
disputeWindowEndsAt: string | null;
|
||||
/** Server truth: when this amount became payout-eligible. `null` while pending. Never computed here. */
|
||||
payoutEligibleAt: string | null;
|
||||
/** `paid` only: when the transfer landed + its opaque reconciliation reference + the owning payout/batch. */
|
||||
paidAt: string | null;
|
||||
transferReference: string | null;
|
||||
nursePayoutId: number | null;
|
||||
batchId: number | null;
|
||||
/** `clawback_applied` only: the clawed-back amount and the resulting net (`= nursePayoutAmount − clawback`). */
|
||||
clawbackAppliedIrr: string | null;
|
||||
netAmountIrr: string | null;
|
||||
}
|
||||
|
||||
/** One `nurse_payouts` row in the nurse's own history (`NursePayoutHistoryDto` + REQ-025 `failureReason`). */
|
||||
export interface NursePayoutHistoryItem {
|
||||
id: number;
|
||||
batchId: number;
|
||||
status: PayoutStatus;
|
||||
/** `netAmountIrr = grossEarningsIrr − clawbackAppliedIrr`, guaranteed server-side. */
|
||||
grossEarningsIrr: string;
|
||||
clawbackAppliedIrr: string;
|
||||
netAmountIrr: string;
|
||||
/** Masked, **last-4 only** — an encrypted field; never a full IBAN. */
|
||||
maskedIban: string;
|
||||
transferReference: string | null;
|
||||
paidAt: string | null;
|
||||
/** The batch window (holiday-shifted server-side), ISO dates `YYYY-MM-DD`. */
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
/** `failed` only (REQ-025 — the nurse history DTO lacks it today). Read-only; the nurse cannot retry. */
|
||||
failureReason: string | null;
|
||||
}
|
||||
|
||||
/** A booking a payout covered (`PayoutBookingLinkDto`). One booking appears in exactly one payout, forever. */
|
||||
export interface NursePayoutBookingLink {
|
||||
bookingId: number;
|
||||
sessionId: number | null;
|
||||
/** This booking's nurse-payout share. Σ over a payout's links = its `grossEarningsIrr`. */
|
||||
payoutAmountIrr: string;
|
||||
}
|
||||
|
||||
/** The `nurse_payout_batches` context a nurse sees for one of their payouts (subset of `PayoutBatchDto`). */
|
||||
export interface NursePayoutBatchContext {
|
||||
id: number;
|
||||
/** Holiday-shifted server-side; ISO dates `YYYY-MM-DD`. */
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
processingDate: string;
|
||||
status: PayoutBatchStatus;
|
||||
totalAmount: string;
|
||||
payoutCount: number;
|
||||
processedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One payout expanded — the nurse's reconciliation view (REQ-025, nurse-scoped analogue of the admin
|
||||
* `PayoutDto`): the money decomposition, the batch window, and the exact bookings it covered.
|
||||
*/
|
||||
export interface NursePayoutDetail {
|
||||
id: number;
|
||||
batchId: number;
|
||||
status: PayoutStatus;
|
||||
grossEarningsIrr: string;
|
||||
clawbackAppliedIrr: string;
|
||||
/** `= grossEarningsIrr − clawbackAppliedIrr`. */
|
||||
netAmountIrr: string;
|
||||
/** What was actually transferred (`= netAmountIrr` on a clean payout). */
|
||||
amountIrr: string;
|
||||
maskedIban: string;
|
||||
transferReference: string | null;
|
||||
paidAt: string | null;
|
||||
failureReason: string | null;
|
||||
batch: NursePayoutBatchContext;
|
||||
bookings: NursePayoutBookingLink[];
|
||||
}
|
||||
|
||||
/** `getNurseEarnings` query params — the state filter is part of the query key so each tab caches separately. */
|
||||
export interface EarningsListParams extends PageParams {
|
||||
state?: EarningsState;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export interface PayoutsApi {
|
||||
getNurseEarningsBalance(): Promise<NurseEarningsSummary>;
|
||||
getNurseEarnings(params: EarningsListParams): Promise<Paginated<NurseEarningsItem>>;
|
||||
getNursePayoutHistory(params: PageParams): Promise<Paginated<NursePayoutHistoryItem>>;
|
||||
getNursePayoutDetail(payoutId: number): Promise<NursePayoutDetail>;
|
||||
}
|
||||
Reference in New Issue
Block a user