import { 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 PriceInput from '../ui/PriceInput'; import SearchableSelect from '../ui/SearchableSelect'; 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; } 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, }: { slot: BookingSlot; onClose: () => void; onSuccess: () => void; serviceMode?: boolean; services?: BookingService[]; date?: string; /** محل نوبت — بدون آن backend نوبت را به مطب شخصی نسبت می‌دهد. */ clinicUuid?: string | null; resource?: BookingResource | null; /** * رزروِ صریحِ یک جلسهٔ درمان. بیمار می‌تواند چند دورهٔ باز داشته باشد، پس بدون این، * اتصال از روی سرویس حدس زده می‌شود و سرویسِ اشتباه یک پروندهٔ موازی می‌سازد. */ treatmentSessionUuid?: string | null; }) { const [mobile, setMobile] = useState(''); const [lookup, setLookup] = useState(null); const [patientName, setPatientName] = useState(''); const [nationalCode, setNationalCode] = useState(''); // معیار جستجوی بیمار: کد ملی (پیش‌فرض) یا موبایل. const [searchBy, setSearchBy] = useState<'mobile' | 'national'>('national'); const [pick, setPick] = useState({ serviceUuids: [], durations: {}, slot: null }); // اپراتور اختیاری است: خالی گذاشتنش جلسه را در صفِ مشترکِ پرسنلِ مجاز می‌گذارد، // پر کردنش آن را از قبل به یک نفر می‌دهد. const [staffUuid, setStaffUuid] = useState(''); // پیش‌فرضِ پروتکل فقط تا وقتی اعمال می‌شود که کاربر دست نزده باشد؛ وگرنه انتخاب // دستیِ منشی با هر تغییرِ سرویس پاک می‌شد. 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 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 }] : []), ...(date ? [{ label: 'تاریخ', value: formatDate(date) }] : []), ]; // اولین چیزی که جلوی ثبت را گرفته، به ترتیبِ همان مراحلِ فرم. دکمهٔ خاکستریِ // بی‌توضیح یعنی کاربر باید حدس بزند چه چیزی کم است. const blockReason = !serviceTimingValid ? (pick.serviceUuids.length === 0 ? 'یک سرویس انتخاب کنید' : 'ساعت شروع را انتخاب کنید') : lookup === null ? 'ابتدا بیمار را جستجو کنید' : needsDetails && !detailsValid ? 'مشخصات بیمار را کامل کنید' : !mobileValid ? 'شماره موبایل بیمار معتبر نیست' : !visitPriceValid ? 'هزینه ویزیت الزامی است' : null; const priceHint = pricingLoading ? 'در حال خواندن تعرفهٔ پزشک…' : freeVisit > 0 ? `تعرفهٔ نوبت‌دهی ${slot.doctor_name}: ${formatRial(freeVisit)} — در صورت نیاز تغییر دهید` : 'برای این پزشک تعرفه‌ای ثبت نشده — در صورت نیاز مبلغ را وارد کنید'; return ( } > {/* هدف نوبت: منبع، یا اسلات/پزشک */}
{resource ? : } {targetFacts.map(f => ( {f.label}: {f.value} ))}
{pickerMode && date && ( {/* اختیاری به‌عمد: پیش‌فرضِ سیستم صفِ مشترک است و منشی فقط وقتی دخالت می‌کند که بیمار اپراتور مشخصی خواسته باشد. */}
({ 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} />
)}
{/* انتخاب معیار جستجو: موبایل یا کد ملی */}
{(['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} /> )}
{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' }} />
)} )}
{/* هزینه ویزیت مرحلهٔ شماره‌دار نیست: در حالت اختیاری یک کلپسِ بسته است و شماره دادن به آن، کاری اختیاری را اجباری نشان می‌داد. */}
{requireVisit ? ( ) : ( // سرِ کلپس — با کلیک باز/بسته می‌شود (فقط وقتی اختیاری است). )} {visitPriceExpanded && ( <>
{ setVisitPriceToman(v); setVisitPriceTouched(true); }} suffix="تومان" />
{requireVisit && visitPriceToman <= 0 ? هزینه ویزیت الزامی است : {priceHint}} {freeVisit > 0 && visitPriceToman !== rialToToman(freeVisit) && ( )} )}
{blockReason && (
برای ثبت نوبت: {blockReason}
)}
); } /** مرحلهٔ شماره‌دار فرم — ترتیب تصمیم‌ها را دیدنی می‌کند، نه فقط ترتیب فیلدها. */ function Step({ n, title, children }: { n: number | null; title: string; children: ReactNode }) { return (

{/* حالت اسلاتی فقط یک مرحله دارد؛ شمارهٔ «۱» تنها، نویز است نه راهنما. */} {n !== null && ( {n} )} {title}

{children}
); }