+
+ {/* تقویم شمسی، نه `input type=date` میلادی: اپراتور تاریخ را شمسی میگوید و
+ ترجمهٔ ذهنی همانجایی است که استثنا یک روز جابهجا ثبت میشود. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ برای ثبت یا حذف استثنا مجوز ویرایش تنظیمات نوبتدهی لازم است.
+
-
- {/* تقویم شمسی، نه `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 }: {
روزهای کاری و ساعت هر روز. تعطیلات رسمی و مرخصی از همین ساعت کسر میشوند.
,
}));
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 (
+