frontend phase 12
This commit is contained in:
@@ -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';
|
||||
Reference in New Issue
Block a user