Files
clinicpro/assets/admin/pages/DoctorsPage.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

895 lines
46 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 } 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<string, { label: string; cls: string }> = {
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 <img src={image} alt={name} className={`${sz} rounded-xl object-cover shrink-0`} />;
return (
<div className={`${sz} rounded-xl bg-gradient-to-br ${AVATAR_COLORS[id % AVATAR_COLORS.length]} flex items-center justify-center text-white font-bold shrink-0`}>
{initials}
</div>
);
}
function SpecialtyBadge({ name, idx }: { name: string; idx: number }) {
return (
<span className={`inline-flex text-[11px] font-medium px-2 py-0.5 rounded-full whitespace-nowrap ${SPECIALTY_COLORS[idx % SPECIALTY_COLORS.length]}`}>
{name}
</span>
);
}
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 <span className={`inline-flex text-[11px] font-medium px-2 py-0.5 rounded-full ${meta.cls}`}>{meta.label}</span>;
}
function ActiveBadge({ active }: { active: boolean }) {
return active ? (
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full bg-green-100 dark:bg-green-400/10 text-green-700 dark:text-green-300">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />فعال
</span>
) : (
<span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full bg-slate-100 dark:bg-slate-400/10 text-slate-500 dark:text-slate-400">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400 shrink-0" />غیرفعال
</span>
);
}
function StarRating({ rate }: { rate: number }) {
return (
<div className="flex items-center gap-0.5" dir="ltr">
{Array.from({ length: 5 }).map((_, i) => (
i < Math.round(rate)
? <StarSolid key={i} className="w-3 h-3 text-amber-400" />
: <StarIcon key={i} className="w-3 h-3 text-slate-300 dark:text-slate-600" />
))}
</div>
);
}
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)}
<mark className="bg-yellow-100 dark:bg-yellow-400/20 text-yellow-800 dark:text-yellow-200 rounded px-0.5 not-italic">
{text.slice(idx, idx + q.length)}
</mark>
{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 (
<div className="cp-card p-4 flex items-center gap-3">
<div className={`cp-stat-icon ${iconBg} shrink-0`}>
<Icon className={`w-5 h-5 ${iconColor}`} />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-slate-500 dark:text-slate-400 mb-0.5 truncate">{label}</p>
{value === undefined
? <div className="h-5 w-16 rounded skeleton" />
: <p className="text-lg font-bold text-slate-900 dark:text-slate-50 leading-none">{formatNumber(value)}</p>
}
{sub && <p className="text-[11px] text-slate-400 dark:text-slate-500 mt-0.5 truncate">{sub}</p>}
</div>
</div>
);
}
// ── 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 (
<div className="relative" data-specfilter>
<button
data-specfilter
onClick={() => setOpen(!open)}
className={`h-10 px-3 flex items-center gap-2 text-sm rounded-xl border transition-all ${
value
? 'border-primary-500 bg-primary-50 dark:bg-primary-500/10 text-primary-700 dark:text-primary-300'
: 'border-slate-200 dark:border-gray-700 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-gray-800'
}`}
>
<FunnelIcon className="w-4 h-4 shrink-0" />
<span className="max-w-28 truncate">{selected?.name ?? 'تخصص'}</span>
<ChevronDownIcon className={`w-3.5 h-3.5 shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div className="absolute right-0 top-12 z-30 w-64 cp-card shadow-2xl border border-slate-200 dark:border-gray-700 animate-scale-in" data-specfilter>
<div className="p-2 border-b border-slate-100 dark:border-gray-700">
<input
autoFocus
type="text"
value={q}
onChange={e => setQ(e.target.value)}
placeholder="جستجوی تخصص..."
className="cp-input text-sm h-8"
data-specfilter
/>
</div>
<div className="max-h-56 overflow-y-auto py-1">
<button
onClick={() => { onChange(''); setOpen(false); setQ(''); }}
className={`w-full text-right px-3 py-2 text-sm transition-colors ${!value ? 'bg-primary-50 dark:bg-primary-500/10 text-primary-700 dark:text-primary-300 font-medium' : 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-gray-800'}`}
>
همه تخصص‌ها
</button>
{filtered.map(s => (
<button
key={s.id}
onClick={() => { onChange(String(s.id)); setOpen(false); setQ(''); }}
className={`w-full text-right px-3 py-2 text-sm transition-colors ${String(s.id) === value ? 'bg-primary-50 dark:bg-primary-500/10 text-primary-700 dark:text-primary-300 font-medium' : 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-gray-800'}`}
>
{s.name}
</button>
))}
{filtered.length === 0 && <p className="text-xs text-slate-400 text-center py-4">نتیجه‌ای یافت نشد</p>}
</div>
</div>
)}
</div>
);
}
// ── 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 (
<div className={`cp-card p-4 transition-all hover:shadow-md ${selected ? 'ring-2 ring-primary-500 ring-offset-1 dark:ring-offset-gray-900' : ''}`}>
{/* Top row */}
<div className="flex items-start gap-3 mb-3">
<input type="checkbox" checked={selected} onChange={onSelect}
className="mt-1 rounded border-slate-300 dark:border-gray-600 text-primary-600 cursor-pointer shrink-0" />
<DoctorAvatar name={doctor.name} id={doctor.id} image={doctor.profile_image} size="lg" />
<div className="flex-1 min-w-0">
<h3
className="font-semibold text-slate-800 dark:text-slate-100 leading-tight cursor-pointer hover:text-primary-600 dark:hover:text-primary-400 transition-colors line-clamp-1"
onClick={onView}
>
دکتر {highlight(doctor.name, search)}
</h3>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
{doctor.degree && <DegreeBadge degree={doctor.degree} />}
<ActiveBadge active={doctor.is_active} />
</div>
</div>
{/* menu */}
<div className="relative shrink-0" data-cardmenu>
<button
data-cardmenu
onClick={() => setMenuOpen(!menuOpen)}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-colors"
>
<EllipsisVerticalIcon className="w-4 h-4" />
</button>
{menuOpen && (
<div className="absolute left-0 top-8 z-30 w-44 cp-card shadow-2xl border border-slate-200 dark:border-gray-700 py-1 animate-scale-in" data-cardmenu>
<button onClick={() => { onView(); setMenuOpen(false); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-gray-800">
<EyeIcon className="w-4 h-4 text-slate-400 shrink-0" />مشاهده پروفایل
</button>
<button onClick={() => { onEdit(); setMenuOpen(false); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-gray-800">
<PencilIcon className="w-4 h-4 text-slate-400 shrink-0" />ویرایش
</button>
<button onClick={() => { onToggle(); setMenuOpen(false); }} disabled={togglePending}
className="w-full flex items-center gap-2 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.is_active
? <XCircleIcon className="w-4 h-4 text-orange-400 shrink-0" />
: <CheckCircleIcon className="w-4 h-4 text-green-500 shrink-0" />
}
{doctor.is_active ? 'غیرفعال کردن' : 'فعال‌سازی'}
</button>
<div className="border-t border-slate-100 dark:border-gray-700 my-1" />
<button onClick={() => { onDelete(); setMenuOpen(false); }}
className="w-full flex items-center gap-2 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>
{/* Specialties */}
{doctor.specialties.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{doctor.specialties.slice(0, 3).map((s, i) => <SpecialtyBadge key={s.id} name={s.name} idx={i} />)}
{doctor.specialties.length > 3 && (
<span className="text-[11px] text-slate-400 dark:text-slate-500">+{doctor.specialties.length - 3}</span>
)}
</div>
)}
{/* Info row */}
<div className="flex items-center justify-between text-[11px] text-slate-500 dark:text-slate-400 mt-2 pt-3 border-t border-slate-100 dark:border-gray-700/50">
<span dir="ltr" className="font-mono">{doctor.mobile ?? '—'}</span>
<StarRating rate={doctor.rate} />
<span>{formatDate(doctor.created_at)}</span>
</div>
</div>
);
}
// ── 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<string[]>([]);
const [activeMenu, setActiveMenu] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AdminDoctor | null>(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<ApiResponse<DoctorStats>>('/api/v1/admin/doctors/stats'),
staleTime: 30_000,
});
const specialtiesQ = useQuery({
queryKey: ['specialties-list'],
queryFn: () => api.get<ApiResponse<SpecialtyOption[]>>('/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<PaginatedResponse<AdminDoctor>>(`/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<ApiResponse<null>>(`/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<ApiResponse<{ is_active: boolean }>>(`/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 (
<div className="animate-slide-up space-y-5">
{/* ── Header ─────────────────────────────────────────────── */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-50">پزشکان</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">مدیریت پزشکان، تخصص‌ها، کلینیک‌ها و وضعیت همکاری</p>
</div>
<button onClick={() => navigate('/admin/doctors/new')} className="cp-btn-primary shrink-0">
<PlusIcon className="w-4 h-4" />
افزودن پزشک
</button>
</div>
{/* ── KPI Cards ──────────────────────────────────────────── */}
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-5 gap-3">
{kpiCards.map(c => <KpiCard key={c.label} {...c} />)}
</div>
{/* ── Main Card ──────────────────────────────────────────── */}
<div className="cp-card overflow-hidden">
{/* Toolbar */}
<div className="p-4 border-b border-slate-200 dark:border-gray-700/50 space-y-3">
{/* Row 1 */}
<div className="flex items-center gap-2 flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-52">
<MagnifyingGlassIcon className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
<input
type="text"
value={searchInput}
onChange={e => setSearch(e.target.value)}
placeholder="نام پزشک، موبایل یا ایمیل..."
className="cp-input pr-9"
/>
{searchInput && (
<button onClick={() => { setSearch(''); setDebouncedSearch(''); }}
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-lg leading-none">×</button>
)}
</div>
{/* Specialty dropdown */}
<SpecialtyFilter value={specialtyId} onChange={setSpecialty} specialties={specialties} />
{/* Status */}
<select value={status} onChange={e => setStatus(e.target.value)} className="cp-select h-10 w-auto min-w-36 text-sm">
<option value="">همه وضعیت‌ها</option>
<option value="1">فعال</option>
<option value="0">غیرفعال</option>
</select>
{/* Gender */}
<select value={gender} onChange={e => setGender(e.target.value)} className="cp-select h-10 w-auto min-w-28 text-sm">
<option value="">همه جنسیت‌ها</option>
<option value="man">مرد</option>
<option value="woman">زن</option>
</select>
{/* Sort */}
<select value={sort} onChange={e => setSort(e.target.value)} className="cp-select h-10 w-auto min-w-32 text-sm">
<option value="newest">جدیدترین</option>
<option value="oldest">قدیمی‌ترین</option>
<option value="rating">بالاترین امتیاز</option>
</select>
{/* Limit */}
<select value={limit} onChange={e => setLimit(Number(e.target.value))} className="cp-select h-10 w-auto min-w-24 text-sm">
{LIMIT_OPTIONS.map(l => <option key={l} value={l}>{formatNumber(l)} عدد</option>)}
</select>
{/* View toggle */}
<div className="flex rounded-xl border border-slate-200 dark:border-gray-700 overflow-hidden shrink-0">
<button
onClick={() => setView('table')}
className={`h-10 w-10 flex items-center justify-center transition-colors ${view === 'table' ? 'bg-primary-600 text-white' : 'text-slate-500 hover:bg-slate-50 dark:hover:bg-gray-800'}`}
title="نمای جدول"
>
<TableCellsIcon className="w-4 h-4" />
</button>
<button
onClick={() => setView('card')}
className={`h-10 w-10 flex items-center justify-center transition-colors ${view === 'card' ? 'bg-primary-600 text-white' : 'text-slate-500 hover:bg-slate-50 dark:hover:bg-gray-800'}`}
title="نمای کارت"
>
<Squares2X2Icon className="w-4 h-4" />
</button>
</div>
{/* Refresh */}
<button
onClick={() => doctorsQ.refetch()}
disabled={doctorsQ.isFetching}
className="cp-btn-secondary h-10 px-3"
>
<ArrowPathIcon className={`w-4 h-4 ${doctorsQ.isFetching ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Active filter chips */}
{hasFilters && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-slate-500 dark:text-slate-400">فیلترهای فعال:</span>
{search && (
<span className="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-primary-50 dark:bg-primary-500/10 text-primary-700 dark:text-primary-300 border border-primary-200 dark:border-primary-500/20">
جستجو: {search}
<button onClick={() => { setSearch(''); setDebouncedSearch(''); }} className="hover:text-primary-900 dark:hover:text-primary-100 ml-0.5">×</button>
</span>
)}
{status && (
<span className="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-slate-100 dark:bg-slate-400/10 text-slate-600 dark:text-slate-400">
وضعیت: {status === '1' ? 'فعال' : 'غیرفعال'}
<button onClick={() => setStatus('')} className="hover:text-slate-900 dark:hover:text-slate-200 ml-0.5">×</button>
</span>
)}
{gender && (
<span className="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-slate-100 dark:bg-slate-400/10 text-slate-600 dark:text-slate-400">
جنسیت: {gender === 'man' ? 'مرد' : 'زن'}
<button onClick={() => setGender('')} className="hover:text-slate-900 dark:hover:text-slate-200 ml-0.5">×</button>
</span>
)}
{specialtyId && (
<span className="inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-slate-100 dark:bg-slate-400/10 text-slate-600 dark:text-slate-400">
تخصص: {specialties.find(s => String(s.id) === specialtyId)?.name}
<button onClick={() => setSpecialty('')} className="hover:text-slate-900 dark:hover:text-slate-200 ml-0.5">×</button>
</span>
)}
<button onClick={clearFilters} className="text-xs text-red-500 dark:text-red-400 hover:underline">پاک کردن همه</button>
</div>
)}
</div>
{/* Bulk actions */}
{selected.length > 0 && (
<div className="px-4 py-2.5 bg-primary-50 dark:bg-primary-500/10 border-b border-primary-200 dark:border-primary-500/20 flex items-center gap-3">
<span className="text-sm font-medium text-primary-700 dark:text-primary-300">
{formatNumber(selected.length)} پزشک انتخاب شده
</span>
<div className="flex gap-2 mr-auto">
<button
onClick={() => setConfirmBulkDel(true)}
className="text-xs px-3 py-1.5 rounded-lg bg-red-100 dark:bg-red-400/10 text-red-600 dark:text-red-400 hover:bg-red-200 dark:hover:bg-red-400/20 transition-colors font-medium"
>
حذف انتخاب‌شده‌ها
</button>
<button onClick={() => setSelected([])} className="text-xs px-3 py-1.5 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-gray-700 transition-colors">
لغو انتخاب
</button>
</div>
</div>
)}
{/* ── TABLE VIEW ─────────────────────────────────────── */}
{view === 'table' && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-50 dark:bg-gray-800/60 border-b border-slate-200 dark:border-gray-700/50">
<th className="px-4 py-3 w-10">
<input type="checkbox" checked={allSelected} onChange={toggleAll}
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 cursor-pointer" />
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide whitespace-nowrap">پزشک</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide whitespace-nowrap hidden md:table-cell">تخصص‌ها</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide whitespace-nowrap hidden sm:table-cell">درجه</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide whitespace-nowrap hidden lg:table-cell">موبایل</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide whitespace-nowrap">وضعیت</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide whitespace-nowrap hidden lg:table-cell">امتیاز</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide whitespace-nowrap hidden xl:table-cell">ثبت</th>
<th className="px-4 py-3 w-10" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-gray-700/40">
{/* Skeleton */}
{doctorsQ.isLoading && Array.from({ length: 8 }).map((_, i) => (
<tr key={i}>
<td className="px-4 py-3.5"><div className="w-4 h-4 rounded skeleton" /></td>
<td className="px-4 py-3.5">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl skeleton shrink-0" />
<div className="space-y-1.5">
<div className="h-3.5 w-28 rounded skeleton" />
<div className="h-3 w-16 rounded skeleton" />
</div>
</div>
</td>
<td className="px-4 py-3.5 hidden md:table-cell"><div className="h-5 w-24 rounded-full skeleton" /></td>
<td className="px-4 py-3.5 hidden sm:table-cell"><div className="h-5 w-14 rounded-full skeleton" /></td>
<td className="px-4 py-3.5 hidden lg:table-cell"><div className="h-3.5 w-24 rounded skeleton" /></td>
<td className="px-4 py-3.5"><div className="h-5 w-12 rounded-full skeleton" /></td>
<td className="px-4 py-3.5 hidden lg:table-cell"><div className="h-3.5 w-16 rounded skeleton" /></td>
<td className="px-4 py-3.5 hidden xl:table-cell"><div className="h-3.5 w-20 rounded skeleton" /></td>
<td className="px-4 py-3.5"><div className="w-6 h-6 rounded skeleton" /></td>
</tr>
))}
{/* Empty state */}
{!doctorsQ.isLoading && items.length === 0 && (
<tr>
<td colSpan={9} className="py-16 text-center">
<div className="flex flex-col items-center gap-3">
<div className="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-gray-800 flex items-center justify-center">
<UserGroupIcon className="w-7 h-7 text-slate-400 dark:text-slate-500" />
</div>
<p className="text-sm text-slate-500 dark:text-slate-400">
{hasFilters ? 'نتیجه‌ای برای فیلترهای انتخابی یافت نشد' : 'هیچ پزشکی ثبت نشده'}
</p>
{hasFilters
? <button onClick={clearFilters} className="text-xs text-primary-600 dark:text-primary-400 hover:underline">پاک کردن فیلترها</button>
: <button onClick={() => navigate('/admin/doctors/new')} className="cp-btn-primary text-xs">افزودن اولین پزشک</button>
}
</div>
</td>
</tr>
)}
{/* Data rows */}
{!doctorsQ.isLoading && items.map(doctor => (
<tr
key={doctor.uuid}
className={`transition-colors ${selected.includes(doctor.uuid) ? 'bg-primary-50/60 dark:bg-primary-500/5' : 'hover:bg-slate-50/80 dark:hover:bg-gray-800/40'}`}
>
{/* Checkbox */}
<td className="px-4 py-3.5" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selected.includes(doctor.uuid)}
onChange={() => toggleOne(doctor.uuid)}
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 cursor-pointer" />
</td>
{/* Avatar + Name */}
<td className="px-4 py-3.5">
<div className="flex items-center gap-3 cursor-pointer group" onClick={() => navigate(`/admin/doctors/${doctor.uuid}`)}>
<DoctorAvatar name={doctor.name} id={doctor.id} image={doctor.profile_image} />
<div className="min-w-0">
<p className="font-medium text-slate-800 dark:text-slate-200 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors truncate max-w-36">
دکتر {highlight(doctor.name, search)}
</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500">
{doctor.gender === 'man' ? '♂ مرد' : doctor.gender === 'woman' ? '♀ زن' : '—'}
</p>
</div>
</div>
</td>
{/* Specialties */}
<td className="px-4 py-3.5 hidden md:table-cell">
<div className="flex flex-wrap gap-1 max-w-52">
{doctor.specialties.slice(0, 2).map((s, i) => <SpecialtyBadge key={s.id} name={s.name} idx={i} />)}
{doctor.specialties.length > 2 && (
<span className="text-[11px] text-slate-400 dark:text-slate-500">+{doctor.specialties.length - 2}</span>
)}
{doctor.specialties.length === 0 && <span className="text-xs text-slate-400"></span>}
</div>
</td>
{/* Degree */}
<td className="px-4 py-3.5 hidden sm:table-cell">
<DegreeBadge degree={doctor.degree} />
</td>
{/* Mobile */}
<td className="px-4 py-3.5 hidden lg:table-cell">
<span className="font-mono text-xs text-slate-500 dark:text-slate-400" dir="ltr">
{highlight(doctor.mobile, search) || '—'}
</span>
</td>
{/* Status */}
<td className="px-4 py-3.5"><ActiveBadge active={doctor.is_active} /></td>
{/* Rating */}
<td className="px-4 py-3.5 hidden lg:table-cell"><StarRating rate={doctor.rate} /></td>
{/* Date */}
<td className="px-4 py-3.5 hidden xl:table-cell text-xs text-slate-500 dark:text-slate-400 whitespace-nowrap">
{formatDate(doctor.created_at)}
</td>
{/* Actions */}
<td className="px-4 py-3.5" onClick={e => e.stopPropagation()}>
<div className="relative flex justify-end" data-rowmenu>
<button
data-rowmenu
onClick={() => setActiveMenu(activeMenu === doctor.uuid ? null : doctor.uuid)}
className="w-7 h-7 flex items-center justify-center rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-gray-700 transition-colors"
>
<EllipsisVerticalIcon className="w-4 h-4" />
</button>
{activeMenu === doctor.uuid && (
<div className="absolute left-0 top-8 z-30 w-48 cp-card shadow-2xl border border-slate-200 dark:border-gray-700 py-1.5 animate-scale-in" data-rowmenu>
<button onClick={() => { navigate(`/admin/doctors/${doctor.uuid}`); setActiveMenu(null); }}
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">
<EyeIcon className="w-4 h-4 text-slate-400 shrink-0" />مشاهده پروفایل
</button>
<button onClick={() => { navigate(`/admin/doctors/${doctor.uuid}?edit=1`); setActiveMenu(null); }}
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">
<PencilIcon className="w-4 h-4 text-slate-400 shrink-0" />ویرایش اطلاعات
</button>
<button
onClick={() => { toggleMut.mutate(doctor.uuid); setActiveMenu(null); }}
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.is_active
? <XCircleIcon className="w-4 h-4 text-orange-400 shrink-0" />
: <CheckCircleIcon className="w-4 h-4 text-green-500 shrink-0" />
}
{doctor.is_active ? 'غیرفعال کردن' : 'فعال‌سازی'}
</button>
<button onClick={() => { navigate(`/admin/appointments?doctor=${doctor.uuid}`); setActiveMenu(null); }}
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">
<StarIcon className="w-4 h-4 text-slate-400 shrink-0" />مشاهده نوبت‌ها
</button>
<div className="border-t border-slate-100 dark:border-gray-700 my-1" />
<button onClick={() => { setDeleteTarget(doctor); setActiveMenu(null); }}
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>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ── CARD VIEW ──────────────────────────────────────── */}
{view === 'card' && (
<div className="p-4">
{doctorsQ.isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="cp-card p-4 space-y-3">
<div className="flex items-start gap-3">
<div className="w-9 h-9 rounded-xl skeleton" />
<div className="flex-1 space-y-2">
<div className="h-4 w-32 rounded skeleton" />
<div className="h-3 w-20 rounded skeleton" />
</div>
</div>
<div className="h-5 w-24 rounded-full skeleton" />
<div className="h-3.5 w-full rounded skeleton" />
</div>
))}
</div>
) : items.length === 0 ? (
<div className="py-16 text-center flex flex-col items-center gap-3">
<div className="w-14 h-14 rounded-2xl bg-slate-100 dark:bg-gray-800 flex items-center justify-center">
<UserGroupIcon className="w-7 h-7 text-slate-400 dark:text-slate-500" />
</div>
<p className="text-sm text-slate-500 dark:text-slate-400">
{hasFilters ? 'نتیجه‌ای یافت نشد' : 'هیچ پزشکی ثبت نشده'}
</p>
{hasFilters && <button onClick={clearFilters} className="text-xs text-primary-600 dark:text-primary-400 hover:underline">پاک کردن فیلترها</button>}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{items.map(doctor => (
<DoctorCard
key={doctor.uuid}
doctor={doctor}
selected={selected.includes(doctor.uuid)}
onSelect={() => 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}
/>
))}
</div>
)}
</div>
)}
{/* Pagination */}
<div className="px-4 border-t border-slate-100 dark:border-gray-700/50">
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</div>
</div>
{/* Delete confirm */}
<ConfirmDialog
open={!!deleteTarget}
title="حذف پزشک"
message={`آیا از حذف دکتر "${deleteTarget?.name}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
confirmLabel="حذف"
danger
loading={deleteMut.isPending}
onConfirm={() => deleteTarget && deleteMut.mutate(deleteTarget.uuid)}
onCancel={() => setDeleteTarget(null)}
/>
{/* Bulk delete confirm */}
<ConfirmDialog
open={confirmBulkDel}
title="حذف گروهی"
message={`آیا از حذف ${formatNumber(selected.length)} پزشک انتخاب‌شده اطمینان دارید؟`}
confirmLabel={`حذف ${formatNumber(selected.length)} پزشک`}
danger
loading={bulkDeleting}
onConfirm={handleBulkDelete}
onCancel={() => setConfirmBulkDel(false)}
/>
</div>
);
}