diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 1fe8c876..a54b39bf 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -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() { } /> {/* تقویم منبع در تب «ساعات کاری» همان صفحه حل شده؛ لینک‌های قدیمی نباید بشکنند. */} } /> + {/* تقویم رسمی کشور — فقط مدیر سیستم. /holidays مالِ محیط است و فقط استثنا می‌زند. */} + } /> } /> } /> } /> diff --git a/assets/admin/components/layout/Sidebar.tsx b/assets/admin/components/layout/Sidebar.tsx index 95f803ce..19feb147 100644 --- a/assets/admin/components/layout/Sidebar.tsx +++ b/assets/admin/components/layout/Sidebar.tsx @@ -235,6 +235,11 @@ function buildSections( icon: CreditCardIcon, label: "اشتراک‌ها", }, + { + to: "/admin/national-holidays", + icon: CalendarDaysIcon, + label: "تعطیلات رسمی", + }, { to: "/admin/settings", icon: Cog6ToothIcon, diff --git a/assets/admin/hooks/useNationalHolidays.ts b/assets/admin/hooks/useNationalHolidays.ts new file mode 100644 index 00000000..4ca0f2e6 --- /dev/null +++ b/assets/admin/hooks/useNationalHolidays.ts @@ -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>( + `/api/v1/national-holidays?year=${year}`, + ), + }); + + const create = useMutation({ + mutationFn: (d: { jalali_date: string; title: string }) => + api.post>('/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>(`/api/v1/admin/national-holiday/${uuid}`, { title }), + onSuccess: () => { toast.success('عنوان به‌روزرسانی شد'); invalidate(); }, + onError: (e) => fail(e, 'به‌روزرسانی ناموفق بود'), + }); + + const remove = useMutation({ + mutationFn: (uuid: string) => api.delete>(`/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, + }; +} diff --git a/assets/admin/pages/NationalHolidaysPage.test.tsx b/assets/admin/pages/NationalHolidaysPage.test.tsx new file mode 100644 index 00000000..7ba5f8c6 --- /dev/null +++ b/assets/admin/pages/NationalHolidaysPage.test.tsx @@ -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(); + + expect(screen.getByText('سیزده‌بدر')).toBeInTheDocument(); + }); + + /** تاریخ کلید یکتاست؛ ویرایش فقط عنوان را عوض می‌کند. */ + it('ویرایش، تاریخ را قفل و فقط عنوان را می‌فرستد', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + 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(); + + 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(); + + await user.click(screen.getByRole('button', { name: 'حذف' })); + + expect(screen.getByText(/از تقویم همهٔ کلینیک‌ها برداشته می‌شود/)).toBeInTheDocument(); + }); + + it('سالِ خالی را صریح می‌گوید', () => { + holidays = []; + renderWithProviders(); + + expect(screen.getByText(/تعطیلی رسمی ثبت نشده است/)).toBeInTheDocument(); + }); + + /** سال در URL می‌نشیند تا «بازگشت» و رفرش همان سال را بدهند. */ + it('سال را از URL می‌خواند', () => { + renderWithProviders(, { route: '/admin/national-holidays?year=1403' }); + + expect(screen.getByText('سال ۱۴۰۳')).toBeInTheDocument(); + }); +}); diff --git a/assets/admin/pages/NationalHolidaysPage.tsx b/assets/admin/pages/NationalHolidaysPage.tsx new file mode 100644 index 00000000..d2a09f2d --- /dev/null +++ b/assets/admin/pages/NationalHolidaysPage.tsx @@ -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(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[] = [ + { key: 'jalali_date', header: 'تاریخ', render: (h) => {formatDate(h.date)} }, + { key: 'title', header: 'مناسبت', render: (h) => {h.title} }, + ]; + + return ( +
+ setEditing({ open: true, holiday: null })}> + افزودن تعطیلی + + } + /> + + + columns={columns} + data={rows} + loading={loading} + searchValue={urlState.search} + onSearchChange={(v) => setUrlState({ search: v })} + searchPlaceholder="جستجو در مناسبت‌ها..." + emptyMessage={`برای سال ${fa(year)} تعطیلی رسمی ثبت نشده است`} + headerExtra={ +
+ ({ value: String(y), label: `سال ${fa(y)}` }))} + value={String(year)} + onChange={(v) => setUrlState({ year: v ? String(v) : String(thisYear) })} + placeholder="سال" + height={36} + /> +
+ } + actions={(h) => ( +
+ + +
+ )} + /> + + 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); + } + }} + /> + + toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })} + onCancel={() => setToDelete(null)} + /> +
+ ); +} + +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 ( + +
+ {holiday ? ( + // تاریخ کلید یکتاست؛ جابه‌جا کردنش یعنی یک تعطیلِ دیگر، پس فقط عنوان ویرایش می‌شود. +
+ تاریخ + {formatDate(holiday.date)} + + تاریخ تغییر نمی‌کند؛ برای جابه‌جایی، این را حذف و روز درست را ثبت کنید. + +
+ ) : ( +
+ تاریخ + + {jalaliDate && ( + ثبت می‌شود: {jalaliDate} + )} +
+ )} + +
+ + setTitle(e.target.value)} + placeholder="مثلاً: سیزده‌بدر" + /> +
+ +
+ + +
+
+
+ ); +} diff --git a/docs/api/resource-calendar.md b/docs/api/resource-calendar.md index 9451dda5..e994d4ed 100644 --- a/docs/api/resource-calendar.md +++ b/docs/api/resource-calendar.md @@ -212,6 +212,10 @@ > `GET /api/v1/national-holidays` برای ادمین هم کار می‌کند: او محیط کاری ندارد، پس > بخش `overrides` برایش خالی برمی‌گردد و فقط تقویم را می‌بیند. +> **پنل:** صفحهٔ `/admin/national-holidays` (منو ← سیستم ← تعطیلات رسمی) تنها جای +> ساخت و حذف است و فقط `ROLE_ADMIN` می‌بیندش. `/admin/holidays` مالِ محیط است و آنجا +> فقط استثنا زده می‌شود، نه خودِ تعطیلی. + ### `POST /api/v1/holiday-overrides` | فیلد | نوع | توضیح |