frontend phase 15

This commit is contained in:
hamid
2026-07-10 20:28:06 +03:30
parent bc51cf59b4
commit 70cf00ce4a
151 changed files with 10711 additions and 44 deletions
@@ -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>
);
}