frontend phase 15
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
'use client';
|
||||
import { FunctionComponent, ReactNode, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import { Box, Chip, Paper, Skeleton, Stack, TextField, Typography } from '@mui/material';
|
||||
import { AppButton, StatusChip } from '@/components';
|
||||
import type { StatusKind } from '@/components';
|
||||
import { AdminEmptyState, AdminErrorState, AdminPager, ConfirmDialog } from '@/components/admin';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { formatIrrToToman, formatShamsiDate } from '@/utils';
|
||||
import { usePayoutBatchDetail, useRecordTransferReference, useRetryPayout } from '@/services/payouts';
|
||||
import type { AdminPayoutRow, PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
|
||||
|
||||
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
|
||||
draft: 'neutral',
|
||||
processing: 'info',
|
||||
partially_failed: 'pending',
|
||||
completed: 'verified',
|
||||
failed: 'rejected',
|
||||
};
|
||||
|
||||
const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
|
||||
pending: 'pending',
|
||||
submitted: 'info',
|
||||
paid: 'verified',
|
||||
failed: 'rejected',
|
||||
};
|
||||
|
||||
/**
|
||||
* Admin payout-batch detail (f15) — one batch expanded: its window + holiday-shifted processing date, and its
|
||||
* paginated per-payout rows (money decomposition, masked IBAN, transfer reference, status). A failed payout
|
||||
* can be retried (idempotency-keyed) and a reconciled bank transfer reference recorded — both gated on
|
||||
* `canPayout`. Money is display-only Toman; the client never recomputes amounts, eligibility, or dates.
|
||||
*/
|
||||
export default function AdminPayoutBatchDetailPage() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const caps = useAdminCapabilities();
|
||||
const params = useParams<{ batchId: string }>();
|
||||
const batchId = Number(params?.batchId);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const detail = usePayoutBatchDetail(Number.isFinite(batchId) ? batchId : null, page);
|
||||
const data = detail.data;
|
||||
const pageCount = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<AppButton
|
||||
variant="text"
|
||||
color="primary"
|
||||
onClick={() => router.push(`/${locale}${ROUTES.ADMIN_PAYOUTS}`)}
|
||||
sx={{ m: 0, alignSelf: 'flex-start' }}
|
||||
>
|
||||
{t('back')}
|
||||
</AppButton>
|
||||
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
|
||||
{t('payout_batch_title', { id: batchId })}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{detail.isLoading ? (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Skeleton variant="rounded" height={120} />
|
||||
<Skeleton variant="rounded" height={96} />
|
||||
<Skeleton variant="rounded" height={96} />
|
||||
</Stack>
|
||||
) : detail.isError ? (
|
||||
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => detail.refetch()} />
|
||||
) : !data ? (
|
||||
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
|
||||
) : (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<StatusChip
|
||||
status={BATCH_STATUS_KIND[data.batch.status]}
|
||||
label={t(`batch_status_${data.batch.status}`)}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
/>
|
||||
<MetaLine label={t('payout_col_period')}>
|
||||
{formatShamsiDate(data.batch.periodStart, locale)} – {formatShamsiDate(data.batch.periodEnd, locale)}
|
||||
</MetaLine>
|
||||
<MetaLine label={t('payout_col_processing')}>
|
||||
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span>{formatShamsiDate(data.batch.processingDate, locale)}</span>
|
||||
{data.batch.holidayShifted ? (
|
||||
<Chip
|
||||
size="small"
|
||||
label={t('payout_holiday_shift')}
|
||||
sx={{ bgcolor: 'var(--bal-warning)', color: 'var(--bal-warning-contrast)', fontWeight: 600 }}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</MetaLine>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Stack sx={{ gap: 1.5 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('payout_rows_title')}
|
||||
</Typography>
|
||||
{data.payouts.length === 0 ? (
|
||||
<AdminEmptyState icon="wallet" title={t('payout_empty')} />
|
||||
) : (
|
||||
data.payouts.map((row) => (
|
||||
<PayoutRowCard key={row.id} row={row} batchId={batchId} canPayout={caps.canPayout} />
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<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 })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One `nurse_payouts` row — the money decomposition (`gross − clawback = net`), masked IBAN + transfer
|
||||
* reference, status, and (for a failed payout) the reason + an idempotency-keyed retry. Recording a
|
||||
* reconciled bank transfer reference is an inline per-row action. Both writes are gated on `canPayout`.
|
||||
*/
|
||||
const PayoutRowCard: FunctionComponent<{ row: AdminPayoutRow; batchId: number; canPayout: boolean }> = ({
|
||||
row,
|
||||
batchId,
|
||||
canPayout,
|
||||
}) => {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
|
||||
const retry = useRetryPayout();
|
||||
const record = useRecordTransferReference();
|
||||
const [retryOpen, setRetryOpen] = useState(false);
|
||||
const [reference, setReference] = useState('');
|
||||
|
||||
const onRetryConfirm = () => {
|
||||
retry.mutate(
|
||||
{ payoutId: row.id, idempotencyKey: crypto?.randomUUID?.() ?? String(Date.now()), batchId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRetryOpen(false);
|
||||
enqueueSnackbar(t('saved'), { variant: 'success' });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const onRecord = () => {
|
||||
record.mutate(
|
||||
{ payoutId: row.id, reference: reference.trim(), batchId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('payout_ref_saved'), { variant: 'success' });
|
||||
setReference('');
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ justifyContent: 'space-between', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||
{t('payout_row_nurse')}: {row.nurseName ?? `#${row.nurseId}`}
|
||||
</Typography>
|
||||
<StatusChip status={PAYOUT_STATUS_KIND[row.status]} label={t(`pstatus_${row.status}`)} />
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('payout_decomp', {
|
||||
gross: formatIrrToToman(row.grossEarningsIrr, locale),
|
||||
clawback: formatIrrToToman(row.clawbackAppliedIrr, locale),
|
||||
net: formatIrrToToman(row.netAmountIrr, locale),
|
||||
})}
|
||||
</Typography>
|
||||
|
||||
<Stack direction="row" sx={{ gap: 3, flexWrap: 'wrap' }}>
|
||||
<Field label={t('masked_iban_label')}>
|
||||
<Box component="span" dir="ltr">
|
||||
{row.maskedIban}
|
||||
</Box>
|
||||
</Field>
|
||||
<Field label={t('payout_row_ref')}>
|
||||
<Box component="span" dir="ltr">
|
||||
{row.transferReference ?? '—'}
|
||||
</Box>
|
||||
</Field>
|
||||
</Stack>
|
||||
|
||||
{row.status === 'failed' ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.25,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderInlineStart: '3px solid',
|
||||
borderInlineStartColor: 'var(--bal-error)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
|
||||
{t('payout_failure_reason', { reason: row.failureReason ?? '—' })}
|
||||
</Typography>
|
||||
{canPayout ? (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setRetryOpen(true)}
|
||||
disabled={retry.isPending}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('payout_retry')}
|
||||
</AppButton>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{canPayout ? (
|
||||
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', flexWrap: 'wrap', mt: 0.5 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label={t('payout_record_ref')}
|
||||
placeholder={t('payout_record_ref_ph')}
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.target.value)}
|
||||
slotProps={{ htmlInput: { dir: 'ltr' } }}
|
||||
sx={{ minWidth: 220 }}
|
||||
/>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={onRecord}
|
||||
disabled={reference.trim().length === 0 || record.isPending}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{record.isPending ? t('saving') : t('save')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<ConfirmDialog
|
||||
open={retryOpen}
|
||||
title={t('payout_retry')}
|
||||
body={t('payout_retry_confirm')}
|
||||
confirmLabel={t('confirm')}
|
||||
cancelLabel={t('cancel')}
|
||||
onConfirm={onRetryConfirm}
|
||||
onClose={() => setRetryOpen(false)}
|
||||
loading={retry.isPending}
|
||||
confirmColor="error"
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
<Box sx={{ fontWeight: 600, typography: 'body2' }}>{children}</Box>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const Field: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => (
|
||||
<Stack sx={{ gap: 0.25 }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{children}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
@@ -0,0 +1,377 @@
|
||||
'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, 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 { formatIrrToToman, 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',
|
||||
];
|
||||
|
||||
/** UTC ISO date (`YYYY-MM-DD`) — the wire shape for the batch window. */
|
||||
const isoDate = (d: Date): string => d.toISOString().slice(0, 10);
|
||||
|
||||
/**
|
||||
* 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 tCommon = useTranslations('common');
|
||||
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) => `${formatIrrToToman(b.totalAmount, locale)} ${tCommon('currency_toman')}`,
|
||||
},
|
||||
{
|
||||
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: 600 }}
|
||||
/>
|
||||
) : 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)} sx={{ m: 0 }}>
|
||||
{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 })}
|
||||
/>
|
||||
|
||||
{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 tCommon = useTranslations('common');
|
||||
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' }}>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
label={t('payout_period_start')}
|
||||
value={periodStart}
|
||||
onChange={(e) => setPeriodStart(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
type="date"
|
||||
size="small"
|
||||
label={t('payout_period_end')}
|
||||
value={periodEnd}
|
||||
onChange={(e) => setPeriodEnd(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={onPreview}
|
||||
disabled={!periodStart || !periodEnd || preview.isPending}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{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: 600 }}
|
||||
/>
|
||||
) : 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: 2 }}>
|
||||
{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: 600 }}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{t('payout_col_gross')}: {formatIrrToToman(n.grossEarningsIrr, locale)} ·{' '}
|
||||
{t('payout_col_clawback')}: {formatIrrToToman(n.clawbackAppliedIrr, locale)} ·{' '}
|
||||
{t('payout_col_net')}: {formatIrrToToman(n.netAmountIrr, locale)} {tCommon('currency_toman')}
|
||||
</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: 2 }}>
|
||||
{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} sx={{ m: 0 }}>
|
||||
{t('cancel')}
|
||||
</AppButton>
|
||||
{canPayout ? (
|
||||
<AppButton
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => setRunConfirmOpen(true)}
|
||||
disabled={!result || run.isPending}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('payout_run')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</DialogActions>
|
||||
|
||||
<ConfirmDialog
|
||||
open={runConfirmOpen}
|
||||
title={t('payout_run_confirm_title')}
|
||||
body={t('payout_run_confirm_body')}
|
||||
confirmLabel={t('payout_run')}
|
||||
cancelLabel={t('cancel')}
|
||||
onConfirm={onRunConfirm}
|
||||
onClose={() => setRunConfirmOpen(false)}
|
||||
loading={run.isPending}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user