From 9b8eef9598ae4a4b1e68ab40997c10d2b35dbced Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 4 Aug 2026 11:39:33 +0330 Subject: [PATCH] feat(appointments): implement resource-based appointment scheduling and update related logic --- .../pages/AppointmentCreatePage.test.tsx | 102 ++++++++++++++++++ assets/admin/pages/AppointmentCreatePage.tsx | 90 ++++++++++++++-- 2 files changed, 182 insertions(+), 10 deletions(-) diff --git a/assets/admin/pages/AppointmentCreatePage.test.tsx b/assets/admin/pages/AppointmentCreatePage.test.tsx index c9095c81..cfefe73c 100644 --- a/assets/admin/pages/AppointmentCreatePage.test.tsx +++ b/assets/admin/pages/AppointmentCreatePage.test.tsx @@ -257,3 +257,105 @@ describe('AppointmentCreatePage — افزودن نوبت', () => { await waitFor(() => expect(btn).not.toBeDisabled()); }); }); + +/** + * نوبت‌دهی منبع‌محور در همین فرم: انتخاب پزشک ⇒ اگر منبعی زیر نظرش باشد، «هدف نوبت» + * ظاهر می‌شود و انتخاب منبع کلِ منطق زمان را به تقویم خودِ منبع می‌برد. + */ +describe('AppointmentCreatePage — نوبت روی منبعِ تحت نظر پزشک', () => { + const RESOURCES = [ + { uuid: 'r-1', name: 'کندلا ۲۰۲۳', type_name: 'دستگاه لیزر', active: true, supervisor: { uuid: 'doc1', name: 'دکتر تست' } }, + { uuid: 'r-2', name: 'لیزر پزشک دیگر', type_name: 'دستگاه لیزر', active: true, supervisor: { uuid: 'doc2', name: 'دکتر دیگر' } }, + ]; + + beforeEach(() => { + get.mockReset(); + post.mockReset(); + // پزشکِ مهمانِ کلینیک: `dbUuid` شناسهٔ کلینیک است و uuid پزشک از `doctorUuid` می‌آید. + useAuthStore.setState({ + primaryRole: 'doctor', dbUuid: 'cl-1', doctorUuid: 'doc1', + context: { type: 'clinic', db_uuid: 'cl-1', name: 'مدیسا', role: 'doctor', scope: 'clinic' }, + availableContexts: [], + } as any); + post.mockResolvedValue({ success: true, data: { uuid: 'new1' } }); + get.mockImplementation((url: string) => { + if (url.startsWith('/api/v1/resources')) return Promise.resolve({ success: true, data: RESOURCES }); + // سرویس‌های خودِ منبع — نه کاتالوگ پزشک. + if (url === '/api/v1/resource/r-1/services') + return Promise.resolve({ success: true, data: [{ + service_uuid: 'sv-laser', service_name: 'لیزر فول‌بادی', active: true, + effective_duration_minutes: 45, effective_price_rials: 900000, + service_section: { uuid: 'sec-l', name: 'لیزر' }, + }] }); + if (url.startsWith('/api/v1/resource/r-1/service-slots')) + return Promise.resolve({ success: true, data: { total_duration_minutes: 45, start_times: [{ start: 1754000000, end: 1754002700, start_time: '09:00' }] } }); + if (url.startsWith('/api/v1/appointment-slots')) return Promise.resolve(SLOTS_RESPONSE); + return Promise.resolve({ success: true, data: [] }); + }); + }); + + it('فقط منابعِ همان پزشک به‌عنوان هدف نوبت پیشنهاد می‌شوند', async () => { + renderWithProviders(, { route: '/admin/appointments/new' }); + + expect(await screen.findByRole('button', { name: 'کندلا ۲۰۲۳' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'خودِ پزشک' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'لیزر پزشک دیگر' })).toBeNull(); + }); + + it('انتخاب منبع: زمان‌ها از تقویم منبع می‌آید و نوبت با resource_uuid ثبت می‌شود', async () => { + renderWithProviders(, { route: '/admin/appointments/new' }); + + fireEvent.click(await screen.findByRole('button', { name: 'کندلا ۲۰۲۳' })); + + fireEvent.click(screen.getByText('مراجعه کننده جدید')); + fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده'), { target: { value: 'علی محمدی' } }); + fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '09121234567' } }); + fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '1234567891' } }); + + // سرویس‌ها از خودِ منبع می‌آیند: بخش «لیزر» → سرویس «لیزر فول‌بادی» + const secInput = await waitFor(() => { + const el = document.getElementById('service-mode-section-select') as HTMLInputElement | null; + if (!el) throw new Error('picker not ready'); + return el; + }); + fireEvent.focus(secInput); + fireEvent.keyDown(secInput, { key: 'ArrowDown' }); + fireEvent.click(await screen.findByText('لیزر')); + fireEvent.click(await screen.findByText('لیزر فول‌بادی')); + fireEvent.click(await screen.findByRole('button', { name: '09:00' })); + + fireEvent.click(screen.getByText('ثبت اطلاعات')); + await waitFor(() => expect(post).toHaveBeenCalled()); + + const [url, body] = post.mock.calls[0]; + expect(url).toBe('/api/v1/my/appointment'); + expect(body).toMatchObject({ + doctor_uuid: 'doc1', + resource_uuid: 'r-1', + clinic_uuid: 'cl-1', + slot_start: 1754000000, + slot_end: 1754002700, + service_item_uuids: ['sv-laser'], + duration_from_services: true, + }); + // وقت‌های آزاد از تقویم منبع خوانده شد، نه از اسلات‌های پزشک. + expect(get.mock.calls.some((c: any[]) => typeof c[0] === 'string' && c[0].includes('/api/v1/resource/r-1/service-slots'))).toBe(true); + }); + + it('بازگشت به «خودِ پزشک»: نوبت دیگر منبع ندارد', async () => { + renderWithProviders(, { route: '/admin/appointments/new' }); + + fireEvent.click(await screen.findByRole('button', { name: 'کندلا ۲۰۲۳' })); + fireEvent.click(screen.getByRole('button', { name: 'خودِ پزشک' })); + + fireEvent.click(screen.getByText('مراجعه کننده جدید')); + fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده'), { target: { value: 'علی محمدی' } }); + fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '09121234567' } }); + fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '1234567891' } }); + await pickSlot(); + fireEvent.click(screen.getByText('ثبت اطلاعات')); + + await waitFor(() => expect(post).toHaveBeenCalled()); + expect(post.mock.calls[0][1].resource_uuid).toBeUndefined(); + }); +}); diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx index 498a6502..f6a5a222 100644 --- a/assets/admin/pages/AppointmentCreatePage.tsx +++ b/assets/admin/pages/AppointmentCreatePage.tsx @@ -12,6 +12,9 @@ import DigitInput from '../components/ui/DigitInput'; import SearchableSelect from '../components/ui/SearchableSelect'; import { WalletChargeLink } from '../components/AppointmentActions'; import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; +import { useResources } from '../hooks/useResources'; +import { useResourceBookingServices } from '../hooks/useResourceBookingServices'; +import { useClinicContext } from '../hooks/useClinicContext'; import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPicker'; import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils'; @@ -48,7 +51,16 @@ export default function AppointmentCreatePage() { const today = todayIso(); // ── پزشک - const [doctorUuid, setDoctorUuid] = useState(isDoctor && dbUuid ? dbUuid : (params.get('doctor') ?? '')); + // در محیط کلینیک، `dbUuid` شناسهٔ کلینیک است نه پزشک؛ uuid پزشک فقط از `doctorUuid` + // می‌آید. بدون این، پزشکِ مهمانِ کلینیک uuid کلینیک را به‌عنوان پزشک می‌فرستاد. + const ownDoctorUuid = useAuthStore(s => s.doctorUuid); + const [doctorUuid, setDoctorUuid] = useState( + isDoctor ? (ownDoctorUuid ?? dbUuid ?? '') : (params.get('doctor') ?? ''), + ); + // hydrate شدنِ استور بعد از رندر اول، پزشک را خالی می‌گذاشت. + useEffect(() => { + if (isDoctor && !doctorUuid && ownDoctorUuid) setDoctorUuid(ownDoctorUuid); + }, [isDoctor, doctorUuid, ownDoctorUuid]); const clinicDoctorsQuery = useQuery>({ queryKey: ['clinic-doctors', dbUuid], queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`), @@ -56,6 +68,24 @@ export default function AppointmentCreatePage() { }); const doctorOptions = (clinicDoctorsQuery.data?.data?.data ?? []).map(d => ({ value: d.uuid, label: d.name })); + // ── هدفِ نوبت: خودِ پزشک، یا یکی از منابعِ تحت نظرش + // + // منبع تقویم مستقل دارد، پس انتخابش کلِ منطق زمان را عوض می‌کند: سرویس‌ها از خودِ + // منبع می‌آیند و وقت آزاد از تقاطعِ تقویم همان منبع — نه از برنامهٔ هفتگیِ پزشک. + // این تنها جایی است که این دو شاخه از هم جدا می‌شوند؛ باقی فرم مشترک است. + const clinicUuid = useClinicContext(); + const { resources: bookableResources } = useResources({ active: '1' }); + const supervisedResources = useMemo( + () => (doctorUuid ? bookableResources.filter(r => r.supervisor?.uuid === doctorUuid) : []), + [bookableResources, doctorUuid], + ); + const [resourceUuid, setResourceUuid] = useState(''); + // تعویض پزشک ⇒ منبعِ پزشک قبلی دیگر زیر نظرِ او نیست. + useEffect(() => { setResourceUuid(''); }, [doctorUuid]); + const activeResource = supervisedResources.find(r => r.uuid === resourceUuid) ?? null; + const resourceMode = activeResource !== null; + const { services: resourceServices } = useResourceBookingServices(activeResource?.uuid); + // ── بیمار: جستجوی رکورد موجود یا ورود شخص جدید const [patientSearch, setPatientSearch] = useState(''); const [picked, setPicked] = useState(null); @@ -111,7 +141,8 @@ export default function AppointmentCreatePage() { // ── روش نوبت‌دهی پزشک (سرویسی/اسلاتی) const { bookingMode, services } = useDoctorBookingServices(doctorUuid); - const serviceMode = bookingMode === 'service'; + // منبع همیشه سرویسی است: اسلات ثابتی ندارد که بشود رویش نشست. + const serviceMode = resourceMode || bookingMode === 'service'; const [servicePick, setServicePick] = useState<{ serviceUuids: string[]; durations: Record; slot: { start: number; end: number } | null }>({ serviceUuids: [], durations: {}, slot: null }); // ── زمان نوبت @@ -158,7 +189,10 @@ export default function AppointmentCreatePage() { const create = useMutation({ mutationFn: async () => { - const createEndpoint = primaryRole === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment'; + // اندپوینت ادمین `resource_uuid` نمی‌شناسد؛ نوبتِ منبع همیشه از مسیر پنل می‌رود. + const createEndpoint = primaryRole === 'admin' && !resourceMode + ? '/api/v1/admin/appointment' + : '/api/v1/my/appointment'; const slotStart = serviceMode ? servicePick.slot!.start : (slotPick ? slotPick.start : toEpoch(date, start)); const slotEnd = serviceMode ? servicePick.slot!.end : (slotPick ? slotPick.end : toEpoch(date, end)); @@ -169,13 +203,18 @@ export default function AppointmentCreatePage() { patient_name: effectiveName, patient_mobile: effectiveMobile, patient_national_code: effectiveNationalCode, + // محل نوبت‌دهی: بدون `clinic_uuid` سرور نوبت را به مطب شخصیِ پزشک می‌نشاند — + // و در حالت منبع، منبعِ کلینیک اصلاً «متعلق به این محیط» شناخته نمی‌شود (۴۲۲). + ...(clinicUuid ? { clinic_uuid: clinicUuid } : {}), + ...(resourceMode ? { resource_uuid: activeResource!.uuid } : {}), ...(serviceMode ? { service_item_uuids: servicePick.serviceUuids, duration_from_services: true, service_durations: servicePick.durations } : { ...(sectionUuid ? { service_section_uuid: sectionUuid } : {}), ...(selectedServices.length ? { service_item_uuids: selectedServices.map(s => s.uuid) } : {}), }), - ...(staffUuid ? { staff_uuid: staffUuid } : {}), + // پرسنل فقط در حالت پزشک معنا دارد؛ در حالت منبع، خودِ منبع مجریِ نوبت است. + ...(staffUuid && !resourceMode ? { staff_uuid: staffUuid } : {}), ...(depositRequired ? { deposit_required: true, deposit_amount_rials: tomanToRial(depositToman) } : {}), ...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}), ...(note.trim() ? { note: note.trim() } : {}), @@ -234,8 +273,37 @@ export default function AppointmentCreatePage() { )} + {/* هدف نوبت — فقط وقتی این پزشک منبعی زیر نظر دارد */} + {supervisedResources.length > 0 && ( + <> +
هدف نوبت:
+
+ {[{ uuid: '', name: 'خودِ پزشک' }, ...supervisedResources.map(r => ({ uuid: r.uuid, name: r.name }))].map(t => { + const active = resourceUuid === t.uuid; + return ( + + ); + })} +
+
+ {resourceMode + ? 'زمان‌ها از تقویم خودِ منبع می‌آیند و سرویس‌ها همان‌هایی‌اند که روی این منبع فعال‌اند.' + : 'زمان‌ها از برنامهٔ هفتگی پزشک می‌آیند.'} +
+ + )} + {/* مراجعه کننده */} -
اطلاعات مراجعه کننده :
+
اطلاعات مراجعه کننده :
{/* آمده از پروندهٔ بیمار: مراجعه‌کننده معلوم است، جستجو معنا ندارد. */} {fromRecordUuid ? ( @@ -358,9 +426,10 @@ export default function AppointmentCreatePage() { )} - {/* مشخصات سرویس */} -
مشخصات سرویس
- {!serviceMode ? ( + {/* مشخصات سرویس — در حالت منبع، سرویس‌ها داخل انتخابگر زمانِ همان منبع انتخاب + می‌شوند (فقط سرویس‌هایی که روی آن منبع فعال‌اند)، پس این بخش جا ندارد. */} + {!resourceMode &&
مشخصات سرویس
} + {resourceMode ? null : !serviceMode ? ( <>
@@ -472,7 +541,7 @@ export default function AppointmentCreatePage() { )} {/* زمان نوبت */} -
زمان نوبت
+
زمان نوبت{resourceMode ? ` — ${activeResource!.name}` : ''}
{serviceMode ? ( <> @@ -481,8 +550,9 @@ export default function AppointmentCreatePage() { {doctorUuid ? ( ) : (