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.
This commit is contained in:
hamed
2026-06-10 17:02:42 +03:30
parent 8ed3d41d27
commit 25701c09b7
13 changed files with 3914 additions and 419 deletions
+631 -102
View File
@@ -1,142 +1,671 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { EyeIcon, TrashIcon, PencilIcon } from '@heroicons/react/24/outline';
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 type { User } from '../types';
import { formatDate, maskMobile } from '../lib/utils';
import PageHeader from '../components/ui/PageHeader';
import DataTable, { Column } from '../components/ui/DataTable';
import { ActiveBadge } from '../components/ui/StatusBadge';
import Pagination from '../components/ui/Pagination';
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 [search, setSearch] = useState('');
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
const limit = 15;
const { data, isLoading } = useQuery({
queryKey: ['users', page, search],
queryFn: () =>
api.get<PaginatedResponse<User>>(
`/api/v1/admin/users?page=${page}&limit=${limit}${search ? `&search=${encodeURIComponent(search)}` : ''}`,
),
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 deleteMutation = useMutation({
mutationFn: (user: User) => api.delete<ApiResponse<null>>(`/api/v1/user/${user.id}`),
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: (err: Error) => toast.error(err.message),
onError: (e: Error) => toast.error(e.message),
});
const columns: Column<User>[] = [
{
key: 'name',
header: 'نام',
render: (u) => (
<span className="font-medium text-slate-800 dark:text-slate-100">
{u.name || (u.first_name || u.last_name ? `${u.first_name ?? ''} ${u.last_name ?? ''}`.trim() : '—')}
</span>
),
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'] });
},
{
key: 'mobile_number',
header: 'موبایل',
render: (u) => <span dir="ltr">{maskMobile(u.mobile_number)}</span>,
},
{
key: 'roles',
header: 'نقش',
render: (u) => (
<span className="text-xs bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-200 px-2 py-0.5 rounded-full">
{u.roles.includes('ROLE_ADMIN')
? 'ادمین'
: u.roles.includes('ROLE_DOCTOR')
? 'پزشک'
: 'کاربر'}
</span>
),
},
{
key: 'is_active',
header: 'وضعیت',
render: (u) => <ActiveBadge active={u.is_active} />,
},
{
key: 'created_at',
header: 'تاریخ ثبت',
render: (u) => formatDate(u.created_at),
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 items = data?.data ?? [];
const total = data?.meta?.totalRecords ?? 0;
const hasFilters = !!(search || role || status);
return (
<div>
<PageHeader
title="کاربران"
breadcrumbs={[{ label: 'داشبورد', to: '/admin/dashboard' }, { label: 'کاربران' }]}
/>
<div className="animate-slide-up space-y-5">
<div className="cp-card p-6">
<DataTable<User>
columns={columns}
data={items}
loading={isLoading}
searchValue={search}
onSearchChange={(v) => { setSearch(v); setPage(1); }}
searchPlaceholder="جستجو بر اساس نام یا موبایل..."
emptyMessage="هیچ کاربری یافت نشد"
actions={(user) => (
<>
<button
onClick={() => navigate(`/admin/users/${user.uuid}`)}
className="cp-action-view"
title="مشاهده"
>
<EyeIcon className="w-4 h-4" />
</button>
<button
onClick={() => navigate(`/admin/users/${user.uuid}?edit=1`)}
className="cp-action-edit"
title="ویرایش"
>
<PencilIcon className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteTarget(user)}
className="cp-action-delete"
title="حذف"
>
<TrashIcon className="w-4 h-4" />
</button>
</>
)}
/>
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
{/* ── 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={deleteMutation.isPending}
onConfirm={() => deleteTarget && deleteMutation.mutate(deleteTarget)}
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>
);
}