import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { ArrowRightIcon, PencilIcon, TrashIcon, XCircleIcon, PhoneIcon, CalendarIcon, ClipboardDocumentIcon, EllipsisVerticalIcon, StarIcon, BuildingOfficeIcon, MapPinIcon, AcademicCapIcon, UserIcon, PlusIcon, CameraIcon, ChevronDownIcon, ChevronRightIcon, ChevronLeftIcon, CheckCircleIcon, XMarkIcon, ExclamationTriangleIcon, HeartIcon, CheckIcon, IdentificationIcon, DocumentTextIcon, GlobeAltIcon, LockClosedIcon, } from '@heroicons/react/24/outline'; import { StarIcon as StarSolid } from '@heroicons/react/24/solid'; import { toast } from 'sonner'; import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { api, ApiError } from '../lib/api'; import { useAuthStore } from '../stores/authStore'; import type { ApiResponse } from '../lib/api'; import { formatNumber, iranMobileOptionalSchema, toDate, toGregorianDate } from '../lib/utils'; import MobileInput from '../components/ui/MobileInput'; import Modal from '../components/ui/Modal'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import NotificationMobileCard from '../components/ui/NotificationMobileCard'; import GlobalSearchableSelect from '../components/ui/SearchableSelect'; import PersianDatePicker from '../components/ui/PersianDatePicker'; import ImageCropModal from '../components/ImageCropModal'; import { ScheduleSection } from '../components/schedule/ScheduleSection'; import type { AddressData } from '../components/schedule/ScheduleSection'; import { latinDigitsField } from '../lib/forms'; // Fix leaflet default marker icons delete (L.Icon.Default.prototype as any)._getIconUrl; L.Icon.Default.mergeOptions({ iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png', iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', }); // ── Types ────────────────────────────────────────────────────────────────── interface DoctorDetail { id: string; uuid: string; name: string; gender: string | null; experience: number; activity_time: string | null; medical_system_code: string | null; detail: string | null; degree: string | null; active: boolean; specialties: { id: string; uuid: string; name: string; parent_id: string | null }[]; img: { url: string; fid: number }[]; expertise: { id: string; uuid: string; name: string }[]; satisfaction: string; point: string; social_media: { instagram: string | null; telegram: string | null; aparat: string | null; youtube: string | null; linkedin: string | null; } | null; address: AddressData[]; state: { id: string; name: string }[]; city: { id: string; name: string }[]; clinics: { id: string; uuid: string; name: string; address: string | null; telephone: string | null }[]; representation: { id: number; uuid: string; full_name: string | null } | null; } interface SpecialtyOpt { id: number; uuid: string; name: string; parent_id: number | null; } interface ServiceOpt { id: number; uuid: string; name: string; specialty_id: number | null; } interface ProvinceOpt { id: number; uuid: string; name: string; } interface CityOpt { id: number; uuid: string; name: string; } interface ImageFileData { fid: number; uuid: string; url: string; filename: string; filemime: string; filesize: number; } // ── Constants ────────────────────────────────────────────────────────────── const DEGREE_LABELS: Record = { general: 'عمومی', specialist: 'متخصص', expert: 'فوق تخصص', subspecialistplus: 'فلوشیپ', }; const DEGREE_OPTIONS = [ { value: 'general', label: 'عمومی' }, { value: 'specialist', label: 'متخصص' }, { value: 'expert', label: 'فوق تخصص' }, { value: 'subspecialistplus', label: 'فلوشیپ' }, ]; const GENDER_OPTIONS = [{ value: 'man', label: 'مرد' }, { value: 'woman', label: 'زن' }]; const AVATAR_COLORS = [ 'from-blue-500 to-cyan-500', 'from-violet-500 to-purple-600', 'from-emerald-500 to-teal-600', 'from-rose-500 to-pink-600', 'from-amber-500 to-orange-600', ]; const SPECIALTY_COLORS = [ 'bg-red-100 dark:bg-red-400/10 text-red-700 dark:text-red-300', 'bg-blue-100 dark:bg-blue-400/10 text-blue-700 dark:text-blue-300', 'bg-emerald-100 dark:bg-emerald-400/10 text-emerald-700 dark:text-emerald-300', 'bg-violet-100 dark:bg-violet-400/10 text-violet-700 dark:text-violet-300', 'bg-orange-100 dark:bg-orange-400/10 text-orange-700 dark:text-orange-300', 'bg-pink-100 dark:bg-pink-400/10 text-pink-700 dark:text-pink-300', ]; // ── Image upload ─────────────────────────────────────────────────────────── async function uploadDoctorImage(file: File): Promise { const token = useAuthStore.getState().token; const res = await fetch('/file/upload/clinic_pro/doctor/field_image', { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'Content-Disposition': `attachment; filename="${file.name}"`, ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: file, }); const json = await res.json().catch(() => ({})); if (res.status === 401) { useAuthStore.getState().logout(); window.location.replace('/admin/login'); throw new Error('نشست منقضی شده است'); } if (!res.ok) throw new Error(json?.errors?.[0]?.message ?? 'خطا در آپلود تصویر'); return json.data as ImageFileData; } // ── Avatar with upload ───────────────────────────────────────────────────── function DoctorAvatar({ name, img, idx, onUpload, uploading }: { name: string; img: string | null; idx: number; onUpload?: (f: File) => void; uploading: boolean; }) { const ref = useRef(null); const initials = name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2) || 'Dr'; return (
onUpload && ref.current?.click()} > {img ? {name} :
{initials}
} {onUpload && (
{uploading ? : }
)} {onUpload && ( { const f = e.target.files?.[0]; if (f) onUpload(f); e.target.value = ''; }} /> )}
); } // ── Info Card ────────────────────────────────────────────────────────────── function InfoCard({ icon: Icon, label, value, mono = false, copyable = false }: { icon: React.ElementType; label: string; value: React.ReactNode; mono?: boolean; copyable?: boolean; }) { return (

