import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { PlusIcon, ChevronRightIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import { useAuthStore } from '../stores/authStore'; import PersianDateInput from '../components/ui/PersianDateInput'; import PriceInput from '../components/ui/PriceInput'; import DigitInput from '../components/ui/DigitInput'; import SearchableSelect from '../components/ui/SearchableSelect'; import { WalletChargeLink } from '../components/AppointmentActions'; import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; import SlotPicker, { type PickedSlot } from '../components/appointments/SlotPicker'; import { tehranWallClockToUnix, rialToToman, tomanToRial } from '../lib/utils'; import BackButton from '../components/ui/BackButton'; import { digitsOnly, todayIso } from '../lib/utils'; /** * افزودن نوبت — صفحهٔ کامل (بازسازی `CreateTurn.jsx` طرح tauri). از همان endpointهای * موجود استفاده می‌کند: `POST /api/v1/my/appointment` (یا admin) + در صورت نیاز * `PATCH .../status`. هیچ endpoint جدیدی ساخته نشده است. */ 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')}`; }; const label = { fontSize: 12.5, color: 'var(--text-3)', display: 'block' } as const; const sectionTitle: React.CSSProperties = { fontSize: 18, fontWeight: 700, color: 'var(--text)', margin: '22px 0 14px' }; export default function AppointmentCreatePage() { const navigate = useNavigate(); const [params] = useSearchParams(); const qc = useQueryClient(); const primaryRole = useAuthStore(s => s.primaryRole); const dbUuid = useAuthStore(s => s.dbUuid); const isDoctor = primaryRole === 'doctor'; const today = todayIso(); // ── پزشک const [doctorUuid, setDoctorUuid] = useState(isDoctor && dbUuid ? dbUuid : (params.get('doctor') ?? '')); const clinicDoctorsQuery = useQuery>({ queryKey: ['clinic-doctors', dbUuid], queryFn: () => api.get(`/api/v1/clinic/doctor-list/${dbUuid}`), enabled: !isDoctor && !!dbUuid, }); const doctorOptions = (clinicDoctorsQuery.data?.data?.data ?? []).map(d => ({ value: d.uuid, label: d.name })); // ── بیمار: جستجوی رکورد موجود یا ورود شخص جدید const [patientSearch, setPatientSearch] = useState(''); const [picked, setPicked] = useState(null); const [newPatient, setNewPatient] = useState(false); // فرم «مراجعه کننده جدید» فعال است؟ const [name, setName] = useState(''); const [mobile, setMobile] = useState(''); const [nationalCode, setNationalCode] = useState(''); const patientsQ = useQuery>({ queryKey: ['create-patients', patientSearch], queryFn: () => api.get(`/api/v1/patients?search=${encodeURIComponent(patientSearch)}&limit=10`), enabled: patientSearch.trim().length >= 2, }); const patients = useMemo(() => patientsQ.data?.data ?? [], [patientsQ.data]); // ورود از تب «نوبت‌ها»ی پروندهٔ بیمار: مراجعه‌کننده از قبل معلوم است، پس به‌جای // جستجو، خودِ پرونده خوانده و قفل می‌شود. const fromRecordUuid = params.get('record') ?? ''; const recordQ = useQuery>({ queryKey: ['create-appt-record', fromRecordUuid], queryFn: () => api.get(`/api/v1/patient/${fromRecordUuid}`), enabled: !!fromRecordUuid, }); useEffect(() => { const rec: any = (recordQ.data?.data as any)?.data ?? recordQ.data?.data; if (!rec) return; setPicked({ uuid: rec.uuid, user_name: rec.user_name, user_mobile: rec.user_mobile, user_national_code: rec.user_national_code ?? rec.profile?.national_code, }); setNationalCode(String(rec.user_national_code ?? rec.profile?.national_code ?? '').replace(/\D/g, '').slice(0, 10)); }, [recordQ.data]); // ── مشخصات سرویس const [sectionUuid, setSectionUuid] = useState(''); // بخشِ در حال مرور (برای دیدن سرویس‌هایش) // سرویس‌های انتخاب‌شده — انباشته از چند بخش؛ هر کدام قابل حذف. نام را نگه می‌داریم تا در chip نشان دهیم. const [selectedServices, setSelectedServices] = useState<{ uuid: string; name: string }[]>([]); const [staffUuid, setStaffUuid] = useState(''); const toggleServiceItem = (uuid: string, name: string) => setSelectedServices(prev => prev.some(s => s.uuid === uuid) ? prev.filter(s => s.uuid !== uuid) : [...prev, { uuid, name }]); const removeService = (uuid: string) => setSelectedServices(prev => prev.filter(s => s.uuid !== uuid)); 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') }); // ── روش نوبت‌دهی پزشک (سرویسی/اسلاتی) const { bookingMode, services } = useDoctorBookingServices(doctorUuid); const serviceMode = bookingMode === 'service'; const [servicePick, setServicePick] = useState<{ serviceUuids: string[]; durations: Record; slot: { start: number; end: number } | null }>({ serviceUuids: [], durations: {}, slot: null }); // ── زمان نوبت const [date, setDate] = useState(params.get('date') || today); // حالت اسلاتی: انتخاب از اسلات‌های واقعیِ برنامهٔ پزشک. ورود دستیِ ساعت فقط وقتی // باقی می‌ماند که پزشک برای آن روز اصلاً برنامه‌ای نداشته باشد (نوبت خارج از برنامه). const [slotPick, setSlotPick] = useState(null); const [manualTime, setManualTime] = useState(false); 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]); // تعویض پزشک/تاریخ ⇒ اسلات انتخاب‌شده دیگر معتبر نیست. useEffect(() => { setSlotPick(null); }, [doctorUuid, date]); // ── بیعانه / وضعیت / توضیحات const [depositRequired, setDepositRequired] = useState(false); const [depositToman, setDepositToman] = useState(0); const [note, setNote] = useState(''); // ── هزینه ویزیت — الزامی بودن از تنظیمات «الزامی کردن هزینه ویزیت» (کاربر بدون // پروفایل doctor/clinic از این endpoint 403 می‌گیرد → فلگ false و فیلد اختیاری می‌ماند) const pricingQ = useQuery<{ data: { free_visit_price_rials: number; require_visit_price: boolean } }>({ 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; // فیلد UI تومان است؛ API ریالی (visit_price_rials). const [visitPriceToman, setVisitPriceToman] = useState(0); const [visitPriceTouched, setVisitPriceTouched] = useState(false); useEffect(() => { if (!visitPriceTouched && freeVisit > 0) setVisitPriceToman(rialToToman(freeVisit)); }, [freeVisit, visitPriceTouched]); 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) : (manualTime ? (!!start && !!end) : !!slotPick); const valid = !!doctorUuid && !!date && effectiveName.length >= 2 && effectiveMobile.length >= 10 && effectiveNationalCode.length === 10 && timingValid && (!requireVisit || visitPriceToman > 0); const create = useMutation({ mutationFn: async () => { const createEndpoint = primaryRole === 'admin' ? '/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)); const payload: Record = { doctor_uuid: doctorUuid, slot_start: slotStart, slot_end: slotEnd, patient_name: effectiveName, patient_mobile: effectiveMobile, patient_national_code: effectiveNationalCode, ...(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 } : {}), ...(depositRequired ? { deposit_required: true, deposit_amount_rials: tomanToRial(depositToman) } : {}), ...(visitPriceToman > 0 ? { visit_price_rials: tomanToRial(visitPriceToman) } : {}), ...(note.trim() ? { note: note.trim() } : {}), }; // سرور نوبت را «ثبت‌شده» متولد می‌کند؛ این صفحه اما نوبتِ **قطعی** می‌سازد، پس // بلافاصله همان endpoint قطعی‌کردن صدا زده می‌شود (بدون پرداخت — پرداخت از // مودال «قطعی کردن» یا صفحهٔ پرداخت انجام می‌شود). const created: any = await api.post(createEndpoint, payload); const uuid = created?.data?.uuid ?? created?.data?.data?.uuid; if (!uuid) return created; try { await api.post(`/api/v1/appointment/${uuid}/confirm`, { payments: [] }); } catch (e: any) { // نوبت ثبت شده و اسلات را گرفته؛ فقط قطعی نشده. کاربر باید بداند. toast.error(e?.message || 'نوبت ثبت شد ولی قطعی نشد؛ از لیست نوبت‌ها قطعی کنید'); } return created; }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['appointments'] }); toast.success('نوبت قطعی ثبت شد'); // به همان روزِ نوبت برگرد، نه امروز. navigate(`/admin/appointments?date=${date}`); }, onError: (e: any) => toast.error(e.message || 'خطا در ثبت نوبت'), }); return (
{/* بردکرامب */}
نوبت ها ثبت نوبت جدید
{/* پزشک — فقط برای admin/clinic */} {!isDoctor && ( <>
پزشک:
setDoctorUuid(v ? String(v) : '')} placeholder="انتخاب پزشک..." isClearable height={44} />
)} {/* مراجعه کننده */}
اطلاعات مراجعه کننده :
{/* آمده از پروندهٔ بیمار: مراجعه‌کننده معلوم است، جستجو معنا ندارد. */} {fromRecordUuid ? (
{picked?.user_name ?? 'در حال بارگذاری پرونده...'} {picked?.user_mobile ?? ''}
{picked && !/^\d{10}$/.test(effectiveNationalCode) ? ( <>
) : (
کد ملی {effectiveNationalCode}
)}
) : ( <> {/* حالت جستجوی مراجعه‌کنندهٔ موجود */} {!newPatient && ( <>
{ setPicked(null); setPatientSearch(e.target.value); }} placeholder="جستجوی نام، شماره تماس، شماره پرونده..." />
{!picked && patients.length > 0 && (
{patients.map(p => ( ))}
)} {/* کارت بیمارِ انتخاب‌شده + کد ملی (برای ثبت نوبت الزامی است) */} {picked && (
{picked.user_name} {picked.user_mobile}
{/^\d{10}$/.test((picked.user_national_code ?? '').replace(/\D/g, '')) ? (
کد ملی {nationalCode}
) : ( <>
)}
)} )} {/* حالت ثبت مراجعه‌کنندهٔ جدید */} {newPatient && ( <>
مراجعه کننده جدید
setName(e.target.value)} placeholder="نام و نام خانوادگی مراجعه کننده" />
)} )} {/* مشخصات سرویس */}
مشخصات سرویس
{!serviceMode ? ( <>
({ value: o.uuid, label: o.name ?? '' }))} value={sectionUuid || null} onChange={v => setSectionUuid(v ? String(v) : '')} placeholder="ابتدا بخش را انتخاب کنید" isLoading={sectionsQ.isLoading} isClearable height={44} />
({ value: o.uuid, label: o.full_name ?? '' }))} value={staffUuid || null} onChange={v => setStaffUuid(v ? String(v) : '')} placeholder="انتخاب پرسنل" isLoading={staffQ.isLoading} isClearable height={44} />
{/* سرویس‌های بخشِ انتخاب‌شده — چند انتخابی، به لیست انباشته اضافه می‌شوند */} {sectionUuid && ( <> {itemsQ.isLoading ? (
در حال بارگذاری...
) : (itemsQ.data?.data ?? []).length === 0 ? (
سرویسی در این بخش تعریف نشده است.
) : (
{(itemsQ.data?.data ?? []).map(o => { const active = selectedServices.some(s => s.uuid === o.uuid); return ( ); })}
)} )} {/* لیستِ انباشتهٔ سرویس‌های انتخاب‌شده (از هر بخش) — قابل حذف */} {selectedServices.length > 0 && (
{selectedServices.map(s => ( {s.name} ))}
)} ) : (
({ value: o.uuid, label: o.full_name ?? '' }))} value={staffUuid || null} onChange={v => setStaffUuid(v ? String(v) : '')} placeholder="انتخاب پرسنل" isLoading={staffQ.isLoading} isClearable height={44} />
)} {/* زمان نوبت */}
زمان نوبت
{serviceMode ? ( <>
{doctorUuid ? ( ) : (
ابتدا پزشک را انتخاب کنید.
)}
) : ( <>
{!manualTime ? (
{doctorUuid ? ( ) : (
ابتدا پزشک را انتخاب کنید.
)}
) : ( <>
setDuration(Math.max(5, Number(digitsOnly(e.target.value)) || 0))} dir="ltr" />
setStart(e.target.value)} dir="ltr" />
setEnd(e.target.value)} dir="ltr" />
)} )} {/* بیعانه */}
بیعانه
{depositRequired && (
)} {/* هزینه ویزیت */}
هزینه ویزیت
{ setVisitPriceToman(v); setVisitPriceTouched(true); }} />
{requireVisit && visitPriceToman <= 0 && ( هزینه ویزیت الزامی است )}