diff --git a/assets/admin/components/holidays/NationalHolidaysCard.tsx b/assets/admin/components/holidays/NationalHolidaysCard.tsx index 7574d861..fbae051a 100644 --- a/assets/admin/components/holidays/NationalHolidaysCard.tsx +++ b/assets/admin/components/holidays/NationalHolidaysCard.tsx @@ -26,7 +26,7 @@ export default function NationalHolidaysCard({ canUpdate, year = currentJalaliYe return (
-

تعطیلات رسمی {year}

+

تعطیلات رسمی {year.toLocaleString('fa-IR', { useGrouping: false })}

این‌ها برای همهٔ پزشکان و منابع اعمال می‌شوند. اگر این محیط روزی را باز است، همین‌جا استثنا بزنید — مدیریت کاملشان در{' '} @@ -37,7 +37,7 @@ export default function NationalHolidaysCard({ canUpdate, year = currentJalaliYe در حال بارگذاری... ) : holidays.length === 0 ? (

- برای سال {year} تعطیلی رسمی ثبت نشده است. + برای سال {year.toLocaleString('fa-IR', { useGrouping: false })} تعطیلی رسمی ثبت نشده است.

) : (
@@ -51,7 +51,7 @@ export default function NationalHolidaysCard({ canUpdate, year = currentJalaliYe {open ? 'باز' : 'تعطیل'} - {formatDate(h.date * 1000)} · {h.title} + {formatDate(h.date)} · {h.title} {canUpdate && ( open ? ( diff --git a/assets/admin/components/resources/ResourceExceptionsPanel.tsx b/assets/admin/components/resources/ResourceExceptionsPanel.tsx index a5e6259e..e647df29 100644 --- a/assets/admin/components/resources/ResourceExceptionsPanel.tsx +++ b/assets/admin/components/resources/ResourceExceptionsPanel.tsx @@ -74,7 +74,7 @@ export default function ResourceExceptionsPanel({ resourceUuid, canUpdate }: { style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, fontSize: 13 }} > - {DAY_LABELS[day.day_of_week]} · {formatDate(day.date * 1000)} + {DAY_LABELS[day.day_of_week]} · {formatDate(day.date)} {day.intervals.length === 0 ? ( @@ -139,7 +139,7 @@ function ExceptionsCard({
{e.type_label} - {formatDate(e.starts_at * 1000)} تا {formatDate(e.ends_at * 1000)} + {formatDate(e.starts_at)} تا {formatDate(e.ends_at)} {e.reason ? ` · ${e.reason}` : ''} {canUpdate && ( diff --git a/assets/admin/components/resources/ResourceWorkingHoursPanel.tsx b/assets/admin/components/resources/ResourceWorkingHoursPanel.tsx index f579af9e..529e1b6d 100644 --- a/assets/admin/components/resources/ResourceWorkingHoursPanel.tsx +++ b/assets/admin/components/resources/ResourceWorkingHoursPanel.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline'; import { useResourceCalendar } from '../../hooks/useResourceCalendar'; -/** ۰ = شنبه — همان قرارداد بک‌اند و ساعت کاری شعبه. */ +/** ۰ = شنبه — همان قرارداد بک‌اند برای روزهای هفته. */ export const DAY_LABELS = ['شنبه', 'یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنجشنبه', 'جمعه']; const MINUTES_IN_DAY = 1440; @@ -23,8 +23,8 @@ function toMinutes(time: string): number | null { /** * شیفت هفتگی یک منبع — روزهای کاری و ساعت هر روز. * - * ساعت واقعی منبع تقاطع این شیفت‌ها با ساعت کاری شعبه است، نه خودشان؛ پس شیفتِ - * بیرون از ساعت شعبه ذخیره می‌شود ولی در دسترس‌پذیری اثری ندارد. + * با حذف دامنهٔ شعبه، این شیفت تنها مرجع ساعت کاری منبع است؛ فقط تعطیلات رسمی و + * استثناهای خودِ منبع از آن کسر می‌شوند. */ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: { resourceUuid?: string; @@ -88,8 +88,7 @@ export default function ResourceWorkingHoursPanel({ resourceUuid, canUpdate }: {

- روزهای کاری و ساعت هر روز. ساعت واقعی از تقاطع این شیفت‌ها با ساعت کاری شعبه به‌دست - می‌آید و تعطیلات و مرخصی از آن کسر می‌شود. + روزهای کاری و ساعت هر روز. تعطیلات رسمی و مرخصی از همین ساعت کسر می‌شوند. {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}` : 'تنظیمات نوبت‌دهی منابع کلینیک')}
- {doctorsQ.isLoading ? ( -

در حال بارگذاری پزشکان...

- ) : doctorList.length === 0 ? ( -
-
- -

هیچ پزشکی به این کلینیک متصل نیست

- مدیریت پزشکان کلینیک -
-
- ) : ( - <> -
-
- {doctorList.map(doc => ( - - ))} -
-
+
+ {SCOPES.map((s) => ( + + ))} +
- {/* key اجباری است: بدون آن state ویرایشِ برنامه بین پزشکان نشت می‌کند */} - {selected && ( -
- {/* نام پزشک انتخاب‌شده، تا هنگام اسکرول هم مشخص باشد تنظیمات مربوط به کیست */} -
- - {selectedDoctor?.name} -
- - -
- )} - + {scope === 'doctors' ? ( + setUrlState({ doctor: uuid })} + /> + ) : ( + setUrlState({ resource: uuid })} + /> )}
); } +function TabBar({ items, selected, onSelect }: { + items: T[]; + selected: T | null; + onSelect: (uuid: string) => void; +}) { + return ( +
+
+ {items.map((item) => ( + + ))} +
+
+ ); +} + +function SelectedHeader({ icon, label }: { icon: React.ReactNode; label: string }) { + return ( +
+ {icon} + {label} +
+ ); +} + +function DoctorsScope({ loading, doctors, selected, clinicUuid, readOnly, onSelect }: { + loading: boolean; + doctors: ClinicDoctorItem[]; + selected: ClinicDoctorItem | null; + clinicUuid: string; + readOnly: boolean; + onSelect: (uuid: string) => void; +}) { + if (loading) return

در حال بارگذاری پزشکان...

; + + if (doctors.length === 0) { + return ( +
+
+ +

هیچ پزشکی به این کلینیک متصل نیست

+ مدیریت پزشکان کلینیک +
+
+ ); + } + + return ( + <> + + + {/* key اجباری است: بدون آن state ویرایشِ برنامه بین پزشکان نشت می‌کند */} + {selected && ( +
+ } + label={selected.name} + /> + + +
+ )} + + ); +} + +function ResourcesScope({ loading, resources, selected, canUpdate, onSelect }: { + loading: boolean; + resources: { uuid: string; name: string; type_name: string }[]; + selected: { uuid: string; name: string; type_name: string } | null; + canUpdate: boolean; + onSelect: (uuid: string) => void; +}) { + if (loading) return

در حال بارگذاری منابع...

; + + if (resources.length === 0) { + return ( +
+
+ +

هنوز منبعی تعریف نشده است

+ تنظیمات ← منابع +
+
+ ); + } + + return ( + <> + + + {/* همان دلیل تب پزشک: بدون key، شیفتِ نیمه‌ویرایش‌شده به منبع بعدی می‌چسبد. */} + {selected && ( +
+ } + label={`${selected.name} · ${selected.type_name}`} + /> + + +
+ )} + + ); +} + export default function ClinicAppointmentSettingsPage() { return ( diff --git a/assets/admin/pages/HolidaysSettingsPage.tsx b/assets/admin/pages/HolidaysSettingsPage.tsx index 2cb9b415..1fb7ec86 100644 --- a/assets/admin/pages/HolidaysSettingsPage.tsx +++ b/assets/admin/pages/HolidaysSettingsPage.tsx @@ -32,7 +32,7 @@ export default function HolidaysSettingsPage() { { key: 'gregorian', header: 'میلادی', - render: (h) => {formatDate(h.date * 1000)}, + render: (h) => {formatDate(h.date)}, }, { key: 'status', @@ -150,7 +150,7 @@ function ClosureCard({ {overrides.map((o) => (
- {formatDate(o.date * 1000)}{o.note ? ` · ${o.note}` : ''} + {formatDate(o.date)}{o.note ? ` · ${o.note}` : ''} {canUpdate && (