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, EyeIcon, } 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 ClinicAddress { id: string; uuid: string; name: string | null; address: string | null; telephone: string | null; map: { latitude: string | null; longitude: string | null }; city: { id: string; name: string } | null; province: { id: string; name: string } | null; } 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(), 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, initialCenter }: { lat: number | null; lng: number | null; onChange: (lat: number, lng: number) => void; flyTarget?: [number, number] | null; initialCenter?: [number, number] | null; }) { const pos: [number, number] | null = lat !== null && lng !== null ? [lat, lng] : null; const center: [number, number] = initialCenter ?? pos ?? IRAN_CENTER; const zoom = initialCenter ?? pos ? 13 : 5; 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 }: { clinic: ClinicDetail; onClose: () => void; onSaved: () => void; }) { 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, 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 watchedSpec = (watch('specialties') ?? []) as number[]; const watchedIns = (watch('insurance') ?? []) as number[]; const watchedSrv = (watch('doctor_services') ?? []) as number[]; 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 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, 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' | 'tags'>('basic'); return (
e.stopPropagation()}>
ویرایش کلینیک
{/* Tab switcher */}
{([['basic', 'اطلاعات پایه'], ['tags', 'تخصص و بیمه']] as const).map(([id, label]) => ( ))}
saveMut.mutate(v))}>
{/* ── Basic tab ── */} {activeTab === 'basic' && ( <>
{errors.name &&
{errors.name.message}
}