import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate, useParams, Link } from 'react-router-dom'; import { ChevronRightIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import PersianDateInput from '../components/ui/PersianDateInput'; import PriceInput from '../components/ui/PriceInput'; import SearchableSelect from '../components/ui/SearchableSelect'; import { WalletChargeLink } from '../components/AppointmentActions'; import { formatRial, rialToToman, tomanToRial } from '../lib/utils'; import { DEFAULT_SERVICE_CATEGORY } from '../lib/insuranceShares'; import BackButton from '../components/ui/BackButton'; import { useAppointmentInsurance } from '../hooks/useAppointmentInsurance'; import { useDoctorBookingServices } from '../hooks/useDoctorBookingServices'; import ServiceSlotPicker from '../components/appointments/ServiceSlotPicker'; import type { PickedService, ServicePick } from '../components/appointments/ServiceSlotPicker'; interface Option { uuid: string; name?: string; full_name?: string } interface AppointmentDetail { uuid: string; slot_start: number; slot_end: number; status: string; version: number; note?: string | null; patient_mobile?: string | null; user?: { uuid: string; mobile: string } | null; deposit_required?: boolean; deposit_amount_rials?: number | null; service_section?: Option | null; service_item?: Option | null; staff?: Option | null; visit_price_rials?: number | null; service_items?: { uuid: string; name?: string; price_rials?: number | null; service_category?: string | null; insurance_covered?: boolean }[] | null; insurance_service_category?: string | null; insurance_base_id?: number | null; doctor?: { uuid: string; name?: string } | null; /** null = مطب شخصی. مبنای تشخیص روش نوبت‌دهیِ همین نوبت، نه محیط جاری پنل. */ clinic_uuid?: string | null; is_reserve?: boolean; service_total_minutes?: number | null; service_buffer_minutes?: number | null; } const isoDate = (ts: number) => { const d = new Date(ts * 1000); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; const isoTime = (ts: number) => { const d = new Date(ts * 1000); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; }; const toEpoch = (date: string, time: string) => Math.floor(new Date(`${date}T${time || '00:00'}`).getTime() / 1000); /** ویرایش نوبت (Figma edit.pdf) — full-page edit of service specs, timing, deposit, status and notes. */ export default function AppointmentEditPage() { const { uuid = '' } = useParams(); const navigate = useNavigate(); const qc = useQueryClient(); const { data, isLoading } = useQuery>({ queryKey: ['appointment-edit', uuid], queryFn: () => api.get(`/api/v1/appointment/${uuid}`), enabled: !!uuid, }); const a = data?.data?.data; const [sectionUuid, setSectionUuid] = useState(''); const [itemUuid, setItemUuid] = useState(''); const [staffUuid, setStaffUuid] = useState(''); const [date, setDate] = useState(''); const [start, setStart] = useState(''); const [end, setEnd] = useState(''); const [depositRequired, setDepositRequired] = useState(false); const [depositToman, setDepositToman] = useState(0); const [status, setStatus] = useState(''); const [note, setNote] = useState(''); const [serviceCategory, setServiceCategory] = useState(''); const [insuranceId, setInsuranceId] = useState(''); // hydrate once the appointment arrives useEffect(() => { if (!a) return; setSectionUuid(a.service_section?.uuid ?? ''); setItemUuid(a.service_item?.uuid ?? ''); setStaffUuid(a.staff?.uuid ?? ''); setDate(isoDate(a.slot_start)); setStart(isoTime(a.slot_start)); setEnd(isoTime(a.slot_end)); setDepositRequired(!!a.deposit_required); setDepositToman(rialToToman(a.deposit_amount_rials ?? 0)); setStatus(a.status); setNote(a.note ?? ''); setServiceCategory(a.insurance_service_category ?? ''); setInsuranceId(a.insurance_base_id ? String(a.insurance_base_id) : ''); }, [a]); const insurance = useAppointmentInsurance(!!uuid); // روش نوبت‌دهی از برنامهٔ **همین نوبت** پرسیده می‌شود (clinic_uuid صریح)، نه از محیط // جاری پنل: یک پزشک می‌تواند در مطب اسلاتی و در کلینیک سرویسی باشد. const { bookingMode, services } = useDoctorBookingServices( a?.doctor?.uuid, a ? (a.clinic_uuid ?? null) : undefined, ); // نوبت رزرو زمان ندارد؛ انتخابگر زمان برایش بی‌معناست. const serviceMode = bookingMode === 'service' && !a?.is_reserve; const [pick, setPick] = useState(null); // سرویس‌های فعلی نوبت، برای هیدریت اولیهٔ انتخابگر. مدتِ هر سرویس از تعریف خودش // خوانده می‌شود، نه از تقسیم مدت کل — تقسیم یک حدس است و override منشی را جعل می‌کند. // سرویسی که دیگر bookable نیست در فهرست services نمی‌آید و اینجا هم رد می‌شود؛ // پس فهرست پس از انتخابگر ممکن است کوتاه‌تر از نوبت باشد و کاربر باید ببیند. const initialSelection = useMemo(() => { if (!a?.service_items?.length || services.length === 0) return []; return a.service_items.flatMap((s) => { const known = services.find((b) => b.uuid === s.uuid); return known ? [{ uuid: known.uuid, name: known.name, section: known.service_section.name, duration: known.duration_minutes ?? 0, }] : []; }); }, [a?.service_items, services]); const droppedServices = (a?.service_items?.length ?? 0) - initialSelection.length; // نوع خدمتِ مؤثر: انتخاب نوبت، وگرنه تنها نوع فعالِ tenant (همان قاعدهٔ سرور). const effectiveCategory = serviceCategory || insurance.defaultCategory || DEFAULT_SERVICE_CATEGORY; const shares = insurance.breakdown([ // نوبتِ بدون هزینهٔ ویزیت، «قیمت ویزیت آزاد» تنظیمات را می‌گیرد (مثل سرور). { total: insurance.visitPriceOf(a?.visit_price_rials), category: effectiveCategory, insured: true }, ...(a?.service_items ?? []).map((s) => ({ total: Number(s.price_rials ?? 0), category: s.service_category ?? DEFAULT_SERVICE_CATEGORY, insured: s.insurance_covered !== false, })), ], insuranceId); 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 save = useMutation({ mutationFn: async () => { // حالت سرویسی: زمان و سرویس‌ها با endpoint سرویس‌آگاه می‌روند — کلاینت مدت // نمی‌فرستد. بقیهٔ فیلدها (بیعانه، بیمه، وضعیت، یادداشت) همان PATCH قبلی. if (serviceMode) { await api.post(`/api/v1/appointment/${uuid}/service-reschedule`, { start: pick!.slot!.start, service_item_uuids: pick!.serviceUuids, durations: pick!.durations, version: a?.version, }); } return api.patch(`/api/v1/appointment/${uuid}`, { ...(serviceMode ? {} : { slot_start: toEpoch(date, start), slot_end: toEpoch(date, end), service_item_uuid: itemUuid, }), service_section_uuid: sectionUuid, staff_uuid: staffUuid, deposit_required: depositRequired, deposit_amount_rials: depositRequired ? tomanToRial(depositToman) : null, note, insurance_service_category: serviceCategory || null, insurance_base_id: insuranceId ? Number(insuranceId) : null, ...(status !== a?.status ? { status } : {}), // نسخه پس از service-reschedule یک قدم جلو رفته؛ optimistic lock را دور نزن. ...(serviceMode ? {} : { version: a?.version }), }); }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['appointments'] }); toast.success('نوبت به‌روزرسانی شد'); // به همان روزِ نوبت برگرد، نه امروز. navigate(`/admin/appointments?date=${date}`); }, onError: (e: any) => toast.error(e.message || 'خطا در ذخیره اطلاعات'), }); const label = { fontSize: 12.5, color: 'var(--text-3)' } as const; if (isLoading || !a) return
در حال بارگذاری...
; const statusOptions: [string, string][] = [ ['pending', 'ثبت شده'], ['confirmed', 'قطعی شده'], ['following_up', 'در حال پیگیری'], ['salon', 'سالن'], ['completed', 'ویزیت شده'], ['cancelled_by_doctor', 'لغو شده'], ]; return (
مشخصات سرویس:
({ value: o.uuid, label: o.name ?? '' }))} value={sectionUuid || null} onChange={v => { setSectionUuid(v ? String(v) : ''); setItemUuid(''); }} placeholder="انتخاب بخش" isLoading={sectionsQ.isLoading} isClearable height={38} />
{/* در حالت سرویسی، انتخاب سرویس داخل ServiceSlotPicker است (چند-سرویسی و مدت‌دار)؛ نگه‌داشتن این SearchableSelect تکی یعنی دو منبع برای یک چیز. */} {!serviceMode && (
({ value: o.uuid, label: o.name ?? '' }))} value={itemUuid || null} onChange={v => setItemUuid(v ? String(v) : '')} placeholder="انتخاب سرویس" isDisabled={!sectionUuid} isLoading={itemsQ.isLoading} isClearable height={38} />
)}
({ value: o.uuid, label: o.full_name ?? '' }))} value={staffUuid || null} onChange={v => setStaffUuid(v ? String(v) : '')} placeholder="انتخاب پرسنل" isLoading={staffQ.isLoading} isClearable height={38} />
بیمه:
{/* نوع خدمت فقط وقتی چند نوع فعال است پرسیده می‌شود. */} {insurance.needsCategoryChoice && (
setServiceCategory(v ? String(v) : '')} placeholder="انتخاب نوع خدمت" isClearable height={38} />
)}
setInsuranceId(v ? String(v) : '')} placeholder="بدون بیمه" noOptionsMessage="قرارداد بیمهٔ فعالی ندارید" isClearable height={38} />
{insuranceId !== '' && (
{formatRial(shares.insurance)} / {formatRial(shares.patient)}
)}
زمان نوبت:
{/* حالت اسلاتی — دقیقاً همان سه فیلد قبلی، دست‌نخورده. */} {!serviceMode && (
setStart(e.target.value)} dir="ltr" />
setEnd(e.target.value)} dir="ltr" />
)} {/* حالت سرویسی — ورودی دستی ساعت پنهان است، نه disabled: فیلد غیرفعال یعنی کاربر فکر می‌کند باید کاری بکند. مدت را سرور از سرویس‌ها حساب می‌کند. */} {serviceMode && (
{droppedServices > 0 && (
{droppedServices} سرویس این نوبت دیگر برای نوبت‌دهی فعال نیست و در فهرست پایین نیامده است.
)} {date && ( )} {pick?.slot && (
زمان انتخابی: {isoTime(pick.slot.start)} {a.service_buffer_minutes ? ` (+${a.service_buffer_minutes} دقیقه فاصله)` : ''}
)}
)}
بیعانه:
{depositRequired && ( <>
)}
({ value: v, label: l }))} value={status || null} onChange={v => setStatus(v ? String(v) : '')} placeholder="انتخاب وضعیت" height={38} />