feat(appointments): add support for doctors without schedules and display relevant notice
This commit is contained in:
@@ -5,6 +5,8 @@
|
|||||||
export interface DoctorTab {
|
export interface DoctorTab {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/** برچسب کوچکِ کنار نام — مثلاً «بدون ساعت کاری». */
|
||||||
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DoctorTabs({
|
export default function DoctorTabs({
|
||||||
@@ -38,6 +40,15 @@ export default function DoctorTabs({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{d.name}
|
{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 && (
|
{active && (
|
||||||
<span style={{
|
<span style={{
|
||||||
position: 'absolute', bottom: -1, left: 0, right: 0, height: 3,
|
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-1', name: 'اتاق ۱', type_name: 'اتاق درمان', supervisor: { uuid: 'd1', name: 'دکتر محمدی' } },
|
||||||
{ uuid: 'r-2', name: 'لیزر CO2', 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-3', name: 'لیزر دکتر رضایی', type_name: 'دستگاه لیزر', supervisor: { uuid: 'd2', name: 'دکتر رضایی' } },
|
||||||
|
{ uuid: 'r-4', name: 'کندلا ۲۰۲۳', type_name: 'دستگاه لیزر', supervisor: { uuid: 'd3', name: 'دکتر بیبرنامه' } },
|
||||||
] });
|
] });
|
||||||
return Promise.resolve({ success: true, data: [] });
|
return Promise.resolve({ success: true, data: [] });
|
||||||
});
|
});
|
||||||
@@ -122,12 +123,44 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', ()
|
|||||||
expect(calls.some((u: string) => u.includes('doctor_uuid='))).toBe(false);
|
expect(calls.some((u: string) => u.includes('doctor_uuid='))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** پزشکی که در تنظیمات نوبتدهی روز کاری تعریف نکرده تب نمیگیرد. */
|
/**
|
||||||
it('پزشک بدون برنامهٔ کاری تب نمیگیرد', async () => {
|
* پزشکِ بدون برنامهٔ هفتگی هم تب میگیرد — با برچسب «بدون ساعت کاری» — چون منابعِ
|
||||||
|
* تحت نظرش تقویم مستقل دارند و پنهانکردنِ پزشک، نوبتدهی آن منابع را هم میبست.
|
||||||
|
*/
|
||||||
|
it('پزشک بدون برنامهٔ کاری تب میگیرد و برچسب میخورد', async () => {
|
||||||
renderWithProviders(<AppointmentsPage />);
|
renderWithProviders(<AppointmentsPage />);
|
||||||
expect(await screen.findByText('دکتر محمدی')).toBeInTheDocument();
|
expect(await screen.findByText('دکتر محمدی')).toBeInTheDocument();
|
||||||
expect(screen.getByText('دکتر رضایی')).toBeInTheDocument();
|
expect(screen.getByText('دکتر بیبرنامه')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('دکتر بیبرنامه')).toBeNull();
|
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(await screen.findByText('افزودن نوبت سریع')).toBeInTheDocument();
|
expect(await screen.findByText('افزودن نوبت سریع')).toBeInTheDocument();
|
||||||
expect(screen.getByText(/ساعت کاری/)).toBeInTheDocument();
|
// «ساعت کاری: …» بازهٔ منبع است؛ برچسبِ تبِ «بدون ساعت کاری» نباید اینجا بیفتد.
|
||||||
|
expect(screen.getByText(/^ساعت کاری:/)).toBeInTheDocument();
|
||||||
// بازهٔ کاری از تقویم منبع خوانده میشود، نه از اسلاتهای پزشک.
|
// بازهٔ کاری از تقویم منبع خوانده میشود، نه از اسلاتهای پزشک.
|
||||||
const askedResourceDay = get.mock.calls.some(
|
const askedResourceDay = get.mock.calls.some(
|
||||||
(c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-2/day-slots'),
|
(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 React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
PlusIcon, ChevronRightIcon, ChevronLeftIcon, ChevronDownIcon, CalendarDaysIcon,
|
PlusIcon, ChevronRightIcon, ChevronLeftIcon, ChevronDownIcon, CalendarDaysIcon,
|
||||||
AdjustmentsHorizontalIcon,
|
AdjustmentsHorizontalIcon,
|
||||||
@@ -229,20 +229,24 @@ export default function AppointmentsPage() {
|
|||||||
const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? [];
|
const clinicDoctorsList = clinicDoctorsQuery.data?.data?.data ?? [];
|
||||||
|
|
||||||
const doctors = React.useMemo(() => {
|
const doctors = React.useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, { name: string; hasSchedule: boolean }>();
|
||||||
// پزشکِ بدون برنامهٔ کاری تب نمیگیرد — تبش جز یک تایملاین همیشهخالی چیزی ندارد.
|
// پزشکِ بدون برنامهٔ کاری هم تب میگیرد: منابعِ تحت نظرش تقویم مستقلِ خودشان را
|
||||||
// نبودِ فیلد (مسیر ادمین) یعنی «نمیدانیم»، پس پنهان نمیشود.
|
// دارند و قابل نوبتدهیاند، پس پنهانکردنِ پزشک آن منابع را هم از دسترس خارج
|
||||||
clinicDoctorsList
|
// میکرد. بهجای حذف، تبش برچسب «بدون ساعت کاری» میگیرد.
|
||||||
.filter(d => d.has_schedule !== false)
|
// نبودِ فیلد (مسیر ادمین) یعنی «نمیدانیم»، پس برچسب نمیخورد.
|
||||||
.forEach(d => map.set(d.uuid, d.name));
|
clinicDoctorsList.forEach(d => map.set(d.uuid, { name: d.name, hasSchedule: d.has_schedule !== false }));
|
||||||
appointments.forEach(a => {
|
appointments.forEach(a => {
|
||||||
if (a.doctor_uuid && a.doctor_name && !map.has(a.doctor_uuid)) {
|
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]);
|
}, [appointments, clinicDoctorsList]);
|
||||||
|
|
||||||
|
const selectedDoctor = doctors.find(d => d.uuid === selectedDoctorUuid) ?? null;
|
||||||
|
/** پزشکِ انتخابشده برنامهٔ هفتگی ندارد ⇒ تایملاین جای خود را به راهنما میدهد. */
|
||||||
|
const doctorHasNoSchedule = selectedDoctor?.hasSchedule === false;
|
||||||
|
|
||||||
// سرویسهای موجود در نوبتهای امروز (برای فیلتر «سرویس مورد نظر...»).
|
// سرویسهای موجود در نوبتهای امروز (برای فیلتر «سرویس مورد نظر...»).
|
||||||
const serviceOptions = React.useMemo(() => {
|
const serviceOptions = React.useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
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` +
|
`/api/v1/appointment-slots?doctor_uuid=${selectedDoctorUuid}&date=${selectedDate}&management=1` +
|
||||||
(effectiveClinicUuid ? `&clinic_uuid=${encodeURIComponent(effectiveClinicUuid)}` : '')
|
(effectiveClinicUuid ? `&clinic_uuid=${encodeURIComponent(effectiveClinicUuid)}` : '')
|
||||||
),
|
),
|
||||||
enabled: viewMode === 'timeline' && !!selectedDoctorUuid,
|
// برنامهٔ هفتگی ندارد ⇒ پاسخ قطعاً خالی است؛ درخواست فرستاده نمیشود.
|
||||||
|
enabled: viewMode === 'timeline' && !!selectedDoctorUuid && !doctorHasNoSchedule,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── روش نوبتدهی پزشکِ انتخابشده (سرویسی/اسلاتی)
|
// ── روش نوبتدهی پزشکِ انتخابشده (سرویسی/اسلاتی)
|
||||||
@@ -473,7 +478,11 @@ export default function AppointmentsPage() {
|
|||||||
}}>
|
}}>
|
||||||
{showDoctorTabs && (
|
{showDoctorTabs && (
|
||||||
<DoctorTabs
|
<DoctorTabs
|
||||||
doctors={doctors}
|
doctors={doctors.map(d => ({
|
||||||
|
uuid: d.uuid,
|
||||||
|
name: d.name,
|
||||||
|
note: d.hasSchedule ? undefined : 'بدون ساعت کاری',
|
||||||
|
}))}
|
||||||
selected={selectedDoctorUuid}
|
selected={selectedDoctorUuid}
|
||||||
onSelect={selectDoctor}
|
onSelect={selectDoctor}
|
||||||
showAll={isAdmin}
|
showAll={isAdmin}
|
||||||
@@ -534,6 +543,20 @@ export default function AppointmentsPage() {
|
|||||||
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>
|
<div style={{ padding: 40, textAlign: 'center', color: 'var(--text-3)' }}>
|
||||||
برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید
|
برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید
|
||||||
</div>
|
</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 && (
|
{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 }: {
|
function ServiceFilterSelect({ value, options, onChange }: {
|
||||||
value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;
|
value: string; options: { uuid: string; name: string }[]; onChange: (v: string) => void;
|
||||||
|
|||||||
Reference in New Issue
Block a user