import React, { useState, useEffect, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; import { useParams, useNavigate } from 'react-router'; 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, } 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 } from '../lib/api'; import type { ClinicDetail } from '../types'; import { formatNumber } from '../lib/utils'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import Modal from '../components/ui/Modal'; import PageHeader from '../components/ui/PageHeader'; import SearchableSelect from '../components/ui/SearchableSelect'; import NotificationMobileCard from '../components/ui/NotificationMobileCard'; import ClinicDoctorsManager from '../components/ClinicDoctorsManager'; import { useAuthStore } from '../stores/authStore'; import { latinDigitsField } from '../lib/forms'; import Switch from '../components/ui/Switch'; // 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]; const MAX_GALLERY_IMAGES = 5; // ── Types ────────────────────────────────────────────────────────────────── 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 urlOrEmpty = z.string().refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'آدرس URL معتبر نیست' }); 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()), sm_instagram: urlOrEmpty, sm_telegram: urlOrEmpty, sm_aparat: urlOrEmpty, sm_youtube: urlOrEmpty, sm_linkedin: urlOrEmpty, }); type EditForm = z.infer; // ── 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 => (
toggle(o.id)} label={o.name} />
))}
); } // ── 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)), sm_instagram: clinic.social_media?.instagram ?? '', sm_telegram: clinic.social_media?.telegram ?? '', sm_aparat: clinic.social_media?.aparat ?? '', sm_youtube: clinic.social_media?.youtube ?? '', sm_linkedin: clinic.social_media?.linkedin ?? '', }, }); 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, social_media: { instagram: values.sm_instagram || null, telegram: values.sm_telegram || null, aparat: values.sm_aparat || null, youtube: values.sm_youtube || null, linkedin: values.sm_linkedin || null, }, }), onSuccess: () => { toast.success('اطلاعات کلینیک ذخیره شد'); onSaved(); onClose(); }, onError: (e: Error) => toast.error(e.message), }); const [activeTab, setActiveTab] = useState<'basic' | 'tags' | 'social'>('basic'); return createPortal(
e.stopPropagation()}>
ویرایش کلینیک
{/* Tab switcher */}
{([['basic', 'اطلاعات پایه'], ['tags', 'تخصص و بیمه'], ['social', 'شبکه‌های اجتماعی']] as const).map(([id, label]) => ( ))}
saveMut.mutate(v))}>
{/* ── Basic tab ── */} {activeTab === 'basic' && ( <>
{errors.name &&
{errors.name.message}
}