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