feat(admin): a page for the official holiday calendar

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>
This commit is contained in:
hamed
2026-08-02 17:14:23 +03:30
co-authored by Claude Opus 5
parent 5e87bbc18b
commit bd4347f9c5
6 changed files with 367 additions and 0 deletions
+3
View File
@@ -83,6 +83,7 @@ import SkillsPage from './pages/SkillsPage';
import ResourcePoolsPage from './pages/ResourcePoolsPage';
import ResourceDetailPage from './pages/ResourceDetailPage';
import HolidaysSettingsPage from './pages/HolidaysSettingsPage';
import NationalHolidaysPage from './pages/NationalHolidaysPage';
import PatientRecordFormPage from './pages/PatientRecordFormPage';
import PatientDetailPage from './pages/PatientDetailPage';
import PaymentSuccessPage from './pages/PaymentSuccessPage';
@@ -300,6 +301,8 @@ export default function App() {
<Route path="resources/:resourceUuid" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><ResourceDetailPage /></RoleRoute>} />
{/* تقویم منبع در تب «ساعات کاری» همان صفحه حل شده؛ لینک‌های قدیمی نباید بشکنند. */}
<Route path="resources/:resourceUuid/calendar" element={<ResourceCalendarRedirect />} />
{/* تقویم رسمی کشور — فقط مدیر سیستم. /holidays مالِ محیط است و فقط استثنا می‌زند. */}
<Route path="national-holidays" element={<RoleRoute roles={['admin']}><NationalHolidaysPage /></RoleRoute>} />
<Route path="holidays" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['appointment_settings', 'view']}><HolidaysSettingsPage /></RoleRoute>} />
<Route path="sms-wallet" element={<RoleRoute roles={['doctor', 'clinic', 'secretary']} blockClinicScope permission={['sms', 'view']}><SmsWalletPage /></RoleRoute>} />
<Route path="my-secretaries" element={<RoleRoute roles={['doctor', 'clinic']} blockClinicScope><MySecretariesPage /></RoleRoute>} />
@@ -235,6 +235,11 @@ function buildSections(
icon: CreditCardIcon,
label: "اشتراک‌ها",
},
{
to: "/admin/national-holidays",
icon: CalendarDaysIcon,
label: "تعطیلات رسمی",
},
{
to: "/admin/settings",
icon: Cog6ToothIcon,
+61
View File
@@ -0,0 +1,61 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { api, ApiError, type ApiResponse } from '../lib/api';
import type { NationalHoliday } from '../types';
/**
* تقویم تعطیلات رسمی از دید **مدیر سیستم** — تنها جایی که ساخته و حذف می‌شود.
*
* ⚠️ با `useHolidays` اشتباه نشود: آن مالِ یک محیط است و فقط «این روز برای ما باز
* است» را ثبت می‌کند (`holiday-overrides`). خودِ تعطیلی سراسری است و محیط ندارد.
*/
export function useNationalHolidays(year: number) {
const qc = useQueryClient();
const key = ['national-holidays', year];
const invalidate = () => {
qc.invalidateQueries({ queryKey: key });
// کارت تعطیلات در تب پزشک و منبع هم از همین سال می‌خواند.
qc.invalidateQueries({ queryKey: ['national-holidays'] });
};
const fail = (e: unknown, fallback: string) =>
toast.error(e instanceof ApiError ? e.message : fallback);
const query = useQuery({
queryKey: key,
queryFn: () =>
api.get<ApiResponse<{ year: number; holidays: NationalHoliday[] }>>(
`/api/v1/national-holidays?year=${year}`,
),
});
const create = useMutation({
mutationFn: (d: { jalali_date: string; title: string }) =>
api.post<ApiResponse<NationalHoliday>>('/api/v1/admin/national-holidays', d),
// سرور upsert می‌کند: همان روز دوباره یعنی تغییر عنوان، نه ردیف دوم.
onSuccess: () => { toast.success('تعطیلی ثبت شد'); invalidate(); },
onError: (e) => fail(e, 'ثبت تعطیلی ناموفق بود'),
});
const update = useMutation({
mutationFn: ({ uuid, title }: { uuid: string; title: string }) =>
api.patch<ApiResponse<NationalHoliday>>(`/api/v1/admin/national-holiday/${uuid}`, { title }),
onSuccess: () => { toast.success('عنوان به‌روزرسانی شد'); invalidate(); },
onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'),
});
const remove = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/admin/national-holiday/${uuid}`),
onSuccess: () => { toast.success('تعطیلی حذف شد'); invalidate(); },
onError: (e) => fail(e, 'حذف تعطیلی ناموفق بود'),
});
return {
holidays: query.data?.data?.holidays ?? [],
loading: query.isLoading,
create,
update,
remove,
};
}
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../test/utils';
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const create = { mutate: vi.fn(), isPending: false };
const update = { mutate: vi.fn(), isPending: false };
const remove = { mutate: vi.fn(), isPending: false };
let holidays: Array<{ uuid: string; date: number; jalali_date: string; jalali_year: number; title: string }> = [];
vi.mock('../hooks/useNationalHolidays', () => ({
useNationalHolidays: () => ({ holidays, loading: false, create, update, remove }),
}));
import NationalHolidaysPage from './NationalHolidaysPage';
// ۱۴۰۵-۰۱-۱۳ ⇒ ۲۰۲۶-۰۴-۰۲
const NOWRUZ13 = {
uuid: 'h-1',
date: Math.floor(new Date(2026, 3, 2).getTime() / 1000),
jalali_date: '1405-01-13',
jalali_year: 1405,
title: 'سیزده‌بدر',
};
describe('NationalHolidaysPage', () => {
beforeEach(() => {
holidays = [NOWRUZ13];
vi.clearAllMocks();
});
it('تعطیلات سال را فهرست می‌کند', () => {
renderWithProviders(<NationalHolidaysPage />);
expect(screen.getByText('سیزده‌بدر')).toBeInTheDocument();
});
/** تاریخ کلید یکتاست؛ ویرایش فقط عنوان را عوض می‌کند. */
it('ویرایش، تاریخ را قفل و فقط عنوان را می‌فرستد', async () => {
const user = userEvent.setup();
renderWithProviders(<NationalHolidaysPage />);
await user.click(screen.getByRole('button', { name: 'ویرایش' }));
expect(screen.getByText(/تاریخ تغییر نمی‌کند/)).toBeInTheDocument();
const title = screen.getByLabelText('مناسبت');
await user.clear(title);
await user.type(title, 'روز طبیعت');
await user.click(screen.getByRole('button', { name: 'ذخیره' }));
expect(update.mutate).toHaveBeenCalledWith(
{ uuid: 'h-1', title: 'روز طبیعت' },
expect.anything(),
);
});
it('بدون تاریخ، دکمهٔ ذخیره غیرفعال است', async () => {
const user = userEvent.setup();
renderWithProviders(<NationalHolidaysPage />);
await user.click(screen.getByRole('button', { name: /افزودن تعطیلی/ }));
await user.type(screen.getByLabelText('مناسبت'), 'مناسبت تازه');
expect(screen.getByRole('button', { name: 'ذخیره' })).toBeDisabled();
});
it('حذف، هشدارِ دامنهٔ اثر را می‌دهد', async () => {
const user = userEvent.setup();
renderWithProviders(<NationalHolidaysPage />);
await user.click(screen.getByRole('button', { name: 'حذف' }));
expect(screen.getByText(/از تقویم همهٔ کلینیک‌ها برداشته می‌شود/)).toBeInTheDocument();
});
it('سالِ خالی را صریح می‌گوید', () => {
holidays = [];
renderWithProviders(<NationalHolidaysPage />);
expect(screen.getByText(/تعطیلی رسمی ثبت نشده است/)).toBeInTheDocument();
});
/** سال در URL می‌نشیند تا «بازگشت» و رفرش همان سال را بدهند. */
it('سال را از URL می‌خواند', () => {
renderWithProviders(<NationalHolidaysPage />, { route: '/admin/national-holidays?year=1403' });
expect(screen.getByText('سال ۱۴۰۳')).toBeInTheDocument();
});
});
+202
View File
@@ -0,0 +1,202 @@
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>
);
}
+4
View File
@@ -212,6 +212,10 @@
> `GET /api/v1/national-holidays` برای ادمین هم کار می‌کند: او محیط کاری ندارد، پس
> بخش `overrides` برایش خالی برمی‌گردد و فقط تقویم را می‌بیند.
> **پنل:** صفحهٔ `/admin/national-holidays` (منو ← سیستم ← تعطیلات رسمی) تنها جای
> ساخت و حذف است و فقط `ROLE_ADMIN` می‌بیندش. `/admin/holidays` مالِ محیط است و آنجا
> فقط استثنا زده می‌شود، نه خودِ تعطیلی.
### `POST /api/v1/holiday-overrides`
| فیلد | نوع | توضیح |