From 348e1cf517aaf2c93c708cc05e9defad2713f161 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 3 Aug 2026 09:37:22 +0330 Subject: [PATCH] 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. --- .../resources/ResourceExceptionsCard.tsx | 183 ++++++++++++++++ .../resources/ResourceExceptionsPanel.tsx | 203 +----------------- .../resources/ResourceWorkingHoursPanel.tsx | 133 ++++++------ .../ClinicAppointmentSettingsPage.test.tsx | 59 ++++- .../pages/ClinicAppointmentSettingsPage.tsx | 66 ++++-- .../admin/pages/ResourceDetailPage.test.tsx | 54 ++++- docs/api/resource-calendar.md | 16 ++ .../Service/ResourceCalendarService.php | 11 +- .../ResourceCalendarValidationTest.php | 99 +++++++++ 9 files changed, 535 insertions(+), 289 deletions(-) create mode 100644 assets/admin/components/resources/ResourceExceptionsCard.tsx create mode 100644 tests/Resource/ResourceCalendarValidationTest.php diff --git a/assets/admin/components/resources/ResourceExceptionsCard.tsx b/assets/admin/components/resources/ResourceExceptionsCard.tsx new file mode 100644 index 00000000..f14a7f94 --- /dev/null +++ b/assets/admin/components/resources/ResourceExceptionsCard.tsx @@ -0,0 +1,183 @@ +import React, { useId, useMemo, useState } from 'react'; +import { XMarkIcon } from '@heroicons/react/24/outline'; +import ConfirmDialog from '../ui/ConfirmDialog'; +import SearchableSelect from '../ui/SearchableSelect'; +import PersianDateInput from '../ui/PersianDateInput'; +import { useResourceExceptions } from '../../hooks/useResourceCalendar'; +import { formatDate } from '../../lib/utils'; +import type { ResourceException } from '../../types'; + +const EXCEPTION_TYPES = [ + { value: 'leave', label: 'مرخصی' }, + { value: 'absence', label: 'غیبت' }, + { value: 'maintenance', label: 'سرویس دوره‌ای' }, + { value: 'closure', label: 'تعطیلی موردی' }, +]; + +/** + * مرخصی و سرویسِ یک منبع — فهرست استثناهای ثبت‌شده و فرم ثبت استثنای تازه. + * + * کارتِ مستقل است چون دو ترکیب متفاوت دارد: در صفحهٔ جزئیات منبع کنار تعطیلات رسمی + * و پیش‌نمایش می‌نشیند (`ResourceExceptionsPanel`)، و در تنظیمات نوبت‌دهی یکی از دو + * تبِ زیر شیفت هفتگی است. حذفِ دوباره‌نویسی، نه انتزاعِ زودرس. + */ +export default function ResourceExceptionsCard({ resourceUuid, canUpdate }: { + resourceUuid?: string; + canUpdate: boolean; +}) { + const { exceptions, create, remove } = useResourceExceptions(resourceUuid); + const [toDelete, setToDelete] = useState(null); + + const [type, setType] = useState('leave'); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [reason, setReason] = useState(''); + + // id یکتا لازم است: این کارت در صفحهٔ منبع و صفحهٔ تنظیمات نوبت‌دهی هر دو رندر می‌شود. + const uid = useId(); + + const toTimestamp = (value: string): number | null => { + if (value === '') return null; + const ms = new Date(`${value}T00:00:00`).getTime(); + return Number.isNaN(ms) ? null : Math.floor(ms / 1000); + }; + + const start = toTimestamp(startDate); + const end = toTimestamp(endDate); + // پایان روزِ انتخاب‌شده، نه آغازش: مرخصیِ «تا سه‌شنبه» شامل خودِ سه‌شنبه است. + const endExclusive = end === null ? null : end + 86400; + const invalid = start === null || endExclusive === null || endExclusive <= start; + + const typeLabel = useMemo( + () => EXCEPTION_TYPES.find((t) => t.value === type)?.label ?? '', + [type], + ); + + return ( +
+

+ مرخصی و سرویس +

+ + {exceptions.length === 0 ? ( +

+ استثنایی ثبت نشده است. +

+ ) : ( +
+ {exceptions.map((e) => ( +
+ {e.type_label} + + {formatDate(e.starts_at)} تا {formatDate(e.ends_at)} + {e.reason ? ` · ${e.reason}` : ''} + + {canUpdate && ( + + )} +
+ ))} +
+ )} + + {canUpdate ? ( +
+
+ + setType(v ? String(v) : 'leave')} + placeholder="نوع استثنا" + height={40} + /> +
+ + {/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی می‌گوید و + ترجمهٔ ذهنی همان‌جایی است که استثنا یک روز جابه‌جا ثبت می‌شود. */} +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+ ) : ( +

+ برای ثبت یا حذف استثنا مجوز ویرایش تنظیمات نوبت‌دهی لازم است. +

+ )} + + toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })} + onCancel={() => setToDelete(null)} + /> +
+ ); +} diff --git a/assets/admin/components/resources/ResourceExceptionsPanel.tsx b/assets/admin/components/resources/ResourceExceptionsPanel.tsx index 888e3262..3a5a581a 100644 --- a/assets/admin/components/resources/ResourceExceptionsPanel.tsx +++ b/assets/admin/components/resources/ResourceExceptionsPanel.tsx @@ -1,14 +1,10 @@ -import React, { useId, useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { XMarkIcon } from '@heroicons/react/24/outline'; -import ConfirmDialog from '../ui/ConfirmDialog'; -import SearchableSelect from '../ui/SearchableSelect'; -import PersianDateInput from '../ui/PersianDateInput'; -import { useResourceAvailability, useResourceExceptions } from '../../hooks/useResourceCalendar'; +import React from 'react'; +import { useResourceAvailability } from '../../hooks/useResourceCalendar'; import { formatDate, formatNumber } from '../../lib/utils'; import { DAY_LABELS } from './ResourceWorkingHoursPanel'; import NationalHolidaysCard from '../holidays/NationalHolidaysCard'; -import type { ResourceAvailability, ResourceException } from '../../types'; +import ResourceExceptionsCard from './ResourceExceptionsCard'; +import type { ResourceAvailability } from '../../types'; /** چرا یک روز خالی است — بدون ترجمه، پاسخ خام سرور به کاربر نشان داده می‌شد. */ const REASON_LABELS: Record = { @@ -20,13 +16,6 @@ const REASON_LABELS: Record = { address_inactive: 'محل نوبت‌دهی غیرفعال است', }; -const EXCEPTION_TYPES = [ - { value: 'leave', label: 'مرخصی' }, - { value: 'absence', label: 'غیبت' }, - { value: 'maintenance', label: 'سرویس دوره‌ای' }, - { value: 'closure', label: 'تعطیلی موردی' }, -]; - /** نیمه‌شبِ امروز به‌صورت timestamp ثانیه‌ای. */ function todayMidnight(): number { const d = new Date(); @@ -35,21 +24,16 @@ function todayMidnight(): number { } /** - * تعطیلات و استثناهای یک منبع، کنار پیش‌نمایش دو هفتهٔ ساعت آزاد. + * تقویمِ استثناهای یک منبع: تعطیلات رسمی، مرخصی و سرویس، و پیش‌نمایش دو هفته. * - * پیش‌نمایش عمداً «ساعت آزاد» نامیده نشده بلکه «خام» است: نوبت‌های ثبت‌شده در آن - * کسر نشده‌اند و اشتباه گرفتنش با «وقت قابل رزرو» به بیش‌رزروی می‌انجامد. - * - * دو ستون، نه `auto-fit`: تعطیلات و استثنا هر دو ورودی‌اند و کنار هم می‌مانند، - * پیش‌نمایش خروجی است و ستون خودش را می‌گیرد. + * این ترکیب مخصوص صفحهٔ جزئیات منبع است، جایی که کل صفحه دربارهٔ همان یک منبع است + * و پیش‌نمایش پاسخِ «بعد از این تنظیمات، چه ساعتی باز می‌ماند؟» را می‌دهد. صفحهٔ + * تنظیمات نوبت‌دهی همان دو کارت را بدون پیش‌نمایش و به‌صورت تب می‌چیند. */ export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: { resourceUuid?: string; canUpdate: boolean; }) { - const { exceptions, create, remove } = useResourceExceptions(resourceUuid); - const [toDelete, setToDelete] = useState(null); - const previewFrom = todayMidnight(); const previewTo = previewFrom + 13 * 86400; const { availability } = useResourceAvailability(resourceUuid, previewFrom, previewTo); @@ -58,27 +42,10 @@ export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: {
- create.mutate(payload)} - onDelete={setToDelete} - /> +
- - toDelete && remove.mutate(toDelete.uuid, { onSuccess: () => setToDelete(null) })} - onCancel={() => setToDelete(null)} - />
); } @@ -137,155 +104,3 @@ function PreviewCard({ availability }: { availability?: ResourceAvailability }) ); } - -function ExceptionsCard({ - exceptions, canUpdate, saving, onCreate, onDelete, -}: { - exceptions: ResourceException[]; - canUpdate: boolean; - saving: boolean; - onCreate: (payload: { type: string; starts_at: number; ends_at: number; reason?: string | null }) => void; - onDelete: (e: ResourceException) => void; -}) { - const [type, setType] = useState('leave'); - const [startDate, setStartDate] = useState(''); - const [endDate, setEndDate] = useState(''); - const [reason, setReason] = useState(''); - - // id یکتا لازم است: این کارت در صفحهٔ منبع و صفحهٔ تنظیمات نوبت‌دهی هر دو رندر می‌شود. - const uid = useId(); - - const toTimestamp = (value: string): number | null => { - if (value === '') return null; - const ms = new Date(`${value}T00:00:00`).getTime(); - return Number.isNaN(ms) ? null : Math.floor(ms / 1000); - }; - - const start = toTimestamp(startDate); - const end = toTimestamp(endDate); - // پایان روزِ انتخاب‌شده، نه آغازش: مرخصیِ «تا سه‌شنبه» شامل خودِ سه‌شنبه است. - const endExclusive = end === null ? null : end + 86400; - const invalid = start === null || endExclusive === null || endExclusive <= start; - - const typeLabel = useMemo( - () => EXCEPTION_TYPES.find((t) => t.value === type)?.label ?? '', - [type], - ); - - return ( -
-

- مرخصی و سرویس -

- - {exceptions.length === 0 ? ( -

- استثنایی ثبت نشده است. -

- ) : ( -
- {exceptions.map((e) => ( -
- {e.type_label} - - {formatDate(e.starts_at)} تا {formatDate(e.ends_at)} - {e.reason ? ` · ${e.reason}` : ''} - - {canUpdate && ( - - )} -
- ))} -
- )} - - {canUpdate ? ( -
-
- - setType(v ? String(v) : 'leave')} - placeholder="نوع استثنا" - height={40} - /> -
- - {/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی می‌گوید و - ترجمهٔ ذهنی همان‌جایی است که استثنا یک روز جابه‌جا ثبت می‌شود. */} -
-
- - -
-
- - -
-
- -
- - -
- - -
- ) : ( -

- برای ثبت یا حذف استثنا مجوز ویرایش تنظیمات نوبت‌دهی لازم است. -

- )} -
- ); -} diff --git a/assets/admin/components/resources/ResourceWorkingHoursPanel.tsx b/assets/admin/components/resources/ResourceWorkingHoursPanel.tsx index 9fc4dfe8..7b0df811 100644 --- a/assets/admin/components/resources/ResourceWorkingHoursPanel.tsx +++ b/assets/admin/components/resources/ResourceWorkingHoursPanel.tsx @@ -1,7 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { - ExclamationTriangleIcon, PlusIcon, Square2StackIcon, TrashIcon, -} from '@heroicons/react/24/outline'; +import { ExclamationTriangleIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; import { useResourceCalendar } from '../../hooks/useResourceCalendar'; import { formatNumber } from '../../lib/utils'; @@ -24,6 +22,40 @@ function toMinutes(time: string): number | null { return minutes >= 0 && minutes <= MINUTES_IN_DAY ? minutes : null; } +/** + * یک روز را اعتبارسنجی می‌کند و اولین ایراد را برمی‌گرداند (یا `null`). + * + * سرور هم همین سه قاعده را دارد (`ResourceCalendarService::assertRanges`) و مرجع + * نهایی اوست؛ این نسخه فقط بازخورد را از «بعد از کلیک ذخیره» به «حین تایپ» می‌آورد، + * چون تداخل ۰۹:۰۰–۱۳:۰۰ با ۱۲:۰۰–۱۷:۰۰ را باید همان‌جا که ساخته می‌شود دید. + */ +function validateDay(day: number, rows: Draft[]): string | null { + const parsed: { start: number; end: number }[] = []; + + for (const row of rows) { + const start = toMinutes(row.start); + const end = row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end); + + if (start === null || end === null) { + return `ساعت روز ${DAY_LABELS[day]} را به شکل ۰۹:۰۰ وارد کنید`; + } + if (end <= start) { + return `در روز ${DAY_LABELS[day]} پایان شیفت باید بعد از شروع آن باشد`; + } + parsed.push({ start, end }); + } + + // مرتب‌سازی لازم است: تداخل بین شیفت اول و سوم هم باید دیده شود، نه فقط همسایه‌ها. + const sorted = [...parsed].sort((a, b) => a.start - b.start); + for (let i = 1; i < sorted.length; i += 1) { + if (sorted[i].start < sorted[i - 1].end) { + return `شیفت‌های روز ${DAY_LABELS[day]} با هم تداخل دارند — بازه‌ها نباید هم‌پوشانی داشته باشند`; + } + } + + return null; +} + /** * شیفت هفتگی یک منبع — روزهای کاری و ساعت هر روز. * @@ -43,7 +75,6 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { const [draft, setDraft] = useState({}); // نسخهٔ سرور به‌صورت رشته نگه داشته می‌شود تا مقایسهٔ dirty یک `===` ساده باشد. const [baseline, setBaseline] = useState(null); - const [error, setError] = useState<{ day: number; message: string } | null>(null); useEffect(() => { if (!calendar) return; @@ -57,7 +88,6 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { }); setDraft(next); setBaseline(JSON.stringify(next)); - setError(null); }, [calendar]); const totalShifts = useMemo( @@ -65,6 +95,14 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { [draft], ); + // خطا مشتقِ draft است، نه state جدا: با state، پیامِ یک ویرایشِ اصلاح‌شده تا + // کلیکِ بعدیِ ذخیره روی صفحه می‌ماند. + const issues = useMemo( + () => DAY_LABELS.map((_, day) => validateDay(day, draft[day] ?? [])), + [draft], + ); + const firstIssue = issues.find((m): m is string => m !== null) ?? null; + // پیش از اولین پاسخ سرور baseline نداریم، پس هیچ‌چیز dirty نیست. const dirty = baseline !== null && JSON.stringify(draft) !== baseline; @@ -77,48 +115,24 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { const removeRange = (day: number, index: number) => setDraft((d) => ({ ...d, [day]: (d[day] ?? []).filter((_, i) => i !== index) })); - /** پرتکرارترین حالت، یک ساعتِ یکسان برای همهٔ روزهای کاری است؛ بدون این، تعریف - * یک هفتهٔ ساده ۱۵ تعامل می‌خواست. */ - const copyToAllDays = (day: number) => - setDraft((d) => { - const source = (d[day] ?? []).map((r) => ({ ...r })); - const next: Days = {}; - DAY_LABELS.forEach((_, i) => { next[i] = source.map((r) => ({ ...r })); }); - return next; - }); - const reset = () => { if (baseline === null) return; setDraft(JSON.parse(baseline) as Days); - setError(null); }; const submit = () => { + // دکمه هم غیرفعال است، ولی گاردِ دوم می‌ماند: ذخیره از راه دیگری هم صدا زده می‌شود. + if (firstIssue) return; + const days: Record = {}; for (const [dayKey, rows] of Object.entries(draft)) { - const day = Number(dayKey); - const parsed: { start_minute: number; end_minute: number }[] = []; - - for (const row of rows) { - const start = toMinutes(row.start); - const end = row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end); - - if (start === null || end === null) { - setError({ day, message: `ساعت روز ${DAY_LABELS[day]} را به شکل ۰۹:۰۰ وارد کنید` }); - return; - } - if (end <= start) { - setError({ day, message: `در روز ${DAY_LABELS[day]} پایان شیفت باید بعد از شروع آن باشد` }); - return; - } - parsed.push({ start_minute: start, end_minute: end }); - } - - days[dayKey] = parsed; + days[dayKey] = rows.map((row) => ({ + start_minute: toMinutes(row.start)!, + end_minute: row.endOfDay ? MINUTES_IN_DAY : toMinutes(row.end)!, + })); } - setError(null); save.mutate(days); }; @@ -133,20 +147,6 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { روزهای کاری و ساعت هر روز. تعطیلات رسمی و مرخصی از همین ساعت کسر می‌شوند.

- {error && ( -
- - {error.message} -
- )} - {loading ? (
{DAY_LABELS.map((_, i) => ( @@ -157,7 +157,8 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {
{DAY_LABELS.map((label, day) => { const rows = draft[day] ?? []; - const cls = ['wh-day', rows.length ? 'has-shift' : '', error?.day === day ? 'err' : ''] + const issue = issues[day]; + const cls = ['wh-day', rows.length ? 'has-shift' : '', issue ? 'err' : ''] .filter(Boolean).join(' '); return ( @@ -218,22 +219,15 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { )}
))} + + {issue && ( +

{issue}

+ )}
)} {canUpdate && (
- {rows.length > 0 && ( - - )} @@ -247,15 +241,22 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { {canUpdate && dirty && (
-
- - تغییرات شیفت ذخیره نشده است +
+ + {firstIssue ?? 'تغییرات شیفت ذخیره نشده است'}
-
diff --git a/assets/admin/pages/ClinicAppointmentSettingsPage.test.tsx b/assets/admin/pages/ClinicAppointmentSettingsPage.test.tsx index b9abc816..e2505e27 100644 --- a/assets/admin/pages/ClinicAppointmentSettingsPage.test.tsx +++ b/assets/admin/pages/ClinicAppointmentSettingsPage.test.tsx @@ -16,8 +16,11 @@ vi.mock('../components/resources/ResourceWorkingHoursPanel', () => ({ default: ({ resourceUuid }: { resourceUuid?: string }) =>
ساعات کاری {resourceUuid}
, DAY_LABELS: [], })); -vi.mock('../components/resources/ResourceExceptionsPanel', () => ({ - default: ({ resourceUuid }: { resourceUuid?: string }) =>
تعطیلات {resourceUuid}
, +vi.mock('../components/resources/ResourceExceptionsCard', () => ({ + default: ({ resourceUuid }: { resourceUuid?: string }) =>
مرخصی {resourceUuid}
, +})); +vi.mock('../components/holidays/NationalHolidaysCard', () => ({ + default: () =>
تعطیلات رسمی سال
, })); 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(); 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(, { + 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(, { + 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(, { + 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(); diff --git a/assets/admin/pages/ClinicAppointmentSettingsPage.tsx b/assets/admin/pages/ClinicAppointmentSettingsPage.tsx index 805c666d..9a9f88cb 100644 --- a/assets/admin/pages/ClinicAppointmentSettingsPage.tsx +++ b/assets/admin/pages/ClinicAppointmentSettingsPage.tsx @@ -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 (
+ {/* سوییچر پزشک/منبع در خودِ ردیف عنوان می‌نشیند: بالاترین نقطهٔ محتوا و همیشه + در دید، بدون اسکرول. پایین‌تر از هدر، کاربر باید دنبالش می‌گشت. */} - -
- {/* دو انتخابگر پشت سر هم می‌مانند — «نما» بعد «مورد». در اسلات action هدر، - سوییچر به لبهٔ مقابلِ صفحه می‌افتاد و از تبی که کنترل می‌کند جدا می‌شد. */} -
- نما -
+ action={( +
{SCOPES.map((s) => (
-
+ )} + /> +
{scope === 'doctors' ? ( - - -
+ )} ); } +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 ( +
+
+ {CALENDAR_TABS.map((t) => ( + + ))} +
+ + {tab === 'shifts' && } + {tab === 'holidays' && } + {tab === 'exceptions' && } +
+ ); +} + export default function ClinicAppointmentSettingsPage() { return ( diff --git a/assets/admin/pages/ResourceDetailPage.test.tsx b/assets/admin/pages/ResourceDetailPage.test.tsx index b3435e39..725d9e12 100644 --- a/assets/admin/pages/ResourceDetailPage.test.tsx +++ b/assets/admin/pages/ResourceDetailPage.test.tsx @@ -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(); }); /** پیش‌نمایش نباید «وقت قابل رزرو» خوانده شود — نوبت‌ها هنوز کسر نشده‌اند. */ diff --git a/docs/api/resource-calendar.md b/docs/api/resource-calendar.md index e994d4ed..f0a387b9 100644 --- a/docs/api/resource-calendar.md +++ b/docs/api/resource-calendar.md @@ -58,6 +58,22 @@ { "days": { "0": [{ "start_minute": 540, "end_minute": 1020 }] } } ``` +### خطاهای اعتبارسنجی (۴۲۲) + +| شرط | `field` | پیام | +|---|---|---| +| `start_minute`/`end_minute` نبود یا عدد نبود | همان فیلد | `در روز شنبه مقدار start_minute الزامی است` | +| خارج از `0..1440` | همان فیلد | `در روز شنبه مقدار end_minute باید بین ۰ و ۱۴۴۰ باشد` | +| `end <= start` | `end_minute` | `در روز شنبه، پایان شیفت باید بعد از شروع آن باشد` | +| دو بازهٔ متداخل در یک روز | `start_minute` | `شیفت‌های روز شنبه با هم تداخل دارند — بازه‌ها نباید هم‌پوشانی داشته باشند` | + +پیام‌ها **نام روز** را می‌گویند نه اندیس عددی را، چون مستقیم به کاربر نشان داده می‌شوند. + +هم‌پوشانی پس از مرتب‌سازی بازه‌ها بررسی می‌شود، پس ترتیب ارسال مهم نیست و تداخل بین +بازهٔ اول و سوم هم گرفته می‌شود. مرزِ چسبیده هم‌پوشانی **نیست**: `09:00–13:00` کنار +`13:00–17:00` معتبر است (شیفت صبح و عصر). پنل ادمین همین قواعد را حین ویرایش هم اجرا +می‌کند و تا رفع تداخل، دکمهٔ ذخیره را غیرفعال نگه می‌دارد؛ ولی مرجع نهایی سرور است. + --- ## استثناها — مرخصی، غیبت، سرویس، تعطیلی موردی diff --git a/src/Resource/Service/ResourceCalendarService.php b/src/Resource/Service/ResourceCalendarService.php index 9737c5a4..d75077f8 100644 --- a/src/Resource/Service/ResourceCalendarService.php +++ b/src/Resource/Service/ResourceCalendarService.php @@ -14,6 +14,9 @@ final class ResourceCalendarService { public const DAYS = [0, 1, 2, 3, 4, 5, 6]; + /** ۰ = شنبه. پیام خطا باید نام روز را بگوید، نه اندیس خام را. */ + private const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']; + public function __construct( private readonly ResourceCalendarRepository $calendars, private readonly EntityManagerInterface $em, @@ -115,7 +118,7 @@ final class ResourceCalendarService if ($end <= $start) { throw new AppException( ErrorCodes::ERR_VALIDATION_001, - sprintf('در روز %d، پایان شیفت باید بعد از شروع آن باشد', $day), + sprintf('در روز %s، پایان شیفت باید بعد از شروع آن باشد', self::DAY_LABELS[$day]), 422, 'end_minute', ); @@ -130,7 +133,7 @@ final class ResourceCalendarService if ($i > 0 && $range['start_minute'] < $parsed[$i - 1]['end_minute']) { throw new AppException( ErrorCodes::ERR_VALIDATION_001, - sprintf('شیفت‌های روز %d با هم هم‌پوشانی دارند', $day), + sprintf('شیفت‌های روز %s با هم تداخل دارند — بازه‌ها نباید هم‌پوشانی داشته باشند', self::DAY_LABELS[$day]), 422, 'start_minute', ); @@ -145,7 +148,7 @@ final class ResourceCalendarService if (!is_numeric($value)) { throw new AppException( ErrorCodes::ERR_VALIDATION_002, - sprintf('در روز %d مقدار %s الزامی است', $day, $field), + sprintf('در روز %s مقدار %s الزامی است', self::DAY_LABELS[$day], $field), 422, $field, ); @@ -156,7 +159,7 @@ final class ResourceCalendarService if ($minute < 0 || $minute > ResourceCalendar::MINUTES_IN_DAY) { throw new AppException( ErrorCodes::ERR_VALIDATION_001, - sprintf('در روز %d مقدار %s باید بین ۰ و ۱۴۴۰ باشد', $day, $field), + sprintf('در روز %s مقدار %s باید بین ۰ و ۱۴۴۰ باشد', self::DAY_LABELS[$day], $field), 422, $field, ); diff --git a/tests/Resource/ResourceCalendarValidationTest.php b/tests/Resource/ResourceCalendarValidationTest.php new file mode 100644 index 00000000..cc822cae --- /dev/null +++ b/tests/Resource/ResourceCalendarValidationTest.php @@ -0,0 +1,99 @@ + $days */ + private function putCalendar(array $days): array + { + [$user, , $address] = $this->clinicWithAddress(); + $resource = $this->createResource($user, $address, $this->resourceType($address)); + + return $this->authJson( + 'PUT', + "/api/v1/resource/{$resource['data']['uuid']}/calendar", + $user, + ['days' => $days], + ); + } + + /** ۰۹:۰۰–۱۳:۰۰ کنار ۱۲:۰۰–۱۷:۰۰ یعنی یک ساعت ظرفیتِ دوباره‌فروخته‌شده. */ + public function testOverlappingShiftsInTheSameDayAreRejected(): void + { + $body = $this->putCalendar([ + 0 => [ + ['start_minute' => 540, 'end_minute' => 780], // ۰۹:۰۰–۱۳:۰۰ + ['start_minute' => 720, 'end_minute' => 1020], // ۱۲:۰۰–۱۷:۰۰ + ], + ]); + + self::assertSame(422, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertSame('start_minute', $body['errors'][0]['field']); + self::assertStringContainsString('شنبه', $body['errors'][0]['message'], 'پیام باید نام روز را بگوید نه اندیس'); + self::assertStringContainsString('تداخل', $body['errors'][0]['message']); + } + + /** ترتیب ورودی نباید مهم باشد: تداخل بین شیفت اول و سوم هم باید دیده شود. */ + public function testOverlapIsDetectedRegardlessOfInputOrder(): void + { + $body = $this->putCalendar([ + 2 => [ + ['start_minute' => 900, 'end_minute' => 1020], // ۱۵:۰۰–۱۷:۰۰ + ['start_minute' => 540, 'end_minute' => 600], // ۰۹:۰۰–۱۰:۰۰ + ['start_minute' => 960, 'end_minute' => 1080], // ۱۶:۰۰–۱۸:۰۰ ← با اولی تداخل دارد + ], + ]); + + self::assertSame(422, $this->responseCode()); + self::assertStringContainsString('دوشنبه', $body['errors'][0]['message']); + } + + /** مرزِ چسبیده هم‌پوشانی نیست: ۱۳:۰۰ پایانِ یکی و شروعِ بعدی، شیفت صبح و عصر است. */ + public function testBackToBackShiftsAreAccepted(): void + { + $body = $this->putCalendar([ + 0 => [ + ['start_minute' => 540, 'end_minute' => 780], + ['start_minute' => 780, 'end_minute' => 1020], + ], + ]); + + self::assertSame(200, $this->responseCode(), json_encode($body, JSON_UNESCAPED_UNICODE)); + self::assertCount(2, $body['data']['days']['0']); + } + + /** اعتبارسنجی کامل پیش از هر حذفی — روز نامعتبر نباید روزهای درست را پاک کند. */ + public function testAnInvalidDayLeavesTheExistingCalendarUntouched(): void + { + [$user, , $address] = $this->clinicWithAddress(); + $resource = $this->createResource($user, $address, $this->resourceType($address)); + $uuid = $resource['data']['uuid']; + + $this->authJson('PUT', "/api/v1/resource/$uuid/calendar", $user, [ + 'days' => [0 => [['start_minute' => 540, 'end_minute' => 1020]]], + ]); + self::assertSame(200, $this->responseCode()); + + $this->authJson('PUT', "/api/v1/resource/$uuid/calendar", $user, [ + 'days' => [ + 0 => [['start_minute' => 540, 'end_minute' => 1020]], + 1 => [ + ['start_minute' => 540, 'end_minute' => 780], + ['start_minute' => 600, 'end_minute' => 900], + ], + ], + ]); + self::assertSame(422, $this->responseCode()); + + $body = $this->authJson('GET', "/api/v1/resource/$uuid/calendar", $user); + self::assertCount(1, $body['data']['days']['0'], 'شنبهٔ قبلی سرِ جایش مانده'); + self::assertSame([], $body['data']['days']['1']); + } +}