diff --git a/assets/admin/pages/ClinicDetailPage.tsx b/assets/admin/pages/ClinicDetailPage.tsx index 9d727910..4ccb1d89 100644 --- a/assets/admin/pages/ClinicDetailPage.tsx +++ b/assets/admin/pages/ClinicDetailPage.tsx @@ -1,84 +1,594 @@ -import React, { useEffect, useState } from 'react'; +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, - BuildingOffice2Icon, - PencilIcon, - CheckIcon, - XMarkIcon, + 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 { formatDate } from '../lib/utils'; +import type { ClinicDetail } from '../types'; +import { formatNumber } from '../lib/utils'; +import ConfirmDialog from '../components/ui/ConfirmDialog'; + +// 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]; -interface ClinicDetail { - uuid: string; - name: string; - is_active: boolean; - phone: string | null; - logo: string | null; - doctors_count?: number; - created_at?: number; +// ── Types ────────────────────────────────────────────────────────────────── + +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, + )} +
+ ); } -export default function ClinicDetailPage() { - const { uuid } = useParams<{ uuid: string }>(); - const navigate = useNavigate(); - const qc = useQueryClient(); +// ── Multi-select checkbox list ───────────────────────────────────────────── - const [editOpen, setEditOpen] = useState(false); - const [editName, setEditName] = useState(''); - const [editPhone, setEditPhone] = useState(''); +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 }: { + clinic: ClinicDetail; onClose: () => void; onSaved: () => void; +}) { + 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'>('basic'); + + 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}
} +
+
+ + +
+
+ +