From f144401dd324868dd65ce6403e1b2ec97d23039e Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Sun, 2 Aug 2026 15:39:48 +0330 Subject: [PATCH] feat(holidays): one official calendar, inherited everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The holiday model was already right — national holidays global, a per-tenant override in both directions, per-doctor and per-resource exceptions — but nothing could create a national holiday. The only writer was an import command, so the calendar the whole product inherits from had no owner. Three admin-only routes give it one. POST upserts, because `date` is unique and re-sending a day should rename it rather than surface a raw database error; PATCH takes only the title, because moving a date means a different holiday. The system admin has no work environment, so the list endpoint now returns the calendar with an empty `overrides` for that role instead of the 403 `pair()` would raise — the person who maintains the calendar has to be able to read it. Both holiday tabs — the doctor's and the resource's — now open with the official calendar above their own exceptions, from one shared card rather than two copies that would drift. Each row can be opted out of with a single click, which is the existing holiday-override endpoint. Co-Authored-By: Claude Opus 5 --- .../holidays/NationalHolidaysCard.test.tsx | 71 ++++++ .../holidays/NationalHolidaysCard.tsx | 84 +++++++ .../resources/ResourceExceptionsPanel.tsx | 3 + .../components/schedule/ScheduleSection.tsx | 5 + assets/admin/lib/utils.ts | 11 + assets/admin/pages/HolidaysSettingsPage.tsx | 8 +- docs/api/resource-calendar.md | 33 +++ src/Resource/Controller/HolidayController.php | 74 ++++++- .../Repository/NationalHolidayRepository.php | 5 + src/Resource/Service/HolidayService.php | 14 ++ tests/Resource/NationalHolidayAdminTest.php | 206 ++++++++++++++++++ 11 files changed, 505 insertions(+), 9 deletions(-) create mode 100644 assets/admin/components/holidays/NationalHolidaysCard.test.tsx create mode 100644 assets/admin/components/holidays/NationalHolidaysCard.tsx create mode 100644 tests/Resource/NationalHolidayAdminTest.php diff --git a/assets/admin/components/holidays/NationalHolidaysCard.test.tsx b/assets/admin/components/holidays/NationalHolidaysCard.test.tsx new file mode 100644 index 00000000..58190f69 --- /dev/null +++ b/assets/admin/components/holidays/NationalHolidaysCard.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } 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 setOverride = { mutate: vi.fn(), isPending: false }; +const removeOverride = { mutate: vi.fn(), isPending: false }; + +let holidays: Array<{ uuid: string; date: number; title: string }> = []; +let overrides: Array<{ uuid: string; date: number; is_working: boolean }> = []; + +vi.mock('../../hooks/useResourceCalendar', () => ({ + useHolidays: () => ({ holidays, overrides, loading: false, setOverride, removeOverride }), +})); + +import NationalHolidaysCard from './NationalHolidaysCard'; + +const NOWRUZ = { uuid: 'h-1', date: 1774040400, title: 'نوروز' }; + +describe('NationalHolidaysCard', () => { + beforeEach(() => { + holidays = [NOWRUZ]; + overrides = []; + vi.clearAllMocks(); + }); + + it('تعطیلات رسمی سال را نشان می‌دهد', () => { + renderWithProviders(); + + expect(screen.getByText(/نوروز/)).toBeInTheDocument(); + expect(screen.getByText('تعطیل')).toBeInTheDocument(); + }); + + /** محیطی که آن روز کار می‌کند، تعطیلیِ سراسری را برای خودش خنثی می‌کند. */ + it('با یک کلیک روز را برای این محیط باز می‌کند', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole('button', { name: 'این روز باز است' })); + + expect(setOverride.mutate).toHaveBeenCalledWith({ date: NOWRUZ.date, is_working: true }); + }); + + it('روزی که استثنا خورده، «باز» است و می‌شود دوباره تعطیلش کرد', async () => { + overrides = [{ uuid: 'o-1', date: NOWRUZ.date, is_working: true }]; + const user = userEvent.setup(); + renderWithProviders(); + + expect(screen.getByText('باز')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'تعطیل کن' })); + + expect(removeOverride.mutate).toHaveBeenCalledWith('o-1'); + }); + + /** ساخت و حذفِ خودِ تعطیلی کارِ مدیر سیستم است؛ اینجا فقط استثنا زده می‌شود. */ + it('بدون مجوز ویرایش، هیچ دکمه‌ای نمی‌دهد', () => { + renderWithProviders(); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.getByText('تنظیمات ← تعطیلات رسمی')).toHaveAttribute('href', '/admin/holidays'); + }); + + it('سالِ بدون تعطیلی را صریح می‌گوید', async () => { + holidays = []; + renderWithProviders(); + + await waitFor(() => expect(screen.getByText(/تعطیلی رسمی ثبت نشده است/)).toBeInTheDocument()); + }); +}); diff --git a/assets/admin/components/holidays/NationalHolidaysCard.tsx b/assets/admin/components/holidays/NationalHolidaysCard.tsx new file mode 100644 index 00000000..7574d861 --- /dev/null +++ b/assets/admin/components/holidays/NationalHolidaysCard.tsx @@ -0,0 +1,84 @@ +import React, { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { useHolidays } from '../../hooks/useResourceCalendar'; +import { formatDate, currentJalaliYear } from '../../lib/utils'; + +/** + * تعطیلات رسمیِ سال، همان‌جایی که تعطیلی اختصاصی تعریف می‌شود. + * + * تقویم رسمی یک بار مرکزی ثبت می‌شود و هر پزشک و هر منبع از آن ارث می‌برد؛ اینجا فقط + * دیده می‌شود و — اگر این محیط آن روز باز باشد — با یک سوییچ خنثی می‌شود. ساخت و حذفِ + * خودِ تعطیلی کارِ مدیر سیستم است، نه کلینیک. + * + * یک کامپوننت برای دو مصرف‌کننده: تب تعطیلات پزشک و تب تعطیلات منبع. دو نسخه یعنی دو + * رفتار که با هم واگرا می‌شوند. + */ +export default function NationalHolidaysCard({ canUpdate, year = currentJalaliYear() }: { + canUpdate: boolean; + year?: number; +}) { + const { holidays, overrides, loading, setOverride, removeOverride } = useHolidays(year); + + const overrideByDate = useMemo( + () => new Map(overrides.map((o) => [o.date, o])), + [overrides], + ); + + return ( +
+

تعطیلات رسمی {year}

+

+ این‌ها برای همهٔ پزشکان و منابع اعمال می‌شوند. اگر این محیط روزی را باز است، + همین‌جا استثنا بزنید — مدیریت کاملشان در{' '} + تنظیمات ← تعطیلات رسمی. +

+ + {loading ? ( + در حال بارگذاری... + ) : holidays.length === 0 ? ( +

+ برای سال {year} تعطیلی رسمی ثبت نشده است. +

+ ) : ( +
+ {holidays.map((h) => { + const override = overrideByDate.get(h.date); + const open = override?.is_working === true; + + return ( +
+ + {open ? 'باز' : 'تعطیل'} + + + {formatDate(h.date * 1000)} · {h.title} + + {canUpdate && ( + open ? ( + + ) : ( + + ) + )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/assets/admin/components/resources/ResourceExceptionsPanel.tsx b/assets/admin/components/resources/ResourceExceptionsPanel.tsx index ec054f1e..a5e6259e 100644 --- a/assets/admin/components/resources/ResourceExceptionsPanel.tsx +++ b/assets/admin/components/resources/ResourceExceptionsPanel.tsx @@ -5,6 +5,7 @@ import PersianDateInput from '../ui/PersianDateInput'; import { useResourceAvailability, useResourceExceptions } from '../../hooks/useResourceCalendar'; import { formatDate } from '../../lib/utils'; import { DAY_LABELS } from './ResourceWorkingHoursPanel'; +import NationalHolidaysCard from '../holidays/NationalHolidaysCard'; import type { ResourceException } from '../../types'; /** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده می‌شد. */ @@ -50,6 +51,8 @@ export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: { return (
+ + + {/* تقویم رسمی بالای تعطیلی اختصاصی: پزشک اول می‌بیند چه چیزی از قبل بسته است، + بعد تعطیلی خودش را رویش اضافه می‌کند. */} + + {!readOnly && (