feat: enhance resource calendar validation and UI

- Implement real-time validation for overlapping shifts in the ResourceWorkingHoursPanel.
- Remove the copy shift functionality to simplify the UI and prevent confusion.
- Introduce ResourceExceptionsCard to manage resource exceptions, including leave and maintenance.
- Update ClinicAppointmentSettingsPage to utilize new components and improve tab navigation for resource management.
- Add comprehensive validation tests for resource calendar to ensure overlapping shifts are correctly handled.
- Update API documentation to reflect new validation error messages and rules.
This commit is contained in:
hamed
2026-08-03 09:37:22 +03:30
parent c42679d98c
commit 348e1cf517
9 changed files with 535 additions and 289 deletions
@@ -16,8 +16,11 @@ vi.mock('../components/resources/ResourceWorkingHoursPanel', () => ({
default: ({ resourceUuid }: { resourceUuid?: string }) => <div>ساعات کاری {resourceUuid}</div>,
DAY_LABELS: [],
}));
vi.mock('../components/resources/ResourceExceptionsPanel', () => ({
default: ({ resourceUuid }: { resourceUuid?: string }) => <div>تعطیلات {resourceUuid}</div>,
vi.mock('../components/resources/ResourceExceptionsCard', () => ({
default: ({ resourceUuid }: { resourceUuid?: string }) => <div>مرخصی {resourceUuid}</div>,
}));
vi.mock('../components/holidays/NationalHolidaysCard', () => ({
default: () => <div>تعطیلات رسمی سال</div>,
}));
let resources: Array<{ uuid: string; name: string; type_name: string }> = [];
@@ -60,17 +63,65 @@ describe('ClinicAppointmentSettingsPage', () => {
});
/** منبع همان‌جایی مدیریت می‌شود که برنامهٔ پزشک — نه در یک صفحهٔ جدا. */
it('تب منابع، ساعت کاری و تعطیلات منبع را می‌آورد', async () => {
it('تب منابع، برنامهٔ کاری منبع را می‌آورد', async () => {
const user = userEvent.setup();
renderWithProviders(<ClinicAppointmentSettingsPage />);
await user.click(screen.getByRole('button', { name: 'منابع' }));
await waitFor(() => expect(screen.getByText('ساعات کاری r-1')).toBeInTheDocument());
expect(screen.getByText('تعطیلات r-1')).toBeInTheDocument();
expect(screen.queryByText('برنامهٔ پزشک')).not.toBeInTheDocument();
});
/**
* سه تب، نه سه بخشِ پشت‌سرهم: هر سه یک سؤال را جواب می‌دهند ولی هم‌زمان لازم
* نیستند و چیدنشان زیر هم صفحه را سه برابر بلند می‌کرد.
*/
it('شیفت، تعطیلات و مرخصی سه تب مجزا هستند', async () => {
const user = userEvent.setup();
renderWithProviders(<ClinicAppointmentSettingsPage />, {
route: '/admin/settings/appointment-settings?scope=resources',
});
// پیش‌فرض روی شیفت هفتگی است — کارِ اصلی صفحه.
await waitFor(() => expect(screen.getByText('ساعات کاری r-1')).toBeInTheDocument());
expect(screen.queryByText('تعطیلات رسمی سال')).not.toBeInTheDocument();
expect(screen.queryByText('مرخصی r-1')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'تعطیلات رسمی' }));
expect(await screen.findByText('تعطیلات رسمی سال')).toBeInTheDocument();
expect(screen.queryByText('ساعات کاری r-1')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'مرخصی و سرویس' }));
expect(await screen.findByText('مرخصی r-1')).toBeInTheDocument();
expect(screen.queryByText('تعطیلات رسمی سال')).not.toBeInTheDocument();
});
/** تب از URL خوانده می‌شود، پس رفرش و «بازگشت» همان نما را نگه می‌دارند. */
it('تب برنامهٔ کاری را از URL می‌خواند', async () => {
renderWithProviders(<ClinicAppointmentSettingsPage />, {
route: '/admin/settings/appointment-settings?scope=resources&calendarTab=exceptions',
});
expect(await screen.findByText('مرخصی r-1')).toBeInTheDocument();
expect(screen.queryByText('ساعات کاری r-1')).not.toBeInTheDocument();
});
/** پیش‌نمایش دو هفته در این صفحه کاربردی ندارد و فقط ارتفاع اضافه می‌کرد. */
it('پیش‌نمایش دو هفته را نشان نمی‌دهد', async () => {
const user = userEvent.setup();
renderWithProviders(<ClinicAppointmentSettingsPage />, {
route: '/admin/settings/appointment-settings?scope=resources',
});
await waitFor(() => expect(screen.getByText('ساعات کاری r-1')).toBeInTheDocument());
expect(screen.queryByText('پیش‌نمایش دو هفته')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'مرخصی و سرویس' }));
await screen.findByText('مرخصی r-1');
expect(screen.queryByText('پیش‌نمایش دو هفته')).not.toBeInTheDocument();
});
it('بین منابع جابه‌جا می‌شود', async () => {
const user = userEvent.setup();
renderWithProviders(<ClinicAppointmentSettingsPage />);
@@ -11,7 +11,8 @@ import { useResources } from '../hooks/useResources';
import SettingsLayout from '../components/layout/SettingsLayout';
import { ScheduleSection } from '../components/schedule/ScheduleSection';
import ResourceWorkingHoursPanel from '../components/resources/ResourceWorkingHoursPanel';
import ResourceExceptionsPanel from '../components/resources/ResourceExceptionsPanel';
import ResourceExceptionsCard from '../components/resources/ResourceExceptionsCard';
import NationalHolidaysCard from '../components/holidays/NationalHolidaysCard';
import FreeVisitPrice from '../components/FreeVisitPrice';
import PageHeader from '../components/ui/PageHeader';
import type { ClinicDoctorItem } from '../components/ClinicDoctorsManager';
@@ -82,18 +83,14 @@ function ClinicAppointmentSettingsContent() {
return (
<div className="fade-in">
{/* سوییچر پزشک/منبع در خودِ ردیف عنوان می‌نشیند: بالاترین نقطهٔ محتوا و همیشه
در دید، بدون اسکرول. پایین‌تر از هدر، کاربر باید دنبالش می‌گشت. */}
<PageHeader
title="مدیریت نوبت‌دهی"
description={description}
backTo="/admin/settings-menu"
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
{/* دو انتخابگر پشت سر هم می‌مانند — «نما» بعد «مورد». در اسلات action هدر،
سوییچر به لبهٔ مقابلِ صفحه می‌افتاد و از تبی که کنترل می‌کند جدا می‌شد. */}
<div>
<span className="field-label" id="appt-scope-tabs">نما</span>
<div className="seg" role="group" aria-labelledby="appt-scope-tabs">
action={(
<div className="seg" role="group" aria-label="نمای تنظیمات">
{SCOPES.map((s) => (
<button
key={s.id}
@@ -105,8 +102,10 @@ function ClinicAppointmentSettingsContent() {
</button>
))}
</div>
</div>
)}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--gap)' }}>
{scope === 'doctors' ? (
<DoctorsScope
loading={doctorsQ.isLoading}
@@ -250,15 +249,54 @@ function ResourcesScope({ loading, resources, selected, canUpdate, onSelect }: {
{/* همان دلیل تب پزشک: بدون key، شیفتِ نیمه‌ویرایش‌شده به منبع بعدی می‌چسبد. */}
{selected && (
<div key={selected.uuid} style={{ display: 'grid', gap: 'var(--gap)' }}>
<ResourceWorkingHoursPanel resourceUuid={selected.uuid} canUpdate={canUpdate} />
<ResourceExceptionsPanel resourceUuid={selected.uuid} canUpdate={canUpdate} />
</div>
<ResourceCalendarTabs key={selected.uuid} resourceUuid={selected.uuid} canUpdate={canUpdate} />
)}
</>
);
}
const CALENDAR_TABS = [
{ id: 'shifts', label: 'شیفت هفتگی' },
{ id: 'holidays', label: 'تعطیلات رسمی' },
{ id: 'exceptions', label: 'مرخصی و سرویس' },
] as const;
type CalendarTab = typeof CALENDAR_TABS[number]['id'];
/**
* برنامهٔ کاری منبع در سه تب: شیفت هفتگی، تعطیلات رسمی، مرخصی و سرویس.
*
* هر سه یک چیز را می‌سازند («این منبع کِی باز است؟») پس یک‌جا می‌مانند؛ ولی هم‌زمان
* دیده‌شدنشان لازم نیست و پشت‌سرهم چیدنشان صفحه را سه برابر بلند می‌کرد. تب فعال در
* URL می‌نشیند تا «بازگشت» و رفرش همان نما را برگردانند.
*/
function ResourceCalendarTabs({ resourceUuid, canUpdate }: { resourceUuid: string; canUpdate: boolean }) {
const [urlState, setUrlState] = useUrlState({ calendarTab: 'shifts' });
const tab = (CALENDAR_TABS.some((t) => t.id === urlState.calendarTab)
? urlState.calendarTab
: 'shifts') as CalendarTab;
return (
<div style={{ display: 'grid', gap: 12 }}>
<div className="seg" style={{ alignSelf: 'flex-start' }} role="group" aria-label="برنامهٔ کاری منبع">
{CALENDAR_TABS.map((t) => (
<button
key={t.id}
className={tab === t.id ? 'active' : ''}
aria-pressed={tab === t.id}
onClick={() => setUrlState({ calendarTab: t.id })}
>
{t.label}
</button>
))}
</div>
{tab === 'shifts' && <ResourceWorkingHoursPanel resourceUuid={resourceUuid} canUpdate={canUpdate} />}
{tab === 'holidays' && <NationalHolidaysCard canUpdate={canUpdate} />}
{tab === 'exceptions' && <ResourceExceptionsCard resourceUuid={resourceUuid} canUpdate={canUpdate} />}
</div>
);
}
export default function ClinicAppointmentSettingsPage() {
return (
<SettingsLayout active="appointment">
+47 -7
View File
@@ -171,20 +171,60 @@ describe('ResourceDetailPage', () => {
expect(screen.getAllByText('بدون شیفت — این روز بسته است')).toHaveLength(7);
});
/** پرتکرارترین حالت یک ساعتِ یکسان برای کل هفته است؛ بدون کپی، ۱۵ تعامل لازم بود. */
it('شیفت یک روز را به همهٔ روزهای هفته کپی می‌کند', async () => {
/**
* دو شیفتِ متداخل در یک روز نباید ذخیره شود. سرور هم همین را رد می‌کند، ولی خطا
* باید حین ویرایش دیده شود نه بعد از رفت‌وبرگشت شبکه.
*/
it('شیفت‌های متداخل را رد می‌کند و ذخیره را می‌بندد', async () => {
const days = emptyDays();
days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 1020, start_time: '09:00', end_time: '17:00', active: true }];
days['0'] = [
{ sequence: 0, start_minute: 540, end_minute: 780, start_time: '09:00', end_time: '13:00', active: true },
{ sequence: 1, start_minute: 1020, end_minute: 1140, start_time: '17:00', end_time: '19:00', active: true },
];
const user = userEvent.setup();
mockApi(days);
renderPage('/admin/resources/r1?tab=hours');
await waitFor(() => expect(screen.getByDisplayValue('17:00')).toBeInTheDocument());
expect(screen.queryByText(/تداخل دارند/)).not.toBeInTheDocument();
// ۱۷:۰۰–۱۹:۰۰ به ۱۲:۰۰ کشیده می‌شود و با ۰۹:۰۰–۱۳:۰۰ تداخل پیدا می‌کند.
await user.clear(screen.getByLabelText('ساعت شروع شیفت ۲ روز شنبه'));
await user.type(screen.getByLabelText('ساعت شروع شیفت ۲ روز شنبه'), '12:00');
// پیام دو جا می‌آید و باید بیاید: کنار خودِ روز، و در نوار ذخیره که دلیل قفل را می‌گوید.
expect(await screen.findAllByText(/شیفت‌های روز شنبه با هم تداخل دارند/)).toHaveLength(2);
expect(screen.getByRole('button', { name: 'ذخیرهٔ شیفت‌ها' })).toBeDisabled();
});
/** بازهٔ بدون تداخل نباید قربانی اعتبارسنجی شود — مرز چسبیده مجاز است. */
it('شیفت‌های چسبیده اما بدون هم‌پوشانی را می‌پذیرد', async () => {
const days = emptyDays();
days['0'] = [
{ sequence: 0, start_minute: 540, end_minute: 780, start_time: '09:00', end_time: '13:00', active: true },
{ sequence: 1, start_minute: 780, end_minute: 1020, start_time: '13:00', end_time: '17:00', active: true },
];
const user = userEvent.setup();
mockApi(days);
renderPage('/admin/resources/r1?tab=hours');
await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument());
await user.click(screen.getByRole('button', { name: 'کپی شیفت‌های شنبه به همهٔ روزهای هفته' }));
expect(screen.queryByText(/تداخل دارند/)).not.toBeInTheDocument();
await waitFor(() => expect(screen.getAllByDisplayValue('09:00')).toHaveLength(7));
expect(screen.getAllByDisplayValue('17:00')).toHaveLength(7);
expect(screen.queryByText('بدون شیفت — این روز بسته است')).not.toBeInTheDocument();
await user.click(screen.getAllByRole('button', { name: 'شیفت' })[1]);
expect(await screen.findByRole('button', { name: 'ذخیرهٔ شیفت‌ها' })).toBeEnabled();
});
/** آیکون کپی گمراه‌کننده بود و حذف شد. */
it('دکمهٔ کپی شیفت ندارد', async () => {
const days = emptyDays();
days['0'] = [{ sequence: 0, start_minute: 540, end_minute: 1020, start_time: '09:00', end_time: '17:00', active: true }];
mockApi(days);
renderPage('/admin/resources/r1?tab=hours');
await waitFor(() => expect(screen.getByDisplayValue('09:00')).toBeInTheDocument());
expect(screen.queryByRole('button', { name: /کپی/ })).not.toBeInTheDocument();
});
/** پیش‌نمایش نباید «وقت قابل رزرو» خوانده شود — نوبت‌ها هنوز کسر نشده‌اند. */