The three admin endpoints shipped without anywhere to call them from, so the person who is supposed to maintain the national calendar could only do it with curl or a console command. That is not "the director can register the year's holidays". /admin/national-holidays is ROLE_ADMIN only and sits under the System group in the admin nav. The year lives in the query string, so back and refresh return to the year being edited. Editing takes only the title: `date` is the unique key, so moving a holiday is really deleting one and creating another, and the form says so rather than silently creating a duplicate. The date field is the shared Jalali picker, which speaks Gregorian, so the page converts before POSTing the jalali_date the API expects — and shows the converted value under the field so the user can see what will be stored. Deleting warns that the day leaves every clinic's calendar, because it does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
203 lines
8.5 KiB
TypeScript
203 lines
8.5 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { PlusIcon } from '@heroicons/react/24/outline';
|
|
import PageHeader from '../components/ui/PageHeader';
|
|
import DataTable, { type Column } from '../components/ui/DataTable';
|
|
import Modal from '../components/ui/Modal';
|
|
import ConfirmDialog from '../components/ui/ConfirmDialog';
|
|
import SearchableSelect from '../components/ui/SearchableSelect';
|
|
import PersianDateInput from '../components/ui/PersianDateInput';
|
|
import { useUrlState } from '../hooks/useUrlState';
|
|
import { useNationalHolidays } from '../hooks/useNationalHolidays';
|
|
import { formatDate, currentJalaliYear } from '../lib/utils';
|
|
import type { NationalHoliday } from '../types';
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
const jalaali = require('jalaali-js') as {
|
|
toJalaali: (date: Date) => { jy: number; jm: number; jd: number };
|
|
};
|
|
|
|
/** تاریخ میلادیِ `PersianDateInput` → رشتهٔ شمسیِ `1405-01-13` که API میخواهد. */
|
|
function toJalaliString(gregorian: string): string | null {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(gregorian)) return null;
|
|
|
|
const [y, m, d] = gregorian.split('-').map(Number);
|
|
const { jy, jm, jd } = jalaali.toJalaali(new Date(y, m - 1, d));
|
|
|
|
return `${String(jy).padStart(4, '0')}-${String(jm).padStart(2, '0')}-${String(jd).padStart(2, '0')}`;
|
|
}
|
|
|
|
const fa = (n: number) => n.toLocaleString('fa-IR', { useGrouping: false });
|
|
|
|
/**
|
|
* تقویم تعطیلات رسمی کشور — فقط مدیر سیستم.
|
|
*
|
|
* تعطیل رسمی به هیچ کلینیکی تعلق ندارد: یک بار اینجا ثبت میشود و همهٔ محیطها، هر
|
|
* پزشک و هر منبع، از همان ارث میبرند. کلینیکی که آن روز باز است در تنظیمات خودش
|
|
* استثنا میزند و به این تقویم دست نمیزند.
|
|
*/
|
|
export default function NationalHolidaysPage() {
|
|
const thisYear = currentJalaliYear();
|
|
const [urlState, setUrlState] = useUrlState({ year: String(thisYear), search: '' });
|
|
const year = Number(urlState.year) || thisYear;
|
|
|
|
const { holidays, loading, create, update, remove } = useNationalHolidays(year);
|
|
|
|
const [editing, setEditing] = useState<{ open: boolean; holiday: NationalHoliday | null }>({ open: false, holiday: null });
|
|
const [toDelete, setToDelete] = useState<NationalHoliday | null>(null);
|
|
|
|
const query = urlState.search.trim();
|
|
const rows = query === '' ? holidays : holidays.filter((h) => h.title.includes(query));
|
|
|
|
// سه سال عقب و جلو کافی است؛ تقویم رسمی آنقدر جلوتر منتشر نمیشود.
|
|
const years = Array.from({ length: 7 }, (_, i) => thisYear - 3 + i);
|
|
|
|
const columns: Column<NationalHoliday>[] = [
|
|
{ key: 'jalali_date', header: 'تاریخ', render: (h) => <span style={{ fontSize: 13 }}>{formatDate(h.date)}</span> },
|
|
{ key: 'title', header: 'مناسبت', render: (h) => <span style={{ fontWeight: 600 }}>{h.title}</span> },
|
|
];
|
|
|
|
return (
|
|
<div className="fade-in">
|
|
<PageHeader
|
|
title="تعطیلات رسمی"
|
|
description="تقویم رسمی کشور؛ یک بار اینجا ثبت میشود و همهٔ کلینیکها، پزشکان و منابع از آن ارث میبرند."
|
|
action={
|
|
<button type="button" className="btn primary" onClick={() => setEditing({ open: true, holiday: null })}>
|
|
<PlusIcon style={{ width: 16 }} /> افزودن تعطیلی
|
|
</button>
|
|
}
|
|
/>
|
|
|
|
<DataTable<NationalHoliday>
|
|
columns={columns}
|
|
data={rows}
|
|
loading={loading}
|
|
searchValue={urlState.search}
|
|
onSearchChange={(v) => setUrlState({ search: v })}
|
|
searchPlaceholder="جستجو در مناسبتها..."
|
|
emptyMessage={`برای سال ${fa(year)} تعطیلی رسمی ثبت نشده است`}
|
|
headerExtra={
|
|
<div style={{ minWidth: 150, marginRight: 'auto' }}>
|
|
<SearchableSelect
|
|
options={years.map((y) => ({ value: String(y), label: `سال ${fa(y)}` }))}
|
|
value={String(year)}
|
|
onChange={(v) => setUrlState({ year: v ? String(v) : String(thisYear) })}
|
|
placeholder="سال"
|
|
height={36}
|
|
/>
|
|
</div>
|
|
}
|
|
actions={(h) => (
|
|
<div style={{ display: 'flex', gap: 6 }}>
|
|
<button type="button" className="btn secondary sm" onClick={() => setEditing({ open: true, holiday: h })}>
|
|
ویرایش
|
|
</button>
|
|
<button type="button" className="btn secondary sm" onClick={() => setToDelete(h)}>
|
|
حذف
|
|
</button>
|
|
</div>
|
|
)}
|
|
/>
|
|
|
|
<HolidayFormModal
|
|
open={editing.open}
|
|
holiday={editing.holiday}
|
|
saving={create.isPending || update.isPending}
|
|
onClose={() => setEditing({ open: false, holiday: null })}
|
|
onSave={(payload) => {
|
|
const done = { onSuccess: () => setEditing({ open: false, holiday: null }) };
|
|
|
|
if (editing.holiday) {
|
|
update.mutate({ uuid: editing.holiday.uuid, title: payload.title }, done);
|
|
} else if (payload.jalali_date) {
|
|
create.mutate({ jalali_date: payload.jalali_date, title: payload.title }, done);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={!!toDelete}
|
|
title="حذف تعطیلی رسمی"
|
|
message={`«${toDelete?.title}» از تقویم همهٔ کلینیکها برداشته میشود. مطمئن هستید؟`}
|
|
confirmLabel="حذف"
|
|
danger
|
|
loading={remove.isPending}
|
|
onConfirm={() => toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })}
|
|
onCancel={() => setToDelete(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function HolidayFormModal({ open, holiday, saving, onClose, onSave }: {
|
|
open: boolean;
|
|
holiday: NationalHoliday | null;
|
|
saving: boolean;
|
|
onClose: () => void;
|
|
onSave: (payload: { jalali_date: string | null; title: string }) => void;
|
|
}) {
|
|
const [date, setDate] = useState('');
|
|
const [title, setTitle] = useState('');
|
|
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
setDate('');
|
|
setTitle(holiday?.title ?? '');
|
|
}, [open, holiday]);
|
|
|
|
const jalaliDate = holiday ? holiday.jalali_date : toJalaliString(date);
|
|
const invalid = title.trim() === '' || (!holiday && jalaliDate === null);
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
onClose={onClose}
|
|
title={holiday ? `ویرایش «${holiday.title}»` : 'افزودن تعطیلی رسمی'}
|
|
>
|
|
<div style={{ display: 'grid', gap: 14 }}>
|
|
{holiday ? (
|
|
// تاریخ کلید یکتاست؛ جابهجا کردنش یعنی یک تعطیلِ دیگر، پس فقط عنوان ویرایش میشود.
|
|
<div style={{ display: 'grid', gap: 6 }}>
|
|
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>تاریخ</span>
|
|
<span className="field" style={{ color: 'var(--text-2)' }}>{formatDate(holiday.date)}</span>
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>
|
|
تاریخ تغییر نمیکند؛ برای جابهجایی، این را حذف و روز درست را ثبت کنید.
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'grid', gap: 6 }}>
|
|
<span style={{ fontSize: 12, color: 'var(--text-2)' }}>تاریخ</span>
|
|
<PersianDateInput value={date} onChange={setDate} placeholder="روز تعطیل را انتخاب کنید" enableYearPicker />
|
|
{jalaliDate && (
|
|
<span style={{ fontSize: 12, color: 'var(--text-3)' }}>ثبت میشود: {jalaliDate}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="field-block">
|
|
<label htmlFor="holiday-title">مناسبت</label>
|
|
<input
|
|
id="holiday-title"
|
|
className="field"
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="مثلاً: سیزدهبدر"
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
|
<button type="button" className="btn secondary" onClick={onClose}>انصراف</button>
|
|
<button
|
|
type="button"
|
|
className="btn primary"
|
|
disabled={saving || invalid}
|
|
onClick={() => onSave({ jalali_date: jalaliDate, title: title.trim() })}
|
|
>
|
|
{saving ? 'در حال ذخیره...' : 'ذخیره'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|