import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { PlusIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import { useClinicContext } from '../hooks/useClinicContext'; import Modal from './ui/Modal'; import PersianDateInput from './ui/PersianDateInput'; import PriceInput from './ui/PriceInput'; import SearchableSelect from './ui/SearchableSelect'; import { WalletChargeLink } from './AppointmentActions'; import { tehranWallClockToUnix, tomanToRial, rialToToman, digitsOnly, sanitizeMobileInput } from '../lib/utils'; interface Option { uuid: string; name?: string; full_name?: string } interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string } const toEpoch = (isoDate: string, time: string) => tehranWallClockToUnix(isoDate, time); const addMinutes = (time: string, min: number) => { const [h, m] = time.split(':').map(Number); const t = h * 60 + m + min; return `${String(Math.floor(t / 60) % 24).padStart(2, '0')}:${String(t % 60).padStart(2, '0')}`; }; /** * اضافه کردن نوبت جدید (Figma add.pdf) — rich create form: patient * search-or-new, بخش/سرویس/پرسنل, date + default-duration + start/end time, * deposit toggle, status and notes. POSTs the extended /my/appointment. */ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey, onClose, isReserve = false }: { doctorUuid: string; /** ISO Y-m-d — the currently viewed day. */ defaultDate: string; queryKey: unknown[]; onClose: () => void; /** true → «اضافه کردن نوبت رزرو» (day-level entry, no time fields). */ isReserve?: boolean; }) { const qc = useQueryClient(); const clinicUuid = useClinicContext(); // ── patient: pick an existing record or enter a new person ──────────────── const [patientSearch, setPatientSearch] = useState(''); const [pickedPatient, setPickedPatient] = useState(null); const [name, setName] = useState(''); const [mobile, setMobile] = useState(''); const [nationalCode, setNationalCode] = useState(''); const patientsQ = useQuery>({ queryKey: ['drawer-patients', patientSearch], queryFn: () => api.get(`/api/v1/patients?search=${encodeURIComponent(patientSearch)}&limit=10`), enabled: patientSearch.trim().length >= 2, }); // ── service specs ────────────────────────────────────────────────────────── const [sectionUuid, setSectionUuid] = useState(''); const [itemUuid, setItemUuid] = useState(''); const [staffUuid, setStaffUuid] = useState(''); // روش نوبت‌دهی پزشک: در حالت «سرویس» زمان از مدت سرویس محاسبه و پیشنهاد می‌شود. const scheduleQ = useQuery>({ queryKey: ['drawer-schedule', doctorUuid, clinicUuid], queryFn: () => api.get( `/api/v1/appointment-settings/weekly-schedule/${doctorUuid}` + (clinicUuid ? `?clinic_uuid=${encodeURIComponent(clinicUuid)}` : '') ), enabled: !!doctorUuid, }); const bookingMode: 'slot' | 'service' = ((scheduleQ.data?.data as any)?.data?.meta ?? (scheduleQ.data?.data as any)?.meta)?.booking_mode === 'service' ? 'service' : 'slot'; const serviceMode = bookingMode === 'service' && !isReserve; const sectionsQ = useQuery>({ queryKey: ['service-sections'], queryFn: () => api.get('/api/v1/service-sections'), }); const itemsQ = useQuery>({ queryKey: ['service-items', sectionUuid], queryFn: () => api.get(`/api/v1/service-items/${sectionUuid}`), enabled: !!sectionUuid, }); const staffQ = useQuery>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff'), }); // ── timing ───────────────────────────────────────────────────────────────── const [date, setDate] = useState(defaultDate); const [duration, setDuration] = useState(40); const [start, setStart] = useState('15:00'); const [end, setEnd] = useState(addMinutes('15:00', 40)); useEffect(() => { setEnd(addMinutes(start, duration)); }, [start, duration]); // ── service-mode: چند سرویس + زمان‌های خالیِ پیشنهادی ───────────────────────── const [serviceUuids, setServiceUuids] = useState([]); const [svcNames, setSvcNames] = useState>({}); const [pickedSlot, setPickedSlot] = useState<{ start: number; end: number } | null>(null); useEffect(() => { setPickedSlot(null); }, [serviceUuids, date]); const svcSlotsQ = useQuery>({ queryKey: ['drawer-service-slots', doctorUuid, date, serviceUuids, clinicUuid], queryFn: () => api.get( `/api/v1/appointment-service-slots?doctor_uuid=${doctorUuid}&date=${date}&management=1` + serviceUuids.map(u => `&service_item_uuids[]=${encodeURIComponent(u)}`).join('') + (clinicUuid ? `&clinic_uuid=${encodeURIComponent(clinicUuid)}` : '') ), enabled: serviceMode && !!date && serviceUuids.length > 0, }); const svcSlots = ((svcSlotsQ.data?.data as any)?.start_times ?? []) as Array<{ start: number; end: number; start_time: string }>; const totalMinutes = (svcSlotsQ.data?.data as any)?.total_duration_minutes as number | undefined; // ── deposit / status / notes ─────────────────────────────────────────────── const [depositRequired, setDepositRequired] = useState(false); const [depositToman, setDepositToman] = useState(0); const [status, setStatus] = useState('pending'); const [note, setNote] = useState(''); // ── هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت» (فیلد UI تومان، // API ریالی). بدون این مقدار وقتی فلگ فعال است backend خطای ۴۲۲ می‌دهد. const pricingQ = useQuery>({ queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'), }); const freeVisit = (pricingQ.data as any)?.data?.free_visit_price_rials ?? 0; const requireVisit = (pricingQ.data as any)?.data?.require_visit_price ?? false; const [visitPriceToman, setVisitPriceToman] = useState(0); const [visitPriceTouched, setVisitPriceTouched] = useState(false); useEffect(() => { if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit)); }, [freeVisit, visitPriceTouched]); const effectiveName = pickedPatient?.user_name || name.trim(); const effectiveMobile = pickedPatient?.user_mobile || mobile.trim(); const effectiveNationalCode = (pickedPatient?.user_national_code || nationalCode).replace(/\D/g, ''); const timingValid = isReserve ? true : serviceMode ? (serviceUuids.length > 0 && !!pickedSlot) : (!!start && !!end); const visitPriceValid = !requireVisit || visitPriceToman > 0; const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 && effectiveNationalCode.length === 10 && timingValid && visitPriceValid; const create = useMutation({ mutationFn: async () => { const slotStart = isReserve ? toEpoch(date, '00:00') : serviceMode ? pickedSlot!.start : toEpoch(date, start); const slotEnd = isReserve ? toEpoch(date, '00:00') : serviceMode ? pickedSlot!.end : toEpoch(date, end); const payload: Record = { doctor_uuid: doctorUuid, slot_start: slotStart, slot_end: slotEnd, patient_name: effectiveName, patient_mobile: effectiveMobile, patient_national_code: effectiveNationalCode, is_reserve: isReserve, ...(sectionUuid ? { service_section_uuid: sectionUuid } : {}), // حالت سرویس: چند سرویس؛ حالت اسلاتی: تک سرویسِ workflow (اختیاری). ...(serviceMode ? { service_item_uuids: serviceUuids } : itemUuid ? { service_item_uuid: itemUuid } : {}), ...(staffUuid ? { staff_uuid: staffUuid } : {}), ...(depositRequired ? { deposit_required: true, deposit_amount_rials: tomanToRial(depositToman) } : {}), ...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}), ...(note.trim() ? { note: note.trim() } : {}), }; const res: any = await api.post('/api/v1/my/appointment', payload); // POST creates a pending booking; apply the picked status afterwards. if (status !== 'pending' && res?.data?.uuid) { await api.patch(`/api/v1/appointment/${res.data.uuid}/status`, { status, version: 1 }); } return res; }, onSuccess: () => { qc.invalidateQueries({ queryKey }); toast.success(isReserve ? 'نوبت رزرو ثبت شد' : 'نوبت با موفقیت ثبت شد'); onClose(); }, onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'), }); const label = { fontSize: 12.5, color: 'var(--text-3)' } as const; const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]); return (
اطلاعات مراجعه کننده:
{ setPickedPatient(null); setPatientSearch(e.target.value); }} placeholder="جستجوی نام، شماره تماس، شماره پرونده..." />
{!pickedPatient && patients.length > 0 && (
{patients.map(p => ( ))}
)} {pickedPatient === null && ( <>
مراجعه کننده جدید
setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده را وارد نمایید" />
setMobile(sanitizeMobileInput(e.target.value))} placeholder="شماره تماس مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" lang="en" maxLength={11} />
setNationalCode(digitsOnly(e.target.value, 10))} placeholder="کد ملی مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" lang="en" maxLength={10} />
)}
مشخصات سرویس:
({ value: o.uuid, label: o.name ?? '' }))} value={sectionUuid || null} onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }} placeholder="انتخاب بخش" isLoading={sectionsQ.isLoading} isClearable height={38} />
({ value: o.uuid, label: o.name ?? '' }))} value={serviceMode ? null : (itemUuid || null)} isDisabled={!sectionUuid} isLoading={itemsQ.isLoading} placeholder={serviceMode ? 'افزودن سرویس' : 'انتخاب سرویس'} onChange={v => { const uuid = v ? String(v) : ''; if (!uuid) { if (!serviceMode) setItemUuid(''); return; } if (serviceMode) { const name = (itemsQ.data?.data ?? []).find(o => o.uuid === uuid)?.name ?? ''; setServiceUuids(prev => prev.includes(uuid) ? prev : [...prev, uuid]); setSvcNames(prev => ({ ...prev, [uuid]: name })); } else { setItemUuid(uuid); } }} height={38} />
{serviceMode && serviceUuids.length > 0 && (
{serviceUuids.map(uuid => ( {svcNames[uuid] ?? uuid} ))} {totalMinutes != null && مدت کل: {totalMinutes} دقیقه}
)}
({ value: o.uuid, label: o.full_name ?? '' }))} value={staffUuid || null} onChange={v => setStaffUuid(v ? String(v) : '')} placeholder="انتخاب..." isLoading={staffQ.isLoading} isClearable height={38} />
زمان نوبت:
{serviceMode ? (
{serviceUuids.length === 0 ? (
ابتدا سرویس را انتخاب کنید.
) : svcSlotsQ.isLoading ? (
در حال محاسبه...
) : svcSlots.length === 0 ? (
برای این سرویس در این روز زمان خالی کافی نیست؛ روز دیگری انتخاب کنید.
) : (
{svcSlots.map(s => { const active = pickedSlot?.start === s.start; return ( ); })}
)}
) : ( <>
setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
{!isReserve && (
setStart(e.target.value)} dir="ltr" />
setEnd(e.target.value)} dir="ltr" />
)} )} {!isReserve && ( <>
بیعانه:
{depositRequired && (
)} )}
هزینه ویزیت:
{ setVisitPriceToman(v); setVisitPriceTouched(true); }} />
{requireVisit && visitPriceToman <= 0 && (
هزینه ویزیت الزامی است
)}
setStatus(v ? String(v) : '')} placeholder="انتخاب وضعیت" height={38} />