feat(appointments): add support for doctors without schedules and display relevant notice
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user