import React, { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { MagnifyingGlassIcon, PlusIcon, EllipsisVerticalIcon, EyeIcon, PencilIcon, TrashIcon, ArrowPathIcon, UserGroupIcon, CheckCircleIcon, XCircleIcon, StarIcon, TableCellsIcon, Squares2X2Icon, FunnelIcon, ChevronDownIcon, } from '@heroicons/react/24/outline'; import { StarIcon as StarSolid } from '@heroicons/react/24/solid'; import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import { formatDate, formatNumber } from '../lib/utils'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import Pagination from '../components/ui/Pagination'; // ── Types ───────────────────────────────────────────────────────────────── interface AdminDoctor { uuid: string; id: number; name: string; gender: string | null; degree: string | null; medical_code: string | null; mobile: string | null; email: string | null; is_active: boolean; rate: number; specialties: { id: number; name: string }[]; profile_image: string | null; created_at: string; } interface DoctorStats { total: number; active: number; inactive: number; male: number; female: number; top_specialty: { name: string; cnt: number }[]; } interface SpecialtyOption { id: number; uuid: string; name: string; } // ── Constants ───────────────────────────────────────────────────────────── const DEGREE_META: Record = { general: { label: 'عمومی', cls: 'bg-blue-100 dark:bg-blue-400/10 text-blue-700 dark:text-blue-300' }, specialist: { label: 'متخصص', cls: 'bg-violet-100 dark:bg-violet-400/10 text-violet-700 dark:text-violet-300' }, expert: { label: 'فوق تخصص', cls: 'bg-pink-100 dark:bg-pink-400/10 text-pink-700 dark:text-pink-300' }, subspecialistplus: { label: 'فلوشیپ', cls: 'bg-amber-100 dark:bg-amber-400/10 text-amber-700 dark:text-amber-300' }, }; 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', 'bg-teal-100 dark:bg-teal-400/10 text-teal-700 dark:text-teal-300', 'bg-indigo-100 dark:bg-indigo-400/10 text-indigo-700 dark:text-indigo-300', ]; 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', 'from-indigo-500 to-blue-600', ]; const LIMIT_OPTIONS = [10, 25, 50, 100]; // ── Sub-components ──────────────────────────────────────────────────────── function DoctorAvatar({ name, id, image, size = 'sm' }: { name: string; id: number; image: string | null; size?: 'sm' | 'lg'; }) { const initials = name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2) || 'Dr'; const sz = size === 'lg' ? 'w-14 h-14 text-lg' : 'w-9 h-9 text-xs'; if (image) return {name}; return (
{initials}
); } function SpecialtyBadge({ name, idx }: { name: string; idx: number }) { return ( {name} ); } function DegreeBadge({ degree }: { degree: string | null }) { if (!degree) return null; const meta = DEGREE_META[degree] ?? { label: degree, cls: 'bg-slate-100 dark:bg-slate-400/10 text-slate-600 dark:text-slate-400' }; return {meta.label}; } function ActiveBadge({ active }: { active: boolean }) { return active ? ( فعال ) : ( غیرفعال ); } function StarRating({ rate }: { rate: number }) { return (
{Array.from({ length: 5 }).map((_, i) => ( i < Math.round(rate) ? : ))}
); } function highlight(text: string | null | undefined, q: string): React.ReactNode { if (!text || !q.trim()) return text ?? '—'; const idx = text.toLowerCase().indexOf(q.toLowerCase()); if (idx === -1) return text; return ( <> {text.slice(0, idx)} {text.slice(idx, idx + q.length)} {text.slice(idx + q.length)} ); } function KpiCard({ label, value, icon: Icon, iconBg, iconColor, sub }: { label: string; value: number | undefined; icon: React.ElementType; iconBg: string; iconColor: string; sub?: string; }) { return (

{label}

{value === undefined ?
:

{formatNumber(value)}

} {sub &&

{sub}

}
); } // ── Specialty Dropdown Filter ────────────────────────────────────────────── function SpecialtyFilter({ value, onChange, specialties }: { value: string; onChange: (v: string) => void; specialties: SpecialtyOption[]; }) { const [open, setOpen] = useState(false); const [q, setQ] = useState(''); const filtered = useMemo( () => specialties.filter(s => s.name.includes(q)), [specialties, q] ); const selected = specialties.find(s => String(s.id) === value); useEffect(() => { if (!open) return; const close = (e: MouseEvent) => { if ((e.target as HTMLElement).closest('[data-specfilter]') === null) setOpen(false); }; document.addEventListener('mousedown', close); return () => document.removeEventListener('mousedown', close); }, [open]); return (
{open && (
setQ(e.target.value)} placeholder="جستجوی تخصص..." className="cp-input text-sm h-8" data-specfilter />
{filtered.map(s => ( ))} {filtered.length === 0 &&

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

}
)}
); } // ── Card View ───────────────────────────────────────────────────────────── function DoctorCard({ doctor, selected, onSelect, onView, onEdit, onToggle, onDelete, search, togglePending }: { doctor: AdminDoctor; selected: boolean; onSelect: () => void; onView: () => void; onEdit: () => void; onToggle: () => void; onDelete: () => void; search: string; togglePending: boolean; }) { const [menuOpen, setMenuOpen] = useState(false); useEffect(() => { if (!menuOpen) return; const close = (e: MouseEvent) => { if ((e.target as HTMLElement).closest('[data-cardmenu]') === null) setMenuOpen(false); }; document.addEventListener('mousedown', close); return () => document.removeEventListener('mousedown', close); }, [menuOpen]); return (
{/* Top row */}

