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
@@ -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';