Files
baya-monorepo/client/src/app/[locale]/(private-routes)/admin/payouts/page.tsx
T
2026-07-27 22:27:04 +03:30

403 lines
15 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 { 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>
);
}