دکتر {highlight(doctor.name, search)}

{doctor.degree && }
{/* menu */}
{menuOpen && (
)}
{/* Specialties */} {doctor.specialties.length > 0 && (
{doctor.specialties.slice(0, 3).map((s, i) => )} {doctor.specialties.length > 3 && ( +{doctor.specialties.length - 3} )}
)} {/* Info row */}
{doctor.mobile ?? '—'} {formatDate(doctor.created_at)}
); } // ── Main Page ───────────────────────────────────────────────────────────── export default function DoctorsPage() { const navigate = useNavigate(); const qc = useQueryClient(); const [page, setPage] = useState(1); const [limit, setLimit] = useState(25); const [searchInput, setSearch] = useState(''); const [search, setDebouncedSearch] = useState(''); const [status, setStatus] = useState(''); const [gender, setGender] = useState(''); const [specialtyId, setSpecialty] = useState(''); const [sort, setSort] = useState('newest'); const [view, setView] = useState<'table' | 'card'>('table'); const [selected, setSelected] = useState([]); const [activeMenu, setActiveMenu] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [confirmBulkDel, setConfirmBulkDel] = useState(false); const [bulkDeleting, setBulkDeleting] = useState(false); // Debounce useEffect(() => { const t = setTimeout(() => { setDebouncedSearch(searchInput); setPage(1); }, 350); return () => clearTimeout(t); }, [searchInput]); useEffect(() => { setPage(1); setSelected([]); }, [status, gender, specialtyId, sort, limit]); // Close table row menu on outside click useEffect(() => { if (!activeMenu) return; const close = (e: MouseEvent) => { if ((e.target as HTMLElement).closest('[data-rowmenu]') === null) setActiveMenu(null); }; document.addEventListener('mousedown', close); return () => document.removeEventListener('mousedown', close); }, [activeMenu]); // ── Queries ── const statsQ = useQuery({ queryKey: ['doctors-stats'], queryFn: () => api.get>('/api/v1/admin/doctors/stats'), staleTime: 30_000, }); const specialtiesQ = useQuery({ queryKey: ['specialties-list'], queryFn: () => api.get>('/api/v1/specialties'), staleTime: 300_000, }); const doctorsQ = useQuery({ queryKey: ['admin-doctors', page, limit, search, status, gender, specialtyId, sort], queryFn: () => { const p = new URLSearchParams({ page: String(page), limit: String(limit), sort }); if (search) p.set('search', search); if (status) p.set('status', status); if (gender) p.set('gender', gender); if (specialtyId) p.set('specialty_id', specialtyId); return api.get>(`/api/v1/admin/doctors?${p}`); }, }); const stats: DoctorStats | undefined = useMemo( () => (statsQ.data?.data as any)?.data ?? statsQ.data?.data, [statsQ.data] ); const items: AdminDoctor[] = doctorsQ.data?.data ?? []; const total = doctorsQ.data?.meta?.totalRecords ?? 0; const specialties: SpecialtyOption[] = useMemo( () => (specialtiesQ.data?.data as any)?.data ?? specialtiesQ.data?.data ?? [], [specialtiesQ.data] ); // ── Mutations ── const deleteMut = useMutation({ mutationFn: (uuid: string) => api.delete>(`/api/v1/doctor/${uuid}`), onSuccess: () => { toast.success('پزشک حذف شد'); setDeleteTarget(null); qc.invalidateQueries({ queryKey: ['admin-doctors'] }); qc.invalidateQueries({ queryKey: ['doctors-stats'] }); }, onError: (e: Error) => toast.error(e.message), }); const toggleMut = useMutation({ mutationFn: (uuid: string) => api.post>(`/api/v1/admin/doctors/${uuid}/status`, {}), onSuccess: () => { toast.success('وضعیت پزشک تغییر کرد'); qc.invalidateQueries({ queryKey: ['admin-doctors'] }); qc.invalidateQueries({ queryKey: ['doctors-stats'] }); }, onError: (e: Error) => toast.error(e.message), }); // ── Bulk ── const allSelected = items.length > 0 && items.every(d => selected.includes(d.uuid)); const toggleAll = () => setSelected(allSelected ? [] : items.map(d => d.uuid)); const toggleOne = (uuid: string) => setSelected(prev => prev.includes(uuid) ? prev.filter(id => id !== uuid) : [...prev, uuid]); const handleBulkDelete = async () => { setBulkDeleting(true); let count = 0; for (const uuid of selected) { try { await api.delete(`/api/v1/doctor/${uuid}`); count++; } catch {} } setBulkDeleting(false); setConfirmBulkDel(false); setSelected([]); toast.success(`${formatNumber(count)} پزشک حذف شد`); qc.invalidateQueries({ queryKey: ['admin-doctors'] }); qc.invalidateQueries({ queryKey: ['doctors-stats'] }); }; const hasFilters = !!(search || status || gender || specialtyId); const clearFilters = () => { setSearch(''); setDebouncedSearch(''); setStatus(''); setGender(''); setSpecialty(''); }; // ── KPI ── const kpiCards = [ { label: 'کل پزشکان', value: stats?.total, icon: UserGroupIcon, iconBg: 'bg-slate-100 dark:bg-slate-400/10', iconColor: 'text-slate-600 dark:text-slate-400' }, { label: 'فعال', value: stats?.active, icon: CheckCircleIcon, iconBg: 'bg-green-100 dark:bg-green-400/10', iconColor: 'text-green-600 dark:text-green-400' }, { label: 'غیرفعال', value: stats?.inactive, icon: XCircleIcon, iconBg: 'bg-slate-100 dark:bg-slate-400/10', iconColor: 'text-slate-500 dark:text-slate-400' }, { label: 'مرد', value: stats?.male, icon: UserGroupIcon, iconBg: 'bg-blue-100 dark:bg-blue-400/10', iconColor: 'text-blue-600 dark:text-blue-400' }, { label: 'زن', value: stats?.female, icon: UserGroupIcon, iconBg: 'bg-pink-100 dark:bg-pink-400/10', iconColor: 'text-pink-600 dark:text-pink-400' }, ]; return (
{/* ── Header ─────────────────────────────────────────────── */}

