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:
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
/** پیشنمایش نباید «وقت قابل رزرو» خوانده شود — نوبتها هنوز کسر نشدهاند. */
|
||||
|
||||
Reference in New Issue
Block a user