'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 = { 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(''); 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[] = [ { 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) => , }, { key: 'status', header: t('payout_col_status'), render: (b) => , }, { key: 'processing', header: t('payout_col_processing'), render: (b) => ( {formatShamsiDate(b.processingDate, locale)} {b.holidayShifted ? ( ) : null} ), }, ]; return ( { setStatus(e.target.value as PayoutBatchStatus | ''); setPage(1); }} sx={{ minWidth: 160 }} > {t('filter_all')} {BATCH_STATUSES.map((s) => ( {t(`batch_status_${s}`)} ))} {caps.canPayout ? ( setPreviewOpen(true)}> {t('payout_preview')} ) : null} } /> {batches.isLoading ? ( {[0, 1, 2].map((k) => )} ) : batches.isError ? ( batches.refetch()} /> ) : items.length === 0 ? ( ) : ( b.id} onRowClick={(b) => router.push(`/${locale}${adminPayoutBatchPath(b.id)}`)} ariaLabel={t('payout_title')} /> )} 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 ? setPreviewOpen(false)} /> : null} ); } /** * 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 ( {t('payout_preview_title')} {t('payout_preview')} {result ? ( {t('payout_col_processing')} {formatShamsiDate(result.processingDate, locale)} {result.holidayShifted ? ( ) : null} {t('payout_eligible_nurses')} {result.eligible.length === 0 ? ( ) : ( } sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}> {result.eligible.map((n) => ( {n.nurseName ?? `#${n.nurseId}`} {!n.hasVerifiedPrimaryIban ? ( ) : null} {t('payout_col_gross')}: ·{' '} {t('payout_col_clawback')}: ·{' '} {t('payout_col_net')}: ))} )} {result.skipped.length > 0 ? ( {t('payout_skipped')} } sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)' }}> {result.skipped.map((n) => ( {n.nurseName ?? `#${n.nurseId}`} {n.reason} ))} ) : null} {t('payout_eligibility_note')} ) : null} {t('cancel')} {canPayout ? ( setRunConfirmOpen(true)} disabled={!result || run.isPending} > {t('payout_run')} ) : null} {t('payout_run_summary_intro')} {t('payout_run_count_label')} {formatNumber(result.eligible.length, locale)} {t('payout_col_processing')} {formatShamsiDate(result.processingDate, locale)} ) : 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')} /> ); }