From bd4347f9c5f2d714b719e894c7febed5b4f151a4 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 2 Aug 2026 17:14:23 +0330 Subject: [PATCH] feat(admin): a page for the official holiday calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- assets/admin/App.tsx | 3 + assets/admin/components/layout/Sidebar.tsx | 5 + assets/admin/hooks/useNationalHolidays.ts | 61 ++++++ .../admin/pages/NationalHolidaysPage.test.tsx | 92 ++++++++ assets/admin/pages/NationalHolidaysPage.tsx | 202 ++++++++++++++++++ docs/api/resource-calendar.md | 4 + 6 files changed, 367 insertions(+) create mode 100644 assets/admin/hooks/useNationalHolidays.ts create mode 100644 assets/admin/pages/NationalHolidaysPage.test.tsx create mode 100644 assets/admin/pages/NationalHolidaysPage.tsx 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` | فیلد | نوع | توضیح |