پزشکان

مدیریت پزشکان، تخصص‌ها، کلینیک‌ها و وضعیت همکاری

{/* ── KPI Cards ──────────────────────────────────────────── */}
{kpiCards.map(c => )}
{/* ── Main Card ──────────────────────────────────────────── */}
{/* Toolbar */}
{/* Row 1 */}
{/* Search */}
setSearch(e.target.value)} placeholder="نام پزشک، موبایل یا ایمیل..." className="cp-input pr-9" /> {searchInput && ( )}
{/* Specialty dropdown */} {/* Status */} {/* Gender */} {/* Sort */} {/* Limit */} {/* View toggle */}
{/* Refresh */}
{/* Active filter chips */} {hasFilters && (
فیلترهای فعال: {search && ( جستجو: {search} )} {status && ( وضعیت: {status === '1' ? 'فعال' : 'غیرفعال'} )} {gender && ( جنسیت: {gender === 'man' ? 'مرد' : 'زن'} )} {specialtyId && ( تخصص: {specialties.find(s => String(s.id) === specialtyId)?.name} )}
)}
{/* Bulk actions */} {selected.length > 0 && (
{formatNumber(selected.length)} پزشک انتخاب شده
)} {/* ── TABLE VIEW ─────────────────────────────────────── */} {view === 'table' && (
{/* Skeleton */} {doctorsQ.isLoading && Array.from({ length: 8 }).map((_, i) => ( ))} {/* Empty state */} {!doctorsQ.isLoading && items.length === 0 && ( )} {/* Data rows */} {!doctorsQ.isLoading && items.map(doctor => ( {/* Checkbox */} {/* Avatar + Name */} {/* Specialties */} {/* Degree */} {/* Mobile */} {/* Status */} {/* Rating */} {/* Date */} {/* Actions */} ))}
پزشک تخصص‌ها درجه موبایل وضعیت امتیاز ثبت

{hasFilters ? 'نتیجه‌ای برای فیلترهای انتخابی یافت نشد' : 'هیچ پزشکی ثبت نشده'}

{hasFilters ? : }
e.stopPropagation()}> toggleOne(doctor.uuid)} className="rounded border-slate-300 dark:border-gray-600 text-primary-600 cursor-pointer" />
navigate(`/admin/doctors/${doctor.uuid}`)}>

