diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index c30376eb..266f0b01 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -1,4 +1,5 @@ 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 } from 'react-hook-form'; @@ -14,7 +15,7 @@ import { } from '@heroicons/react/24/outline'; import { StarIcon as StarSolid } from '@heroicons/react/24/solid'; import { toast } from 'sonner'; -import { MapContainer, TileLayer, Marker, useMapEvents } from 'react-leaflet'; +import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { api } from '../lib/api'; @@ -182,14 +183,35 @@ function StarRating({ rate }: { rate: number }) { const IRAN_CENTER: [number, number] = [32.4279, 53.6880]; +async function geocodeCityInIran(cityName: string): Promise<[number, number] | null> { + try { + const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(cityName + ',ایران')}&format=json&countrycodes=ir&limit=1`; + 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)]; + return null; + } catch { + return null; + } +} + function MapClickHandler({ onPick }: { onPick: (lat: number, lng: number) => void }) { useMapEvents({ click: e => onPick(e.latlng.lat, e.latlng.lng) }); return null; } -function MapPicker({ lat, lng, onChange }: { +function MapController({ flyTarget }: { flyTarget: [number, number] | null }) { + const map = useMap(); + useEffect(() => { + if (flyTarget) 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; @@ -201,12 +223,101 @@ function MapPicker({ lat, lng, onChange }: { attribution='© OpenStreetMap' /> + {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 }: { @@ -430,10 +541,12 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: { 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'], @@ -495,40 +608,62 @@ function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: { } >
saveMut.mutate(v))} className="space-y-4"> + + {/* Row 1: Name + Phone */}
-
+
-
- -