From 4715649fd8bfd9760e9e2bc80a0bf07bbcff5ec2 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 16 Jul 2026 00:02:36 +0330 Subject: [PATCH] feat(appointment): implement service mode functionality with service slot selection and validation --- .../appointments/ServiceSlotPicker.tsx | 120 ++++++++++++++++++ .../admin/hooks/useDoctorBookingServices.ts | 36 ++++++ .../pages/AppointmentCreatePage.test.tsx | 27 ++++ assets/admin/pages/AppointmentCreatePage.tsx | 99 ++++++++++----- assets/admin/pages/AppointmentsPage.tsx | 106 +++++++++++++--- 5 files changed, 339 insertions(+), 49 deletions(-) create mode 100644 assets/admin/components/appointments/ServiceSlotPicker.tsx create mode 100644 assets/admin/hooks/useDoctorBookingServices.ts diff --git a/assets/admin/components/appointments/ServiceSlotPicker.tsx b/assets/admin/components/appointments/ServiceSlotPicker.tsx new file mode 100644 index 00000000..b423cd6c --- /dev/null +++ b/assets/admin/components/appointments/ServiceSlotPicker.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '../../lib/api'; +import type { ApiResponse } from '../../lib/api'; +import type { BookingService } from '../../hooks/useDoctorBookingServices'; + +interface ServiceSlot { start: number; end: number; start_time: string } + +/** + * انتخاب سرویس (یک/چند) + زمان‌های خالیِ کافیِ پیشنهادی برای نوبت‌دهی سرویسی. + * مدت نوبت از مجموع مدت سرویس‌ها می‌آید؛ زمان‌ها از `appointment-service-slots`. + * انتخاب را از طریق onSelect بالا می‌فرستد تا فرمِ میزبان payload بسازد. + */ +export default function ServiceSlotPicker({ + doctorUuid, date, services, onSelect, +}: { + doctorUuid: string; + date: string; + services: BookingService[]; + onSelect: (v: { serviceUuids: string[]; slot: ServiceSlot | null }) => void; +}) { + const [serviceUuids, setServiceUuids] = useState([]); + const [pickedSlot, setPickedSlot] = useState(null); + + useEffect(() => { setPickedSlot(null); }, [serviceUuids, date, doctorUuid]); + useEffect(() => { onSelect({ serviceUuids, slot: pickedSlot }); }, [serviceUuids, pickedSlot]); + + const slotsQ = useQuery>({ + queryKey: ['service-slots-picker', doctorUuid, date, serviceUuids], + queryFn: () => api.get( + `/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}` + + serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('') + ), + enabled: !!doctorUuid && !!date && serviceUuids.length > 0, + }); + const startTimes: ServiceSlot[] = (slotsQ.data?.data as any)?.start_times ?? []; + const totalMinutes = (slotsQ.data?.data as any)?.total_duration_minutes as number | undefined; + + const label = { fontSize: 12.5, color: 'var(--text-3)', display: 'block' } as const; + + const toggle = (uuid: string) => + setServiceUuids(prev => prev.includes(uuid) ? prev.filter(u => u !== uuid) : [...prev, uuid]); + + return ( +
+ + {services.length === 0 ? ( +
+ سرویسی با «نمایش در نوبت‌دهی» برای این پزشک تعریف نشده است. +
+ ) : ( +
+ {services.map(s => { + const active = serviceUuids.includes(s.uuid); + return ( + + ); + })} +
+ )} + + {serviceUuids.length > 0 && ( + <> + + {slotsQ.isLoading ? ( +
در حال محاسبه...
+ ) : startTimes.length === 0 ? ( +
+ برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید. +
+ ) : ( +
+ {startTimes.map(s => { + const active = pickedSlot?.start === s.start; + return ( + + ); + })} +
+ )} + + )} +
+ ); +} diff --git a/assets/admin/hooks/useDoctorBookingServices.ts b/assets/admin/hooks/useDoctorBookingServices.ts new file mode 100644 index 00000000..874700b1 --- /dev/null +++ b/assets/admin/hooks/useDoctorBookingServices.ts @@ -0,0 +1,36 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '../lib/api'; +import type { ApiResponse } from '../lib/api'; + +export interface BookingService { + uuid: string; + name: string; + duration_minutes: number | null; + price_rials: number; +} + +interface BookingServicesData { + booking_mode: 'slot' | 'service'; + buffer_minutes: number; + services: BookingService[]; +} + +/** + * روش نوبت‌دهی و سرویس‌های قابل‌انتخابِ یک پزشک — از endpoint عمومیِ + * `appointment-booking-services`. برای سرویس‌محور کردن فرم‌های ثبت نوبت پنل. + */ +export function useDoctorBookingServices(doctorUuid: string | null | undefined) { + const q = useQuery>({ + queryKey: ['booking-services', doctorUuid], + queryFn: () => api.get(`/api/v1/appointment-booking-services/${doctorUuid}`), + enabled: !!doctorUuid, + }); + + const data = q.data?.data as BookingServicesData | undefined; + return { + bookingMode: (data?.booking_mode === 'service' ? 'service' : 'slot') as 'slot' | 'service', + bufferMinutes: data?.buffer_minutes ?? 0, + services: data?.services ?? [], + isLoading: q.isLoading, + }; +} diff --git a/assets/admin/pages/AppointmentCreatePage.test.tsx b/assets/admin/pages/AppointmentCreatePage.test.tsx index f0d2e086..e4e6d9a3 100644 --- a/assets/admin/pages/AppointmentCreatePage.test.tsx +++ b/assets/admin/pages/AppointmentCreatePage.test.tsx @@ -38,6 +38,33 @@ describe('AppointmentCreatePage — افزودن نوبت', () => { expect(body).toMatchObject({ doctor_uuid: 'doc1', patient_name: 'علی محمدی', patient_mobile: '09121234567', patient_national_code: '1234567891' }); }); + it('service mode: picks service + suggested time and posts service_item_uuids', async () => { + get.mockImplementation((url: string) => { + if (url.startsWith('/api/v1/appointment-booking-services/')) + return Promise.resolve({ success: true, data: { booking_mode: 'service', buffer_minutes: 5, services: [{ uuid: 'sv1', name: 'عصب‌کشی', duration_minutes: 30, price_rials: 500000 }] } }); + if (url.startsWith('/api/v1/appointment-service-slots')) + return Promise.resolve({ success: true, data: { total_duration_minutes: 30, buffer_minutes: 5, start_times: [{ start: 1754000000, end: 1754001800, start_time: '15:00' }] } }); + return Promise.resolve({ success: true, data: [] }); + }); + + renderWithProviders(, { route: '/admin/appointments/new' }); + + fireEvent.change(screen.getByPlaceholderText('نام و نام خانوادگی مراجعه کننده'), { target: { value: 'علی محمدی' } }); + fireEvent.change(screen.getByPlaceholderText('شماره تماس مراجعه کننده'), { target: { value: '09121234567' } }); + fireEvent.change(screen.getByPlaceholderText('کد ملی مراجعه کننده'), { target: { value: '1234567891' } }); + + // حالت سرویس: ورودی ساعت شروع نباید باشد + await waitFor(() => expect(screen.queryByLabelText('ساعت شروع')).toBeNull()); + + fireEvent.click(await screen.findByText('عصب‌کشی')); + fireEvent.click(await screen.findByRole('button', { name: '15:00' })); + + fireEvent.click(screen.getByText('ثبت اطلاعات')); + await waitFor(() => expect(post).toHaveBeenCalled()); + const [, body] = post.mock.calls[0]; + expect(body).toMatchObject({ doctor_uuid: 'doc1', slot_start: 1754000000, slot_end: 1754001800, service_item_uuids: ['sv1'] }); + }); + it('keeps the submit button disabled until a valid patient is entered (boundary)', () => { renderWithProviders(, { route: '/admin/appointments/new' }); const btn = screen.getByText('ثبت اطلاعات') as HTMLButtonElement; diff --git a/assets/admin/pages/AppointmentCreatePage.tsx b/assets/admin/pages/AppointmentCreatePage.tsx index 4b61cb0d..95f656fe 100644 --- a/assets/admin/pages/AppointmentCreatePage.tsx +++ b/assets/admin/pages/AppointmentCreatePage.tsx @@ -10,6 +10,8 @@ import PersianDateInput from '../components/ui/PersianDateInput'; import PriceInput from '../components/ui/PriceInput'; import SearchableSelect from '../components/ui/SearchableSelect'; import { WalletChargeLink } from '../components/AppointmentActions'; +import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; +import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; /** * افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای @@ -76,6 +78,11 @@ export default function AppointmentCreatePage() { }); const staffQ = useQuery>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff') }); + // ── روش نوبت‌دهی پزشک (سرویسی/اسلاتی) + const { bookingMode, services } = useDoctorBookingServices(doctorUuid); + const serviceMode = bookingMode === 'service'; + const [servicePick, setServicePick] = useState<{ serviceUuids: string[]; slot: { start: number; end: number } | null }>({ serviceUuids: [], slot: null }); + // ── زمان نوبت const [date, setDate] = useState(params.get('date') || today); const [duration, setDuration] = useState(40); @@ -92,21 +99,28 @@ export default function AppointmentCreatePage() { const effectiveName = picked?.user_name || name.trim(); const effectiveMobile = picked?.user_mobile || mobile.trim(); const effectiveNationalCode = (picked?.user_national_code || nationalCode).replace(/\D/g, ''); + const timingValid = serviceMode + ? (servicePick.serviceUuids.length > 0 && !!servicePick.slot) + : (!!start && !!end); const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 - && effectiveNationalCode.length === 10 && !!start && !!end; + && effectiveNationalCode.length === 10 && timingValid; const create = useMutation({ mutationFn: async () => { const createEndpoint = primaryRole === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment'; const payload: Record = { doctor_uuid: doctorUuid, - slot_start: toEpoch(date, start), - slot_end: toEpoch(date, end), + slot_start: serviceMode ? servicePick.slot!.start : toEpoch(date, start), + slot_end: serviceMode ? servicePick.slot!.end : toEpoch(date, end), patient_name: effectiveName, patient_mobile: effectiveMobile, patient_national_code: effectiveNationalCode, - ...(sectionUuid ? { service_section_uuid: sectionUuid } : {}), - ...(itemUuid ? { service_item_uuid: itemUuid } : {}), + ...(serviceMode + ? { service_item_uuids: servicePick.serviceUuids } + : { + ...(sectionUuid ? { service_section_uuid: sectionUuid } : {}), + ...(itemUuid ? { service_item_uuid: itemUuid } : {}), + }), ...(staffUuid ? { staff_uuid: staffUuid } : {}), ...(depositRequired ? { deposit_required: true, deposit_amount_rials: depositRials } : {}), ...(note.trim() ? { note: note.trim() } : {}), @@ -210,22 +224,24 @@ export default function AppointmentCreatePage() { {/* مشخصات سرویس */}
مشخصات سرویس:
-
-
- - + {!serviceMode && ( +
+
+ + +
+
+ + +
-
- - -
-
+ )} setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" /> + {serviceMode ? ( +
+ {doctorUuid ? ( + + ) : ( +
ابتدا پزشک را انتخاب کنید.
+ )} +
+ ) : ( +
+
+ +
+ setDuration(Math.max(5, Number(e.target.value) || 0))} dir="ltr" /> +
+
+
+ +
setStart(e.target.value)} dir="ltr" />
+
+
+ +
setEnd(e.target.value)} dir="ltr" />
-
- -
setStart(e.target.value)} dir="ltr" />
-
-
- -
setEnd(e.target.value)} dir="ltr" />
-
-
+ )} {/* بیعانه */}
بیعانه:
diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index 0ecb3932..fc8d66d8 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -22,6 +22,8 @@ import type { TurnsViewMode } from '../components/appointments/TurnsViewToggle'; import DoctorTabs from '../components/appointments/DoctorTabs'; import TurnsTimeline from '../components/appointments/TurnsTimeline'; import TurnsTable from '../components/appointments/TurnsTable'; +import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; +import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; import { CANCELLED_STATUSES } from '../components/appointments/turnStatus'; import type { TimelineSlot } from '../components/appointments/types'; @@ -97,17 +99,26 @@ interface BookingSlot { start: number; end: number; start_time: string; end_time interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null } export function NewAppointmentModal({ - slot, onClose, onSuccess, -}: { slot: BookingSlot; onClose: () => void; onSuccess: () => void }) { + slot, onClose, onSuccess, serviceMode = false, services = [], date, +}: { + slot: BookingSlot; + onClose: () => void; + onSuccess: () => void; + serviceMode?: boolean; + services?: import('../hooks/useDoctorBookingServices').BookingService[]; + date?: string; +}) { const [mobile, setMobile] = useState(''); const [lookup, setLookup] = useState(null); const [patientName, setPatientName] = useState(''); const [nationalCode, setNationalCode] = useState(''); + const [pick, setPick] = useState<{ serviceUuids: string[]; slot: { start: number; end: number } | null }>({ serviceUuids: [], slot: null }); const role = useAuthStore(s => s.primaryRole); const createEndpoint = role === 'admin' ? '/api/v1/admin/appointment' : '/api/v1/my/appointment'; const mobileValid = /^09\d{9}$/.test(mobile); + const serviceTimingValid = !serviceMode || (pick.serviceUuids.length > 0 && !!pick.slot); // یک بیمارِ یافت‌شده که کد ملی دارد، بدون فرم اضافی قابل استفاده است. const foundWithNationalCode = !!lookup?.found && !!lookup.national_code; const needsDetails = lookup !== null && !foundWithNationalCode; // یافت‌نشده، یا یافت‌شده بدون کد ملی @@ -115,7 +126,7 @@ export function NewAppointmentModal({ const effectiveName = foundWithNationalCode ? (lookup?.name ?? '') : patientName.trim(); const effectiveNationalCode = foundWithNationalCode ? (lookup?.national_code ?? '') : nationalCode; const detailsValid = effectiveName.length >= 2 && effectiveNationalCode.length === 10; - const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)); + const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid; const search = useMutation({ mutationFn: () => api.get(`/api/v1/my/appointment/patient-lookup?mobile=${encodeURIComponent(mobile)}`), @@ -131,11 +142,12 @@ export function NewAppointmentModal({ const mutation = useMutation({ mutationFn: () => api.post(createEndpoint, { doctor_uuid: slot.doctor_uuid, - slot_start: slot.start, - slot_end: slot.end, + slot_start: serviceMode ? pick.slot!.start : slot.start, + slot_end: serviceMode ? pick.slot!.end : slot.end, patient_mobile: mobile, patient_name: effectiveName, patient_national_code: effectiveNationalCode, + ...(serviceMode ? { service_item_uuids: pick.serviceUuids } : {}), }), onSuccess: () => { toast.success('نوبت با موفقیت ثبت شد'); @@ -180,9 +192,22 @@ export function NewAppointmentModal({ }} onClick={e => e.stopPropagation()}>
ثبت نوبت
- {slot.start_time} تا {slot.end_time} — {slot.doctor_name} + {serviceMode + ? slot.doctor_name + : `${slot.start_time} تا ${slot.end_time} — ${slot.doctor_name}`}
+ {serviceMode && date && ( +
+ +
+ )} +
@@ -374,10 +399,23 @@ export default function AppointmentsPage() { enabled: viewMode === 'timeline' && !!selectedDoctorUuid, }); + // ── روش نوبت‌دهی پزشکِ انتخاب‌شده (سرویسی/اسلاتی) + const { bookingMode, services } = useDoctorBookingServices(selectedDoctorUuid); + const serviceMode = bookingMode === 'service'; + + // بازهٔ کاری پزشک در این روز (برای هدرِ تایم‌لاینِ سرویسی). + const workingRange = React.useMemo(() => { + const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? []; + if (!rawSessions.length) return null; + const starts = rawSessions.map(s => s.start_time).filter(Boolean).sort(); + const ends = rawSessions.map(s => s.end_time).filter(Boolean).sort(); + if (!starts.length || !ends.length) return null; + return { start: starts[0], end: ends[ends.length - 1] }; + }, [slotsQuery.data]); + // ── Merge sessions + appointments → flat timeline slots const timelineSlots: TimelineSlot[] = React.useMemo(() => { if (viewMode !== 'timeline') return []; - const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? []; const activeByStart = new Map(); const cancelledByStart = new Map(); @@ -391,6 +429,27 @@ export default function AppointmentsPage() { } }); + // حالت سرویسی: اسلات ثابت وجود ندارد — تایم‌لاین از خودِ نوبت‌های رزروشده + // (با مدت واقعی) ساخته می‌شود، مرتب بر اساس زمان شروع. + if (serviceMode) { + const fmt = (ts: number) => new Date(ts * 1000).toLocaleTimeString('fa-IR', { hour: '2-digit', minute: '2-digit' }); + return [...activeByStart.values()] + .sort((a, b) => Number(a.slot_start) - Number(b.slot_start)) + .map(a => { + const start = Number(a.slot_start); + const end = Number(a.slot_end); + return { + start, end, + start_time: fmt(start), + end_time: fmt(end), + is_available: false, + appointment: a, + cancelled_appointment: null, + } as TimelineSlot; + }); + } + + const rawSessions: any[] = (slotsQuery.data?.data as any)?.sessions ?? []; const out: TimelineSlot[] = []; rawSessions.forEach((session: any) => { (session.slots as any[]).forEach((s: any) => { @@ -408,7 +467,7 @@ export default function AppointmentsPage() { }); }); return out; - }, [viewMode, slotsQuery.data, appointments]); + }, [viewMode, slotsQuery.data, appointments, serviceMode]); // ── Slot click → quick booking modal function handleSlotClick(slot: TimelineSlot) { @@ -513,13 +572,27 @@ export default function AppointmentsPage() { برای نمایش زمانبندی، ابتدا یک پزشک انتخاب کنید
) : ( - + <> + {serviceMode && ( +
+ نوبت‌دهی سرویسی — نوبت‌ها بر اساس مدت سرویس چیده می‌شوند. + {workingRange && ( + ساعت کاری: {workingRange.start} - {workingRange.end} + )} +
+ )} + + )} )} @@ -531,6 +604,9 @@ export default function AppointmentsPage() { {bookingSlot && ( setBookingSlot(null)} onSuccess={() => { qc.invalidateQueries({ queryKey: apptQueryKey });