feat(appointments): add support for doctors without schedules and display relevant notice

This commit is contained in:
hamed
2026-08-04 10:26:36 +03:30
parent 654c1e8303
commit 41b3109478
3 changed files with 145 additions and 16 deletions
@@ -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 && (
<span style={{
marginRight: 6, fontSize: 10.5, fontWeight: 500, padding: '2px 6px',
borderRadius: 'var(--r-pill)', background: 'var(--warning-bg)', color: 'var(--warning)',
verticalAlign: 'middle',
}}>
{d.note}
</span>
)}
{active && (
<span style={{
position: 'absolute', bottom: -1, left: 0, right: 0, height: 3,
+39 -5
View File
@@ -85,6 +85,7 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', ()
{ uuid: 'r-1', name: 'اتاق ۱', type_name: 'اتاق درمان', supervisor: { uuid: 'd1', name: 'دکتر محمدی' } },
{ uuid: 'r-2', name: 'لیزر CO2', type_name: 'دستگاه لیزر', supervisor: { uuid: 'd1', name: 'دکتر محمدی' } },
{ uuid: 'r-3', name: 'لیزر دکتر رضایی', type_name: 'دستگاه لیزر', supervisor: { uuid: 'd2', name: 'دکتر رضایی' } },
{ uuid: 'r-4', name: 'کندلا ۲۰۲۳', type_name: 'دستگاه لیزر', supervisor: { uuid: 'd3', name: 'دکتر بی‌برنامه' } },
] });
return Promise.resolve({ success: true, data: [] });
});
@@ -122,12 +123,44 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', ()
expect(calls.some((u: string) => u.includes('doctor_uuid='))).toBe(false);
});
/** پزشکی که در تنظیمات نوبت‌دهی روز کاری تعریف نکرده تب نمی‌گیرد. */
it('پزشک بدون برنامهٔ کاری تب نمی‌گیرد', async () => {
/**
* پزشکِ بدون برنامهٔ هفتگی هم تب می‌گیرد — با برچسب «بدون ساعت کاری» — چون منابعِ
* تحت نظرش تقویم مستقل دارند و پنهان‌کردنِ پزشک، نوبت‌دهی آن منابع را هم می‌بست.
*/
it('پزشک بدون برنامهٔ کاری تب می‌گیرد و برچسب می‌خورد', async () => {
renderWithProviders(<AppointmentsPage />);
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(<AppointmentsPage />);
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(<AppointmentsPage />, { 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'),
+95 -11
View File
@@ -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<string, string>();
// پزشکِ بدون برنامهٔ کاری تب نمی‌گیرد — تبش جز یک تایم‌لاین همیشه‌خالی چیزی ندارد.
// نبودِ فیلد (مسیر ادمین) یعنی «نمی‌دانیم»، پس پنهان نمی‌شود.
clinicDoctorsList
.filter(d => d.has_schedule !== false)
.forEach(d => map.set(d.uuid, d.name));
const map = new Map<string, { name: string; hasSchedule: boolean }>();
// پزشکِ بدون برنامهٔ کاری هم تب می‌گیرد: منابعِ تحت نظرش تقویم مستقلِ خودشان را
// دارند و قابل نوبت‌دهی‌اند، پس پنهان‌کردنِ پزشک آن منابع را هم از دسترس خارج
// می‌کرد. به‌جای حذف، تبش برچسب «بدون ساعت کاری» می‌گیرد.
// نبودِ فیلد (مسیر ادمین) یعنی «نمی‌دانیم»، پس برچسب نمی‌خورد.
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<string, string>();
@@ -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 && (
<DoctorTabs
doctors={doctors}
doctors={doctors.map(d => ({
uuid: d.uuid,
name: d.name,
note: d.hasSchedule ? undefined : 'بدون ساعت کاری',
}))}
selected={selectedDoctorUuid}
onSelect={selectDoctor}
showAll={isAdmin}
@@ -534,6 +543,20 @@ export default function AppointmentsPage() {
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>
برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید
</div>
) : doctorHasNoSchedule ? (
/* بدون برنامهٔ هفتگی، خودِ پزشک اسلاتی ندارد؛ اما منابعِ تحت نظرش
تقویم مستقل دارند و همچنان قابل نوبت‌دهی‌اند. */
<NoScheduleNotice
settingsHref={
isClinic || isClinicScopedSecretary
? `/admin/settings/appointment-settings?scope=doctors&doctor=${selectedDoctorUuid}`
: isDoctor || isSecretary ? '/admin/appointment-settings' : null
}
resources={supervisedResources}
canCreate={!isRepresentation && canCreateAppt}
onBookResource={setBookingResource}
onOpenResource={selectResource}
/>
) : (
<>
{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 (
<div style={{ padding: '32px 16px', textAlign: 'center' }}>
<div style={{ fontWeight: 700, fontSize: 15, color: 'var(--text)' }}>ساعت کاری تنظیم نشده است</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 4 }}>
برای این پزشک برنامهٔ هفتگی تعریف نشده، پس تایملاین نوبتهایش خالی است.
</div>
{settingsHref && (
<Link to={settingsHref} className="btn primary sm" style={{ display: 'inline-flex', marginTop: 14 }}>
تنظیم ساعت کاری
</Link>
)}
{resources.length > 0 && (
<div style={{
marginTop: 22, paddingTop: 18, borderTop: '1px solid var(--border)',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10,
}}>
<div style={{ fontSize: 12.5, color: 'var(--text-2)' }}>
منابع تحت نظر این پزشک تقویم مستقل دارند و همچنان قابل نوبتدهی هستند:
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center' }}>
{resources.map((r) => (
<div key={r.uuid} style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px',
border: '1px solid var(--border)', borderRadius: 'var(--r-sm)', background: 'var(--surface-2)',
}}>
<button
type="button"
className="btn sm"
onClick={() => onOpenResource(r.uuid)}
style={{ background: 'none', border: 'none', color: 'var(--text)', fontSize: 13, padding: 0 }}
>
{r.name}
</button>
{canCreate && (
<button type="button" className="btn primary sm" onClick={() => onBookResource(r)}>
ثبت نوبت
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
/** فیلتر سرویس نوار ابزار — ردیف‌های بارگذاری‌شدهٔ روز را بر اساس سرویس فیلتر می‌کند. */
function ServiceFilterSelect({ value, options, onChange }: {
value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;