- 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.
895 lines
46 KiB
TypeScript
895 lines
46 KiB
TypeScript
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>
|
||
);
|
||
}
|