frontend phase 15
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useSnackbar } from 'notistack';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
MenuItem,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import { AppButton } from '@/components';
|
||||
import { AdminDataTable, AdminEmptyState, AdminErrorState, AdminPageHeader, type AdminTableColumn } from '@/components/admin';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useAdminCapabilities } from '@/hooks';
|
||||
import type { Holiday, HolidayInput, HolidayType } from '@/services/admin/types';
|
||||
import { useHolidays, useUpsertHoliday } from '@/services/admin';
|
||||
|
||||
const HOLIDAY_TYPES: readonly HolidayType[] = ['official', 'religious', 'national'];
|
||||
|
||||
/**
|
||||
* Iranian-holiday calendar manager (f15). Lists `iranian_holidays`, each with its Shamsi date, name, type,
|
||||
* and an `is_bank_closed` flag — the flag that shifts payout scheduling (the copy surfaces that consequence).
|
||||
* The client only maintains the calendar the **server** uses for the next-business-day shift; it never
|
||||
* computes the shift itself (phase §5).
|
||||
*/
|
||||
export default function AdminHolidaysPage() {
|
||||
const t = useTranslations('admin');
|
||||
const locale = useLocale();
|
||||
const caps = useAdminCapabilities();
|
||||
const holidays = useHolidays({}, 1);
|
||||
const [editing, setEditing] = useState<Holiday | 'new' | null>(null);
|
||||
|
||||
const columns: AdminTableColumn<Holiday>[] = [
|
||||
{ key: 'date', header: t('hol_col_date'), render: (h) => formatShamsiDate(h.holidayDate, locale) },
|
||||
{ key: 'name', header: t('hol_col_name'), render: (h) => h.nameFa },
|
||||
{ key: 'type', header: t('hol_col_type'), render: (h) => <Chip size="small" variant="outlined" label={t(`htype_${h.type}`)} /> },
|
||||
{
|
||||
key: 'bank',
|
||||
header: t('hol_col_bank'),
|
||||
render: (h) => (
|
||||
<Chip
|
||||
size="small"
|
||||
label={h.isBankClosed ? t('yes') : t('no')}
|
||||
sx={{
|
||||
bgcolor: h.isBankClosed ? 'var(--bal-warning)' : 'var(--bal-divider)',
|
||||
color: h.isBankClosed ? 'var(--bal-warning-contrast)' : 'var(--bal-text-secondary)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(caps.canConfig
|
||||
? [
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (h: Holiday) => (
|
||||
<AppButton variant="text" color="primary" startIcon="edit" onClick={() => setEditing(h)} sx={{ m: 0 }}>
|
||||
{t('cfg_edit')}
|
||||
</AppButton>
|
||||
),
|
||||
} as AdminTableColumn<Holiday>,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<AdminPageHeader
|
||||
title={t('hol_title')}
|
||||
subtitle={t('hol_subtitle')}
|
||||
actions={
|
||||
caps.canConfig ? (
|
||||
<AppButton variant="contained" color="primary" startIcon="add" onClick={() => setEditing('new')} sx={{ m: 0 }}>
|
||||
{t('hol_add')}
|
||||
</AppButton>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{holidays.isLoading ? (
|
||||
<Skeleton variant="rounded" height={200} />
|
||||
) : holidays.isError ? (
|
||||
<AdminErrorState message={t('error_generic')} retryLabel={t('retry')} onRetry={() => holidays.refetch()} />
|
||||
) : (holidays.data?.items.length ?? 0) === 0 ? (
|
||||
<AdminEmptyState icon="calendar" title={t('hol_empty')} />
|
||||
) : (
|
||||
<AdminDataTable columns={columns} rows={holidays.data?.items ?? []} getRowKey={(h) => h.id} ariaLabel={t('hol_title')} />
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<HolidayDialog holiday={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const TODAY_ISO = ''; // seeded below via state default so no Date at module load
|
||||
|
||||
function HolidayDialog({ holiday, onClose }: { holiday: Holiday | null; onClose: () => void }) {
|
||||
const t = useTranslations('admin');
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const upsert = useUpsertHoliday();
|
||||
const [form, setForm] = useState<HolidayInput>({
|
||||
holidayDate: holiday?.holidayDate?.slice(0, 10) ?? TODAY_ISO,
|
||||
nameFa: holiday?.nameFa ?? '',
|
||||
type: holiday?.type ?? 'official',
|
||||
isBankClosed: holiday?.isBankClosed ?? true,
|
||||
});
|
||||
|
||||
const valid = form.holidayDate.length > 0 && form.nameFa.trim().length > 0;
|
||||
|
||||
const onSave = () => {
|
||||
if (!valid) return;
|
||||
upsert.mutate(
|
||||
{ ...form, nameFa: form.nameFa.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
enqueueSnackbar(t('hol_saved'), { variant: 'success' });
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={upsert.isPending ? undefined : onClose} fullWidth maxWidth="xs">
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{holiday ? t('hol_edit') : t('hol_add')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack sx={{ gap: 2, mt: 1 }}>
|
||||
<TextField
|
||||
type="date"
|
||||
label={t('hol_col_date')}
|
||||
value={form.holidayDate}
|
||||
onChange={(e) => setForm((f) => ({ ...f, holidayDate: e.target.value }))}
|
||||
disabled={!!holiday}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField
|
||||
label={t('hol_name_fa')}
|
||||
value={form.nameFa}
|
||||
onChange={(e) => setForm((f) => ({ ...f, nameFa: e.target.value }))}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
label={t('hol_col_type')}
|
||||
value={form.type}
|
||||
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as HolidayType }))}
|
||||
>
|
||||
{HOLIDAY_TYPES.map((ty) => (
|
||||
<MenuItem key={ty} value={ty}>
|
||||
{t(`htype_${ty}`)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.isBankClosed} onChange={(e) => setForm((f) => ({ ...f, isBankClosed: e.target.checked }))} />}
|
||||
label={t('hol_bank_hint')}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<AppButton variant="text" color="inherit" onClick={onClose} disabled={upsert.isPending} sx={{ m: 0 }}>
|
||||
{t('cancel')}
|
||||
</AppButton>
|
||||
<AppButton variant="contained" color="primary" onClick={onSave} disabled={!valid || upsert.isPending} sx={{ m: 0 }}>
|
||||
{upsert.isPending ? t('saving') : t('save')}
|
||||
</AppButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user