{label}

{value ?? ثبت نشده}

{copyable && typeof value === 'string' && ( )}
); } function StarRating({ rate }: { rate: number }) { return (
{Array.from({ length: 5 }).map((_, i) => i < Math.round(rate) ? : )} {rate.toFixed(1)}
); } // ── Map Picker ───────────────────────────────────────────────────────────── const IRAN_CENTER: [number, number] = [32.4279, 53.6880]; async function geocodeCityInIran(cityName: string): Promise<[number, number] | null> { const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(cityName + ',ایران')}&format=json&countrycodes=ir&limit=1`; // nominatim گاهی روی اولین فراخوان خالی/۴۲۹ برمی‌گرداند؛ یک retry تا انتخاب اول هم کار کند. for (let attempt = 0; attempt < 2; attempt++) { try { const res = await fetch(url, { headers: { 'Accept-Language': 'fa' } }); const data = await res.json(); if (data?.[0]) return [parseFloat(data[0].lat), parseFloat(data[0].lon)]; } catch { /* تلاش بعدی */ } if (attempt === 0) await new Promise(r => setTimeout(r, 900)); } return null; } function MapClickHandler({ onPick }: { onPick: (lat: number, lng: number) => void }) { useMapEvents({ click: e => onPick(e.latlng.lat, e.latlng.lng) }); return null; } function MapController({ flyTarget }: { flyTarget: [number, number] | null }) { const map = useMap(); // نقشهٔ تازه‌مانت‌شده ابعادش را نگرفته؛ flyTo بی‌اثر می‌ماند تا invalidateSize صدا زده شود. useEffect(() => { map.invalidateSize(); }, [map]); useEffect(() => { if (!flyTarget) return; map.invalidateSize(); map.flyTo(flyTarget, 12, { duration: 1.2 }); }, [flyTarget, map]); return null; } function MapPicker({ lat, lng, onChange, flyTarget }: { lat: number | null; lng: number | null; onChange: (lat: number, lng: number) => void; flyTarget?: [number, number] | null; }) { const pos: [number, number] | null = lat !== null && lng !== null ? [lat, lng] : null; const center: [number, number] = pos ?? IRAN_CENTER; return (
{pos && }
); } // ── Searchable Select ────────────────────────────────────────────────────── function SearchableSelect({ options, value, onChange, placeholder, disabled = false }: { options: { value: number; label: string }[]; value: number | null; onChange: (value: number | null, label: string | null) => void; placeholder?: string; disabled?: boolean; }) { const [open, setOpen] = useState(false); const [q, setQ] = useState(''); const [rect, setRect] = useState(null); const btnRef = useRef(null); const dropRef = useRef(null); const filtered = useMemo( () => q ? options.filter(o => o.label.includes(q)) : options, [options, q] ); const selected = useMemo(() => options.find(o => o.value === value) ?? null, [options, value]); const openDropdown = () => { if (disabled || !btnRef.current) return; setRect(btnRef.current.getBoundingClientRect()); setOpen(v => !v); setQ(''); }; useEffect(() => { if (!open) return; const onDown = (e: MouseEvent) => { if ( dropRef.current && !dropRef.current.contains(e.target as Node) && btnRef.current && !btnRef.current.contains(e.target as Node) ) setOpen(false); }; document.addEventListener('mousedown', onDown); return () => document.removeEventListener('mousedown', onDown); }, [open]); const dropStyle: React.CSSProperties = rect ? { position: 'fixed', top: rect.bottom + 4, left: rect.left, width: rect.width, zIndex: 9999 } : {}; return (
{open && createPortal(
setQ(e.target.value)} placeholder="جستجو..." className="cp-input text-sm h-8" />
{filtered.map(o => ( ))} {filtered.length === 0 && (

نتیجه‌ای یافت نشد

)}
, document.body )}
); } // ── Hierarchical Specialty Picker ────────────────────────────────────────── function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: { selected: number[]; onChange: (ids: number[]) => void; specialties: SpecialtyOpt[]; }) { const [q, setQ] = useState(''); const [openParents, setOpenParents] = useState>(new Set()); const parents = useMemo(() => specialties.filter(s => s.parent_id === null), [specialties]); const childMap = useMemo(() => { const m = new Map(); specialties.filter(s => s.parent_id !== null).forEach(s => { const arr = m.get(s.parent_id!) ?? []; arr.push(s); m.set(s.parent_id!, arr); }); return m; }, [specialties]); const filteredFlat = useMemo(() => q ? specialties.filter(s => s.name.includes(q)) : [], [q, specialties]); const toggleParent = (id: number) => setOpenParents(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; }); const toggleSelect = (id: number) => onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]); const selectedLabels = useMemo( () => selected.map(id => ({ id, name: specialties.find(s => s.id === id)?.name ?? '' })).filter(x => x.name), [selected, specialties] ); return (
setQ(e.target.value)} placeholder="جستجوی تخصص..." className="cp-input text-sm h-8" />
{selectedLabels.length > 0 && (
{selectedLabels.map(({ id, name }) => ( {name} ))}
)}
{q ? ( filteredFlat.length > 0 ? filteredFlat.map(s => ( )) :

نتیجه‌ای یافت نشد

) : ( parents.map(parent => { const children = childMap.get(parent.id) ?? []; const isOpen = openParents.has(parent.id); const numSel = children.filter(c => selected.includes(c.id)).length; return (
children.length > 0 ? toggleParent(parent.id) : toggleSelect(parent.id)}> {children.length > 0 ? (isOpen ? : ) : } 0 ? 'text-primary-600 dark:text-primary-400' : 'text-slate-700 dark:text-slate-300'}`}> {parent.name} {numSel > 0 && ( {numSel} انتخاب )}
{isOpen && children.map(child => ( ))}
); }) )}
); } // ── Services Picker — filtered by selected specialties ───────────────────── function ServicesPicker({ selected, onChange, services, specialties, selectedSpecialtyIds }: { selected: number[]; onChange: (ids: number[]) => void; services: ServiceOpt[]; specialties: SpecialtyOpt[]; selectedSpecialtyIds: number[]; }) { const [q, setQ] = useState(''); const [activeTab, setActiveTab] = useState('all'); // Only services whose specialty is currently selected const relevantServices = useMemo(() => { if (selectedSpecialtyIds.length === 0) return []; return services.filter(s => s.specialty_id !== null && selectedSpecialtyIds.includes(s.specialty_id)); }, [services, selectedSpecialtyIds]); // Specialties that actually have services const tabs = useMemo(() => { const specIds = [...new Set(relevantServices.map(s => s.specialty_id!))]; return specIds.map(id => ({ id, name: specialties.find(s => s.id === id)?.name ?? String(id) })); }, [relevantServices, specialties]); const visibleServices = useMemo(() => { let pool = activeTab === 'all' ? relevantServices : relevantServices.filter(s => s.specialty_id === activeTab); return q ? pool.filter(s => s.name.includes(q)) : pool; }, [activeTab, relevantServices, q]); const toggle = (id: number) => onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]); const selectedLabels = useMemo( () => selected.map(id => ({ id, name: services.find(s => s.id === id)?.name ?? '' })).filter(x => x.name), [selected, services] ); if (selectedSpecialtyIds.length === 0) { return (
ابتدا تخصص‌هایی را انتخاب کنید تا خدمات نمایش داده شوند
); } if (relevantServices.length === 0) { return (
خدمتی برای تخصص‌های انتخاب‌شده یافت نشد
); } return (
{tabs.length > 1 && (
{[{ id: 'all' as const, name: 'همه' }, ...tabs].map(t => ( ))}
)}
setQ(e.target.value)} placeholder="جستجوی خدمت..." className="cp-input text-sm h-8" />
{selectedLabels.length > 0 && (
{selectedLabels.map(({ id, name }) => ( {name} ))}
)}
{visibleServices.map(svc => ( ))} {visibleServices.length === 0 && q && (

نتیجه‌ای یافت نشد

)}
); } // ── Address Modal ────────────────────────────────────────────────────────── const addrSchema = z.object({ name: z.string().optional(), address: z.string().min(1, 'آدرس کامل اجباری است'), telephone: z.string().min(1, 'تلفن اجباری است').max(20), province_id: z.number().nullable().optional(), city_id: z.number().nullable().optional(), latitude: z.string().optional(), longitude: z.string().optional(), }).superRefine((data, ctx) => { if (!data.province_id) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'استان اجباری است', path: ['province_id'] }); } if (!data.city_id) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'شهر اجباری است', path: ['city_id'] }); } }); type AddrForm = z.infer; function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: { open: boolean; onClose: () => void; existing: AddressData | null; doctorUuid: string; onSaved: () => void; }) { const { register, handleSubmit, watch, setValue, reset, formState: { errors } } = useForm({ resolver: zodResolver(addrSchema), }); const provinceId = watch('province_id'); const cityId = watch('city_id'); const latStr = watch('latitude'); const lngStr = watch('longitude'); const lat = latStr ? parseFloat(latStr) : null; const lng = lngStr ? parseFloat(lngStr) : null; const [mapFlyTarget, setMapFlyTarget] = useState<[number, number] | null>(null); const provincesQ = useQuery({ queryKey: ['provinces'], queryFn: () => api.get>('/api/v1/provinces'), staleTime: 600_000, enabled: open, }); const citiesQ = useQuery({ queryKey: ['cities', provinceId], queryFn: () => api.get>(`/api/v1/cities?province_id=${provinceId}`), enabled: open && !!provinceId, staleTime: 300_000, }); const provinces: ProvinceOpt[] = useMemo( () => provincesQ.data?.data?.data ?? provincesQ.data?.data ?? [], [provincesQ.data]); const cities: CityOpt[] = useMemo( () => citiesQ.data?.data?.data ?? citiesQ.data?.data ?? [], [citiesQ.data]); useEffect(() => { if (open) { reset({ name: existing?.name ?? '', address: existing?.address ?? '', telephone: existing?.telephone ?? '', province_id: existing?.province ? Number(existing.province.id) : null, city_id: existing?.city ? Number(existing.city.id) : null, latitude: existing?.map?.latitude ?? '', longitude: existing?.map?.longitude ?? '', }); } }, [open, existing, reset]); const saveMut = useMutation({ mutationFn: (values: AddrForm) => { const body: Record = { name: values.name || undefined, address: values.address || undefined, telephone: values.telephone || undefined, province_id: values.province_id ?? undefined, city_id: values.city_id ?? undefined, latitude: values.latitude ? parseFloat(values.latitude) : undefined, longitude: values.longitude ? parseFloat(values.longitude) : undefined, }; if (existing) return api.patch>(`/api/v1/clinic-pro/doctor-address/${existing.id}`, body); return api.post>('/api/v1/clinic-pro/doctor-address', { ...body, doctor_uuid: doctorUuid }); }, onSuccess: () => { toast.success(existing ? 'آدرس ویرایش شد' : 'آدرس اضافه شد'); onSaved(); onClose(); }, onError: (e: Error) => toast.error(e.message), }); return ( } >
saveMut.mutate(v))} className="space-y-4"> {/* Row 1: Name + Phone */}
{errors.telephone &&

{errors.telephone.message}

}
{/* Row 2: Full address */}