import React, { useEffect, useState } from 'react'; import type { ReactNode } from 'react'; import { useMutation, useQuery } from '@tanstack/react-query'; import { ChevronDownIcon, ClockIcon, MagnifyingGlassIcon, CheckCircleIcon, CpuChipIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; import { digitsOnly, sanitizeMobileInput, rialToToman, tomanToRial, formatRial, formatDate } from '../../lib/utils'; import { useAuthStore } from '../../stores/authStore'; import Modal from '../ui/Modal'; import PersianDateInput from '../ui/PersianDateInput'; import PriceInput from '../ui/PriceInput'; import SearchableSelect from '../ui/SearchableSelect'; import Stepper from '../ui/Stepper'; import ServiceSlotPicker from './ServiceSlotPicker'; import type { ServicePick } from './ServiceSlotPicker'; import type { BookingService } from '../../hooks/useDoctorBookingServices'; export interface BookingSlot { start: number; end: number; start_time: string; end_time: string; doctor_uuid: string; doctor_name: string; } type StepKey = 'service' | 'patient' | 'confirm'; const STEP_TITLES: Record = { service: 'خدمت و زمان', patient: 'بیمار', confirm: 'تأیید و ثبت', }; interface PatientLookup { found: boolean; name?: string | null; mobile?: string; national_code?: string | null } /** منبعِ هدفِ نوبت — دستگاه/اتاق/پرسنلی که نوبت رویش می‌نشیند. */ export interface BookingResource { uuid: string; name: string; } /** * ثبت نوبت از روی یک اسلات/منبع: جستجوی بیمار، انتخاب سرویس‌ها و زمان، هزینهٔ ویزیت. * * سه حالت دارد و هر سه یک فرم‌اند — سه مودال یعنی سه رفتار: * - اسلاتی: زمان همان اسلاتِ کلیک‌شده است. * - سرویسی (پزشک): زمان از `ServiceSlotPicker` و برنامهٔ هفتگیِ پزشک می‌آید. * - منبع: همان انتخابگر، ولی زمان‌ها از تقویم خودِ منبع و ثبت با `resource_uuid`. */ export default function NewAppointmentModal({ slot, onClose, onSuccess, serviceMode = false, services = [], date, clinicUuid = null, resource = null, treatmentSessionUuid = null, patient = null, }: { slot: BookingSlot; onClose: () => void; onSuccess: () => void; serviceMode?: boolean; services?: BookingService[]; date?: string; /** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت می‌دهد. */ clinicUuid?: string | null; resource?: BookingResource | null; /** * رزروِ صریحِ یک جلسهٔ درمان. بیمار می‌تواند چند دورهٔ باز داشته باشد، پس بدون این، * اتصال از روی سرویس حدس زده می‌شود و سرویسِ اشتباه یک پروندهٔ موازی می‌سازد. */ treatmentSessionUuid?: string | null; /** * بیمارِ از پیش معلوم — وقتی مودال از پروندهٔ خودِ بیمار باز می‌شود. * * مرحلهٔ جستجو رد می‌شود: کسی که پروندهٔ بیمار را باز کرده نباید همان بیمار را * دوباره با کد ملی پیدا کند. */ patient?: { name: string | null; mobile: string; national_code: string | null } | null; }) { const [mobile, setMobile] = useState(patient?.mobile ?? ''); const [lookup, setLookup] = useState( patient === null ? null : { found: true, name: patient.name, mobile: patient.mobile, national_code: patient.national_code }, ); const [patientName, setPatientName] = useState(patient?.name ?? ''); const [nationalCode, setNationalCode] = useState(patient?.national_code ?? ''); // معیار جستجوی بیمار: کد ملی (پیش‌فرض) یا موبایل. const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national'); const [pick, setPick] = useState({ serviceUuids: [], durations: {}, slot: null }); // اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز می‌گذارد، // پر کردنش آن را از قبل به یک نفر می‌دهد. const [staffUuid, setStaffUuid] = useState(''); /** * تاریخ داخل خودِ مودال قابل ویرایش است. * * prop فقط نقطهٔ شروع را می‌دهد — تاریخِ کارتِ جلسه یا روزِ انتخاب‌شدهٔ صفحه. ولی * وقت پیشنهادی همیشه در دسترس نیست و کاربر باید بتواند همان‌جا روز دیگری بگیرد، * بدون بستن فرمی که بیمار و سرویسش را پر کرده. */ const [activeDate, setActiveDate] = useState(date ?? ''); useEffect(() => { setActiveDate(date ?? ''); }, [date]); // پیش‌فرضِ پروتکل فقط تا وقتی اعمال می‌شود که کاربر دست نزده باشد؛ وگرنه انتخاب // دستیِ منشی با هر تغییرِ سرویس پاک می‌شد. const [staffTouched, setStaffTouched] = useState(false); const role = useAuthStore(s => s.primaryRole); // نوبتِ منبع فقط مسیر پنل را دارد؛ اندپوینت ادمین `resource_uuid` نمی‌شناسد. const createEndpoint = role === 'admin' && resource === null ? '/api/v1/admin/appointment' : '/api/v1/my/appointment'; // منبع تقویم خودش را دارد، پس نوبتش همیشه سرویسی است — اسلات ثابتی وجود ندارد // که بشود رویش نشست. const pickerMode = serviceMode || resource !== null; // هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت». فیلد UI تومان، // API ریالی (visit_price_rials). بدون این مقدار، وقتی فلگ فعال است backend خطای ۴۲۲ می‌دهد. // // قیمت باید از تنظیمات نوبت‌دهیِ *پزشکِ همین اسلات* بیاید، نه از entity کاربر جاری؛ // منشی/کلینیک قیمت خودشان را ندارند و فیلد صفر می‌ماند. اگر دسترسی به تنظیمات آن // پزشک نبود (۴۰۳)، به تنظیمات خودِ کاربر برمی‌گردیم تا فلگ الزامی‌بودن از دست نرود. type Pricing = { data: { free_visit_price_rials: number; require_visit_price: boolean } }; const doctorPricingQ = useQuery({ queryKey: ['insurance-pricing', slot.doctor_uuid], queryFn: () => api.get(`/api/v1/insurance-pricing?doctor_uuid=${encodeURIComponent(slot.doctor_uuid)}`), enabled: !!slot.doctor_uuid, retry: false, }); const staffQ = useQuery>({ queryKey: ['staff-list'], queryFn: () => api.get('/api/v1/staff'), enabled: pickerMode, staleTime: 60_000, }); /** * پرسنلِ مجازِ «طول درمان» همان سرویس. * * منشی نباید چیزی را دوباره انتخاب کند که در تنظیمات سرویس یک بار تعریف شده؛ * پیش‌فرض همان است و تغییرش آزاد. */ const primaryService = pick.serviceUuids[0] ?? ''; const protocolQ = useQuery>({ queryKey: ['service-protocol', primaryService], queryFn: () => api.get(`/api/v1/service-item/${primaryService}/treatment-protocol`), enabled: primaryService !== '', staleTime: 60_000, }); const protocolStaff = protocolQ.data?.data?.staff ?? []; useEffect(() => { if (staffTouched || protocolStaff.length === 0) return; setStaffUuid(protocolStaff[0].uuid); }, [protocolStaff, staffTouched]); const selfPricingQ = useQuery({ queryKey: ['insurance-pricing'], queryFn: () => api.get('/api/v1/insurance-pricing'), enabled: doctorPricingQ.isError || !slot.doctor_uuid, }); const pricing = (doctorPricingQ.data ?? selfPricingQ.data) as any; const freeVisit = pricing?.data?.free_visit_price_rials ?? 0; const requireVisit = pricing?.data?.require_visit_price ?? false; const pricingLoading = doctorPricingQ.isLoading || selfPricingQ.isLoading; const [visitPriceToman, setVisitPriceToman] = useState(0); const [visitPriceTouched, setVisitPriceTouched] = useState(false); useEffect(() => { if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit)); }, [freeVisit, visitPriceTouched]); // هزینه ویزیت اختیاری داخل کلپسِ بسته می‌نشیند؛ وقتی الزامی است کلپس همیشه باز است. const [visitPriceOpen, setVisitPriceOpen] = useState(false); const visitPriceExpanded = requireVisit || visitPriceOpen; const [stepIdx, setStepIdx] = useState(0); const mobileValid = /^09\d{9}$/.test(mobile); const nationalCodeValid = /^\d{10}$/.test(nationalCode); // اعتبار کلید جستجو بسته به معیار انتخاب‌شده. const searchValid = searchBy === 'mobile' ? mobileValid : nationalCodeValid; const serviceTimingValid = !pickerMode || (pick.serviceUuids.length > 0 && !!pick.slot); // یک بیمارِ یافت‌شده که کد ملی دارد، بدون فرم اضافی قابل استفاده است. const foundWithNationalCode = !!lookup?.found && !!lookup.national_code; const needsDetails = lookup !== null && !foundWithNationalCode; // یافت‌نشده، یا یافت‌شده بدون کد ملی const effectiveName = foundWithNationalCode ? (lookup?.name ?? '') : patientName.trim(); const effectiveNationalCode = foundWithNationalCode ? (lookup?.national_code ?? '') : nationalCode; const detailsValid = effectiveName.length >= 2 && effectiveNationalCode.length === 10; const visitPriceValid = !requireVisit || visitPriceToman > 0; const isValid = mobileValid && (foundWithNationalCode || (needsDetails && detailsValid)) && serviceTimingValid && visitPriceValid; const search = useMutation({ mutationFn: () => { const q = searchBy === 'mobile' ? `mobile=${encodeURIComponent(mobile)}` : `national_code=${encodeURIComponent(nationalCode)}`; return api.get(`/api/v1/my/appointment/patient-lookup?${q}`); }, onSuccess: (res: any) => { const data: PatientLookup = res?.data ?? { found: false }; setLookup(data); setPatientName(data.found ? (data.name ?? '') : ''); // موبایل و کد ملیِ بیمارِ یافت‌شده را پر می‌کنیم تا ثبت مستقل از معیار جستجو کار کند. if (data.found) { if (data.mobile) setMobile(data.mobile); setNationalCode(data.national_code ?? ''); } else if (searchBy === 'mobile') { setNationalCode(''); } }, onError: (e: any) => toast.error(e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در جستجو'), }); const mutation = useMutation({ mutationFn: () => api.post(createEndpoint, { ...(slot.doctor_uuid ? { doctor_uuid: slot.doctor_uuid } : {}), slot_start: pickerMode ? pick.slot!.start : slot.start, slot_end: pickerMode ? pick.slot!.end : slot.end, patient_mobile: mobile, patient_name: effectiveName, patient_national_code: effectiveNationalCode, ...(clinicUuid ? { clinic_uuid: clinicUuid } : {}), ...(staffUuid ? { staff_uuid: staffUuid } : {}), ...(treatmentSessionUuid ? { treatment_session_uuid: treatmentSessionUuid } : {}), ...(pickerMode ? { service_item_uuids: pick.serviceUuids } : {}), // منبع: مدت را سرور از سرویس‌های همین منبع می‌سازد، پس ساعت پایان حدس نیست. ...(resource ? { resource_uuid: resource.uuid, duration_from_services: true, service_durations: pick.durations, } : {}), ...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}), }), onSuccess: () => { toast.success('نوبت با موفقیت ثبت شد'); onSuccess(); onClose(); }, onError: (e: any) => { const msg = e?.response?.data?.errors?.[0]?.message ?? e?.message ?? 'خطا در ثبت نوبت'; toast.error(msg); }, }); // تغییر کلید جستجو نتیجه‌ی جستجوی قبلی را باطل می‌کند تا کاربر دوباره جستجو کند. function invalidateLookup() { if (lookup !== null) { setLookup(null); setPatientName(''); } } function onMobileChange(v: string) { // ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته می‌شود). setMobile(sanitizeMobileInput(v)); if (lookup !== null) { setLookup(null); setPatientName(''); if (searchBy === 'mobile') setNationalCode(''); } } function onNationalSearchChange(v: string) { setNationalCode(digitsOnly(v, 10)); invalidateLookup(); } // جابه‌جایی معیار جستجو همه‌چیز را از نو شروع می‌کند. function onSwitchSearchBy(mode: 'mobile' | 'national') { setSearchBy(mode); setLookup(null); setPatientName(''); setMobile(''); setNationalCode(''); } // هدفِ نوبت به‌صورت «برچسب: مقدار» — دو اسمِ لخت کنار هم معلوم نمی‌کند کدام دستگاه // است و کدام پزشک. تاریخ هم اینجاست چون تنها جای مودال بود که اصلاً دیده نمی‌شد. const targetFacts: { label: string; value: string }[] = [ resource ? { label: 'دستگاه', value: resource.name } : pickerMode ? { label: 'پزشک', value: slot.doctor_name } : { label: 'ساعت', value: `${slot.start_time} تا ${slot.end_time}` }, ...((resource || !pickerMode) && slot.doctor_name ? [{ label: resource ? 'پزشک ناظر' : 'پزشک', value: slot.doctor_name }] : []), /* در حالت انتخابگر، خودِ فیلدِ تاریخ همین را می‌گوید و تکرارش در هدر نویز است. */ ...(activeDate && !pickerMode ? [{ label: 'تاریخ', value: formatDate(activeDate) }] : []), ]; /** * مراحلِ ویزارد — فقط مراحلی که واقعاً تصمیمی دارند. * * فرم قبلاً یک صفحهٔ بلند بود: انتخاب سرویس و زمان، جستجوی بیمار و هزینه همه با هم. * مرحله‌ای که داده‌اش از قبل معلوم است ساخته نمی‌شود — نوبتِ اسلاتی زمان دارد و * فرمِ بازشده از پروندهٔ بیمار، بیمار. */ const stepKeys: StepKey[] = [ ...(pickerMode && activeDate ? (['service'] as StepKey[]) : []), ...(patient === null ? (['patient'] as StepKey[]) : []), 'confirm', ]; const currentStep = stepKeys[Math.min(stepIdx, stepKeys.length - 1)]; const isLastStep = currentStep === 'confirm'; // اولین چیزی که جلوی رفتن به مرحلهٔ بعد (یا ثبت) را گرفته. دکمهٔ خاکستریِ // بی‌توضیح یعنی کاربر باید حدس بزند چه چیزی کم است. const stepBlockReason: Record = { service: !serviceTimingValid ? (pick.serviceUuids.length === 0 ? 'یک سرویس انتخاب کنید' : 'ساعت شروع را انتخاب کنید') : null, patient: lookup === null ? 'ابتدا بیمار را جستجو کنید' : needsDetails && !detailsValid ? 'مشخصات بیمار را کامل کنید' : !mobileValid ? 'شماره موبایل بیمار معتبر نیست' : null, confirm: !visitPriceValid ? 'هزینه ویزیت الزامی است' : null, }; const blockReason = stepBlockReason[currentStep]; // خلاصهٔ مرحلهٔ آخر: همان چیزی که ثبت می‌شود، پیش از ثبت. const pickedServiceNames = pick.serviceUuids .map(uuid => services.find(sv => sv.uuid === uuid)?.name ?? uuid); const staffName = (staffQ.data?.data ?? []).find(st => st.uuid === staffUuid)?.full_name ?? null; const summaryRows: { label: string; value: string }[] = [ ...targetFacts.map(f => ({ label: f.label, value: f.value })), ...(pickerMode && activeDate ? [{ label: 'تاریخ', value: formatDate(activeDate) }] : []), ...(pickerMode && pick.slot ? [{ label: 'ساعت', value: pick.slot.start_time }] : []), ...(pickedServiceNames.length > 0 ? [{ label: 'سرویس', value: pickedServiceNames.join('، ') }] : []), ...(staffName ? [{ label: 'پرسنل', value: staffName }] : []), { label: 'بیمار', value: effectiveName || (patient?.name ?? '—') }, { label: 'موبایل', value: mobile || (patient?.mobile ?? '—') }, ]; const priceHint = pricingLoading ? 'در حال خواندن تعرفهٔ پزشک…' : freeVisit > 0 ? `تعرفهٔ نوبت‌دهی ${slot.doctor_name}: ${formatRial(freeVisit)} — در صورت نیاز تغییر دهید` : 'برای این پزشک تعرفه‌ای ثبت نشده — در صورت نیاز مبلغ را وارد کنید'; return ( {/* «قبلی» جای «انصراف» را نمی‌گیرد: بستنِ فرم همیشه باید یک کلیک باشد. */} {stepIdx > 0 && ( )} {isLastStep ? ( ) : ( )} } > {/* هدف نوبت: منبع، یا اسلات/پزشک */}
{resource ? : } {targetFacts.map(f => ( {f.label}: {f.value} ))}
{stepKeys.length > 1 && ( ({ key, title: STEP_TITLES[key] }))} current={currentStep} ariaLabel="مراحل ثبت نوبت" /> )} {currentStep === 'service' && (
{/* اختیاری به‌عمد: پیش‌فرضِ سیستم صفِ مشترک است و منشی فقط وقتی دخالت می‌کند که بیمار اپراتور مشخصی خواسته باشد. */}
({ value: s.uuid, label: s.full_name }))} value={staffUuid || null} onChange={v => { setStaffUuid(v ? String(v) : ''); setStaffTouched(true); }} placeholder={protocolQ.isLoading ? 'در حال خواندن پرسنل سرویس…' : 'در صف مشترک پرسنل بماند'} isLoading={staffQ.isLoading} isClearable height={40} />
)} {currentStep === 'patient' && ( {/* بیمارِ از پیش معلوم: فقط تأیید می‌شود، جستجو لازم نیست. */} {patient !== null && (
{patient.name || 'بدون نام'}
{patient.mobile}{patient.national_code ? ` · ${patient.national_code}` : ''}
)}
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
{(['national', 'mobile'] as const).map(mode => ( ))}
{searchBy === 'mobile' ? ( onMobileChange(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }} placeholder="مثال: 09123456789" style={{ direction: 'ltr' }} autoFocus={!pickerMode} /> ) : ( onNationalSearchChange(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && searchValid && !search.isPending) search.mutate(); }} placeholder="کد ملی ۱۰ رقمی" style={{ direction: 'ltr' }} // در حالت انتخابگر، اولین تصمیم «بخش» است نه بیمار؛ فوکوسِ خودکار // اینجا کاربر را از مرحلهٔ یک رد می‌کرد. autoFocus={!pickerMode} /> )}
{/* وقتی بیمار از قبل معلوم است، کارت بالا همین را می‌گوید؛ دو بار گفتنش نویز است. */} {patient === null && foundWithNationalCode && (
بیمار یافت شد
{lookup?.name}
کد ملی: {lookup?.national_code}
)} {needsDetails && ( <>
{lookup?.found ? 'برای این بیمار کد ملی ثبت نشده — لطفاً تکمیل کنید:' : 'بیماری با این مشخصات یافت نشد — بیمار جدید:'}
setPatientName(e.target.value)} placeholder="مثال: علی محمدی" />
{/* در جستجو با کد ملی، موبایل هنوز نامعلوم است و برای ثبت لازم می‌شود. */} {searchBy === 'national' && (
setMobile(sanitizeMobileInput(e.target.value))} placeholder="مثال: 09123456789" style={{ direction: 'ltr' }} />
)} {/* در جستجو با کد ملی، همان مقدار کلیدِ جستجو استفاده می‌شود و فیلد تکراری لازم نیست. */} {searchBy === 'mobile' && (
setNationalCode(digitsOnly(e.target.value, 10))} placeholder="کد ملی ۱۰ رقمی" style={{ direction: 'ltr' }} />
)} )}
)} {currentStep === 'confirm' && ( {/* خلاصهٔ همان چیزی که ثبت می‌شود — تنها جایی که کاربر پیش از ثبت، انتخاب سرویس و زمان و بیمار را با هم می‌بیند. */}
{summaryRows.map(row => (
{row.label}
{row.value}
))}
{/* هزینه ویزیت در حالت اختیاری یک کلپسِ بسته است. */}
{requireVisit ? ( ) : ( // سرِ کلپس — با کلیک باز/بسته می‌شود (فقط وقتی اختیاری است). )} {visitPriceExpanded && ( <>
{ setVisitPriceToman(v); setVisitPriceTouched(true); }} suffix="تومان" />
{requireVisit && visitPriceToman <= 0 ? هزینه ویزیت الزامی است : {priceHint}} {freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && ( )} )}
)} {blockReason && (
{isLastStep ? 'برای ثبت نوبت' : 'برای مرحلهٔ بعد'}: {blockReason}
)}
); } /** بدنهٔ یک مرحله — عنوانش در `Stepper` هم هست، اینجا سرِ همان بخش است. */ function Step({ title, children }: { title: string; children: ReactNode }) { return (

{title}

{children}
); }