Files
clinicpro/assets/admin/pages/DoctorDetailPage.tsx
T
hamed 25701c09b7 feat(admin): add user and doctor management APIs and frontend form
- Updated security configuration to include new API route for file uploads.
- Added new endpoints in AdminApiController for user statistics, toggling user status, updating user roles, and managing user details.
- Implemented doctor statistics and management endpoints, including toggling doctor status and creating new doctors.
- Enhanced user listing with filtering options for roles and status.
- Introduced DoctorFormPage component for adding new doctors with specialties selection.
- Integrated react-leaflet for mapping functionalities and added necessary dependencies.
- Updated package.json and package-lock.json to include new dependencies.
2026-06-10 17:02:42 +03:30

1145 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { formatNumber } from '../lib/utils';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
// 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<string, string> = {
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<ImageFileData> {
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<HTMLInputElement>(null);
const initials = name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2) || 'Dr';
return (
<div className="relative shrink-0 group cursor-pointer" onClick={() => ref.current?.click()}>
{img
? <img src={img} alt={name} className="w-24 h-24 rounded-2xl object-cover shadow-xl ring-4 ring-white dark:ring-gray-900" />
: <div className={`w-24 h-24 rounded-2xl bg-gradient-to-br ${AVATAR_COLORS[idx % AVATAR_COLORS.length]} flex items-center justify-center text-white text-3xl font-bold shadow-xl ring-4 ring-white dark:ring-gray-900`}>{initials}</div>
}
<div className="absolute inset-0 rounded-2xl bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
{uploading
? <span className="w-6 h-6 border-2 border-white/40 border-t-white rounded-full animate-spin" />
: <CameraIcon className="w-7 h-7 text-white" />
}
</div>
<input ref={ref} type="file" accept="image/*" className="hidden"
onChange={e => { const f = e.target.files?.[0]; if (f) onUpload(f); e.target.value = ''; }} />
</div>
);
}
// ── 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 (
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-50 dark:bg-gray-800/60 hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors group">
<div className="w-9 h-9 rounded-lg bg-white dark:bg-gray-700 shadow-sm flex items-center justify-center shrink-0">
<Icon className="w-4 h-4 text-slate-500 dark:text-slate-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[11px] font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wide mb-0.5">{label}</p>
<p className={`text-sm font-medium text-slate-800 dark:text-slate-200 ${mono ? 'font-mono' : ''}`} dir={mono ? 'ltr' : undefined}>
{value ?? <span className="text-slate-400 font-normal">ثبت نشده</span>}
</p>
</div>
{copyable && typeof value === 'string' && (
<button onClick={() => { navigator.clipboard.writeText(value); toast.success('کپی شد'); }}
className="opacity-0 group-hover:opacity-100 w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-slate-600 transition-all shrink-0">
<ClipboardDocumentIcon className="w-3.5 h-3.5" />
</button>
)}
</div>
);
}
function StarRating({ rate }: { rate: number }) {
return (
<div className="flex items-center gap-1" dir="ltr">
{Array.from({ length: 5 }).map((_, i) =>
i < Math.round(rate)
? <StarSolid key={i} className="w-4 h-4 text-amber-400" />
: <StarIcon key={i} className="w-4 h-4 text-slate-300 dark:text-slate-600" />
)}
<span className="text-sm font-medium text-slate-600 dark:text-slate-400 mr-1">{rate.toFixed(1)}</span>
</div>
);
}
// ── 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 (
<div className="rounded-xl overflow-hidden border border-slate-200 dark:border-gray-700" style={{ height: 300 }}>
<MapContainer center={center} zoom={pos ? 13 : 5} style={{ height: '100%', width: '100%' }}>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://openstreetmap.org">OpenStreetMap</a>'
/>
<MapClickHandler onPick={onChange} />
{pos && <Marker position={pos} />}
</MapContainer>
</div>
);
}
// ── Hierarchical Specialty Picker ──────────────────────────────────────────
function HierarchicalSpecialtyPicker({ selected, onChange, specialties }: {
selected: number[]; onChange: (ids: number[]) => void;
specialties: SpecialtyOpt[];
}) {
const [q, setQ] = useState('');
const [openParents, setOpenParents] = useState<Set<number>>(new Set());
const parents = useMemo(() => specialties.filter(s => s.parent_id === null), [specialties]);
const childMap = useMemo(() => {
const m = new Map<number, SpecialtyOpt[]>();
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 (
<div className="border border-slate-200 dark:border-gray-700 rounded-xl overflow-hidden">
<div className="p-2 border-b border-slate-100 dark:border-gray-700 bg-slate-50 dark:bg-gray-800/60">
<input type="text" value={q} onChange={e => setQ(e.target.value)} placeholder="جستجوی تخصص..."
className="cp-input text-sm h-8" />
</div>
{selectedLabels.length > 0 && (
<div className="px-3 py-2 border-b border-slate-100 dark:border-gray-700 flex flex-wrap gap-1">
{selectedLabels.map(({ id, name }) => (
<span key={id} className="inline-flex items-center gap-1 text-xs px-2.5 py-0.5 rounded-full bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300">
{name}
<button type="button" onClick={() => toggleSelect(id)} className="hover:text-primary-900 dark:hover:text-primary-100 leading-none">×</button>
</span>
))}
</div>
)}
<div className="max-h-56 overflow-y-auto">
{q ? (
filteredFlat.length > 0
? filteredFlat.map(s => (
<label key={s.id} className="flex items-center gap-2.5 px-3 py-2 hover:bg-slate-50 dark:hover:bg-gray-800 cursor-pointer">
<input type="checkbox" checked={selected.includes(s.id)} onChange={() => toggleSelect(s.id)}
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 shrink-0" />
<span className="text-sm text-slate-700 dark:text-slate-300">{s.name}</span>
{s.parent_id !== null && (
<span className="text-[10px] text-slate-400 mr-auto truncate max-w-[100px]">
{specialties.find(p => p.id === s.parent_id)?.name}
</span>
)}
</label>
))
: <p className="text-xs text-slate-400 text-center py-4">نتیجه‌ای یافت نشد</p>
) : (
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 (
<div key={parent.id}>
<div className="flex items-center gap-2 px-3 py-2.5 hover:bg-slate-50 dark:hover:bg-gray-800 cursor-pointer select-none"
onClick={() => children.length > 0 ? toggleParent(parent.id) : toggleSelect(parent.id)}>
{children.length > 0
? (isOpen
? <ChevronDownIcon className="w-4 h-4 text-slate-400 shrink-0" />
: <ChevronRightIcon className="w-4 h-4 text-slate-400 shrink-0" />)
: <input type="checkbox" checked={selected.includes(parent.id)} readOnly
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 pointer-events-none shrink-0" />
}
<span className={`text-sm font-medium ${numSel > 0 ? 'text-primary-600 dark:text-primary-400' : 'text-slate-700 dark:text-slate-300'}`}>
{parent.name}
</span>
{numSel > 0 && (
<span className="mr-auto text-[10px] bg-primary-100 dark:bg-primary-500/20 text-primary-700 dark:text-primary-300 px-1.5 py-0.5 rounded-full">
{numSel} انتخاب
</span>
)}
</div>
{isOpen && children.map(child => (
<label key={child.id} className="flex items-center gap-2.5 px-3 py-2 pr-9 hover:bg-slate-50 dark:hover:bg-gray-800 cursor-pointer">
<input type="checkbox" checked={selected.includes(child.id)} onChange={() => toggleSelect(child.id)}
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 shrink-0" />
<span className="text-sm text-slate-600 dark:text-slate-400">{child.name}</span>
</label>
))}
</div>
);
})
)}
</div>
</div>
);
}
// ── 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<number | 'all'>('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 (
<div className="rounded-xl border border-dashed border-slate-300 dark:border-gray-600 py-8 text-center text-slate-400 dark:text-slate-500 text-sm">
ابتدا تخصص‌هایی را انتخاب کنید تا خدمات نمایش داده شوند
</div>
);
}
if (relevantServices.length === 0) {
return (
<div className="rounded-xl border border-dashed border-slate-300 dark:border-gray-600 py-8 text-center text-slate-400 dark:text-slate-500 text-sm">
خدمتی برای تخصص‌های انتخاب‌شده یافت نشد
</div>
);
}
return (
<div className="border border-slate-200 dark:border-gray-700 rounded-xl overflow-hidden">
{tabs.length > 1 && (
<div className="flex gap-1 px-2 py-2 border-b border-slate-100 dark:border-gray-700 bg-slate-50 dark:bg-gray-800/60 overflow-x-auto">
{[{ id: 'all' as const, name: 'همه' }, ...tabs].map(t => (
<button key={String(t.id)} type="button"
onClick={() => setActiveTab(t.id as number | 'all')}
className={`px-2.5 py-1 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${activeTab === t.id ? 'bg-primary-600 text-white' : 'text-slate-600 dark:text-slate-400 hover:bg-white dark:hover:bg-gray-700'}`}>
{t.name}
</button>
))}
</div>
)}
<div className="p-2 border-b border-slate-100 dark:border-gray-700">
<input type="text" value={q} onChange={e => setQ(e.target.value)} placeholder="جستجوی خدمت..."
className="cp-input text-sm h-8" />
</div>
{selectedLabels.length > 0 && (
<div className="px-3 py-2 border-b border-slate-100 dark:border-gray-700 flex flex-wrap gap-1">
{selectedLabels.map(({ id, name }) => (
<span key={id} className="inline-flex items-center gap-1 text-xs px-2.5 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-500/20 text-emerald-700 dark:text-emerald-300">
{name}
<button type="button" onClick={() => toggle(id)} className="hover:text-emerald-900 dark:hover:text-emerald-100 leading-none">×</button>
</span>
))}
</div>
)}
<div className="max-h-44 overflow-y-auto">
{visibleServices.map(svc => (
<label key={svc.id} className="flex items-center gap-2.5 px-3 py-2 hover:bg-slate-50 dark:hover:bg-gray-800 cursor-pointer">
<input type="checkbox" checked={selected.includes(svc.id)} onChange={() => toggle(svc.id)}
className="rounded border-slate-300 dark:border-gray-600 text-emerald-600 shrink-0" />
<span className="text-sm text-slate-700 dark:text-slate-300">{svc.name}</span>
</label>
))}
{visibleServices.length === 0 && q && (
<p className="text-xs text-slate-400 text-center py-4">نتیجه‌ای یافت نشد</p>
)}
</div>
</div>
);
}
// ── 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<typeof addrSchema>;
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<AddrForm>({
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<ApiResponse<any>>('/api/v1/provinces'),
staleTime: 600_000, enabled: open,
});
const citiesQ = useQuery({
queryKey: ['cities', provinceId],
queryFn: () => api.get<ApiResponse<any>>(`/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<string, unknown> = {
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<ApiResponse<any>>(`/api/v1/clinic-pro/doctor-address/${existing.id}`, body);
return api.post<ApiResponse<any>>('/api/v1/clinic-pro/doctor-address', { ...body, doctor_uuid: doctorUuid });
},
onSuccess: () => { toast.success(existing ? 'آدرس ویرایش شد' : 'آدرس اضافه شد'); onSaved(); onClose(); },
onError: (e: Error) => toast.error(e.message),
});
return (
<Modal open={open} title={existing ? 'ویرایش آدرس' : 'افزودن آدرس جدید'} size="lg" onClose={onClose}
footer={
<>
<button type="button" onClick={onClose} className="cp-btn-secondary px-5">لغو</button>
<button form="addr-form" type="submit" disabled={saveMut.isPending} className="cp-btn-primary">
{saveMut.isPending ? 'در حال ذخیره...' : 'ذخیره'}
</button>
</>
}
>
<form id="addr-form" onSubmit={handleSubmit(v => saveMut.mutate(v))} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">نام مطب / کلینیک</label>
<input type="text" className="cp-input" placeholder="مثال: کلینیک مهر" {...register('name')} />
</div>
<div className="sm:col-span-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">آدرس کامل</label>
<textarea rows={2} className="cp-input resize-none" placeholder="خیابان، کوچه، پلاک..." {...register('address')} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">تلفن</label>
<input type="text" dir="ltr" className="cp-input text-left" placeholder="021..." {...register('telephone')} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">استان</label>
<select className="cp-select h-11" value={provinceId ?? ''}
onChange={e => { setValue('province_id', e.target.value ? Number(e.target.value) : null); setValue('city_id', null); }}>
<option value="">انتخاب استان</option>
{provinces.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">شهر</label>
<select className="cp-select h-11" disabled={!provinceId}
{...register('city_id', { setValueAs: v => v ? Number(v) : null })}>
<option value="">انتخاب شهر</option>
{cities.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
</div>
{/* Map picker */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
موقعیت روی نقشه
</label>
{lat && lng && (
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono" dir="ltr">
{lat.toFixed(5)}, {lng.toFixed(5)}
</span>
)}
</div>
<p className="text-xs text-slate-400 dark:text-slate-500 mb-2">روی نقشه کلیک کنید تا موقعیت انتخاب شود</p>
<MapPicker lat={lat} lng={lng}
onChange={(lt, ln) => { setValue('latitude', String(lt)); setValue('longitude', String(ln)); }} />
{lat && lng && (
<button type="button"
onClick={() => { setValue('latitude', ''); setValue('longitude', ''); }}
className="mt-2 text-xs text-red-500 hover:underline flex items-center gap-1">
<XMarkIcon className="w-3 h-3" />حذف موقعیت
</button>
)}
</div>
</form>
</Modal>
);
}
// ── Address Card ───────────────────────────────────────────────────────────
function AddressCard({ addr, onEdit, onDelete }: {
addr: AddressData; onEdit: () => void; onDelete: () => void;
}) {
return (
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-50 dark:bg-gray-800/60 border border-slate-100 dark:border-gray-700 group">
<div className="w-9 h-9 rounded-lg bg-white dark:bg-gray-700 shadow-sm flex items-center justify-center shrink-0 mt-0.5">
<MapPinIcon className="w-4 h-4 text-slate-400" />
</div>
<div className="flex-1 min-w-0">
{addr.name && <p className="text-sm font-semibold text-slate-800 dark:text-slate-200 mb-0.5">{addr.name}</p>}
{addr.address && <p className="text-xs text-slate-600 dark:text-slate-400 leading-relaxed">{addr.address}</p>}
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-1.5">
{addr.telephone && (
<span className="text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1 font-mono" dir="ltr">
<PhoneIcon className="w-3 h-3" />{addr.telephone}
</span>
)}
{addr.province && (
<span className="text-xs text-slate-500 dark:text-slate-400">
{addr.province.name}{addr.city ? ` — ${addr.city.name}` : ''}
</span>
)}
{addr.map.latitude && addr.map.longitude && (
<span className="text-xs text-slate-400 dark:text-slate-500 flex items-center gap-1" dir="ltr">
<MapPinIcon className="w-3 h-3" />{parseFloat(addr.map.latitude).toFixed(4)}, {parseFloat(addr.map.longitude).toFixed(4)}
</span>
)}
</div>
</div>
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity shrink-0">
<button onClick={onEdit}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-500/10 transition-colors">
<PencilIcon className="w-3.5 h-3.5" />
</button>
<button onClick={onDelete}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">
<TrashIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}
// ── Edit schema ────────────────────────────────────────────────────────────
const editSchema = z.object({
name: z.string().min(2, 'نام حداقل ۲ کاراکتر'),
gender: z.enum(['man', 'woman']).optional().or(z.literal('')),
degree: z.string().optional().or(z.literal('')),
medical_system_code: z.string().max(30).optional().or(z.literal('')),
mobile_number: z.string().max(15).optional().or(z.literal('')),
info: z.string().max(2000).optional().or(z.literal('')),
specialties: z.array(z.number()).optional(),
services: z.array(z.number()).optional(),
});
type EditForm = z.infer<typeof editSchema>;
// ── Main Page ──────────────────────────────────────────────────────────────
export default function DoctorDetailPage() {
const { uuid } = useParams<{ uuid: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const qc = useQueryClient();
const [editOpen, setEditOpen] = useState(searchParams.get('edit') === '1');
const [deleteOpen, setDeleteOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [uploadingImg, setUploadingImg] = useState(false);
const [addrModalOpen, setAddrModalOpen] = useState(false);
const [editingAddr, setEditingAddr] = useState<AddressData | null>(null);
const [deletingAddrId, setDeletingAddrId] = useState<string | null>(null);
// ── Queries ──
const { data, isLoading, isError } = useQuery({
queryKey: ['doctor-detail', uuid],
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/doctor/${uuid}`),
enabled: !!uuid,
});
const specialtiesQ = useQuery({
queryKey: ['specialties-all'],
queryFn: () => api.get<ApiResponse<any>>('/api/v1/specialties'),
staleTime: 600_000,
});
const servicesQ = useQuery({
queryKey: ['doctor-services-all'],
queryFn: () => api.get<ApiResponse<any>>('/api/v1/doctor-services'),
staleTime: 600_000,
});
const doctor: DoctorDetail | undefined = useMemo(() => {
const raw = data?.data;
return (raw as any)?.data ?? raw;
}, [data]);
const specialties: SpecialtyOpt[] = useMemo(
() => specialtiesQ.data?.data?.data ?? specialtiesQ.data?.data ?? [], [specialtiesQ.data]);
const services: ServiceOpt[] = useMemo(
() => servicesQ.data?.data?.data ?? servicesQ.data?.data ?? [], [servicesQ.data]);
// ── Edit form ──
const { register, handleSubmit, reset, watch, setValue } = useForm<EditForm>({
resolver: zodResolver(editSchema),
});
const watchedSpecialties = (watch('specialties') ?? []) as number[];
const watchedServices = (watch('services') ?? []) as number[];
useEffect(() => {
if (doctor) {
reset({
name: doctor.name,
gender: (doctor.gender as any) ?? '',
degree: doctor.degree ?? '',
medical_system_code: doctor.medical_system_code ?? '',
mobile_number: '',
info: doctor.detail ?? '',
specialties: doctor.specialties.map(s => Number(s.id)),
services: doctor.expertise.map(s => Number(s.id)),
});
}
}, [doctor, reset]);
// Close menu on outside click
useEffect(() => {
if (!menuOpen) return;
const close = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('[data-dmenu]') === null) setMenuOpen(false);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [menuOpen]);
// ── Mutations ──
const updateMut = useMutation({
mutationFn: (body: EditForm) => api.patch<ApiResponse<any>>(`/api/v1/doctor/${uuid}`, {
title: body.name,
gender: body.gender || undefined,
degree: body.degree || undefined,
medical_system_code: body.medical_system_code || undefined,
mobile_number: body.mobile_number || undefined,
info: body.info || undefined,
specialties: body.specialties ?? [],
doctor_services: body.services ?? [],
}),
onSuccess: () => {
toast.success('اطلاعات پزشک بروزرسانی شد');
setEditOpen(false);
qc.invalidateQueries({ queryKey: ['doctor-detail', uuid] });
qc.invalidateQueries({ queryKey: ['admin-doctors'] });
},
onError: (e: Error) => toast.error(e.message),
});
const toggleMut = useMutation({
mutationFn: () => api.post<ApiResponse<any>>(`/api/v1/admin/doctors/${uuid}/status`, {}),
onSuccess: (res) => {
const isActive = (res?.data as any)?.is_active;
toast.success(isActive ? 'پزشک فعال شد' : 'پزشک غیرفعال شد');
qc.invalidateQueries({ queryKey: ['doctor-detail', uuid] });
qc.invalidateQueries({ queryKey: ['admin-doctors'] });
},
onError: (e: Error) => toast.error(e.message),
});
const deleteMut = useMutation({
mutationFn: () => api.delete<ApiResponse<null>>(`/api/v1/doctor/${uuid}`),
onSuccess: () => { toast.success('پزشک حذف شد'); navigate('/admin/doctors'); },
onError: (e: Error) => toast.error(e.message),
});
const deleteAddrMut = useMutation({
mutationFn: (id: string) => api.delete<ApiResponse<null>>(`/api/v1/clinic-pro/doctor-address/${id}`),
onSuccess: () => {
toast.success('آدرس حذف شد'); setDeletingAddrId(null);
qc.invalidateQueries({ queryKey: ['doctor-detail', uuid] });
},
onError: (e: Error) => toast.error(e.message),
});
// ── Image upload ──
const handleImageUpload = useCallback(async (file: File) => {
setUploadingImg(true);
try {
const fileData = await uploadDoctorImage(file);
await api.patch(`/api/v1/doctor/${uuid}`, { images: [fileData] });
toast.success('تصویر با موفقیت آپلود شد');
qc.invalidateQueries({ queryKey: ['doctor-detail', uuid] });
qc.invalidateQueries({ queryKey: ['admin-doctors'] });
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'خطا در آپلود تصویر');
} finally {
setUploadingImg(false);
}
}, [uuid, qc]);
// ── Loading / Error ──
if (isLoading) {
return (
<div className="animate-slide-up space-y-5">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg skeleton" /><div className="h-5 w-32 rounded skeleton" />
</div>
<div className="cp-card p-6">
<div className="flex items-start gap-5 mb-8">
<div className="w-24 h-24 rounded-2xl skeleton shrink-0" />
<div className="flex-1 space-y-2 pt-2">
<div className="h-7 w-48 rounded skeleton" />
<div className="flex gap-2 mt-2"><div className="h-5 w-16 rounded-full skeleton" /><div className="h-5 w-14 rounded-full skeleton" /></div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-16 rounded-xl skeleton" />)}
</div>
</div>
</div>
);
}
if (isError || !doctor) {
return (
<div className="animate-slide-up cp-card p-16 flex flex-col items-center gap-4 text-center">
<div className="w-16 h-16 rounded-2xl bg-red-100 dark:bg-red-400/10 flex items-center justify-center">
<XCircleIcon className="w-8 h-8 text-red-500" />
</div>
<p className="font-semibold text-slate-800 dark:text-slate-200">پزشک یافت نشد</p>
<button onClick={() => navigate('/admin/doctors')} className="cp-btn-secondary mt-2">
بازگشت به لیست پزشکان
</button>
</div>
);
}
const mainImage = doctor.img?.[0]?.url ?? null;
const idNum = parseInt(doctor.id ?? '0', 10);
const degreeLabel = DEGREE_LABELS[doctor.degree ?? ''] ?? doctor.degree ?? '—';
const rateNum = parseFloat(doctor.point ?? '3.5');
const addresses = doctor.address ?? [];
return (
<div className="animate-slide-up space-y-5">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<button onClick={() => navigate('/admin/doctors')}
className="flex items-center gap-1.5 hover:text-slate-800 dark:hover:text-slate-100 transition-colors">
<ArrowRightIcon className="w-4 h-4" />پزشکان
</button>
<span>/</span>
<span className="text-slate-700 dark:text-slate-300 font-medium">پروفایل پزشک</span>
</div>
{/* Profile Card */}
<div className="cp-card overflow-hidden">
<div className="h-28 bg-gradient-to-l from-blue-600/20 via-blue-500/10 to-transparent dark:from-blue-500/20" />
<div className="px-6 pb-6 -mt-12 flex flex-col sm:flex-row sm:items-end gap-4">
<div className="flex flex-col items-center gap-1.5">
<DoctorAvatar name={doctor.name} img={mainImage} idx={idNum}
onUpload={handleImageUpload} uploading={uploadingImg} />
<span className="text-[10px] text-slate-400 dark:text-slate-500">کلیک برای تغییر عکس</span>
</div>
<div className="flex-1 min-w-0 sm:mb-1">
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">دکتر {doctor.name}</h1>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
{/* Active status badge */}
{doctor.active
? <span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-400/10 text-emerald-700 dark:text-emerald-300">
<CheckCircleIcon className="w-3 h-3" />فعال
</span>
: <span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full bg-slate-100 dark:bg-gray-700 text-slate-500 dark:text-slate-400">
<XCircleIcon className="w-3 h-3" />غیرفعال
</span>
}
{doctor.degree && (
<span className="inline-flex text-xs font-medium px-2.5 py-0.5 rounded-full bg-blue-100 dark:bg-blue-400/10 text-blue-700 dark:text-blue-300">{degreeLabel}</span>
)}
{doctor.gender && (
<span className="text-xs text-slate-500 dark:text-slate-400">{doctor.gender === 'man' ? '♂ مرد' : '♀ زن'}</span>
)}
</div>
{doctor.specialties.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{doctor.specialties.map((s, i) => (
<span key={s.id} className={`text-[11px] font-medium px-2 py-0.5 rounded-full ${SPECIALTY_COLORS[i % SPECIALTY_COLORS.length]}`}>{s.name}</span>
))}
</div>
)}
{doctor.expertise.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{doctor.expertise.map(e => (
<span key={e.id} className="text-[11px] px-2 py-0.5 rounded-full bg-slate-100 dark:bg-gray-700 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-gray-600">{e.name}</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 sm:mb-1">
<button onClick={() => setEditOpen(true)} className="cp-btn-primary text-sm">
<PencilIcon className="w-4 h-4" />ویرایش
</button>
<div className="relative" data-dmenu>
<button data-dmenu onClick={() => setMenuOpen(!menuOpen)}
className="cp-btn-secondary h-10 w-10 flex items-center justify-center px-0">
<EllipsisVerticalIcon className="w-4 h-4" />
</button>
{menuOpen && (
<div className="absolute left-0 top-12 z-30 w-56 cp-card shadow-2xl border border-slate-200 dark:border-gray-700 py-1.5 animate-scale-in" data-dmenu>
<button onClick={() => { toggleMut.mutate(); setMenuOpen(false); }}
disabled={toggleMut.isPending}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-gray-800 disabled:opacity-50">
{doctor.active
? <><XCircleIcon className="w-4 h-4 text-orange-400 shrink-0" />غیرفعال کردن پزشک</>
: <><CheckCircleIcon className="w-4 h-4 text-emerald-500 shrink-0" />فعال کردن پزشک</>
}
</button>
<div className="border-t border-slate-100 dark:border-gray-700 my-1" />
<button onClick={() => { setDeleteOpen(true); setMenuOpen(false); }}
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10">
<TrashIcon className="w-4 h-4 shrink-0" />حذف این پزشک
</button>
</div>
)}
</div>
</div>
</div>
</div>
{/* Info Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
<div className="lg:col-span-2 space-y-5">
<div className="cp-card p-6 space-y-4">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">اطلاعات پایه</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<InfoCard icon={UserIcon} label="نام کامل" value={doctor.name} />
<InfoCard icon={AcademicCapIcon} label="کد نظام پزشکی" value={doctor.medical_system_code} mono copyable />
<InfoCard icon={CalendarIcon} label="سابقه (سال)" value={doctor.experience > 0 ? String(doctor.experience) : null} />
<InfoCard icon={StarIcon} label="امتیاز" value={<div dir="ltr"><StarRating rate={rateNum} /></div>} />
</div>
</div>
{doctor.detail && (
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">بیوگرافی</h2>
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">{doctor.detail}</p>
</div>
)}
{/* Addresses */}
<div className="cp-card p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">
آدرس‌های مطب ({formatNumber(addresses.length)})
</h2>
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }}
className="flex items-center gap-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline">
<PlusIcon className="w-4 h-4" />افزودن آدرس
</button>
</div>
{addresses.length > 0 ? (
<div className="space-y-2">
{addresses.map(addr => (
<AddressCard key={addr.id} addr={addr}
onEdit={() => { setEditingAddr(addr); setAddrModalOpen(true); }}
onDelete={() => setDeletingAddrId(addr.id)}
/>
))}
</div>
) : (
<div className="text-center py-8 text-slate-400 dark:text-slate-500">
<MapPinIcon className="w-10 h-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">هنوز آدرسی ثبت نشده است</p>
<button onClick={() => { setEditingAddr(null); setAddrModalOpen(true); }}
className="mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline">
اولین آدرس را اضافه کنید
</button>
</div>
)}
</div>
{doctor.clinics && doctor.clinics.length > 0 && (
<div className="cp-card p-6">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">
کلینیک‌های مرتبط ({formatNumber(doctor.clinics.length)})
</h2>
<div className="space-y-2">
{doctor.clinics.map(c => (
<div key={c.uuid} className="flex items-start gap-3 p-3 rounded-xl bg-slate-50 dark:bg-gray-800/60">
<div className="w-8 h-8 rounded-lg bg-white dark:bg-gray-700 shadow-sm flex items-center justify-center shrink-0">
<BuildingOfficeIcon className="w-4 h-4 text-slate-400" />
</div>
<div>
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">{c.name}</p>
{c.address && <p className="text-xs text-slate-500 mt-0.5 flex items-center gap-1"><MapPinIcon className="w-3 h-3" />{c.address}</p>}
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-5">
<div className="cp-card p-5">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">
تخصص‌ها ({formatNumber(doctor.specialties.length)})
</h2>
{doctor.specialties.length > 0
? <div className="flex flex-wrap gap-1.5">
{doctor.specialties.map((s, i) => (
<span key={s.id} className={`text-xs font-medium px-2.5 py-1 rounded-lg ${SPECIALTY_COLORS[i % SPECIALTY_COLORS.length]}`}>{s.name}</span>
))}
</div>
: <p className="text-sm text-slate-400">تخصصی ثبت نشده</p>
}
<button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline text-right">
ویرایش تخصص‌ها
</button>
</div>
<div className="cp-card p-5">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">
خدمات ({formatNumber(doctor.expertise.length)})
</h2>
{doctor.expertise.length > 0
? <div className="flex flex-wrap gap-1.5">
{doctor.expertise.map(e => (
<span key={e.id} className="text-xs px-2.5 py-1 rounded-lg bg-slate-100 dark:bg-gray-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-gray-700">{e.name}</span>
))}
</div>
: <p className="text-sm text-slate-400">خدمتی ثبت نشده</p>
}
<button onClick={() => setEditOpen(true)} className="w-full mt-3 text-xs text-primary-600 dark:text-primary-400 hover:underline text-right">
ویرایش خدمات
</button>
</div>
<div className="cp-card p-5">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">امتیاز و رضایت</h2>
<div className="space-y-3">
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">امتیاز کلی</p>
<div dir="ltr"><StarRating rate={rateNum} /></div>
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">درصد رضایت</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 rounded-full bg-slate-200 dark:bg-gray-700">
<div className="h-full rounded-full bg-amber-400 transition-all" style={{ width: `${doctor.satisfaction}%` }} />
</div>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{doctor.satisfaction}%</span>
</div>
</div>
</div>
</div>
<div className="cp-card p-5">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">UUID</h2>
<div className="flex items-center gap-2">
<p className="text-[11px] font-mono text-slate-500 dark:text-slate-400 break-all flex-1" dir="ltr">{doctor.uuid}</p>
<button onClick={() => { navigator.clipboard.writeText(doctor.uuid); toast.success('UUID کپی شد'); }}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors shrink-0">
<ClipboardDocumentIcon className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</div>
{/* ── Edit Modal ─────────────────────────────────────────── */}
<Modal open={editOpen} title="ویرایش اطلاعات پزشک" size="lg" onClose={() => setEditOpen(false)}
footer={
<>
<button onClick={() => setEditOpen(false)} className="cp-btn-secondary px-5">لغو</button>
<button form="edit-doctor-form" type="submit" disabled={updateMut.isPending} className="cp-btn-primary">
{updateMut.isPending ? 'در حال ذخیره...' : 'ذخیره تغییرات'}
</button>
</>
}
>
<form id="edit-doctor-form" onSubmit={handleSubmit(v => updateMut.mutate(v))} className="space-y-5">
<div className="space-y-4">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide">اطلاعات پایه</p>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">نام کامل *</label>
<input type="text" className="cp-input" {...register('name')} />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">جنسیت</label>
<select className="cp-select h-11" {...register('gender')}>
<option value="">انتخاب کنید</option>
{GENDER_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">درجه تحصیلی</label>
<select className="cp-select h-11" {...register('degree')}>
<option value="">انتخاب کنید</option>
{DEGREE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">کد نظام پزشکی</label>
<input type="text" dir="ltr" className="cp-input text-left" {...register('medical_system_code')} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">شماره موبایل مطب</label>
<input type="text" dir="ltr" className="cp-input text-left" placeholder="09..." {...register('mobile_number')} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">بیوگرافی</label>
<textarea rows={3} className="cp-input resize-none" {...register('info')} />
</div>
</div>
<div className="space-y-2 border-t border-slate-100 dark:border-gray-700 pt-4">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide">
تخصص‌ها
{watchedSpecialties.length > 0 && (
<span className="text-primary-600 dark:text-primary-400 mr-2 normal-case font-normal">
({formatNumber(watchedSpecialties.length)} انتخاب)
</span>
)}
</p>
<HierarchicalSpecialtyPicker
selected={watchedSpecialties}
onChange={ids => setValue('specialties', ids)}
specialties={specialties}
/>
</div>
<div className="space-y-2 border-t border-slate-100 dark:border-gray-700 pt-4">
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wide">
خدمات
{watchedServices.length > 0 && (
<span className="text-emerald-600 dark:text-emerald-400 mr-2 normal-case font-normal">
({formatNumber(watchedServices.length)} انتخاب)
</span>
)}
</p>
<ServicesPicker
selected={watchedServices}
onChange={ids => setValue('services', ids)}
services={services}
specialties={specialties}
selectedSpecialtyIds={watchedSpecialties}
/>
</div>
</form>
</Modal>
{uuid && (
<AddressModal
open={addrModalOpen}
onClose={() => { setAddrModalOpen(false); setEditingAddr(null); }}
existing={editingAddr}
doctorUuid={uuid}
onSaved={() => qc.invalidateQueries({ queryKey: ['doctor-detail', uuid] })}
/>
)}
<ConfirmDialog
open={!!deletingAddrId} title="حذف آدرس" message="آیا از حذف این آدرس اطمینان دارید؟"
confirmLabel="بله، حذف کن" danger loading={deleteAddrMut.isPending}
onConfirm={() => deletingAddrId && deleteAddrMut.mutate(deletingAddrId)}
onCancel={() => setDeletingAddrId(null)}
/>
<ConfirmDialog
open={deleteOpen} title="حذف پزشک"
message={`آیا از حذف دکتر "${doctor.name}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
confirmLabel="بله، حذف کن" danger loading={deleteMut.isPending}
onConfirm={() => deleteMut.mutate()} onCancel={() => setDeleteOpen(false)}
/>
</div>
);
}