feat(holidays): one official calendar, inherited everywhere

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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-02 15:39:48 +03:30
co-authored by Claude Opus 5
parent dd284ec622
commit f144401dd3
11 changed files with 505 additions and 9 deletions
@@ -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(<NationalHolidaysCard canUpdate year={1405} />);
expect(screen.getByText(/نوروز/)).toBeInTheDocument();
expect(screen.getByText('تعطیل')).toBeInTheDocument();
});
/** محیطی که آن روز کار می‌کند، تعطیلیِ سراسری را برای خودش خنثی می‌کند. */
it('با یک کلیک روز را برای این محیط باز می‌کند', async () => {
const user = userEvent.setup();
renderWithProviders(<NationalHolidaysCard canUpdate year={1405} />);
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(<NationalHolidaysCard canUpdate year={1405} />);
expect(screen.getByText('باز')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'تعطیل کن' }));
expect(removeOverride.mutate).toHaveBeenCalledWith('o-1');
});
/** ساخت و حذفِ خودِ تعطیلی کارِ مدیر سیستم است؛ اینجا فقط استثنا زده می‌شود. */
it('بدون مجوز ویرایش، هیچ دکمه‌ای نمی‌دهد', () => {
renderWithProviders(<NationalHolidaysCard canUpdate={false} year={1405} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByText('تنظیمات ← تعطیلات رسمی')).toHaveAttribute('href', '/admin/holidays');
});
it('سالِ بدون تعطیلی را صریح می‌گوید', async () => {
holidays = [];
renderWithProviders(<NationalHolidaysCard canUpdate year={1405} />);
await waitFor(() => expect(screen.getByText(/تعطیلی رسمی ثبت نشده است/)).toBeInTheDocument());
});
});
@@ -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 (
<div className="card" style={{ padding: 14 }}>
<h2 className="section-title" style={{ margin: '0 0 4px' }}>تعطیلات رسمی {year}</h2>
<p style={{ fontSize: 12, color: 'var(--text-3)', margin: '0 0 10px', lineHeight: 1.9 }}>
اینها برای همهٔ پزشکان و منابع اعمال میشوند. اگر این محیط روزی را باز است،
همینجا استثنا بزنید مدیریت کاملشان در{' '}
<Link to="/admin/holidays" style={{ color: 'var(--primary)' }}>تنظیمات تعطیلات رسمی</Link>.
</p>
{loading ? (
<span style={{ fontSize: 13, color: 'var(--text-3)' }}>در حال بارگذاری...</span>
) : holidays.length === 0 ? (
<p style={{ fontSize: 13, color: 'var(--text-3)', margin: 0 }}>
برای سال {year} تعطیلی رسمی ثبت نشده است.
</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{holidays.map((h) => {
const override = overrideByDate.get(h.date);
const open = override?.is_working === true;
return (
<div key={h.uuid} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
<span className={`badge ${open ? 'gray' : 'red'}`} style={{ fontSize: 11 }}>
{open ? 'باز' : 'تعطیل'}
</span>
<span style={{ flex: 1, color: 'var(--text-2)' }}>
{formatDate(h.date * 1000)} · {h.title}
</span>
{canUpdate && (
open ? (
<button
type="button"
className="btn secondary sm"
disabled={removeOverride.isPending}
onClick={() => override && removeOverride.mutate(override.uuid)}
>
تعطیل کن
</button>
) : (
<button
type="button"
className="btn secondary sm"
disabled={setOverride.isPending}
onClick={() => setOverride.mutate({ date: h.date, is_working: true })}
>
این روز باز است
</button>
)
)}
</div>
);
})}
</div>
)}
</div>
);
}
@@ -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 (
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', alignItems: 'start' }}>
<NationalHolidaysCard canUpdate={canUpdate} />
<ExceptionsCard
exceptions={exceptions}
canUpdate={canUpdate}
@@ -14,6 +14,7 @@ import { formatNumber, digitsOnly, todayIso, unixToIso } from '../../lib/utils';
import Modal from '../ui/Modal';
import ConfirmDialog from '../ui/ConfirmDialog';
import GlobalSearchableSelect from '../ui/SearchableSelect';
import NationalHolidaysCard from '../holidays/NationalHolidaysCard';
/**
* ساختار واحد تنظیمات نوبت‌دهی یک پزشک — برنامه هفتگی، استثناهای تاریخ و تعطیلات.
@@ -1363,6 +1364,10 @@ function HolidaysTab({ doctorUuid, clinicUuid, readOnly = false }: { doctorUuid:
return (
<div className="space-y-3">
{/* تقویم رسمی بالای تعطیلی اختصاصی: پزشک اول می‌بیند چه چیزی از قبل بسته است،
بعد تعطیلی خودش را رویش اضافه می‌کند. */}
<NationalHolidaysCard canUpdate={!readOnly} />
{!readOnly && (
<div className="flex justify-end">
<button type="button" onClick={() => { setEditing(null); setModalOpen(true); }}