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:
@@ -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); }}
|
||||
|
||||
Reference in New Issue
Block a user