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,213 @@
'use client';
import { useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppButton, AppIcon, EarningsBalanceHeader, EarningsRow } from '@/components';
import { nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types';
import { useNurseEarnings, useNurseEarningsBalance } from '@/services/payouts';
type EarningsTab = 'all' | EarningsState;
const TABS: readonly EarningsTab[] = ['all', ...EARNINGS_STATES];
/**
* Nurse earnings home (f12) — the read-only "where is my money?" surface. Shows the ledger-derived net
* payable balance + four-bucket breakdown (`EarningsBalanceHeader`), a plain-Persian explainer of the
* weekly cadence + dispute-window gate, and a state-segmented earnings list. Each row deep-links to the f8
* booking detail (never rebuilt here) and, when paid, to the payout detail. The server owns eligibility and
* amounts; this screen only renders them.
*/
export default function NurseEarningsPage() {
const t = useTranslations('payouts');
const locale = useLocale();
const router = useRouter();
const [tab, setTab] = useState<EarningsTab>('all');
const [page, setPage] = useState(1);
const [explainerOpen, setExplainerOpen] = useState(false);
const stateFilter = tab === 'all' ? undefined : tab;
const balance = useNurseEarningsBalance();
const earnings = useNurseEarnings(stateFilter, page);
const items = earnings.data?.items ?? [];
const total = earnings.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
const openBooking = (bookingId: number) => router.push(`/${locale}${nurseBookingDetailPath(bookingId)}`);
const openPayout = (payoutId: number) => router.push(`/${locale}${nursePayoutDetailPath(payoutId)}`);
const onTabChange = (_: React.SyntheticEvent, next: EarningsTab) => {
setTab(next);
setPage(1);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
{balance.isLoading ? (
<Skeleton variant="rounded" height={200} />
) : balance.isError ? (
<ErrorPanel message={t('balance_error')} onRetry={() => balance.refetch()} retryLabel={t('retry')} />
) : balance.data ? (
<EarningsBalanceHeader summary={balance.data} />
) : null}
<ExplainerCard open={explainerOpen} onToggle={() => setExplainerOpen((v) => !v)} />
<Stack sx={{ gap: 2 }}>
<Tabs
value={tab}
onChange={onTabChange}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
>
{TABS.map((value) => (
<Tab key={value} value={value} label={t(`tab_${value}`)} sx={{ textTransform: 'none' }} />
))}
</Tabs>
{earnings.isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((k) => (
<Skeleton key={k} variant="rounded" height={200} />
))}
</Stack>
) : earnings.isError ? (
<ErrorPanel message={t('list_error')} onRetry={() => earnings.refetch()} retryLabel={t('retry')} />
) : items.length === 0 ? (
<EmptyPanel title={t('list_empty_title')} body={t('list_empty_body')} />
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<EarningsRow key={item.bookingId} item={item} onViewBooking={openBooking} onViewPayout={openPayout} />
))}
</Stack>
)}
<Pager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
/>
</Stack>
</Box>
);
}
/** Collapsible "how payouts work" — the cadence + dispute-window + method-invariant copy (both locales). */
function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void }) {
const t = useTranslations('payouts');
const points = useMemo(() => ['explainer_point_1', 'explainer_point_2', 'explainer_point_3'] as const, []);
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
borderInlineStartColor: 'var(--bal-info)',
}}
>
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}
onClick={onToggle}
>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('explainer_title')}
</Typography>
</Stack>
<AppIcon icon={open ? 'visibilityoff' : 'visibilityon'} size={18} color="var(--bal-text-secondary)" />
</Stack>
<Collapse in={open}>
<Stack component="ul" sx={{ gap: 0.75, mt: 1.5, mb: 0, pl: 2.5 }}>
{points.map((key) => (
<Typography key={key} component="li" variant="body2" sx={{ color: 'text.secondary' }}>
{t(key)}
</Typography>
))}
</Stack>
</Collapse>
</Paper>
);
}
function EmptyPanel({ title, body }: { title: string; body: string }) {
return (
<Paper
elevation={0}
sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
>
<AppIcon icon="earnings" size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{body}
</Typography>
</Paper>
);
}
function ErrorPanel({ message, onRetry, retryLabel }: { message: string; onRetry: () => void; retryLabel: string }) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
{message}
</Typography>
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={onRetry} sx={{ m: 0 }}>
{retryLabel}
</AppButton>
</Paper>
);
}
/** Prev/next pager — rendered only when there is more than one page. */
function Pager({
page,
pageCount,
onPrev,
onNext,
}: {
page: number;
pageCount: number;
onPrev: () => void;
onNext: () => void;
}) {
const t = useTranslations('payouts');
const locale = useLocale();
if (pageCount <= 1) return null;
const fmt = (n: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(n);
return (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
<AppButton variant="text" color="primary" onClick={onPrev} disabled={page <= 1} sx={{ m: 0 }}>
{t('page_prev')}
</AppButton>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
</Typography>
<AppButton variant="text" color="primary" onClick={onNext} disabled={page >= pageCount} sx={{ m: 0 }}>
{t('page_next')}
</AppButton>
</Stack>
);
}
@@ -0,0 +1,220 @@
'use client';
import { FunctionComponent, ReactNode } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, PriceBreakdown, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { nurseBookingDetailPath, ROUTES } from '@/constants';
import { formatIrrToToman, formatShamsiDate, parseIrr } from '@/utils';
import { useNursePayoutDetail } from '@/services/payouts';
import type { PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
pending: 'pending',
submitted: 'info',
paid: 'verified',
failed: 'rejected',
};
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
draft: 'neutral',
processing: 'info',
partially_failed: 'pending',
completed: 'verified',
failed: 'rejected',
};
/**
* Nurse payout/batch reconciliation detail (f12) — one payout expanded: the batch window (holiday-shifted
* server-side), the money decomposition (`gross_earnings clawback_applied = net_amount`, plus the amount
* actually transferred), the masked IBAN + transfer reference, a failed reason (read-only), and the exact
* bookings the payout covered (each deep-linking to its f8 booking detail — "this transfer paid for these
* visits"). Money is display-only; the client never recomputes eligibility, dates, or amounts.
*/
export default function NursePayoutDetailPage() {
const t = useTranslations('payouts');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ id: string }>();
const payoutId = Number(params?.id);
const { data, isLoading, isError } = useNursePayoutDetail(Number.isFinite(payoutId) ? payoutId : undefined);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 0.5 }}>
<AppButton
variant="text"
color="primary"
startIcon="wallet"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS_PAYOUTS}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('back_to_history')}
</AppButton>
<Typography variant="h5" component="h1">
{t('detail_title')}
</Typography>
</Stack>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="rounded" height={160} />
<Skeleton variant="rounded" height={200} />
</Stack>
) : isError || !data ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('detail_not_found')}
</Typography>
</Paper>
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('detail_batch')}
</Typography>
<Stack direction="row" sx={{ gap: 0.75, flexWrap: 'wrap' }}>
<StatusChip status={BATCH_STATUS_KIND[data.batch.status]} label={t(`bstatus_${data.batch.status}`)} />
<StatusChip status={PAYOUT_STATUS_KIND[data.status]} label={t(`pstatus_${data.status}`)} />
</Stack>
</Stack>
<MetaLine label={t('period_label')}>
{formatShamsiDate(data.batch.periodStart, locale)} {formatShamsiDate(data.batch.periodEnd, locale)}
</MetaLine>
{data.batch.processedAt ? (
<MetaLine label={t('processed_on_label')}>{formatShamsiDate(data.batch.processedAt, locale)}</MetaLine>
) : null}
<MetaLine label={t('masked_iban_label')}>
<Box component="span" dir="ltr">
{data.maskedIban}
</Box>
</MetaLine>
{data.transferReference ? (
<MetaLine label={t('transfer_reference_label')}>
<Box component="span" dir="ltr">
{data.transferReference}
</Box>
</MetaLine>
) : null}
{data.paidAt ? (
<MetaLine label={t('paid_at_label')}>{formatShamsiDate(data.paidAt, locale)}</MetaLine>
) : null}
</Stack>
</Paper>
{data.status === 'failed' ? (
<Box
sx={{
p: 1.75,
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>
{data.failureReason ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('failure_reason_label')}: {data.failureReason}
</Typography>
) : null}
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('failure_hint')}
</Typography>
</Box>
) : null}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('detail_money_title')}
</Typography>
<PriceBreakdown
rows={
parseIrr(data.clawbackAppliedIrr) > BigInt(0)
? [
{ key: 'gross_earnings', label: t('gross_earnings_label'), amountIrr: data.grossEarningsIrr },
{
key: 'clawback',
label: t('clawback_applied_label'),
amountIrr: String(-parseIrr(data.clawbackAppliedIrr)),
},
]
: [{ key: 'gross_earnings', label: t('gross_earnings_label'), amountIrr: data.grossEarningsIrr }]
}
totalLabel={t('net_amount_label')}
totalAmountIrr={data.netAmountIrr}
/>
<Stack direction="row" sx={{ justifyContent: 'space-between', px: 0.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('amount_transferred_label')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatIrrToToman(data.amountIrr, locale)}
</Typography>
</Stack>
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('detail_bookings_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('detail_bookings_hint')}
</Typography>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
<Stack divider={<Divider />}>
{data.bookings.map((link) => (
<Stack
key={link.bookingId}
direction="row"
sx={{ p: 1.75, gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('booking_ref', { id: link.bookingId })}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatIrrToToman(link.payoutAmountIrr, locale)} {tc('currency_toman')}
</Typography>
</Stack>
<AppButton
variant="text"
color="primary"
endIcon="visits"
onClick={() => router.push(`/${locale}${nurseBookingDetailPath(link.bookingId)}`)}
sx={{ m: 0 }}
>
{t('view_booking')}
</AppButton>
</Stack>
))}
</Stack>
</Paper>
</Stack>
</>
)}
</Box>
);
}
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>
);
@@ -0,0 +1,102 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, PayoutHistoryRow } from '@/components';
import { nursePayoutDetailPath, ROUTES } from '@/constants';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { useNursePayoutHistory } from '@/services/payouts';
/**
* Nurse payout history (f12) — a paginated, read-only list of the nurse's `nurse_payouts`, newest first.
* Each row (`PayoutHistoryRow`) shows the net amount transferred, the status chip, the batch window, the
* masked IBAN + transfer reference, and — when failed — the reason (no retry; that's an admin action). A row
* opens the payout/batch reconciliation detail.
*/
export default function NursePayoutHistoryPage() {
const t = useTranslations('payouts');
const locale = useLocale();
const router = useRouter();
const [page, setPage] = useState(1);
const history = useNursePayoutHistory(page);
const items = history.data?.items ?? [];
const total = history.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
const fmt = (n: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(n);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 0.5 }}>
<AppButton
variant="text"
color="primary"
startIcon="earnings"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('back_to_earnings')}
</AppButton>
<Typography variant="h5" component="h1">
{t('history_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('history_subtitle')}
</Typography>
</Stack>
{history.isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((k) => (
<Skeleton key={k} variant="rounded" height={180} />
))}
</Stack>
) : history.isError ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
{t('history_error')}
</Typography>
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => history.refetch()} sx={{ m: 0 }}>
{t('retry')}
</AppButton>
</Paper>
) : items.length === 0 ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon="earnings" size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
{t('history_empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('history_empty_body')}
</Typography>
</Paper>
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<PayoutHistoryRow
key={item.id}
item={item}
onOpen={(id) => router.push(`/${locale}${nursePayoutDetailPath(id)}`)}
/>
))}
</Stack>
)}
{pageCount > 1 ? (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1} sx={{ m: 0 }}>
{t('page_prev')}
</AppButton>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
</Typography>
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount} sx={{ m: 0 }}>
{t('page_next')}
</AppButton>
</Stack>
) : null}
</Box>
);
}