feat(appointments): resource tabs follow their supervising doctor
Resource tabs now sit under the selected doctor and list only the resources that doctor supervises, so moving between a doctor's own appointments and the devices under them is one row of tabs rather than a flat list of everything. The booking modal reads the doctor from the resource's supervisor instead of asking again. The doctor↔resource relation is defined once, on the resource, and repeating the question here would have made a second source of truth. A resource whose supervisor was removed is blocked with a message pointing at the fix rather than a silently disabled button. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ const resource = {
|
|||||||
uuid: 'r-laser',
|
uuid: 'r-laser',
|
||||||
name: 'لیزر CO2',
|
name: 'لیزر CO2',
|
||||||
address_uuid: 'addr-1',
|
address_uuid: 'addr-1',
|
||||||
|
supervisor: { uuid: 'd-1', name: 'دکتر مرادی' },
|
||||||
} as never;
|
} as never;
|
||||||
|
|
||||||
/** یکی از این دو وقت مالِ همین منبع است و دیگری مالِ دستگاه دیگری. */
|
/** یکی از این دو وقت مالِ همین منبع است و دیگری مالِ دستگاه دیگری. */
|
||||||
@@ -87,14 +88,21 @@ describe('ResourceBookingModal', () => {
|
|||||||
expect(timeButtons).toHaveLength(1);
|
expect(timeButtons).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** پزشک مسئول اجباری است: `confirm` بدون آن اصلاً کار نمیکند. */
|
/**
|
||||||
it('تا پزشک مسئول انتخاب نشود، ثبت غیرفعال است', async () => {
|
* پزشک از ناظرِ خودِ منبع میآید، نه از یک انتخابگر دیگر — ارتباط پزشک↔منبع یک جا
|
||||||
|
* تعریف شده و تکرارش اینجا یعنی دو منبعِ حقیقت.
|
||||||
|
*/
|
||||||
|
it('پزشک را از ناظرِ منبع میگیرد و انتخابگر پزشک ندارد', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
<ResourceBookingModal resource={resource} onClose={vi.fn()} onBooked={vi.fn()} />,
|
<ResourceBookingModal resource={resource} onClose={vi.fn()} onBooked={vi.fn()} />,
|
||||||
);
|
);
|
||||||
|
|
||||||
await screen.findByText('ثبت نوبت — لیزر CO2');
|
await screen.findByText('ثبت نوبت — لیزر CO2');
|
||||||
|
expect(screen.getByText(/پزشک ناظر: دکتر مرادی/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('پزشک انجامدهنده')).toBeNull();
|
||||||
|
|
||||||
|
// بدون سرویس و زمان هنوز غیرفعال است.
|
||||||
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
|
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
|
||||||
|
|
||||||
await user.click(await screen.findByText('سرویس را انتخاب کنید'));
|
await user.click(await screen.findByText('سرویس را انتخاب کنید'));
|
||||||
@@ -104,8 +112,18 @@ describe('ResourceBookingModal', () => {
|
|||||||
const timeBtn = screen.getAllByRole('button').find((b) => /\d{2}:\d{2}/.test(b.textContent ?? ''));
|
const timeBtn = screen.getAllByRole('button').find((b) => /\d{2}:\d{2}/.test(b.textContent ?? ''));
|
||||||
await user.click(timeBtn!);
|
await user.click(timeBtn!);
|
||||||
|
|
||||||
// زمان انتخاب شد ولی پزشک نه → هنوز غیرفعال.
|
// سرویس + زمان + ناظر ⇒ حالا قابل ثبت است.
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeEnabled());
|
||||||
|
});
|
||||||
|
|
||||||
|
/** منبعی که ناظرش حذف شده نباید بیصدا غیرقابلثبت بماند. */
|
||||||
|
it('منبع بدون ناظر را با پیام روشن مسدود میکند', async () => {
|
||||||
|
const orphan = { uuid: 'r-x', name: 'لیزر یتیم', address_uuid: 'addr-1', supervisor: null } as never;
|
||||||
|
renderWithProviders(
|
||||||
|
<ResourceBookingModal resource={orphan} onClose={vi.fn()} onBooked={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/این منبع پزشک ناظر ندارد/)).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
|
expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled();
|
||||||
expect(await screen.findByText('پزشک انجامدهنده')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,9 +47,12 @@ export default function ResourceBookingModal({ resource, onClose, onBooked }: {
|
|||||||
const [serviceUuid, setServiceUuid] = useState('');
|
const [serviceUuid, setServiceUuid] = useState('');
|
||||||
const [days, setDays] = useState('7');
|
const [days, setDays] = useState('7');
|
||||||
const [pickedSlot, setPickedSlot] = useState<AvailableSlot | null>(null);
|
const [pickedSlot, setPickedSlot] = useState<AvailableSlot | null>(null);
|
||||||
const [doctorUuid, setDoctorUuid] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// پزشکِ نوبت از ناظرِ خودِ منبع میآید، نه از یک انتخابگرِ دیگر: ارتباط پزشک و منبع
|
||||||
|
// یک جا تعریف شده (فرم منبع) و تکرارش اینجا یعنی دو منبعِ حقیقت.
|
||||||
|
const doctorUuid = resource.supervisor?.uuid ?? '';
|
||||||
|
|
||||||
// سرویسهای همین منبع، نه کل کاتالوگ: منبعی که سرویسی را ارائه نمیدهد نباید
|
// سرویسهای همین منبع، نه کل کاتالوگ: منبعی که سرویسی را ارائه نمیدهد نباید
|
||||||
// در فهرست بیاید — سرور هم همان را با ۴۲۲ رد میکند.
|
// در فهرست بیاید — سرور هم همان را با ۴۲۲ رد میکند.
|
||||||
const offeringsQuery = useQuery<ApiResponse<ResourceServiceOffering[]>>({
|
const offeringsQuery = useQuery<ApiResponse<ResourceServiceOffering[]>>({
|
||||||
@@ -58,13 +61,6 @@ export default function ResourceBookingModal({ resource, onClose, onBooked }: {
|
|||||||
});
|
});
|
||||||
const offerings = (offeringsQuery.data?.data ?? []).filter((o) => o.active);
|
const offerings = (offeringsQuery.data?.data ?? []).filter((o) => o.active);
|
||||||
|
|
||||||
const doctorsQuery = useQuery<ApiResponse<{ data: { uuid: string; name: string }[] }>>({
|
|
||||||
queryKey: ['booking-doctors'],
|
|
||||||
queryFn: () => api.get('/api/v1/my/clinic-doctors'),
|
|
||||||
staleTime: 60_000,
|
|
||||||
});
|
|
||||||
const doctors = doctorsQuery.data?.data?.data ?? [];
|
|
||||||
|
|
||||||
const range = useMemo(() => {
|
const range = useMemo(() => {
|
||||||
const from = Math.floor(Date.now() / 1000);
|
const from = Math.floor(Date.now() / 1000);
|
||||||
return { from, to: from + Number(days) * DAY };
|
return { from, to: from + Number(days) * DAY };
|
||||||
@@ -82,6 +78,8 @@ export default function ResourceBookingModal({ resource, onClose, onBooked }: {
|
|||||||
|
|
||||||
const reasonText = result?.reason ? REASON_LABELS[result.reason] ?? result.reason : null;
|
const reasonText = result?.reason ? REASON_LABELS[result.reason] ?? result.reason : null;
|
||||||
const canSubmit = !!serviceUuid && !!pickedSlot && !!doctorUuid && !submitting;
|
const canSubmit = !!serviceUuid && !!pickedSlot && !!doctorUuid && !submitting;
|
||||||
|
// ناظر در API الزامی است؛ ردیفهای قدیمیای که پزشکشان حذف شده `supervisor: null` دارند.
|
||||||
|
const missingSupervisor = !resource.supervisor;
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
if (!pickedSlot) return;
|
if (!pickedSlot) return;
|
||||||
@@ -128,6 +126,12 @@ export default function ResourceBookingModal({ resource, onClose, onBooked }: {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{missingSupervisor && (
|
||||||
|
<p className="field-err" role="alert" style={{ marginTop: 0 }}>
|
||||||
|
این منبع پزشک ناظر ندارد و نوبتی برایش ثبت نمیشود — از «منابع» ناظرش را تعیین کنید.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="field-block" style={{ marginBottom: 14 }}>
|
<div className="field-block" style={{ marginBottom: 14 }}>
|
||||||
<label htmlFor="rb-service">سرویسهای این منبع <span className="req">*</span></label>
|
<label htmlFor="rb-service">سرویسهای این منبع <span className="req">*</span></label>
|
||||||
{offeringsQuery.isLoading ? (
|
{offeringsQuery.isLoading ? (
|
||||||
@@ -206,22 +210,14 @@ export default function ResourceBookingModal({ resource, onClose, onBooked }: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pickedSlot && (
|
<div className="toggle-row">
|
||||||
<div className="field-block">
|
<div>
|
||||||
<label htmlFor="rb-doctor">پزشک مسئول <span className="req">*</span></label>
|
<div className="tr-title">پزشک ناظر: {resource.supervisor?.name ?? '—'}</div>
|
||||||
<SearchableSelect
|
<div className="tr-desc">
|
||||||
inputId="rb-doctor"
|
نوبت به نام همین پزشک ثبت میشود. برای تغییرش، ناظرِ منبع را در «منابع» ویرایش کنید.
|
||||||
options={doctors.map((d) => ({ value: d.uuid, label: d.name }))}
|
</div>
|
||||||
value={doctorUuid || null}
|
|
||||||
onChange={(v) => setDoctorUuid(v ? String(v) : '')}
|
|
||||||
placeholder="پزشک انجامدهنده"
|
|
||||||
height={40}
|
|
||||||
/>
|
|
||||||
<p className="field-hint">
|
|
||||||
هر نوبت پزشک مسئول دارد؛ منبع میگوید کار روی چه دستگاهی انجام میشود.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,9 +66,11 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', ()
|
|||||||
] } });
|
] } });
|
||||||
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
|
if (url.includes('appointment-slots')) return Promise.resolve({ success: true, data: { sessions: [], empty_reason: 'holiday' } });
|
||||||
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
if (url.includes('/my/appointments')) return Promise.resolve({ success: true, data: [], meta: { totalRecords: 0, totalPages: 0, currentPage: 1 } });
|
||||||
|
// منابع زیر نظر پزشکاند؛ تب هر منبع فقط زیر ناظرِ خودش دیده میشود.
|
||||||
if (url.includes('/api/v1/resources')) return Promise.resolve({ success: true, data: [
|
if (url.includes('/api/v1/resources')) return Promise.resolve({ success: true, data: [
|
||||||
{ uuid: 'r-1', name: 'اتاق ۱', type_name: 'اتاق درمان' },
|
{ uuid: 'r-1', name: 'اتاق ۱', type_name: 'اتاق درمان', supervisor: { uuid: 'd1', name: 'دکتر محمدی' } },
|
||||||
{ uuid: 'r-2', name: 'لیزر CO2', type_name: 'دستگاه لیزر' },
|
{ uuid: 'r-2', name: 'لیزر CO2', type_name: 'دستگاه لیزر', supervisor: { uuid: 'd1', name: 'دکتر محمدی' } },
|
||||||
|
{ uuid: 'r-3', name: 'لیزر دکتر رضایی', type_name: 'دستگاه لیزر', supervisor: { uuid: 'd2', name: 'دکتر رضایی' } },
|
||||||
] });
|
] });
|
||||||
return Promise.resolve({ success: true, data: [] });
|
return Promise.resolve({ success: true, data: [] });
|
||||||
});
|
});
|
||||||
@@ -85,10 +87,12 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', ()
|
|||||||
* منابع مثل پزشکان تب خودشان را دارند: نوبتِ «لیزر CO2» به دستگاه تعلق دارد، نه به
|
* منابع مثل پزشکان تب خودشان را دارند: نوبتِ «لیزر CO2» به دستگاه تعلق دارد، نه به
|
||||||
* پزشکی که پشتش ایستاده.
|
* پزشکی که پشتش ایستاده.
|
||||||
*/
|
*/
|
||||||
it('برای هر منبع فعال یک تب نشان میدهد', async () => {
|
it('فقط منابعِ تحت نظارت پزشکِ انتخابشده تب میگیرند', async () => {
|
||||||
renderWithProviders(<AppointmentsPage />);
|
renderWithProviders(<AppointmentsPage />);
|
||||||
|
// پزشک اول (d1) خودکار انتخاب میشود، پس فقط منابع او دیده میشوند.
|
||||||
expect(await screen.findByText('لیزر CO2')).toBeInTheDocument();
|
expect(await screen.findByText('لیزر CO2')).toBeInTheDocument();
|
||||||
expect(screen.getByText('اتاق ۱')).toBeInTheDocument();
|
expect(screen.getByText('اتاق ۱')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('لیزر دکتر رضایی')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** تب فعال از URL خوانده میشود و فهرست با resource_uuid فیلتر میشود، نه doctor_uuid. */
|
/** تب فعال از URL خوانده میشود و فهرست با resource_uuid فیلتر میشود، نه doctor_uuid. */
|
||||||
|
|||||||
@@ -527,6 +527,19 @@ export default function AppointmentsPage() {
|
|||||||
const selectedResourceUuid = urlState.resource;
|
const selectedResourceUuid = urlState.resource;
|
||||||
const { resources: bookableResources } = useResources({ active: '1' });
|
const { resources: bookableResources } = useResources({ active: '1' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* منابعی که پزشکِ انتخابشده ناظرشان است.
|
||||||
|
*
|
||||||
|
* وقتی خودِ تب منبع فعال است `selectedDoctorUuid` خالی میشود، پس ناظرِ همان منبع
|
||||||
|
* مبنا قرار میگیرد — وگرنه نوار منابع زیر پای کاربر خالی میشد.
|
||||||
|
*/
|
||||||
|
const supervisorFilterUuid = selectedDoctorUuid
|
||||||
|
|| bookableResources.find((r) => r.uuid === selectedResourceUuid)?.supervisor?.uuid
|
||||||
|
|| '';
|
||||||
|
const supervisedResources = supervisorFilterUuid
|
||||||
|
? bookableResources.filter((r) => r.supervisor?.uuid === supervisorFilterUuid)
|
||||||
|
: [];
|
||||||
|
|
||||||
const [bookingResource, setBookingResource] = useState<ClinicResource | null>(null);
|
const [bookingResource, setBookingResource] = useState<ClinicResource | null>(null);
|
||||||
const activeResource = bookableResources.find((r) => r.uuid === selectedResourceUuid) ?? null;
|
const activeResource = bookableResources.find((r) => r.uuid === selectedResourceUuid) ?? null;
|
||||||
|
|
||||||
@@ -867,13 +880,13 @@ export default function AppointmentsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* منابع مثل پزشکان تب خودشان را دارند: نوبتِ «لیزر CO2» به دستگاه تعلق دارد،
|
{/* منابعِ همین پزشک، نه همهٔ منابع: ارتباط پزشک↔منبع روی خودِ منبع تعریف شده
|
||||||
نه به پزشکی که پشتش ایستاده. */}
|
(پزشک ناظر)، پس کاربر بین نوبتهای پزشک و دستگاههای تحت نظرش جابهجا میشود. */}
|
||||||
{bookableResources.length > 0 && (
|
{supervisedResources.length > 0 && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '0 16px', borderBottom: '1px solid var(--border)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '0 16px', borderBottom: '1px solid var(--border)' }}>
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-3)', flexShrink: 0 }}>منابع</span>
|
<span style={{ fontSize: 12, color: 'var(--text-3)', flexShrink: 0 }}>منابعِ این پزشک</span>
|
||||||
<DoctorTabs
|
<DoctorTabs
|
||||||
doctors={bookableResources.map((r) => ({ uuid: r.uuid, name: r.name }))}
|
doctors={supervisedResources.map((r) => ({ uuid: r.uuid, name: r.name }))}
|
||||||
selected={selectedResourceUuid}
|
selected={selectedResourceUuid}
|
||||||
onSelect={selectResource}
|
onSelect={selectResource}
|
||||||
showAll={false}
|
showAll={false}
|
||||||
|
|||||||
Reference in New Issue
Block a user