diff --git a/assets/admin/components/appointments/DoctorTabs.tsx b/assets/admin/components/appointments/DoctorTabs.tsx index b084702d..9ef1d553 100644 --- a/assets/admin/components/appointments/DoctorTabs.tsx +++ b/assets/admin/components/appointments/DoctorTabs.tsx @@ -5,6 +5,8 @@ export interface DoctorTab { uuid: string; name: string; + /** برچسب کوچکِ کنار نام — مثلاً «بدون ساعت کاری». */ + note?: string; } export default function DoctorTabs({ @@ -38,6 +40,15 @@ export default function DoctorTabs({ }} > {d.name} + {d.note && ( + + {d.note} + + )} {active && ( u.includes('doctor_uuid='))).toBe(false); }); - /** پزشکی که در تنظیمات نوبت‌دهی روز کاری تعریف نکرده تب نمی‌گیرد. */ - it('پزشک بدون برنامهٔ کاری تب نمی‌گیرد', async () => { + /** + * پزشکِ بدون برنامهٔ هفتگی هم تب می‌گیرد — با برچسب «بدون ساعت کاری» — چون منابعِ + * تحت نظرش تقویم مستقل دارند و پنهان‌کردنِ پزشک، نوبت‌دهی آن منابع را هم می‌بست. + */ + it('پزشک بدون برنامهٔ کاری تب می‌گیرد و برچسب می‌خورد', async () => { renderWithProviders(); expect(await screen.findByText('دکتر محمدی')).toBeInTheDocument(); - expect(screen.getByText('دکتر رضایی')).toBeInTheDocument(); - expect(screen.queryByText('دکتر بی‌برنامه')).toBeNull(); + expect(screen.getByText('دکتر بی‌برنامه')).toBeInTheDocument(); + expect(screen.getByText('بدون ساعت کاری')).toBeInTheDocument(); + }); + + it('پزشک بدون برنامهٔ کاری: پیام «ساعت کاری تنظیم نشده است» و منابعِ قابل نوبت‌دهی', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(await screen.findByRole('button', { name: /دکتر بی‌برنامه/ })); + + expect(await screen.findByText('ساعت کاری تنظیم نشده است')).toBeInTheDocument(); + // منبعِ همان پزشک هم در نوار تب‌ها و هم در میان‌بُرِ رزرو دیده می‌شود. + expect(screen.getAllByRole('button', { name: 'کندلا ۲۰۲۳' }).length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeInTheDocument(); + // برای پزشکِ بدون برنامه اسلات درخواست نمی‌شود. + expect(get.mock.calls.some( + (c: any[]) => typeof c[0] === 'string' && c[0].includes('appointment-slots') && c[0].includes('doctor_uuid=d3'), + )).toBe(false); + }); + + it('منبعِ پزشکِ بدون برنامه، تایم‌لاین تقویم خودش را می‌دهد', async () => { + const user = userEvent.setup(); + renderWithProviders(, { route: '/admin/appointments?resource=r-4' }); + + await user.click(await screen.findByRole('button', { name: /دکتر بی‌برنامه/ })); + await user.click((await screen.findAllByRole('button', { name: 'کندلا ۲۰۲۳' }))[0]); + + expect(await screen.findByText(/نوبت‌ها بر اساس مدت سرویس چیده می‌شوند/)).toBeInTheDocument(); + expect(get.mock.calls.some( + (c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-4/day-slots'), + )).toBe(true); }); /** @@ -175,7 +208,8 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', () expect(await screen.findByText(/نوبت‌ها بر اساس مدت سرویس چیده می‌شوند/)).toBeInTheDocument(); expect(await screen.findByText('افزودن نوبت سریع')).toBeInTheDocument(); - expect(screen.getByText(/ساعت کاری/)).toBeInTheDocument(); + // «ساعت کاری: …» بازهٔ منبع است؛ برچسبِ تبِ «بدون ساعت کاری» نباید اینجا بیفتد. + expect(screen.getByText(/^ساعت کاری:/)).toBeInTheDocument(); // بازهٔ کاری از تقویم منبع خوانده می‌شود، نه از اسلات‌های پزشک. const askedResourceDay = get.mock.calls.some( (c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-2/day-slots'), diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 04ec95cd..8c01bcc1 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useNavigate, useSearchParams } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { PlusIcon, ChevronRightIcon, ChevronLeftIcon, ChevronDownIcon, CalendarDaysIcon, AdjustmentsHorizontalIcon, @@ -229,20 +229,24 @@ export default function AppointmentsPage() { const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? []; const doctors = React.useMemo(() => { - const map = new Map(); - // پزشکِ بدون برنامهٔ کاری تب نمی‌گیرد — تبش جز یک تایم‌لاین همیشه‌خالی چیزی ندارد. - // نبودِ فیلد (مسیر ادمین) یعنی «نمی‌دانیم»، پس پنهان نمی‌شود. - clinicDoctorsList - .filter(d => d.has_schedule !== false) - .forEach(d => map.set(d.uuid, d.name)); + const map = new Map(); + // پزشکِ بدون برنامهٔ کاری هم تب می‌گیرد: منابعِ تحت نظرش تقویم مستقلِ خودشان را + // دارند و قابل نوبت‌دهی‌اند، پس پنهان‌کردنِ پزشک آن منابع را هم از دسترس خارج + // می‌کرد. به‌جای حذف، تبش برچسب «بدون ساعت کاری» می‌گیرد. + // نبودِ فیلد (مسیر ادمین) یعنی «نمی‌دانیم»، پس برچسب نمی‌خورد. + clinicDoctorsList.forEach(d => map.set(d.uuid, { name: d.name, hasSchedule: d.has_schedule !== false })); appointments.forEach(a => { if (a.doctor_uuid && a.doctor_name && !map.has(a.doctor_uuid)) { - map.set(a.doctor_uuid, a.doctor_name); + map.set(a.doctor_uuid, { name: a.doctor_name, hasSchedule: true }); } }); - return Array.from(map.entries()).map(([uuid, name]) => ({ uuid, name })); + return Array.from(map.entries()).map(([uuid, v]) => ({ uuid, name: v.name, hasSchedule: v.hasSchedule })); }, [appointments, clinicDoctorsList]); + const selectedDoctor = doctors.find(d => d.uuid === selectedDoctorUuid) ?? null; + /** پزشکِ انتخاب‌شده برنامهٔ هفتگی ندارد ⇒ تایم‌لاین جای خود را به راهنما می‌دهد. */ + const doctorHasNoSchedule = selectedDoctor?.hasSchedule === false; + // سرویس‌های موجود در نوبت‌های امروز (برای فیلتر «سرویس مورد نظر...»). const serviceOptions = React.useMemo(() => { const map = new Map(); @@ -291,7 +295,8 @@ export default function AppointmentsPage() { `/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}&management=1` + (effectiveClinicUuid ? `&clinic_uuid=${encodeURIComponent(effectiveClinicUuid)}` : '') ), - enabled: viewMode === 'timeline' && !!selectedDoctorUuid, + // برنامهٔ هفتگی ندارد ⇒ پاسخ قطعاً خالی است؛ درخواست فرستاده نمی‌شود. + enabled: viewMode === 'timeline' && !!selectedDoctorUuid && !doctorHasNoSchedule, }); // ── روش نوبت‌دهی پزشکِ انتخاب‌شده (سرویسی/اسلاتی) @@ -473,7 +478,11 @@ export default function AppointmentsPage() { }}> {showDoctorTabs && ( ({ + uuid: d.uuid, + name: d.name, + note: d.hasSchedule ? undefined : 'بدون ساعت کاری', + }))} selected={selectedDoctorUuid} onSelect={selectDoctor} showAll={isAdmin} @@ -534,6 +543,20 @@ export default function AppointmentsPage() {
برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید
+ ) : doctorHasNoSchedule ? ( + /* بدون برنامهٔ هفتگی، خودِ پزشک اسلاتی ندارد؛ اما منابعِ تحت نظرش + تقویم مستقل دارند و همچنان قابل نوبت‌دهی‌اند. */ + ) : ( <> {serviceMode && ( @@ -613,6 +636,67 @@ export default function AppointmentsPage() { ); } +/** + * پزشکِ بدون برنامهٔ هفتگی — به‌جای تایم‌لاینِ همیشه‌خالی. + * + * منبع، زیرمجموعهٔ پزشک است ولی تقویمش مستقل؛ پس نبودِ ساعت کاریِ پزشک نوبت‌دهیِ + * منابعش را متوقف نمی‌کند و همین‌جا میان‌بُرِ رزروشان می‌آید. + */ +function NoScheduleNotice({ settingsHref, resources, canCreate, onBookResource, onOpenResource }: { + settingsHref: string | null; + resources: ClinicResource[]; + canCreate: boolean; + onBookResource: (r: ClinicResource) => void; + onOpenResource: (uuid: string) => void; +}) { + return ( +
+
ساعت کاری تنظیم نشده است
+
+ برای این پزشک برنامهٔ هفتگی تعریف نشده، پس تایم‌لاین نوبت‌هایش خالی است. +
+ {settingsHref && ( + + تنظیم ساعت کاری + + )} + + {resources.length > 0 && ( +
+
+ منابع تحت نظر این پزشک تقویم مستقل دارند و همچنان قابل نوبت‌دهی هستند: +
+
+ {resources.map((r) => ( +
+ + {canCreate && ( + + )} +
+ ))} +
+
+ )} +
+ ); +} + /** فیلتر سرویس نوار ابزار — ردیف‌های بارگذاری‌شدهٔ روز را بر اساس سرویس فیلتر می‌کند. */ function ServiceFilterSelect({ value, options, onChange }: { value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;