Files
baya-monorepo/client/src/components/EarningsRow/EarningsRow.tsx
T
2026-07-10 14:48:15 +03:30

196 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;