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

672 lines
33 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,
ShieldCheckIcon, HeartIcon, UserIcon, FunnelIcon,
} from '@heroicons/react/24/outline';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import { formatDate, formatNumber, maskMobile } from '../lib/utils';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import Pagination from '../components/ui/Pagination';
// ── Types ─────────────────────────────────────────────────────────────────
interface AdminUser {
uuid: string;
id: number;
mobile_number: string;
name: string | null;
email: string | null;
roles: string[];
is_active: boolean;
status: number;
created_at: string;
}
interface UserStats {
total: number;
active: number;
inactive: number;
admins: number;
doctors: number;
patients: number;
}
// ── Role helpers ──────────────────────────────────────────────────────────
const ROLE_META: Record<string, { label: string; cls: string; dot: string }> = {
admin: { label: 'ادمین', cls: 'bg-violet-100 dark:bg-violet-400/10 text-violet-700 dark:text-violet-300', dot: '#8b5cf6' },
doctor: { label: 'پزشک', cls: 'bg-blue-100 dark:bg-blue-400/10 text-blue-700 dark:text-blue-300', dot: '#3b82f6' },
secretary: { label: 'منشی', cls: 'bg-orange-100 dark:bg-orange-400/10 text-orange-700 dark:text-orange-300', dot: '#f97316' },
clinic: { label: 'کلینیک', cls: 'bg-emerald-100 dark:bg-emerald-400/10 text-emerald-700 dark:text-emerald-300', dot: '#10b981' },
patient: { label: 'بیمار', cls: 'bg-slate-100 dark:bg-slate-400/10 text-slate-600 dark:text-slate-400', dot: '#94a3b8' },
};
function getPrimaryRole(roles: string[]): string {
if (roles.includes('ROLE_ADMIN')) return 'admin';
if (roles.includes('ROLE_DOCTOR')) return 'doctor';
if (roles.includes('ROLE_SECRETARY')) return 'secretary';
if (roles.includes('ROLE_CLINIC')) return 'clinic';
return 'patient';
}
// ── Sub-components ────────────────────────────────────────────────────────
const AVATAR_COLORS = [
'bg-violet-100 text-violet-700 dark:bg-violet-400/20 dark:text-violet-300',
'bg-blue-100 text-blue-700 dark:bg-blue-400/20 dark:text-blue-300',
'bg-emerald-100 text-emerald-700 dark:bg-emerald-400/20 dark:text-emerald-300',
'bg-pink-100 text-pink-700 dark:bg-pink-400/20 dark:text-pink-300',
'bg-amber-100 text-amber-700 dark:bg-amber-400/20 dark:text-amber-300',
];
function Avatar({ name, id }: { name: string | null; id: number }) {
const initials = name
? name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2)
: '؟';
return (
<div className={`w-8 h-8 rounded-full ${AVATAR_COLORS[id % AVATAR_COLORS.length]} flex items-center justify-center text-xs font-bold shrink-0`}>
{initials}
</div>
);
}
function RoleBadge({ roles }: { roles: string[] }) {
const key = getPrimaryRole(roles);
const meta = ROLE_META[key];
return (
<span className={`inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full ${meta.cls}`}>
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: meta.dot }} />
{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 highlight(text: string | null | undefined, query: string): React.ReactNode {
if (!text || !query.trim()) return text ?? '—';
const idx = text.toLowerCase().indexOf(query.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 + query.length)}
</mark>
{text.slice(idx + query.length)}
</>
);
}
// ── KPI Card ──────────────────────────────────────────────────────────────
function KpiCard({ label, value, icon: Icon, iconBg, iconColor }: {
label: string; value: number | undefined;
icon: React.ElementType; iconBg: string; iconColor: 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="min-w-0 flex-1">
<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>
}
</div>
</div>
);
}
// ── Role Change Modal ─────────────────────────────────────────────────────
function ChangeRoleModal({ user, onClose, onSave, loading }: {
user: AdminUser; onClose: () => void;
onSave: (role: string) => void; loading: boolean;
}) {
const [selected, setSelected] = useState(getPrimaryRole(user.roles));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm animate-fade-in">
<div className="cp-card w-full max-w-sm p-6 animate-scale-in shadow-2xl">
<h3 className="text-base font-bold text-slate-900 dark:text-slate-50 mb-1">تغییر نقش</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mb-5">{user.name || user.mobile_number}</p>
<div className="space-y-2 mb-6">
{Object.entries(ROLE_META).map(([key, meta]) => (
<label
key={key}
className={`flex items-center gap-3 p-3 rounded-xl cursor-pointer border-2 transition-all ${
selected === key
? 'border-primary-500 bg-primary-50 dark:bg-primary-500/10'
: 'border-transparent hover:bg-slate-50 dark:hover:bg-gray-800 border border-slate-200 dark:border-gray-700'
}`}
>
<input type="radio" name="role" value={key} checked={selected === key}
onChange={() => setSelected(key)} className="sr-only" />
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: meta.dot }} />
<span className="text-sm font-medium text-slate-800 dark:text-slate-200 flex-1">{meta.label}</span>
{selected === key && <CheckCircleIcon className="w-4 h-4 text-primary-600 dark:text-primary-400" />}
</label>
))}
</div>
<div className="flex gap-2">
<button onClick={() => onSave(selected)} disabled={loading} className="cp-btn-primary flex-1">
{loading ? 'در حال ذخیره...' : 'ذخیره'}
</button>
<button onClick={onClose} disabled={loading} className="cp-btn-secondary px-5">لغو</button>
</div>
</div>
</div>
);
}
// ── Constants ─────────────────────────────────────────────────────────────
const ROLE_TABS = [
{ key: '', label: 'همه', dot: '' },
{ key: 'admin', label: 'ادمین', dot: '#8b5cf6' },
{ key: 'doctor', label: 'پزشک', dot: '#3b82f6' },
{ key: 'secretary', label: 'منشی', dot: '#f97316' },
{ key: 'clinic', label: 'کلینیک', dot: '#10b981' },
{ key: 'patient', label: 'بیمار', dot: '#94a3b8' },
];
const LIMIT_OPTIONS = [10, 25, 50, 100];
// ── Main Page ─────────────────────────────────────────────────────────────
export default function UsersPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(25);
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [role, setRole] = useState('');
const [status, setStatus] = useState('');
const [sort, setSort] = useState('newest');
const [selected, setSelected] = useState<string[]>([]);
const [activeMenu, setActiveMenu] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null);
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
const [confirmBulkDel, setConfirmBulkDel] = useState(false);
const [bulkDeleting, setBulkDeleting] = useState(false);
// Debounce search
useEffect(() => {
const t = setTimeout(() => { setSearch(searchInput); setPage(1); }, 350);
return () => clearTimeout(t);
}, [searchInput]);
// Reset page + selection when filters change
useEffect(() => { setPage(1); setSelected([]); }, [role, status, sort, limit]);
// Close action dropdown on outside click
useEffect(() => {
if (!activeMenu) return;
const close = (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('[data-menu]') === null) setActiveMenu(null);
};
document.addEventListener('mousedown', close);
return () => document.removeEventListener('mousedown', close);
}, [activeMenu]);
// ── Queries ──
const statsQ = useQuery({
queryKey: ['users-stats'],
queryFn: () => api.get<ApiResponse<UserStats>>('/api/v1/admin/users/stats'),
staleTime: 30_000,
});
const usersQ = useQuery({
queryKey: ['users', page, limit, search, role, status, sort],
queryFn: () => {
const p = new URLSearchParams({ page: String(page), limit: String(limit), sort });
if (search) p.set('search', search);
if (role) p.set('role', role);
if (status) p.set('status', status);
return api.get<PaginatedResponse<AdminUser>>(`/api/v1/admin/users?${p}`);
},
});
const stats = useMemo<UserStats | undefined>(
() => (statsQ.data?.data as any)?.data ?? statsQ.data?.data, [statsQ.data]
);
const items = usersQ.data?.data ?? [];
const total = usersQ.data?.meta?.totalRecords ?? 0;
// ── Mutations ──
const deleteMut = useMutation({
mutationFn: (uuid: string) => api.delete<ApiResponse<null>>(`/api/v1/admin/users/${uuid}`),
onSuccess: () => {
toast.success('کاربر حذف شد');
setDeleteTarget(null);
qc.invalidateQueries({ queryKey: ['users'] });
qc.invalidateQueries({ queryKey: ['users-stats'] });
},
onError: (e: Error) => toast.error(e.message),
});
const toggleStatusMut = useMutation({
mutationFn: (uuid: string) => api.post<ApiResponse<{ is_active: boolean }>>(`/api/v1/admin/users/${uuid}/status`, {}),
onSuccess: () => {
toast.success('وضعیت کاربر تغییر کرد');
qc.invalidateQueries({ queryKey: ['users'] });
qc.invalidateQueries({ queryKey: ['users-stats'] });
},
onError: (e: Error) => toast.error(e.message),
});
const updateRoleMut = useMutation({
mutationFn: ({ uuid, role: r }: { uuid: string; role: string }) =>
api.put<ApiResponse<null>>(`/api/v1/admin/users/${uuid}/role`, { role: r }),
onSuccess: () => {
toast.success('نقش کاربر بروزرسانی شد');
setRoleTarget(null);
qc.invalidateQueries({ queryKey: ['users'] });
qc.invalidateQueries({ queryKey: ['users-stats'] });
},
onError: (e: Error) => toast.error(e.message),
});
// ── Bulk actions ──
const allSelected = items.length > 0 && items.every(u => selected.includes(u.uuid));
const toggleAll = () => setSelected(allSelected ? [] : items.map(u => u.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/admin/users/${uuid}`); count++; } catch {}
}
setBulkDeleting(false);
setConfirmBulkDel(false);
setSelected([]);
toast.success(`${formatNumber(count)} کاربر حذف شد`);
qc.invalidateQueries({ queryKey: ['users'] });
qc.invalidateQueries({ queryKey: ['users-stats'] });
};
// ── KPI cards ──
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?.admins, icon: ShieldCheckIcon, iconBg: 'bg-violet-100 dark:bg-violet-400/10', iconColor: 'text-violet-600 dark:text-violet-400' },
{ label: 'پزشکان', value: stats?.doctors, icon: HeartIcon, iconBg: 'bg-blue-100 dark:bg-blue-400/10', iconColor: 'text-blue-600 dark:text-blue-400' },
{ label: 'بیماران', value: stats?.patients, icon: UserIcon, iconBg: 'bg-orange-100 dark:bg-orange-400/10', iconColor: 'text-orange-600 dark:text-orange-400' },
];
const hasFilters = !!(search || role || status);
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 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-6 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: Search + Status + Sort + Limit + Refresh */}
<div className="flex items-center gap-2 flex-wrap">
<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) => setSearchInput(e.target.value)}
placeholder="نام، موبایل یا ایمیل..."
className="cp-input pr-9"
/>
{searchInput && (
<button
onClick={() => { setSearchInput(''); setSearch(''); }}
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>
<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>
<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>
</select>
<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>
<button
onClick={() => usersQ.refetch()}
disabled={usersQ.isFetching}
className="cp-btn-secondary h-10 px-3"
title="به‌روزرسانی"
>
<ArrowPathIcon className={`w-4 h-4 ${usersQ.isFetching ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Row 2: Role filter pills */}
<div className="flex items-center gap-1.5 flex-wrap">
<span className="flex items-center gap-1 text-xs text-slate-500 dark:text-slate-400 shrink-0">
<FunnelIcon className="w-3.5 h-3.5" />نقش:
</span>
{ROLE_TABS.map((t) => (
<button
key={t.key}
onClick={() => setRole(t.key)}
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg border transition-all ${
role === t.key
? 'bg-primary-600 border-primary-600 text-white shadow-sm'
: 'bg-transparent border-slate-200 dark:border-gray-700 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-gray-800'
}`}
>
{t.dot && (
<span className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: role === t.key ? 'white' : t.dot }} />
)}
{t.label}
</button>
))}
{hasFilters && (
<button
onClick={() => { setSearchInput(''); setSearch(''); setRole(''); setStatus(''); }}
className="text-xs text-red-500 dark:text-red-400 hover:underline mr-2"
>
پاک کردن فیلترها
</button>
)}
</div>
</div>
{/* Bulk actions bar */}
{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 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-700 transition-colors">
لغو انتخاب
</button>
</div>
</div>
)}
{/* 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 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 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">نقش</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 w-10" />
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-gray-700/40">
{/* Loading skeleton */}
{usersQ.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-8 h-8 rounded-full skeleton shrink-0" />
<div className="space-y-1.5 flex-1">
<div className="h-3.5 rounded skeleton w-28" />
<div className="h-3 rounded skeleton w-10" />
</div>
</div>
</td>
<td className="px-4 py-3.5 hidden sm:table-cell"><div className="h-3.5 rounded skeleton w-24" /></td>
<td className="px-4 py-3.5 hidden md:table-cell"><div className="h-3.5 rounded skeleton w-36" /></td>
<td className="px-4 py-3.5"><div className="h-5 rounded-full skeleton w-14" /></td>
<td className="px-4 py-3.5 hidden sm:table-cell"><div className="h-5 rounded-full skeleton w-12" /></td>
<td className="px-4 py-3.5 hidden lg:table-cell"><div className="h-3.5 rounded skeleton w-20" /></td>
<td className="px-4 py-3.5"><div className="w-6 h-6 rounded skeleton" /></td>
</tr>
))}
{/* Empty state */}
{!usersQ.isLoading && items.length === 0 && (
<tr>
<td colSpan={8} 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={() => { setSearchInput(''); setSearch(''); setRole(''); setStatus(''); }}
className="text-xs text-primary-600 dark:text-primary-400 hover:underline">
پاک کردن فیلترها
</button>
)}
</div>
</td>
</tr>
)}
{/* Data rows */}
{!usersQ.isLoading && items.map((user) => (
<tr
key={user.uuid}
className={`transition-colors ${
selected.includes(user.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(user.uuid)}
onChange={() => toggleOne(user.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/users/${user.uuid}`)}>
<Avatar name={user.name} id={user.id} />
<div className="min-w-0">
<p className="font-medium text-slate-800 dark:text-slate-200 truncate max-w-36 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors">
{highlight(user.name, search) || '—'}
</p>
<p className="text-[11px] text-slate-400 dark:text-slate-500">#{user.id}</p>
</div>
</div>
</td>
{/* Mobile */}
<td className="px-4 py-3.5 hidden sm:table-cell">
<span className="font-mono text-xs text-slate-600 dark:text-slate-400" dir="ltr">
{highlight(user.mobile_number, search) || maskMobile(user.mobile_number)}
</span>
</td>
{/* Email */}
<td className="px-4 py-3.5 hidden md:table-cell">
<span className="text-xs text-slate-500 dark:text-slate-400 truncate max-w-44 block">
{user.email ? highlight(user.email, search) : '—'}
</span>
</td>
{/* Role */}
<td className="px-4 py-3.5"><RoleBadge roles={user.roles} /></td>
{/* Status */}
<td className="px-4 py-3.5 hidden sm:table-cell"><ActiveBadge active={user.is_active} /></td>
{/* Date */}
<td className="px-4 py-3.5 hidden lg:table-cell text-xs text-slate-500 dark:text-slate-400 whitespace-nowrap">
{formatDate(user.created_at)}
</td>
{/* Actions dropdown */}
<td className="px-4 py-3.5" onClick={(e) => e.stopPropagation()}>
<div className="relative flex justify-end" data-menu>
<button
onClick={() => setActiveMenu(activeMenu === user.uuid ? null : user.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"
data-menu
>
<EllipsisVerticalIcon className="w-4 h-4" />
</button>
{activeMenu === user.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-menu>
<button
onClick={() => { navigate(`/admin/users/${user.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 transition-colors"
>
<EyeIcon className="w-4 h-4 text-slate-400 shrink-0" />مشاهده پروفایل
</button>
<button
onClick={() => { navigate(`/admin/users/${user.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 transition-colors"
>
<PencilIcon className="w-4 h-4 text-slate-400 shrink-0" />ویرایش اطلاعات
</button>
<button
onClick={() => { setRoleTarget(user); 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 transition-colors"
>
<ShieldCheckIcon className="w-4 h-4 text-slate-400 shrink-0" />تغییر نقش
</button>
<button
onClick={() => { toggleStatusMut.mutate(user.uuid); setActiveMenu(null); }}
disabled={toggleStatusMut.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 transition-colors disabled:opacity-50"
>
{user.is_active
? <XCircleIcon className="w-4 h-4 text-orange-400 shrink-0" />
: <CheckCircleIcon className="w-4 h-4 text-green-500 shrink-0" />
}
{user.is_active ? 'غیرفعال کردن' : 'فعال‌سازی'}
</button>
<div className="border-t border-slate-100 dark:border-gray-700 my-1" />
<button
onClick={() => { setDeleteTarget(user); 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 transition-colors"
>
<TrashIcon className="w-4 h-4 shrink-0" />حذف کاربر
</button>
</div>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-4">
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
</div>
</div>
{/* Delete confirm */}
<ConfirmDialog
open={!!deleteTarget}
title="حذف کاربر"
message={`آیا از حذف کاربر "${deleteTarget?.name ?? deleteTarget?.mobile_number}" اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
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)}
/>
{/* Role change modal */}
{roleTarget && (
<ChangeRoleModal
user={roleTarget}
loading={updateRoleMut.isPending}
onClose={() => setRoleTarget(null)}
onSave={(r) => updateRoleMut.mutate({ uuid: roleTarget.uuid, role: r })}
/>
)}
</div>
);
}