diff --git a/assets/admin/App.tsx b/assets/admin/App.tsx index 6ab43496..cabe759c 100644 --- a/assets/admin/App.tsx +++ b/assets/admin/App.tsx @@ -8,6 +8,7 @@ import UsersPage from './pages/UsersPage'; import UserDetailPage from './pages/UserDetailPage'; import DoctorsPage from './pages/DoctorsPage'; import DoctorDetailPage from './pages/DoctorDetailPage'; +import DoctorFormPage from './pages/DoctorFormPage'; import ClinicsPage from './pages/ClinicsPage'; import ClinicDetailPage from './pages/ClinicDetailPage'; import AppointmentsPage from './pages/AppointmentsPage'; @@ -63,6 +64,7 @@ export default function App() { {/* Doctors */} } /> + } /> } /> {/* Clinics */} diff --git a/assets/admin/lib/api.ts b/assets/admin/lib/api.ts index 4b6025a0..7812e997 100644 --- a/assets/admin/lib/api.ts +++ b/assets/admin/lib/api.ts @@ -1,3 +1,5 @@ +import { useAuthStore } from '../stores/authStore'; + const BASE_URL = ''; function getToken(): string | null { @@ -35,6 +37,11 @@ async function request( const res = await fetch(`${BASE_URL}${path}`, { ...options, headers }); if (!res.ok) { + if (res.status === 401) { + useAuthStore.getState().logout(); + window.location.replace('/admin/login'); + throw new ApiError(401, 'ERR_UNAUTHORIZED', 'نشست منقضی شده است'); + } const body = await res.json().catch(() => ({})); const firstErr = body?.errors?.[0]; throw new ApiError( diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index 4e6216fb..c30376eb 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -1,110 +1,1144 @@ -import React from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; -import { ArrowRightIcon } from '@heroicons/react/24/outline'; +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; +import { useParams, useNavigate, useSearchParams } 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, + XCircleIcon, PhoneIcon, CalendarIcon, + ClipboardDocumentIcon, EllipsisVerticalIcon, StarIcon, + BuildingOfficeIcon, MapPinIcon, AcademicCapIcon, UserIcon, + PlusIcon, CameraIcon, ChevronDownIcon, ChevronRightIcon, + CheckCircleIcon, XMarkIcon, +} 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 L from 'leaflet'; +import 'leaflet/dist/leaflet.css'; import { api } from '../lib/api'; +import { useAuthStore } from '../stores/authStore'; import type { ApiResponse } from '../lib/api'; -import type { Doctor } from '../types'; -import { formatDate } from '../lib/utils'; -import PageHeader from '../components/ui/PageHeader'; -import { ActiveBadge } from '../components/ui/StatusBadge'; +import { formatNumber } from '../lib/utils'; +import Modal from '../components/ui/Modal'; +import ConfirmDialog from '../components/ui/ConfirmDialog'; -function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { +// Fix leaflet default marker 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', +}); + +// ── Types ────────────────────────────────────────────────────────────────── + +interface DoctorDetail { + id: string; uuid: string; name: string; gender: string | null; + experience: number; activity_time: string | null; medical_system_code: string | null; + detail: string | null; degree: string | null; active: boolean; + specialties: { id: string; uuid: string; name: string; parent_id: string | null }[]; + img: { url: string; fid: number }[]; + expertise: { id: string; uuid: string; name: string }[]; + satisfaction: string; point: string; + address: AddressData[]; + state: { id: string; name: string }[]; + city: { id: string; name: string }[]; + clinics: { id: string; uuid: string; name: string; address: string | null; telephone: string | null }[]; +} + +interface SpecialtyOpt { id: number; uuid: string; name: string; parent_id: number | null; } +interface ServiceOpt { id: number; uuid: string; name: string; specialty_id: number | null; } +interface ProvinceOpt { id: number; uuid: string; name: string; } +interface CityOpt { id: number; uuid: string; name: string; } +interface ImageFileData { fid: number; uuid: string; url: string; filename: string; filemime: string; filesize: number; } + +interface AddressData { + 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; +} + +// ── Constants ────────────────────────────────────────────────────────────── + +const DEGREE_LABELS: Record = { + general: 'عمومی', specialist: 'متخصص', + expert: 'فوق تخصص', subspecialistplus: 'فلوشیپ', +}; +const DEGREE_OPTIONS = [ + { value: 'general', label: 'عمومی' }, + { value: 'specialist', label: 'متخصص' }, + { value: 'expert', label: 'فوق تخصص' }, + { value: 'subspecialistplus', label: 'فلوشیپ' }, +]; +const GENDER_OPTIONS = [{ value: 'man', label: 'مرد' }, { value: 'woman', label: 'زن' }]; +const AVATAR_COLORS = [ + 'from-blue-500 to-cyan-500', 'from-violet-500 to-purple-600', + 'from-emerald-500 to-teal-600', 'from-rose-500 to-pink-600', + 'from-amber-500 to-orange-600', +]; +const SPECIALTY_COLORS = [ + 'bg-red-100 dark:bg-red-400/10 text-red-700 dark:text-red-300', + 'bg-blue-100 dark:bg-blue-400/10 text-blue-700 dark:text-blue-300', + 'bg-emerald-100 dark:bg-emerald-400/10 text-emerald-700 dark:text-emerald-300', + 'bg-violet-100 dark:bg-violet-400/10 text-violet-700 dark:text-violet-300', + 'bg-orange-100 dark:bg-orange-400/10 text-orange-700 dark:text-orange-300', + 'bg-pink-100 dark:bg-pink-400/10 text-pink-700 dark:text-pink-300', +]; + +// ── Image upload ─────────────────────────────────────────────────────────── + +async function uploadDoctorImage(file: File): Promise { + const token = useAuthStore.getState().token; + const res = await fetch('/file/upload/clinic_pro/doctor/field_image', { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${file.name}"`, + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: file, + }); + const json = await res.json().catch(() => ({})); + if (res.status === 401) { + useAuthStore.getState().logout(); + window.location.replace('/admin/login'); + throw new Error('نشست منقضی شده است'); + } + if (!res.ok) throw new Error(json?.errors?.[0]?.message ?? 'خطا در آپلود تصویر'); + return json.data as ImageFileData; +} + +// ── Avatar with upload ───────────────────────────────────────────────────── + +function DoctorAvatar({ name, img, idx, onUpload, uploading }: { + name: string; img: string | null; idx: number; + onUpload: (f: File) => void; uploading: boolean; +}) { + const ref = useRef(null); + const initials = name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2) || 'Dr'; return ( -
- {label} - {value ?? '—'} +
ref.current?.click()}> + {img + ? {name} + :
{initials}
+ } +
+ {uploading + ? + : + } +
+ { const f = e.target.files?.[0]; if (f) onUpload(f); e.target.value = ''; }} />
); } -export default function DoctorDetailPage() { - const { uuid } = useParams<{ uuid: string }>(); - const navigate = useNavigate(); - - const { data, isLoading } = useQuery({ - queryKey: ['doctor', uuid], - queryFn: () => api.get>(`/api/v1/doctor/${uuid}`), - enabled: !!uuid, - }); - - const doctor = data?.data; +// ── Info Card ────────────────────────────────────────────────────────────── +function InfoCard({ icon: Icon, label, value, mono = false, copyable = false }: { + icon: React.ElementType; label: string; value: React.ReactNode; + mono?: boolean; copyable?: boolean; +}) { return ( -
- navigate('/admin/doctors')} - className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-100 transition-colors" - > - - بازگشت - - } - /> - - {isLoading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( -
- ))} -
- ) : doctor ? ( -
-
-
- {doctor.profile_image ? ( - - ) : ( -
- {doctor.first_name?.[0] ?? '?'} -
- )} -
-

دکتر {doctor.first_name} {doctor.last_name}

-

{doctor.degree}

-
-
- {doctor.medical_code}} /> - - } /> - -
- -
-

