- روزهای کاری و ساعت هر روز. ساعت واقعی از تقاطع این شیفتها با ساعت کاری شعبه بهدست
- میآید و تعطیلات و مرخصی از آن کسر میشود.
+ روزهای کاری و ساعت هر روز. تعطیلات رسمی و مرخصی از همین ساعت کسر میشوند.
{totalShifts > 0 && <> · {totalShifts} شیفت>}
{canUpdate && (
diff --git a/assets/admin/pages/ClinicAppointmentSettingsPage.test.tsx b/assets/admin/pages/ClinicAppointmentSettingsPage.test.tsx
new file mode 100644
index 00000000..b9abc816
--- /dev/null
+++ b/assets/admin/pages/ClinicAppointmentSettingsPage.test.tsx
@@ -0,0 +1,103 @@
+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() } }));
+vi.mock('../hooks/usePermissions', () => ({ usePermissions: () => ({ can: () => true }) }));
+
+// ScheduleSection یک درختِ سنگین با کوئریهای خودش است؛ اینجا فقط باید ثابت شود کدام
+// scope رندر میشود، نه اینکه برنامهٔ پزشک درست کار میکند — آن تست خودش را دارد.
+vi.mock('../components/schedule/ScheduleSection', () => ({
+ ScheduleSection: () =>
برنامهٔ پزشک
,
+}));
+vi.mock('../components/FreeVisitPrice', () => ({ default: () =>
قیمت ویزیت
}));
+vi.mock('../components/resources/ResourceWorkingHoursPanel', () => ({
+ default: ({ resourceUuid }: { resourceUuid?: string }) =>
ساعات کاری {resourceUuid}
,
+ DAY_LABELS: [],
+}));
+vi.mock('../components/resources/ResourceExceptionsPanel', () => ({
+ default: ({ resourceUuid }: { resourceUuid?: string }) =>
تعطیلات {resourceUuid}
,
+}));
+
+let resources: Array<{ uuid: string; name: string; type_name: string }> = [];
+vi.mock('../hooks/useResources', () => ({
+ useResources: () => ({ resources, loading: false }),
+}));
+
+vi.mock('../lib/api', () => ({
+ api: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), put: vi.fn(), delete: vi.fn() },
+ ApiError: class extends Error {},
+}));
+
+import { api } from '../lib/api';
+import { useAuthStore } from '../stores/authStore';
+import ClinicAppointmentSettingsPage from './ClinicAppointmentSettingsPage';
+
+const get = api.get as ReturnType
;
+
+const LASER = { uuid: 'r-1', name: 'لیزر دایود', type_name: 'دستگاه لیزر' };
+const ROOM = { uuid: 'r-2', name: 'اتاق ۱', type_name: 'اتاق درمان' };
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ resources = [LASER, ROOM];
+ useAuthStore.setState({
+ primaryRole: 'clinic',
+ dbUuid: 'c-1',
+ context: { type: 'clinic', db_uuid: 'c-1' } as never,
+ availableContexts: [],
+ });
+ get.mockResolvedValue({ success: true, data: { data: [{ uuid: 'd-1', name: 'دکتر مرادی' }] } });
+});
+
+describe('ClinicAppointmentSettingsPage', () => {
+ it('پیشفرض روی پزشکان است', async () => {
+ renderWithProviders();
+
+ await waitFor(() => expect(screen.getByText('برنامهٔ پزشک')).toBeInTheDocument());
+ expect(screen.queryByText(/ساعات کاری r-/)).not.toBeInTheDocument();
+ });
+
+ /** منبع همانجایی مدیریت میشود که برنامهٔ پزشک — نه در یک صفحهٔ جدا. */
+ 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();
+
+ await user.click(screen.getByRole('button', { name: 'منابع' }));
+ await user.click(await screen.findByRole('button', { name: 'اتاق ۱' }));
+
+ await waitFor(() => expect(screen.getByText('ساعات کاری r-2')).toBeInTheDocument());
+ });
+
+ /** scope در URL مینشیند، وگرنه «بازگشت» و رفرش کاربر را به تب پزشکان میپراند. */
+ it('scope را از URL میخواند', async () => {
+ renderWithProviders(, {
+ route: '/admin/settings/appointment-settings?scope=resources',
+ });
+
+ await waitFor(() => expect(screen.getByText('ساعات کاری r-1')).toBeInTheDocument());
+ });
+
+ it('کلینیک بدون منبع، حالت خالی با راهحل میدهد', async () => {
+ resources = [];
+ const user = userEvent.setup();
+ renderWithProviders();
+
+ await user.click(screen.getByRole('button', { name: 'منابع' }));
+
+ expect(await screen.findByText('هنوز منبعی تعریف نشده است')).toBeInTheDocument();
+ expect(screen.getByText('تنظیمات ← منابع')).toHaveAttribute('href', '/admin/resources');
+ });
+});
diff --git a/assets/admin/pages/ClinicAppointmentSettingsPage.tsx b/assets/admin/pages/ClinicAppointmentSettingsPage.tsx
index 6a4460d1..5c970a4f 100644
--- a/assets/admin/pages/ClinicAppointmentSettingsPage.tsx
+++ b/assets/admin/pages/ClinicAppointmentSettingsPage.tsx
@@ -1,29 +1,44 @@
-import { useMemo, useState } from 'react';
+import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
-import { UserGroupIcon } from '@heroicons/react/24/outline';
+import { CubeIcon, UserGroupIcon } from '@heroicons/react/24/outline';
import { api } from '../lib/api';
import type { ApiResponse } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';
+import { useUrlState } from '../hooks/useUrlState';
+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 FreeVisitPrice from '../components/FreeVisitPrice';
import BackButton from '../components/ui/BackButton';
import type { ClinicDoctorItem } from '../components/ClinicDoctorsManager';
+const SCOPES = [
+ { id: 'doctors', label: 'پزشکان' },
+ { id: 'resources', label: 'منابع' },
+] as const;
+type Scope = typeof SCOPES[number]['id'];
+
/**
- * تنظیمات نوبتدهی همه پزشکان کلینیک — یک تب به ازای هر پزشک.
+ * تنظیمات نوبتدهی کلینیک — یک تب به ازای هر پزشک، و یک تب به ازای هر منبع.
*
- * هر تب دقیقاً همان ScheduleSection پنل پزشک مستقل را رندر میکند؛ تنها تفاوت،
- * امکان جابهجایی بین پزشکان است.
+ * منبع در مدل Resource-First واحدِ ظرفیت است و ساعت کاری و تعطیلات خودش را دارد، پس
+ * دقیقاً همانجایی مدیریت میشود که برنامهٔ پزشک — نه در یک صفحهٔ جدا. تب پزشک همان
+ * `ScheduleSection` پنل پزشک مستقل است و تب منبع همان پنلهای صفحهٔ منبع؛ هیچکدام
+ * نسخهٔ دومی ندارند.
*/
function ClinicAppointmentSettingsContent() {
const { dbUuid, context, availableContexts } = useAuthStore();
const { can } = usePermissions();
// منشیِ بدون مجوزِ ویرایشِ تنظیمات نوبتدهی، فقط مشاهده میکند.
const apptReadOnly = !can('appointment_settings', 'update');
- const [activeUuid, setActiveUuid] = useState(null);
+
+ // انتخابها در URL مینشینند تا «بازگشت» و رفرش همان تب را برگردانند.
+ const [urlState, setUrlState] = useUrlState({ scope: 'doctors', doctor: '', resource: '' });
+ const scope = (SCOPES.some((s) => s.id === urlState.scope) ? urlState.scope : 'doctors') as Scope;
// کاربری که هم پزشک است هم مالک کلینیک، dbUuidاش ممکن است uuid پزشک باشد.
const clinicUuid = useMemo(() => {
@@ -42,8 +57,10 @@ function ClinicAppointmentSettingsContent() {
return (raw as any)?.data ?? raw ?? [];
}, [doctorsQ.data]);
- const selected = activeUuid ?? doctorList[0]?.uuid ?? null;
- const selectedDoctor = doctorList.find(d => d.uuid === selected) ?? null;
+ const { resources, loading: resourcesLoading } = useResources({ active: '1' });
+
+ const selectedDoctor = doctorList.find(d => d.uuid === urlState.doctor) ?? doctorList[0] ?? null;
+ const selectedResource = resources.find(r => r.uuid === urlState.resource) ?? resources[0] ?? null;
if (!clinicUuid) {
return (
@@ -64,59 +81,163 @@ function ClinicAppointmentSettingsContent() {
مدیریت نوبت دهی
- {selectedDoctor ? `تنظیمات نوبتدهی ${selectedDoctor.name}` : 'تنظیمات نوبتدهی پزشکان کلینیک'}
+ {scope === 'doctors'
+ ? (selectedDoctor ? `تنظیمات نوبتدهی ${selectedDoctor.name}` : 'تنظیمات نوبتدهی پزشکان کلینیک')
+ : (selectedResource ? `تنظیمات نوبتدهی ${selectedResource.name}` : 'تنظیمات نوبتدهی منابع کلینیک')}
- {/* key اجباری است: بدون آن state ویرایشِ برنامه بین پزشکان نشت میکند */}
- {selected && (
-