frontend phase 12
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import type { NurseEarningsSummary } from '@/services/payouts/types';
|
||||
|
||||
// next-intl mocked to echo keys; locale 'en' so the money util renders ASCII digits for assertions.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import EarningsBalanceHeader from './EarningsBalanceHeader';
|
||||
|
||||
const BASE: NurseEarningsSummary = {
|
||||
pendingTotalIrr: '4250000',
|
||||
eligibleTotalIrr: '3400000',
|
||||
paidTotalIrr: '8500000',
|
||||
clawbackOutstandingIrr: '1700000',
|
||||
netPayableBalanceIrr: '5950000',
|
||||
};
|
||||
|
||||
function renderHeader(summary: NurseEarningsSummary) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<EarningsBalanceHeader summary={summary} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<EarningsBalanceHeader/> component', () => {
|
||||
it('renders a positive net balance as the payable state with the Toman magnitude', () => {
|
||||
const { container } = renderHeader(BASE);
|
||||
expect(container.querySelector('[data-balance-state="payable"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('balance_net_label')).toBeInTheDocument();
|
||||
// 5,950,000 IRR ÷ 10 = 595,000 Toman
|
||||
expect(screen.getByText('595,000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a negative net balance as "owed back" — magnitude only, never a bare minus', () => {
|
||||
const { container } = renderHeader({ ...BASE, netPayableBalanceIrr: '-4350000' });
|
||||
expect(container.querySelector('[data-balance-state="owed"]')).toBeInTheDocument();
|
||||
expect(screen.getByText('balance_owed_label')).toBeInTheDocument();
|
||||
// The magnitude 435,000 shows; the raw "-435,000" must not.
|
||||
expect(screen.getByText('435,000')).toBeInTheDocument();
|
||||
expect(screen.queryByText('-435,000')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all four buckets with their formatted Toman amounts', () => {
|
||||
const { container } = renderHeader(BASE);
|
||||
for (const bucket of ['pending', 'eligible', 'paid', 'clawback']) {
|
||||
expect(container.querySelector(`[data-bucket="${bucket}"]`)).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getByText('425,000')).toBeInTheDocument(); // pending 4,250,000
|
||||
expect(screen.getByText('340,000')).toBeInTheDocument(); // eligible 3,400,000
|
||||
expect(screen.getByText('850,000')).toBeInTheDocument(); // paid lifetime 8,500,000
|
||||
expect(screen.getByText('170,000')).toBeInTheDocument(); // clawback outstanding 1,700,000
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useMemo } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import AppIcon from '../common/AppIcon';
|
||||
import { formatIrrToToman, parseIrr } from '@/utils';
|
||||
import type { NurseEarningsSummary } from '@/services/payouts/types';
|
||||
|
||||
export interface EarningsBalanceHeaderProps {
|
||||
summary: NurseEarningsSummary;
|
||||
}
|
||||
|
||||
/** The four roll-up buckets — each keyed to a semantic token + icon so the states read at a glance. */
|
||||
const BUCKETS: ReadonlyArray<{
|
||||
key: 'pending' | 'eligible' | 'paid' | 'clawback';
|
||||
amountKey: keyof NurseEarningsSummary;
|
||||
token: string;
|
||||
icon: string;
|
||||
}> = [
|
||||
{ key: 'pending', amountKey: 'pendingTotalIrr', token: 'var(--bal-warning)', icon: 'pending' },
|
||||
{ key: 'eligible', amountKey: 'eligibleTotalIrr', token: 'var(--bal-info)', icon: 'schedule' },
|
||||
{ key: 'paid', amountKey: 'paidTotalIrr', token: 'var(--bal-success)', icon: 'verified' },
|
||||
{ key: 'clawback', amountKey: 'clawbackOutstandingIrr', token: 'var(--bal-error)', icon: 'refresh' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The nurse's money home header: the **net payable balance** prominently, with the four-bucket breakdown
|
||||
* (pending / eligible / paid-lifetime / clawback-outstanding) beneath. The net balance is **ledger-derived
|
||||
* and may be negative** — when it is, this renders an explicit **"owed back"** state (a distinct error-toned
|
||||
* card + the magnitude, never a bare minus sign as if it were a positive amount). Every amount is formatted
|
||||
* Toman via the money util (integer-safe BigInt); this component only formats, never computes a figure.
|
||||
* @component EarningsBalanceHeader
|
||||
*/
|
||||
const EarningsBalanceHeader: FunctionComponent<EarningsBalanceHeaderProps> = ({ summary }) => {
|
||||
const t = useTranslations('payouts');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
|
||||
const net = useMemo(() => parseIrr(summary.netPayableBalanceIrr), [summary.netPayableBalanceIrr]);
|
||||
const isOwed = net < BigInt(0);
|
||||
// Render the magnitude; the "owed back" framing carries the sign in words, never a bare minus.
|
||||
const magnitude = isOwed ? -net : net;
|
||||
const accent = isOwed ? 'var(--bal-error)' : 'var(--bal-primary)';
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-balance-state={isOwed ? 'owed' : 'payable'}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '4px solid',
|
||||
borderInlineStartColor: accent,
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<AppIcon icon={isOwed ? 'warning' : 'wallet'} size={20} color={accent} />
|
||||
<Typography variant="subtitle2" sx={{ color: 'text.secondary', fontWeight: 600 }}>
|
||||
{isOwed ? t('balance_owed_label') : t('balance_net_label')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||
<Typography component="span" sx={{ fontWeight: 800, fontSize: '2rem', color: accent }}>
|
||||
{formatIrrToToman(magnitude, locale)}
|
||||
</Typography>
|
||||
<Typography component="span" variant="subtitle1" sx={{ color: 'text.secondary', fontWeight: 600 }}>
|
||||
{tc('currency_toman')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{isOwed ? t('balance_owed_hint') : t('balance_net_hint')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gap: 1.5,
|
||||
gridTemplateColumns: { xs: 'repeat(2, 1fr)', md: 'repeat(4, 1fr)' },
|
||||
}}
|
||||
>
|
||||
{BUCKETS.map((bucket) => (
|
||||
<Paper
|
||||
key={bucket.key}
|
||||
elevation={0}
|
||||
data-bucket={bucket.key}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '3px solid',
|
||||
borderInlineStartColor: bucket.token,
|
||||
}}
|
||||
>
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
|
||||
<AppIcon icon={bucket.icon} size={16} color={bucket.token} />
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
|
||||
{t(`bucket_${bucket.key}`)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{formatIrrToToman(summary[bucket.amountKey], locale)}{' '}
|
||||
<Typography component="span" variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{tc('currency_toman')}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EarningsBalanceHeader;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './EarningsBalanceHeader';
|
||||
export type { EarningsBalanceHeaderProps } from './EarningsBalanceHeader';
|
||||
@@ -0,0 +1,101 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import type { EarningsState, NurseEarningsItem } from '@/services/payouts/types';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import EarningsRow from './EarningsRow';
|
||||
|
||||
function makeItem(state: EarningsState, overrides: Partial<NurseEarningsItem> = {}): NurseEarningsItem {
|
||||
return {
|
||||
bookingId: 5001,
|
||||
patientName: 'Test Patient',
|
||||
scheduledDate: '2026-06-01',
|
||||
grossPriceIrr: '5000000',
|
||||
balinyaarCommissionIrr: '750000',
|
||||
nursePayoutAmount: '4250000',
|
||||
state,
|
||||
disputeWindowEndsAt: null,
|
||||
payoutEligibleAt: null,
|
||||
paidAt: null,
|
||||
transferReference: null,
|
||||
nursePayoutId: null,
|
||||
batchId: null,
|
||||
clawbackAppliedIrr: null,
|
||||
netAmountIrr: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderRow(item: NurseEarningsItem, handlers?: { onViewBooking?: jest.Mock; onViewPayout?: jest.Mock }) {
|
||||
const onViewBooking = handlers?.onViewBooking ?? jest.fn();
|
||||
const onViewPayout = handlers?.onViewPayout ?? jest.fn();
|
||||
const utils = render(
|
||||
<ThemeProvider>
|
||||
<EarningsRow item={item} onViewBooking={onViewBooking} onViewPayout={onViewPayout} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { ...utils, onViewBooking, onViewPayout };
|
||||
}
|
||||
|
||||
const STATE_KIND: Array<{ state: EarningsState; kind: string }> = [
|
||||
{ state: 'pending', kind: 'pending' },
|
||||
{ state: 'eligible', kind: 'info' },
|
||||
{ state: 'paid', kind: 'verified' },
|
||||
{ state: 'clawback_applied', kind: 'rejected' },
|
||||
];
|
||||
|
||||
describe('<EarningsRow/> component', () => {
|
||||
it.each(STATE_KIND)('maps the $state state to the $kind chip kind', ({ state, kind }) => {
|
||||
const { container } = renderRow(makeItem(state));
|
||||
expect(container.querySelector(`[data-earnings-state="${state}"]`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`[data-status="${kind}"]`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the reconciled payout as the breakdown total (gross − commission = payout)', () => {
|
||||
const { container } = renderRow(makeItem('eligible'));
|
||||
const total = container.querySelector('[data-row="total"]');
|
||||
// 4,250,000 IRR ÷ 10 = 425,000 Toman
|
||||
expect(total?.textContent).toContain('425,000');
|
||||
});
|
||||
|
||||
it('shows the pending dispute-window affordance', () => {
|
||||
renderRow(makeItem('pending', { disputeWindowEndsAt: '2999-01-01T00:00:00Z' }));
|
||||
expect(screen.getByText('pending_affordance')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the clawback net-explanation block for a clawed-back earning', () => {
|
||||
// Reconciling fixture: original payout 1,700,000 − clawback 1,700,000 = net 0 (PriceBreakdown guard stays silent).
|
||||
renderRow(
|
||||
makeItem('clawback_applied', {
|
||||
grossPriceIrr: '2000000',
|
||||
balinyaarCommissionIrr: '300000',
|
||||
nursePayoutAmount: '1700000',
|
||||
clawbackAppliedIrr: '1700000',
|
||||
netAmountIrr: '0',
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText('clawback_explainer')).toBeInTheDocument();
|
||||
expect(screen.getByText('clawback_net')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links a paid earning to its payout detail', () => {
|
||||
const onViewPayout = jest.fn();
|
||||
renderRow(
|
||||
makeItem('paid', { paidAt: '2026-06-10T00:00:00Z', transferReference: 'PAYA-1', nursePayoutId: 9001 }),
|
||||
{ onViewPayout },
|
||||
);
|
||||
fireEvent.click(screen.getByText('view_payout'));
|
||||
expect(onViewPayout).toHaveBeenCalledWith(9001);
|
||||
});
|
||||
|
||||
it('deep-links every row to its booking detail', () => {
|
||||
const onViewBooking = jest.fn();
|
||||
renderRow(makeItem('eligible', { bookingId: 5002 }), { onViewBooking });
|
||||
fireEvent.click(screen.getByText('view_booking'));
|
||||
expect(onViewBooking).toHaveBeenCalledWith(5002);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client';
|
||||
import { FunctionComponent, useMemo } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import StatusChip, { StatusKind } from '@/components/StatusChip';
|
||||
import PriceBreakdown from '@/components/PriceBreakdown';
|
||||
import CountdownTimer from '@/components/CountdownTimer';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import { formatShamsiDate, parseIrr } from '@/utils';
|
||||
import type { EarningsState, NurseEarningsItem } from '@/services/payouts/types';
|
||||
|
||||
export interface EarningsRowProps {
|
||||
item: NurseEarningsItem;
|
||||
/** Deep-link to the f8 nurse booking detail (`/nurse/visits/{bookingId}`) — never rebuilt here. */
|
||||
onViewBooking: (bookingId: number) => void;
|
||||
/** Deep-link to this earning's payout detail (paid rows only). */
|
||||
onViewPayout: (payoutId: number) => void;
|
||||
}
|
||||
|
||||
/** Each earnings state maps to a distinct semantic chip so the four states read instantly (§5). */
|
||||
const EARNINGS_STATE_KIND: Record<EarningsState, StatusKind> = {
|
||||
pending: 'pending',
|
||||
eligible: 'info',
|
||||
paid: 'verified',
|
||||
clawback_applied: 'rejected',
|
||||
};
|
||||
|
||||
/**
|
||||
* One earnings row — a completed booking's contribution to the nurse's pay. Shows the booking reference,
|
||||
* the **three-amount breakdown** framed for the nurse (`gross − commission = your payout`, reconciled by
|
||||
* `PriceBreakdown`), the **earnings-state chip**, and the state-specific affordance:
|
||||
* - `pending` → "in escrow · dispute window open" + a **display-only** countdown off `disputeWindowEndsAt`
|
||||
* (the server owns eligibility — this never gates anything, only renders time remaining);
|
||||
* - `eligible` → "cleared · awaiting the next weekly batch";
|
||||
* - `paid` → `paidAt` (Shamsi) + `transferReference`, links to the payout detail;
|
||||
* - `clawback_applied` → the net explanation (`original − clawback = net`) so a lower paid total is explained.
|
||||
*
|
||||
* Money is display-only IRR digit-strings through the money util (no float, no client computation). Every
|
||||
* row deep-links to the booking detail. Read-only: no transfer/retry/eligibility action lives here.
|
||||
* @component EarningsRow
|
||||
*/
|
||||
const EarningsRow: FunctionComponent<EarningsRowProps> = ({ item, onViewBooking, onViewPayout }) => {
|
||||
const t = useTranslations('payouts');
|
||||
const locale = useLocale();
|
||||
|
||||
// gross + (−commission) = payout — the invariant PriceBreakdown reconciles against.
|
||||
const negativeCommissionIrr = useMemo(
|
||||
() => String(-parseIrr(item.balinyaarCommissionIrr)),
|
||||
[item.balinyaarCommissionIrr],
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-earnings-state={item.state}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<Stack sx={{ gap: 1.75 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}
|
||||
>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('booking_ref', { id: item.bookingId })}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{item.patientName} · {formatShamsiDate(item.scheduledDate, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<StatusChip status={EARNINGS_STATE_KIND[item.state]} label={t(`estate_${item.state}`)} />
|
||||
</Stack>
|
||||
|
||||
<PriceBreakdown
|
||||
rows={[
|
||||
{ key: 'gross', label: t('amount_gross'), amountIrr: item.grossPriceIrr },
|
||||
{ key: 'commission', label: t('amount_commission'), amountIrr: negativeCommissionIrr },
|
||||
]}
|
||||
totalLabel={t('amount_your_payout')}
|
||||
totalAmountIrr={item.nursePayoutAmount}
|
||||
/>
|
||||
|
||||
{item.state === 'clawback_applied' && item.clawbackAppliedIrr != null && item.netAmountIrr != null ? (
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('clawback_explainer')}
|
||||
</Typography>
|
||||
<PriceBreakdown
|
||||
rows={[
|
||||
{ key: 'original', label: t('clawback_original'), amountIrr: item.nursePayoutAmount },
|
||||
{ key: 'clawback', label: t('clawback_amount'), amountIrr: String(-parseIrr(item.clawbackAppliedIrr)) },
|
||||
]}
|
||||
totalLabel={t('clawback_net')}
|
||||
totalAmountIrr={item.netAmountIrr}
|
||||
/>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
<StateAffordance item={item} onViewPayout={onViewPayout} />
|
||||
|
||||
<Stack direction="row" sx={{ justifyContent: 'flex-end' }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
endIcon="visits"
|
||||
onClick={() => onViewBooking(item.bookingId)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
/** The state-specific explanation block below the money breakdown. */
|
||||
const StateAffordance: FunctionComponent<{
|
||||
item: NurseEarningsItem;
|
||||
onViewPayout: (payoutId: number) => void;
|
||||
}> = ({ item, onViewPayout }) => {
|
||||
const t = useTranslations('payouts');
|
||||
const locale = useLocale();
|
||||
|
||||
if (item.state === 'pending') {
|
||||
return (
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
|
||||
<AppIcon icon="lock" size={18} color="var(--bal-warning)" />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('pending_affordance')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{item.disputeWindowEndsAt ? (
|
||||
<CountdownTimer
|
||||
deadlineIso={item.disputeWindowEndsAt}
|
||||
label={t('dispute_window_label')}
|
||||
elapsedText={t('dispute_window_elapsed')}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.state === 'eligible') {
|
||||
return (
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
|
||||
<AppIcon icon="schedule" size={18} color="var(--bal-info)" />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('eligible_affordance')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.state === 'paid') {
|
||||
return (
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<AppIcon icon="verified" size={18} color="var(--bal-success)" />
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{item.paidAt ? t('paid_on', { date: formatShamsiDate(item.paidAt, locale) }) : t('estate_paid')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{item.transferReference ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
|
||||
{t('transfer_reference_label')}: {item.transferReference}
|
||||
</Typography>
|
||||
) : null}
|
||||
{item.nursePayoutId != null ? (
|
||||
<Stack direction="row" sx={{ justifyContent: 'flex-start' }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
endIcon="wallet"
|
||||
onClick={() => onViewPayout(item.nursePayoutId as number)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('view_payout')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// clawback_applied — the net explanation block above already carries the "why"; nothing more here.
|
||||
return null;
|
||||
};
|
||||
|
||||
export default EarningsRow;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './EarningsRow';
|
||||
export type { EarningsRowProps } from './EarningsRow';
|
||||
@@ -0,0 +1,73 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import type { NursePayoutHistoryItem, PayoutStatus } from '@/services/payouts/types';
|
||||
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import PayoutHistoryRow from './PayoutHistoryRow';
|
||||
|
||||
function makeItem(status: PayoutStatus, overrides: Partial<NursePayoutHistoryItem> = {}): NursePayoutHistoryItem {
|
||||
return {
|
||||
id: 9001,
|
||||
batchId: 7001,
|
||||
status,
|
||||
grossEarningsIrr: '4250000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '4250000',
|
||||
maskedIban: 'IR••••4821',
|
||||
transferReference: status === 'paid' ? 'PAYA-1' : null,
|
||||
paidAt: status === 'paid' ? '2026-06-10T00:00:00Z' : null,
|
||||
periodStart: '2026-06-01',
|
||||
periodEnd: '2026-06-07',
|
||||
failureReason: status === 'failed' ? 'invalid_sheba' : null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderRow(item: NursePayoutHistoryItem, onOpen = jest.fn()) {
|
||||
const utils = render(
|
||||
<ThemeProvider>
|
||||
<PayoutHistoryRow item={item} onOpen={onOpen} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
return { ...utils, onOpen };
|
||||
}
|
||||
|
||||
const STATUS_KIND: Array<{ status: PayoutStatus; kind: string }> = [
|
||||
{ status: 'pending', kind: 'pending' },
|
||||
{ status: 'submitted', kind: 'info' },
|
||||
{ status: 'paid', kind: 'verified' },
|
||||
{ status: 'failed', kind: 'rejected' },
|
||||
];
|
||||
|
||||
describe('<PayoutHistoryRow/> component', () => {
|
||||
it.each(STATUS_KIND)('maps the $status payout status to the $kind chip kind', ({ status, kind }) => {
|
||||
const { container } = renderRow(makeItem(status));
|
||||
expect(container.querySelector(`[data-payout-status="${status}"]`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`[data-status="${kind}"]`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the net amount transferred in Toman and the masked IBAN', () => {
|
||||
renderRow(makeItem('paid'));
|
||||
expect(screen.getByText('425,000')).toBeInTheDocument(); // 4,250,000 ÷ 10
|
||||
expect(screen.getByText('IR••••4821')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a failed payout reason as a read-only banner with no retry control', () => {
|
||||
renderRow(makeItem('failed'));
|
||||
expect(screen.getByText('failure_title')).toBeInTheDocument();
|
||||
expect(screen.getByText(/invalid_sheba/)).toBeInTheDocument();
|
||||
// No retry affordance for the nurse — only the "view detail" link exists.
|
||||
expect(screen.queryByText('retry')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the payout detail on click', () => {
|
||||
const onOpen = jest.fn();
|
||||
renderRow(makeItem('paid', { id: 9002 }), onOpen);
|
||||
fireEvent.click(screen.getByText('view_payout_detail'));
|
||||
expect(onOpen).toHaveBeenCalledWith(9002);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import StatusChip, { StatusKind } from '@/components/StatusChip';
|
||||
import AppButton from '@/components/common/AppButton';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import type { NursePayoutHistoryItem, PayoutStatus } from '@/services/payouts/types';
|
||||
|
||||
export interface PayoutHistoryRowProps {
|
||||
item: NursePayoutHistoryItem;
|
||||
/** Open this payout's reconciliation detail. */
|
||||
onOpen: (payoutId: number) => void;
|
||||
}
|
||||
|
||||
/** Each payout status maps to a distinct semantic chip. `submitted` = handed to the bank rail (in transit). */
|
||||
const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
|
||||
pending: 'pending',
|
||||
submitted: 'info',
|
||||
paid: 'verified',
|
||||
failed: 'rejected',
|
||||
};
|
||||
|
||||
/**
|
||||
* One payout-history row: the **net amount transferred**, the payout-status chip, the batch window (Shamsi),
|
||||
* `paidAt` when settled, the **masked IBAN** (last-4, LTR), and the `transferReference` for reconciliation.
|
||||
* A `failed` payout surfaces its `failureReason` as a **read-only** banner — the nurse cannot retry (retry
|
||||
* is an admin action). Money is display-only through the money util. Deep-links to the payout detail.
|
||||
* @component PayoutHistoryRow
|
||||
*/
|
||||
const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOpen }) => {
|
||||
const t = useTranslations('payouts');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const isFailed = item.status === 'failed';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
data-payout-status={item.status}
|
||||
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}
|
||||
>
|
||||
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
|
||||
{t('payout_net_amount')}
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
|
||||
{formatIrrToToman(item.netAmountIrr, locale)}{' '}
|
||||
<Typography component="span" variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{tc('currency_toman')}
|
||||
</Typography>
|
||||
</Typography>
|
||||
</Stack>
|
||||
<StatusChip status={PAYOUT_STATUS_KIND[item.status]} label={t(`pstatus_${item.status}`)} />
|
||||
</Stack>
|
||||
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<MetaLine label={t('period_label')}>
|
||||
{formatShamsiDate(item.periodStart, locale)} – {formatShamsiDate(item.periodEnd, locale)}
|
||||
</MetaLine>
|
||||
{item.paidAt ? (
|
||||
<MetaLine label={t('paid_at_label')}>{formatShamsiDate(item.paidAt, locale)}</MetaLine>
|
||||
) : null}
|
||||
<MetaLine label={t('masked_iban_label')}>
|
||||
<Box component="span" dir="ltr">
|
||||
{item.maskedIban}
|
||||
</Box>
|
||||
</MetaLine>
|
||||
{item.transferReference ? (
|
||||
<MetaLine label={t('transfer_reference_label')}>
|
||||
<Box component="span" dir="ltr">
|
||||
{item.transferReference}
|
||||
</Box>
|
||||
</MetaLine>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
{isFailed ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '3px solid',
|
||||
borderInlineStartColor: 'var(--bal-error)',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', mb: 0.5 }}>
|
||||
<AppIcon icon="warning" size={18} color="var(--bal-error)" />
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{t('failure_title')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{item.failureReason ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
|
||||
{t('failure_reason_label')}: {item.failureReason}
|
||||
</Typography>
|
||||
) : null}
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
|
||||
{t('failure_hint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
<Stack direction="row" sx={{ justifyContent: 'flex-end' }}>
|
||||
<AppButton variant="text" color="primary" endIcon="wallet" onClick={() => onOpen(item.id)} sx={{ m: 0 }}>
|
||||
{t('view_payout_detail')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => (
|
||||
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export default PayoutHistoryRow;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './PayoutHistoryRow';
|
||||
export type { PayoutHistoryRowProps } from './PayoutHistoryRow';
|
||||
@@ -72,6 +72,8 @@ import EmergencyIcon from '@mui/icons-material/LocalPhoneOutlined';
|
||||
import LockIcon from '@mui/icons-material/LockOutlined';
|
||||
// BNPL — the installment-checkout surface (f11/b12): installment plans + repayment schedule
|
||||
import InstallmentsIcon from '@mui/icons-material/PaymentsOutlined';
|
||||
// Payouts — the nurse earnings & payout-history surface (f12/b13)
|
||||
import EarningsIcon from '@mui/icons-material/PaidOutlined';
|
||||
|
||||
/**
|
||||
* List of all available Icon names
|
||||
@@ -153,4 +155,5 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
|
||||
emergency: EmergencyIcon,
|
||||
lock: LockIcon,
|
||||
installments: InstallmentsIcon,
|
||||
earnings: EarningsIcon,
|
||||
};
|
||||
|
||||
@@ -26,6 +26,9 @@ import EscrowNotice from './EscrowNotice';
|
||||
import PaymentStatusBadge from './PaymentStatusBadge';
|
||||
import BnplPlanCard from './BnplPlanCard';
|
||||
import InstallmentScheduleRow from './InstallmentScheduleRow';
|
||||
import EarningsBalanceHeader from './EarningsBalanceHeader';
|
||||
import EarningsRow from './EarningsRow';
|
||||
import PayoutHistoryRow from './PayoutHistoryRow';
|
||||
|
||||
export {
|
||||
UserInfo,
|
||||
@@ -54,6 +57,9 @@ export {
|
||||
PaymentStatusBadge,
|
||||
BnplPlanCard,
|
||||
InstallmentScheduleRow,
|
||||
EarningsBalanceHeader,
|
||||
EarningsRow,
|
||||
PayoutHistoryRow,
|
||||
};
|
||||
export type { PlaceholderScreenProps } from './PlaceholderScreen';
|
||||
export type { OtpInputProps } from './OtpInput';
|
||||
@@ -79,3 +85,6 @@ export type { PriceBreakdownProps, PriceBreakdownRow } from './PriceBreakdown';
|
||||
export type { PaymentStatusBadgeProps } from './PaymentStatusBadge';
|
||||
export type { BnplPlanCardProps } from './BnplPlanCard';
|
||||
export type { InstallmentScheduleRowProps } from './InstallmentScheduleRow';
|
||||
export type { EarningsBalanceHeaderProps } from './EarningsBalanceHeader';
|
||||
export type { EarningsRowProps } from './EarningsRow';
|
||||
export type { PayoutHistoryRowProps } from './PayoutHistoryRow';
|
||||
|
||||
Reference in New Issue
Block a user