import React, { useState, useEffect, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { ArrowRightIcon, PencilIcon, TrashIcon, BuildingOffice2Icon, PhoneIcon, MapPinIcon, XMarkIcon, PlusIcon, CameraIcon, ChevronDownIcon, EnvelopeIcon, ArrowPathIcon, NoSymbolIcon, } from '@heroicons/react/24/outline'; import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import type { ClinicDetail } from '../types'; import { formatNumber } from '../lib/utils'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import NotificationMobileCard from '../components/ui/NotificationMobileCard'; import InviteDoctorModal from '../components/ui/InviteDoctorModal'; import { useAuthStore } from '../stores/authStore'; // Fix leaflet 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', }); const HUES_LIST = [256, 205, 162, 295, 272]; const IRAN_CENTER: [number, number] = [32.4279, 53.6880]; // ── Types ────────────────────────────────────────────────────────────────── interface ClinicInvitation { uuid: string; mobile: string; invited_name: string | null; invited_specialty: string | null; status: 'pending' | 'accepted' | 'rejected' | 'suspended' | 'removed'; token_used: boolean; invited_at: number; expires_at: number; responded_at: number | null; doctor: { uuid: string; name: string } | null; } interface ClinicDoctorItem { id: string; uuid: string; name: string; gender: string | null; degree: string | null; img: { url: string }[]; specialties: { id: string; name: string }[]; active: boolean; } interface Opt { id: number; name: string; } interface OptUuid { id: number; uuid: string; name: string; } // ── Edit form schema ─────────────────────────────────────────────────────── const editSchema = z.object({ name: z.string().min(1, 'نام الزامی است'), telephone: z.string().optional(), info: z.string().optional(), is_247: z.boolean(), address: z.string().optional(), province_id: z.number().nullable(), city_id: z.number().nullable(), latitude: z.string().optional(), longitude: z.string().optional(), specialties: z.array(z.number()), insurance: z.array(z.number()), doctor_services: z.array(z.number()), }); type EditForm = z.infer; // ── Searchable Select (template CSS) ────────────────────────────────────── function SearchableSelectField({ options, value, onChange, placeholder, disabled = false }: { options: Opt[]; value: number | null; onChange: (v: number | null, label?: string) => 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.name.includes(q)) : options, [options, q], ); const selected = useMemo(() => options.find(o => o.id === value) ?? null, [options, value]); const openDD = () => { 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="input" style={{ fontSize: 13 }} />
{filtered.map(o => ( ))} {filtered.length === 0 && (

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

)}
, document.body, )}
); } // ── Multi-select checkbox list ───────────────────────────────────────────── function MultiCheckList({ options, selected, onChange, placeholder }: { options: Opt[]; selected: number[]; onChange: (v: number[]) => void; placeholder?: string; }) { const [q, setQ] = useState(''); const filtered = useMemo(() => q ? options.filter(o => o.name.includes(q)) : options, [options, q]); const toggle = (id: number) => onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]); return (
setQ(e.target.value)} placeholder={placeholder ?? 'جستجو...'} className="input" style={{ fontSize: 13 }} />
{selected.length > 0 && (
{selected.map(id => { const opt = options.find(o => o.id === id); if (!opt) return null; return ( toggle(id)}> {opt.name} × ); })}
)}
{filtered.length === 0 ?

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

: filtered.map(o => ( ))}
); } // ── Leaflet helpers ──────────────────────────────────────────────────────── function MapClickHandler({ onPick }: { onPick: (lat: number, lng: number) => void }) { useMapEvents({ click: e => onPick(e.latlng.lat, e.latlng.lng) }); return null; } function MapFlyController({ target }: { target: [number, number] | null }) { const map = useMap(); useEffect(() => { if (target) map.flyTo(target, 12, { duration: 1.2 }); }, [target, 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 && }
); } // ── Geocode helper ───────────────────────────────────────────────────────── async function geocodeCity(name: string): Promise<[number, number] | null> { try { const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(name + ',ایران')}&format=json&countrycodes=ir&limit=1`; const res = await fetch(url, { headers: { 'Accept-Language': 'fa' } }); const d = await res.json(); if (d?.[0]) return [parseFloat(d[0].lat), parseFloat(d[0].lon)]; return null; } catch { return null; } } // ── Edit Modal ───────────────────────────────────────────────────────────── function EditModal({ clinic, onClose, onSaved, initialTab = 'basic' }: { clinic: ClinicDetail; onClose: () => void; onSaved: () => void; initialTab?: 'basic' | 'location' | 'tags'; }) { const [mapFlyTarget, setMapFlyTarget] = useState<[number, number] | null>(null); const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({ resolver: zodResolver(editSchema), defaultValues: { name: clinic.name ?? '', telephone: clinic.phone ?? clinic.phone_number ?? '', info: clinic.caption ?? '', is_247: clinic['24_7'] ?? false, address: clinic.location ?? '', province_id: clinic.state?.[0] ? Number(clinic.state[0].id) : null, city_id: clinic.city?.[0] ? Number(clinic.city[0].id) : null, latitude: clinic.map?.latitude ?? '', longitude: clinic.map?.longitude ?? '', specialties: (clinic.specialties ?? []).map(s => Number(s.id)), insurance: (clinic.list_bime ?? []).map(s => Number(s.id)), doctor_services: (clinic.services ?? []).map(s => Number(s.id)), }, }); const provinceId = watch('province_id'); const cityId = watch('city_id'); const lat = watch('latitude'); const lng = watch('longitude'); const latN = lat ? parseFloat(lat) : null; const lngN = lng ? parseFloat(lng) : null; const watchedSpec = (watch('specialties') ?? []) as number[]; const watchedIns = (watch('insurance') ?? []) as number[]; const watchedSrv = (watch('doctor_services') ?? []) as number[]; const provincesQ = useQuery({ queryKey: ['provinces'], staleTime: 600_000, queryFn: () => api.get>('/api/v1/provinces'), }); const citiesQ = useQuery({ queryKey: ['cities', provinceId], staleTime: 300_000, queryFn: () => api.get>(`/api/v1/cities?province_id=${provinceId}`), enabled: !!provinceId, }); const specialtiesQ = useQuery({ queryKey: ['specialties-all'], staleTime: 600_000, queryFn: () => api.get>('/api/v1/specialties'), }); const insurancesQ = useQuery({ queryKey: ['insurances-all'], staleTime: 600_000, queryFn: () => api.get>('/api/v1/insurances'), }); const servicesQ = useQuery({ queryKey: ['doctor-services-all'], staleTime: 600_000, queryFn: () => api.get>('/api/v1/doctor-services'), }); const provinces: Opt[] = useMemo(() => (provincesQ.data?.data?.data ?? provincesQ.data?.data ?? []).map((p: any) => ({ id: Number(p.id ?? p.nid), name: p.name })), [provincesQ.data]); const cities: Opt[] = useMemo(() => (citiesQ.data?.data?.data ?? citiesQ.data?.data ?? []).map((c: any) => ({ id: Number(c.id ?? c.nid), name: c.name })), [citiesQ.data]); const specialties: Opt[] = useMemo(() => { const raw = specialtiesQ.data?.data?.data ?? specialtiesQ.data?.data ?? []; return raw.map((s: any) => ({ id: Number(s.id), name: s.name })); }, [specialtiesQ.data]); const insurances: Opt[] = useMemo(() => { const raw = insurancesQ.data?.data?.data ?? insurancesQ.data?.data ?? []; return raw.map((i: any) => ({ id: Number(i.id), name: i.name })); }, [insurancesQ.data]); const services: Opt[] = useMemo(() => { const raw = servicesQ.data?.data?.data ?? servicesQ.data?.data ?? []; return raw.map((s: any) => ({ id: Number(s.id), name: s.name })); }, [servicesQ.data]); const saveMut = useMutation({ mutationFn: (values: EditForm) => api.patch>(`/api/v1/clinic/${clinic.uuid}`, { name: values.name, telephone: values.telephone || undefined, info: values.info || undefined, '24_7': values.is_247, address: values.address || undefined, state: values.province_id ? [values.province_id] : undefined, city: values.city_id ? [values.city_id] : undefined, latitude: values.latitude ? parseFloat(values.latitude) : undefined, longitude: values.longitude ? parseFloat(values.longitude) : undefined, specialties: values.specialties, insurance: values.insurance, doctor_services: values.doctor_services, }), onSuccess: () => { toast.success('اطلاعات کلینیک ذخیره شد'); onSaved(); onClose(); }, onError: (e: Error) => toast.error(e.message), }); const [activeTab, setActiveTab] = useState<'basic' | 'location' | 'tags'>(initialTab); return (
e.stopPropagation()}>
ویرایش کلینیک
{/* Tab switcher */}
{([['basic', 'اطلاعات پایه'], ['location', 'موقعیت'], ['tags', 'تخصص و بیمه']] as const).map(([id, label]) => ( ))}
saveMut.mutate(v))}>
{/* ── Basic tab ── */} {activeTab === 'basic' && ( <>
{errors.name &&
{errors.name.message}
}