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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -212,6 +212,10 @@
|
||||
> `GET /api/v1/national-holidays` برای ادمین هم کار میکند: او محیط کاری ندارد، پس
|
||||
> بخش `overrides` برایش خالی برمیگردد و فقط تقویم را میبیند.
|
||||
|
||||
> **پنل:** صفحهٔ `/admin/national-holidays` (منو ← سیستم ← تعطیلات رسمی) تنها جای
|
||||
> ساخت و حذف است و فقط `ROLE_ADMIN` میبیندش. `/admin/holidays` مالِ محیط است و آنجا
|
||||
> فقط استثنا زده میشود، نه خودِ تعطیلی.
|
||||
|
||||
### `POST /api/v1/holiday-overrides`
|
||||
|
||||
| فیلد | نوع | توضیح |
|
||||
|
||||
Reference in New Issue
Block a user