frontend phase 12

This commit is contained in:
hamid
2026-07-10 14:48:15 +03:30
parent 67c028562e
commit 6186f54294
34 changed files with 2363 additions and 1 deletions
+7 -1
View File
@@ -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 ~710 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=grossclawback; Σ 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` 710-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 (D1D5): 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 D1D4 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
+85
View File
@@ -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."
}
}
+85
View File
@@ -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,
};
+9
View File
@@ -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';
+12
View File
@@ -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];
+1
View File
@@ -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}`)),
};
+10
View File
@@ -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;
+339
View File
@@ -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 (50015004) 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);
},
};
+38
View File
@@ -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,
});
}
+8
View File
@@ -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';
+25
View File
@@ -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,
};
+186
View File
@@ -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>;
}