دکتر {highlight(doctor.name, search)}

{doctor.gender === 'man' ? '♂ مرد' : doctor.gender === 'woman' ? '♀ زن' : '—'}

{doctor.specialties.slice(0, 2).map((s, i) => )} {doctor.specialties.length > 2 && ( +{doctor.specialties.length - 2} )} {doctor.specialties.length === 0 && }
{highlight(doctor.mobile, search) || '—'} {formatDate(doctor.created_at)} e.stopPropagation()}>
{activeMenu === doctor.uuid && (
)}
)} {/* ── CARD VIEW ──────────────────────────────────────── */} {view === 'card' && (
{doctorsQ.isLoading ? (
{Array.from({ length: 6 }).map((_, i) => (
))}
) : items.length === 0 ? (

{hasFilters ? 'نتیجه‌ای یافت نشد' : 'هیچ پزشکی ثبت نشده'}

{hasFilters && }
) : (
{items.map(doctor => ( toggleOne(doctor.uuid)} onView={() => navigate(`/admin/doctors/${doctor.uuid}`)} onEdit={() => navigate(`/admin/doctors/${doctor.uuid}?edit=1`)} onToggle={() => toggleMut.mutate(doctor.uuid)} onDelete={() => setDeleteTarget(doctor)} search={search} togglePending={toggleMut.isPending} /> ))}
)}
)} {/* Pagination */}
{/* Delete confirm */} deleteTarget && deleteMut.mutate(deleteTarget.uuid)} onCancel={() => setDeleteTarget(null)} /> {/* Bulk delete confirm */} setConfirmBulkDel(false)} />
); }