تخصص‌ها

-
- {doctor.specialties?.length ? ( - doctor.specialties.map((s) => ( - - {s.name} - - )) - ) : ( - تخصصی ثبت نشده - )} -
- - {doctor.bio && ( -
-

بیوگرافی

-

{doctor.bio}

-
- )} -
-
- ) : ( -
- پزشکی یافت نشد -
+
+
+ +
+
+

{label}

+

+ {value ?? ثبت نشده} +

+
+ {copyable && typeof value === 'string' && ( + )}
); } + +function StarRating({ rate }: { rate: number }) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => + i < Math.round(rate) + ? + : + )} + {rate.toFixed(1)} +
+ ); +} + +// ── Map Picker ───────────────────────────────────────────────────────────── + +const IRAN_CENTER: [number, number] = [32.4279, 53.6880]; + +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 }: { + lat: number | null; lng: number | null; + onChange: (lat: number, lng: number) => void; +}) { + const pos: [number, number] | null = lat !== null && lng !== null ? [lat, lng] : null; + const center: [number, number] = pos ?? IRAN_CENTER; + return ( +
+ + + + {pos && } + +
+ ); +} + +// ── Hierarchical Specialty Picker ────────────────────────────────────────── + +function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: { + selected: number[]; onChange: (ids: number[]) => void; + specialties: SpecialtyOpt[]; +}) { + const [q, setQ] = useState(''); + const [openParents, setOpenParents] = useState>(new Set()); + + const parents = useMemo(() => specialties.filter(s => s.parent_id === null), [specialties]); + const childMap = useMemo(() => { + const m = new Map(); + specialties.filter(s => s.parent_id !== null).forEach(s => { + const arr = m.get(s.parent_id!) ?? []; + arr.push(s); + m.set(s.parent_id!, arr); + }); + return m; + }, [specialties]); + + const filteredFlat = useMemo(() => q ? specialties.filter(s => s.name.includes(q)) : [], [q, specialties]); + + const toggleParent = (id: number) => setOpenParents(prev => { + const n = new Set(prev); + n.has(id) ? n.delete(id) : n.add(id); + return n; + }); + const toggleSelect = (id: number) => + onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]); + + const selectedLabels = useMemo( + () => selected.map(id => ({ id, name: specialties.find(s => s.id === id)?.name ?? '' })).filter(x => x.name), + [selected, specialties] + ); + + return ( +
+
+ setQ(e.target.value)} placeholder="جستجوی تخصص..." + className="cp-input text-sm h-8" /> +
+ {selectedLabels.length > 0 && ( +
+ {selectedLabels.map(({ id, name }) => ( + + {name} + + + ))} +
+ )} +
+ {q ? ( + filteredFlat.length > 0 + ? filteredFlat.map(s => ( + + )) + :

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

+ ) : ( + parents.map(parent => { + const children = childMap.get(parent.id) ?? []; + const isOpen = openParents.has(parent.id); + const numSel = children.filter(c => selected.includes(c.id)).length; + return ( +
+
children.length > 0 ? toggleParent(parent.id) : toggleSelect(parent.id)}> + {children.length > 0 + ? (isOpen + ? + : ) + : + } + 0 ? 'text-primary-600 dark:text-primary-400' : 'text-slate-700 dark:text-slate-300'}`}> + {parent.name} + + {numSel > 0 && ( + + {numSel} انتخاب + + )} +
+ {isOpen && children.map(child => ( + + ))} +
+ ); + }) + )} +
+
+ ); +} + +// ── Services Picker — filtered by selected specialties ───────────────────── + +function ServicesPicker({ selected, onChange, services, specialties, selectedSpecialtyIds }: { + selected: number[]; onChange: (ids: number[]) => void; + services: ServiceOpt[]; specialties: SpecialtyOpt[]; selectedSpecialtyIds: number[]; +}) { + const [q, setQ] = useState(''); + const [activeTab, setActiveTab] = useState('all'); + + // Only services whose specialty is currently selected + const relevantServices = useMemo(() => { + if (selectedSpecialtyIds.length === 0) return []; + return services.filter(s => s.specialty_id !== null && selectedSpecialtyIds.includes(s.specialty_id)); + }, [services, selectedSpecialtyIds]); + + // Specialties that actually have services + const tabs = useMemo(() => { + const specIds = [...new Set(relevantServices.map(s => s.specialty_id!))]; + return specIds.map(id => ({ id, name: specialties.find(s => s.id === id)?.name ?? String(id) })); + }, [relevantServices, specialties]); + + const visibleServices = useMemo(() => { + let pool = activeTab === 'all' ? relevantServices : relevantServices.filter(s => s.specialty_id === activeTab); + return q ? pool.filter(s => s.name.includes(q)) : pool; + }, [activeTab, relevantServices, q]); + + const toggle = (id: number) => + onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]); + + const selectedLabels = useMemo( + () => selected.map(id => ({ id, name: services.find(s => s.id === id)?.name ?? '' })).filter(x => x.name), + [selected, services] + ); + + if (selectedSpecialtyIds.length === 0) { + return ( +
+ ابتدا تخصص‌هایی را انتخاب کنید تا خدمات نمایش داده شوند +
+ ); + } + + if (relevantServices.length === 0) { + return ( +
+ خدمتی برای تخصص‌های انتخاب‌شده یافت نشد +
+ ); + } + + return ( +
+ {tabs.length > 1 && ( +
+ {[{ id: 'all' as const, name: 'همه' }, ...tabs].map(t => ( + + ))} +
+ )} +
+ setQ(e.target.value)} placeholder="جستجوی خدمت..." + className="cp-input text-sm h-8" /> +
+ {selectedLabels.length > 0 && ( +
+ {selectedLabels.map(({ id, name }) => ( + + {name} + + + ))} +
+ )} +
+ {visibleServices.map(svc => ( + + ))} + {visibleServices.length === 0 && q && ( +

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

+ )} +
+
+ ); +} + +// ── Address Modal ────────────────────────────────────────────────────────── + +const addrSchema = z.object({ + name: z.string().optional(), + address: z.string().optional(), + telephone: z.string().max(20).optional(), + province_id: z.number().nullable().optional(), + city_id: z.number().nullable().optional(), + latitude: z.string().optional(), + longitude: z.string().optional(), +}); +type AddrForm = z.infer; + +function AddressModal({ open, onClose, existing, doctorUuid, onSaved }: { + open: boolean; onClose: () => void; + existing: AddressData | null; doctorUuid: string; + onSaved: () => void; +}) { + const { register, handleSubmit, watch, setValue, reset } = useForm({ + resolver: zodResolver(addrSchema), + }); + const provinceId = watch('province_id'); + const latStr = watch('latitude'); + const lngStr = watch('longitude'); + const lat = latStr ? parseFloat(latStr) : null; + const lng = lngStr ? parseFloat(lngStr) : null; + + const provincesQ = useQuery({ + queryKey: ['provinces'], + queryFn: () => api.get>('/api/v1/provinces'), + staleTime: 600_000, enabled: open, + }); + const citiesQ = useQuery({ + queryKey: ['cities', provinceId], + queryFn: () => api.get>(`/api/v1/cities?province_id=${provinceId}`), + enabled: open && !!provinceId, staleTime: 300_000, + }); + + const provinces: ProvinceOpt[] = useMemo( + () => provincesQ.data?.data?.data ?? provincesQ.data?.data ?? [], [provincesQ.data]); + const cities: CityOpt[] = useMemo( + () => citiesQ.data?.data?.data ?? citiesQ.data?.data ?? [], [citiesQ.data]); + + useEffect(() => { + if (open) { + reset({ + name: existing?.name ?? '', + address: existing?.address ?? '', + telephone: existing?.telephone ?? '', + province_id: existing?.province ? Number(existing.province.id) : null, + city_id: existing?.city ? Number(existing.city.id) : null, + latitude: existing?.map?.latitude ?? '', + longitude: existing?.map?.longitude ?? '', + }); + } + }, [open, existing, reset]); + + const saveMut = useMutation({ + mutationFn: (values: AddrForm) => { + const body: Record = { + name: values.name || undefined, + address: values.address || undefined, + telephone: values.telephone || undefined, + province_id: values.province_id ?? undefined, + city_id: values.city_id ?? undefined, + latitude: values.latitude ? parseFloat(values.latitude) : undefined, + longitude: values.longitude ? parseFloat(values.longitude) : undefined, + }; + if (existing) return api.patch>(`/api/v1/clinic-pro/doctor-address/${existing.id}`, body); + return api.post>('/api/v1/clinic-pro/doctor-address', { ...body, doctor_uuid: doctorUuid }); + }, + onSuccess: () => { toast.success(existing ? 'آدرس ویرایش شد' : 'آدرس اضافه شد'); onSaved(); onClose(); }, + onError: (e: Error) => toast.error(e.message), + }); + + return ( + + + + + } + > +
saveMut.mutate(v))} className="space-y-4"> +
+
+ + +
+
+ +