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