From cadf18d07a0647254f2b36ecb04d1cc82f5cc9a4 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 3 Aug 2026 12:25:10 +0330 Subject: [PATCH] feat(appointments): resource tabs follow their supervising doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ResourceBookingModal.test.tsx | 26 ++++++++++-- .../appointments/ResourceBookingModal.tsx | 42 +++++++++---------- assets/admin/pages/AppointmentsPage.test.tsx | 10 +++-- assets/admin/pages/AppointmentsPage.tsx | 23 +++++++--- 4 files changed, 66 insertions(+), 35 deletions(-) diff --git a/assets/admin/components/appointments/ResourceBookingModal.test.tsx b/assets/admin/components/appointments/ResourceBookingModal.test.tsx index d1e8a22c..828168af 100644 --- a/assets/admin/components/appointments/ResourceBookingModal.test.tsx +++ b/assets/admin/components/appointments/ResourceBookingModal.test.tsx @@ -19,6 +19,7 @@ const resource = { uuid: 'r-laser', name: 'لیزر CO2', address_uuid: 'addr-1', + supervisor: { uuid: 'd-1', name: 'دکتر مرادی' }, } as never; /** یکی از این دو وقت مالِ همین منبع است و دیگری مالِ دستگاه دیگری. */ @@ -87,14 +88,21 @@ describe('ResourceBookingModal', () => { expect(timeButtons).toHaveLength(1); }); - /** پزشک مسئول اجباری است: `confirm` بدون آن اصلاً کار نمی‌کند. */ - it('تا پزشک مسئول انتخاب نشود، ثبت غیرفعال است', async () => { + /** + * پزشک از ناظرِ خودِ منبع می‌آید، نه از یک انتخابگر دیگر — ارتباط پزشک↔منبع یک جا + * تعریف شده و تکرارش اینجا یعنی دو منبعِ حقیقت. + */ + it('پزشک را از ناظرِ منبع می‌گیرد و انتخابگر پزشک ندارد', async () => { const user = userEvent.setup(); renderWithProviders( , ); await screen.findByText('ثبت نوبت — لیزر CO2'); + expect(screen.getByText(/پزشک ناظر: دکتر مرادی/)).toBeInTheDocument(); + expect(screen.queryByText('پزشک انجام‌دهنده')).toBeNull(); + + // بدون سرویس و زمان هنوز غیرفعال است. expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled(); 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 ?? '')); 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( + , + ); + + expect(await screen.findByText(/این منبع پزشک ناظر ندارد/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'ثبت نوبت' })).toBeDisabled(); - expect(await screen.findByText('پزشک انجام‌دهنده')).toBeInTheDocument(); }); }); diff --git a/assets/admin/components/appointments/ResourceBookingModal.tsx b/assets/admin/components/appointments/ResourceBookingModal.tsx index d09cd36f..d2a36e2b 100644 --- a/assets/admin/components/appointments/ResourceBookingModal.tsx +++ b/assets/admin/components/appointments/ResourceBookingModal.tsx @@ -47,9 +47,12 @@ export default function ResourceBookingModal({ resource, onClose, onBooked }: { const [serviceUuid, setServiceUuid] = useState(''); const [days, setDays] = useState('7'); const [pickedSlot, setPickedSlot] = useState(null); - const [doctorUuid, setDoctorUuid] = useState(''); const [submitting, setSubmitting] = useState(false); + // پزشکِ نوبت از ناظرِ خودِ منبع می‌آید، نه از یک انتخابگرِ دیگر: ارتباط پزشک و منبع + // یک جا تعریف شده (فرم منبع) و تکرارش اینجا یعنی دو منبعِ حقیقت. + const doctorUuid = resource.supervisor?.uuid ?? ''; + // سرویس‌های همین منبع، نه کل کاتالوگ: منبعی که سرویسی را ارائه نمی‌دهد نباید // در فهرست بیاید — سرور هم همان را با ۴۲۲ رد می‌کند. const offeringsQuery = useQuery>({ @@ -58,13 +61,6 @@ export default function ResourceBookingModal({ resource, onClose, onBooked }: { }); const offerings = (offeringsQuery.data?.data ?? []).filter((o) => o.active); - const doctorsQuery = useQuery>({ - queryKey: ['booking-doctors'], - queryFn: () => api.get('/api/v1/my/clinic-doctors'), - staleTime: 60_000, - }); - const doctors = doctorsQuery.data?.data?.data ?? []; - const range = useMemo(() => { const from = Math.floor(Date.now() / 1000); 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 canSubmit = !!serviceUuid && !!pickedSlot && !!doctorUuid && !submitting; + // ناظر در API الزامی است؛ ردیف‌های قدیمی‌ای که پزشکشان حذف شده `supervisor: null` دارند. + const missingSupervisor = !resource.supervisor; const submit = async () => { if (!pickedSlot) return; @@ -128,6 +126,12 @@ export default function ResourceBookingModal({ resource, onClose, onBooked }: { } > + {missingSupervisor && ( +

+ این منبع پزشک ناظر ندارد و نوبتی برایش ثبت نمی‌شود — از «منابع» ناظرش را تعیین کنید. +

+ )} +
{offeringsQuery.isLoading ? ( @@ -206,22 +210,14 @@ export default function ResourceBookingModal({ resource, onClose, onBooked }: {
)} - {pickedSlot && ( -
- - ({ value: d.uuid, label: d.name }))} - value={doctorUuid || null} - onChange={(v) => setDoctorUuid(v ? String(v) : '')} - placeholder="پزشک انجام‌دهنده" - height={40} - /> -

- هر نوبت پزشک مسئول دارد؛ منبع می‌گوید کار روی چه دستگاهی انجام می‌شود. -

+
+
+
پزشک ناظر: {resource.supervisor?.name ?? '—'}
+
+ نوبت به نام همین پزشک ثبت می‌شود. برای تغییرش، ناظرِ منبع را در «منابع» ویرایش کنید. +
- )} +
); } diff --git a/assets/admin/pages/AppointmentsPage.test.tsx b/assets/admin/pages/AppointmentsPage.test.tsx index 0c4dd62f..ac7c6279 100644 --- a/assets/admin/pages/AppointmentsPage.test.tsx +++ b/assets/admin/pages/AppointmentsPage.test.tsx @@ -66,9 +66,11 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', () ] } }); 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('/api/v1/resources')) return Promise.resolve({ success: true, data: [ - { uuid: 'r-1', name: 'اتاق ۱', type_name: 'اتاق درمان' }, - { uuid: 'r-2', name: 'لیزر CO2', type_name: 'دستگاه لیزر' }, + { 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: 'دکتر رضایی' } }, ] }); return Promise.resolve({ success: true, data: [] }); }); @@ -85,10 +87,12 @@ describe('AppointmentsPage — پروفایل کلینیک چندپزشکه', () * منابع مثل پزشکان تب خودشان را دارند: نوبتِ «لیزر CO2» به دستگاه تعلق دارد، نه به * پزشکی که پشتش ایستاده. */ - it('برای هر منبع فعال یک تب نشان می‌دهد', async () => { + it('فقط منابعِ تحت نظارت پزشکِ انتخاب‌شده تب می‌گیرند', async () => { renderWithProviders(); + // پزشک اول (d1) خودکار انتخاب می‌شود، پس فقط منابع او دیده می‌شوند. expect(await screen.findByText('لیزر CO2')).toBeInTheDocument(); expect(screen.getByText('اتاق ۱')).toBeInTheDocument(); + expect(screen.queryByText('لیزر دکتر رضایی')).toBeNull(); }); /** تب فعال از URL خوانده می‌شود و فهرست با resource_uuid فیلتر می‌شود، نه doctor_uuid. */ diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index af92c17d..09b5891e 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -527,6 +527,19 @@ export default function AppointmentsPage() { const selectedResourceUuid = urlState.resource; 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(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 && (
- منابع + منابعِ این پزشک ({ uuid: r.uuid, name: r.name }))} + doctors={supervisedResources.map((r) => ({ uuid: r.uuid, name: r.name }))} selected={selectedResourceUuid} onSelect={selectResource} showAll={false}