403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
'use client';
|
||
import { useState } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import { useLocale, useTranslations } from 'next-intl';
|
||
import { useSnackbar } from 'notistack';
|
||
import {
|
||
Box,
|
||
Chip,
|
||
Dialog,
|
||
DialogActions,
|
||
DialogContent,
|
||
DialogTitle,
|
||
Divider,
|
||
MenuItem,
|
||
Skeleton,
|
||
Stack,
|
||
TextField,
|
||
Typography,
|
||
} from '@mui/material';
|
||
import { AppButton, JalaliDateField, Money, StatusChip } from '@/components';
|
||
import type { StatusKind } from '@/components';
|
||
import {
|
||
AdminDataTable,
|
||
AdminEmptyState,
|
||
AdminErrorState,
|
||
AdminPageHeader,
|
||
AdminPager,
|
||
ConfirmDialog,
|
||
} from '@/components/admin';
|
||
import type { AdminTableColumn } from '@/components/admin';
|
||
import { useAdminCapabilities } from '@/hooks';
|
||
import { adminPayoutBatchPath } from '@/constants';
|
||
import { formatNumber, formatShamsiDate } from '@/utils';
|
||
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
|
||
import { usePayoutBatches, usePreviewPayoutBatch, useRunPayoutBatch } from '@/services/payouts';
|
||
import type { PayoutBatchStatus, PayoutBatchSummary } from '@/services/payouts/types';
|
||
|
||
/** Batch lifecycle → semantic chip color (server truth; the client only renders it). */
|
||
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
|
||
draft: 'neutral',
|
||
processing: 'info',
|
||
partially_failed: 'pending',
|
||
completed: 'verified',
|
||
failed: 'rejected',
|
||
};
|
||
|
||
const BATCH_STATUSES: readonly PayoutBatchStatus[] = [
|
||
'draft',
|
||
'processing',
|
||
'partially_failed',
|
||
'completed',
|
||
'failed',
|
||
];
|
||
|
||
/**
|
||
* ISO date (`YYYY-MM-DD`) — the wire shape for the batch window. Formats using the browser's **local**
|
||
* date fields, never `toISOString()` (which converts to UTC first): near Tehran local midnight that would
|
||
* silently roll the date back/forward a day from the admin's actual wall-clock date.
|
||
*/
|
||
const isoDate = (d: Date): string => {
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
return `${y}-${m}-${day}`;
|
||
};
|
||
|
||
/**
|
||
* Admin payout-batch dashboard (f15) — the reconciliation list of weekly `nurse_payout_batches` and the
|
||
* entry point to previewing + running the next batch. Money is IRR digit-strings rendered as display-only
|
||
* Toman; the server owns eligibility and the holiday-shifted processing date — the client never computes
|
||
* them. Running a batch moves money, so it is gated (`canPayout`), idempotency-keyed, and confirmed.
|
||
*/
|
||
export default function AdminPayoutsPage() {
|
||
const t = useTranslations('admin');
|
||
const locale = useLocale();
|
||
const router = useRouter();
|
||
const caps = useAdminCapabilities();
|
||
|
||
const [status, setStatus] = useState<PayoutBatchStatus | ''>('');
|
||
const [page, setPage] = useState(1);
|
||
const [previewOpen, setPreviewOpen] = useState(false);
|
||
|
||
const filters = { status: status || undefined };
|
||
const batches = usePayoutBatches(filters, page);
|
||
|
||
const items = batches.data?.items ?? [];
|
||
const pageCount = Math.max(1, Math.ceil((batches.data?.total ?? 0) / PAYOUTS_PAGE_SIZE));
|
||
|
||
const columns: AdminTableColumn<PayoutBatchSummary>[] = [
|
||
{
|
||
key: 'period',
|
||
header: t('payout_col_period'),
|
||
render: (b) => `${formatShamsiDate(b.periodStart, locale)} – ${formatShamsiDate(b.periodEnd, locale)}`,
|
||
},
|
||
{
|
||
key: 'count',
|
||
header: t('payout_col_count'),
|
||
render: (b) => b.payoutCount,
|
||
},
|
||
{
|
||
key: 'total',
|
||
header: t('payout_col_total'),
|
||
render: (b) => <Money amountIrr={b.totalAmount} size="sm" />,
|
||
},
|
||
{
|
||
key: 'status',
|
||
header: t('payout_col_status'),
|
||
render: (b) => <StatusChip status={BATCH_STATUS_KIND[b.status]} label={t(`batch_status_${b.status}`)} />,
|
||
},
|
||
{
|
||
key: 'processing',
|
||
header: t('payout_col_processing'),
|
||
render: (b) => (
|
||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<span>{formatShamsiDate(b.processingDate, locale)}</span>
|
||
{b.holidayShifted ? (
|
||
<Chip
|
||
size="small"
|
||
label={t('payout_holiday_shift')}
|
||
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 500 }}
|
||
/>
|
||
) : null}
|
||
</Stack>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||
<AdminPageHeader
|
||
title={t('payout_title')}
|
||
subtitle={t('payout_subtitle')}
|
||
actions={
|
||
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<TextField
|
||
select
|
||
size="small"
|
||
label={t('payout_col_status')}
|
||
value={status}
|
||
onChange={(e) => {
|
||
setStatus(e.target.value as PayoutBatchStatus | '');
|
||
setPage(1);
|
||
}}
|
||
sx={{ minWidth: 160 }}
|
||
>
|
||
<MenuItem value="">{t('filter_all')}</MenuItem>
|
||
{BATCH_STATUSES.map((s) => (
|
||
<MenuItem key={s} value={s}>
|
||
{t(`batch_status_${s}`)}
|
||
</MenuItem>
|
||
))}
|
||
</TextField>
|
||
{caps.canPayout ? (
|
||
<AppButton variant="contained" color="primary" onClick={() => setPreviewOpen(true)}>
|
||
{t('payout_preview')}
|
||
</AppButton>
|
||
) : null}
|
||
</Stack>
|
||
}
|
||
/>
|
||
|
||
{batches.isLoading ? (
|
||
<Stack sx={{ gap: 2 }}>{[0, 1, 2].map((k) => <Skeleton key={k} variant="rounded" height={64} />)}</Stack>
|
||
) : batches.isError ? (
|
||
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => batches.refetch()} />
|
||
) : items.length === 0 ? (
|
||
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
|
||
) : (
|
||
<AdminDataTable
|
||
columns={columns}
|
||
rows={items}
|
||
getRowKey={(b) => b.id}
|
||
onRowClick={(b) => router.push(`/${locale}${adminPayoutBatchPath(b.id)}`)}
|
||
ariaLabel={t('payout_title')}
|
||
/>
|
||
)}
|
||
|
||
<AdminPager
|
||
page={page}
|
||
pageCount={pageCount}
|
||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||
prevLabel={t('prev_page')}
|
||
nextLabel={t('next_page')}
|
||
indicator={t('page_indicator', { page, total: pageCount })}
|
||
/>
|
||
|
||
{previewOpen ? <PreviewBatchDialog canPayout={caps.canPayout} onClose={() => setPreviewOpen(false)} /> : null}
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* The eligibility dry-run + run-batch dialog. Preview is a mutation (runs only when the admin asks), and its
|
||
* eligible/skipped breakdown + the server's holiday-shifted processing date are read straight from the
|
||
* mutation's `data`. Running is idempotency-keyed and confirmed; on success it deep-links to the new batch.
|
||
*/
|
||
function PreviewBatchDialog({ canPayout, onClose }: { canPayout: boolean; onClose: () => void }) {
|
||
const t = useTranslations('admin');
|
||
const locale = useLocale();
|
||
const router = useRouter();
|
||
const { enqueueSnackbar } = useSnackbar();
|
||
|
||
// Default the window to the last 7 days (end = today). A lazy initializer runs the `Date` read once.
|
||
const [periodStart, setPeriodStart] = useState(() =>
|
||
isoDate(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)),
|
||
);
|
||
const [periodEnd, setPeriodEnd] = useState(() => isoDate(new Date()));
|
||
const [runConfirmOpen, setRunConfirmOpen] = useState(false);
|
||
|
||
const preview = usePreviewPayoutBatch();
|
||
const run = useRunPayoutBatch();
|
||
const result = preview.data;
|
||
|
||
const onPreview = () => {
|
||
if (!periodStart || !periodEnd) return;
|
||
preview.mutate({ periodStart, periodEnd });
|
||
};
|
||
|
||
const onRunConfirm = () => {
|
||
const idempotencyKey = crypto?.randomUUID
|
||
? crypto.randomUUID()
|
||
: `batch_${periodStart}_${periodEnd}_${Date.now()}`;
|
||
run.mutate(
|
||
{ periodStart, periodEnd, idempotencyKey },
|
||
{
|
||
onSuccess: (batch) => {
|
||
setRunConfirmOpen(false);
|
||
onClose();
|
||
enqueueSnackbar(t('payout_ran'), { variant: 'success' });
|
||
router.push(`/${locale}${adminPayoutBatchPath(batch.id)}`);
|
||
},
|
||
},
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Dialog open onClose={run.isPending ? undefined : onClose} fullWidth maxWidth="sm">
|
||
<DialogTitle sx={{ fontWeight: 800 }}>{t('payout_preview_title')}</DialogTitle>
|
||
<DialogContent>
|
||
<Stack direction="row" sx={{ gap: 1.5, mt: 1, flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<JalaliDateField
|
||
size="small"
|
||
label={t('payout_period_start')}
|
||
value={periodStart}
|
||
onChange={setPeriodStart}
|
||
/>
|
||
<JalaliDateField size="small" label={t('payout_period_end')} value={periodEnd} onChange={setPeriodEnd} />
|
||
<AppButton
|
||
variant="outlined"
|
||
color="primary"
|
||
onClick={onPreview}
|
||
disabled={!periodStart || !periodEnd || preview.isPending}
|
||
>
|
||
{t('payout_preview')}
|
||
</AppButton>
|
||
</Stack>
|
||
|
||
{result ? (
|
||
<Stack sx={{ gap: 2, mt: 2.5 }}>
|
||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||
{t('payout_col_processing')}
|
||
</Typography>
|
||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||
{formatShamsiDate(result.processingDate, locale)}
|
||
</Typography>
|
||
{result.holidayShifted ? (
|
||
<Chip
|
||
size="small"
|
||
label={t('payout_holiday_shift')}
|
||
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 500 }}
|
||
/>
|
||
) : null}
|
||
</Stack>
|
||
|
||
<Stack sx={{ gap: 1 }}>
|
||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||
{t('payout_eligible_nurses')}
|
||
</Typography>
|
||
{result.eligible.length === 0 ? (
|
||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||
—
|
||
</Typography>
|
||
) : (
|
||
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||
{result.eligible.map((n) => (
|
||
<Stack key={n.nurseId} sx={{ p: 1.5, gap: 0.5 }}>
|
||
<Stack
|
||
direction="row"
|
||
sx={{ justifyContent: 'space-between', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
|
||
>
|
||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||
{n.nurseName ?? `#${n.nurseId}`}
|
||
</Typography>
|
||
{!n.hasVerifiedPrimaryIban ? (
|
||
<Chip
|
||
size="small"
|
||
label={t('payout_no_iban')}
|
||
sx={{ bgcolor: 'var(--bal-error)', color: 'var(--bal-error-contrast)', fontWeight: 500 }}
|
||
/>
|
||
) : null}
|
||
</Stack>
|
||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||
{t('payout_col_gross')}: <Money amountIrr={n.grossEarningsIrr} size="sm" hideUnit /> ·{' '}
|
||
{t('payout_col_clawback')}: <Money amountIrr={n.clawbackAppliedIrr} size="sm" hideUnit /> ·{' '}
|
||
{t('payout_col_net')}: <Money amountIrr={n.netAmountIrr} size="sm" />
|
||
</Typography>
|
||
</Stack>
|
||
))}
|
||
</Stack>
|
||
)}
|
||
</Stack>
|
||
|
||
{result.skipped.length > 0 ? (
|
||
<Stack sx={{ gap: 1 }}>
|
||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||
{t('payout_skipped')}
|
||
</Typography>
|
||
<Stack divider={<Divider />} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}>
|
||
{result.skipped.map((n) => (
|
||
<Stack
|
||
key={n.nurseId}
|
||
direction="row"
|
||
sx={{ p: 1.5, justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}
|
||
>
|
||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||
{n.nurseName ?? `#${n.nurseId}`}
|
||
</Typography>
|
||
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
|
||
{n.reason}
|
||
</Typography>
|
||
</Stack>
|
||
))}
|
||
</Stack>
|
||
</Stack>
|
||
) : null}
|
||
|
||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||
{t('payout_eligibility_note')}
|
||
</Typography>
|
||
</Stack>
|
||
) : null}
|
||
</DialogContent>
|
||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||
<AppButton variant="text" color="inherit" onClick={onClose} disabled={run.isPending}>
|
||
{t('cancel')}
|
||
</AppButton>
|
||
{canPayout ? (
|
||
<AppButton
|
||
variant="contained"
|
||
color="primary"
|
||
onClick={() => setRunConfirmOpen(true)}
|
||
disabled={!result || run.isPending}
|
||
>
|
||
{t('payout_run')}
|
||
</AppButton>
|
||
) : null}
|
||
</DialogActions>
|
||
|
||
<ConfirmDialog
|
||
open={runConfirmOpen}
|
||
title={t('payout_run_confirm_title')}
|
||
body={
|
||
result ? (
|
||
<Stack sx={{ gap: 1.5, mt: 0.5 }}>
|
||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||
{t('payout_run_summary_intro')}
|
||
</Typography>
|
||
<Money amountIrr={result.totalNetIrr} size="lg" tone="emphasis" />
|
||
<Stack sx={{ gap: 0.5 }}>
|
||
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 1 }}>
|
||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||
{t('payout_run_count_label')}
|
||
</Typography>
|
||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||
{formatNumber(result.eligible.length, locale)}
|
||
</Typography>
|
||
</Stack>
|
||
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 1 }}>
|
||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||
{t('payout_col_processing')}
|
||
</Typography>
|
||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||
{formatShamsiDate(result.processingDate, locale)}
|
||
</Typography>
|
||
</Stack>
|
||
</Stack>
|
||
</Stack>
|
||
) : null
|
||
}
|
||
confirmLabel={t('payout_run')}
|
||
cancelLabel={t('cancel')}
|
||
onConfirm={onRunConfirm}
|
||
onClose={() => setRunConfirmOpen(false)}
|
||
loading={run.isPending}
|
||
requireTypedConfirmation={result ? ['تایید', result.totalNetIrr] : []}
|
||
typedConfirmationLabel={t('payout_run_type_to_confirm')}
|
||
/>
|
||
</Dialog>
|
||
);
|
||
}
|