Refactor code structure for improved readability and maintainability
This commit is contained in:
+196
-389
@@ -2,10 +2,9 @@ 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,
|
||||
MagnifyingGlassIcon, PlusIcon,
|
||||
EyeIcon, TrashIcon, ArrowPathIcon,
|
||||
CheckCircleIcon, XCircleIcon, ShieldCheckIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '../lib/api';
|
||||
@@ -13,6 +12,7 @@ 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';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -37,14 +37,16 @@ interface UserStats {
|
||||
patients: number;
|
||||
}
|
||||
|
||||
// ── Role helpers ──────────────────────────────────────────────────────────
|
||||
// ── 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' },
|
||||
const HUES_LIST = [256, 205, 162, 295, 272];
|
||||
|
||||
const ROLE_META: Record<string, { label: string; badgeCls: string }> = {
|
||||
admin: { label: 'ادمین', badgeCls: 'violet' },
|
||||
doctor: { label: 'پزشک', badgeCls: 'blue' },
|
||||
secretary: { label: 'منشی', badgeCls: 'amber' },
|
||||
clinic: { label: 'کلینیک', badgeCls: 'green' },
|
||||
patient: { label: 'بیمار', badgeCls: 'gray' },
|
||||
};
|
||||
|
||||
function getPrimaryRole(roles: string[]): string {
|
||||
@@ -55,22 +57,16 @@ function getPrimaryRole(roles: string[]): string {
|
||||
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 }) {
|
||||
function UserAvatar({ name, id, size = 'sm' }: { name: string | null; id: number; size?: 'sm' | 'lg' }) {
|
||||
const initials = name
|
||||
? name.split(' ').filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2)
|
||||
? name.split(' ').filter(Boolean).map((w) => w[0]).join('').toUpperCase().slice(0, 2)
|
||||
: '؟';
|
||||
const hue = HUES_LIST[id % HUES_LIST.length];
|
||||
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`}>
|
||||
<div
|
||||
className={`avatar ${size}`}
|
||||
style={{ background: `linear-gradient(145deg, oklch(0.62 0.15 ${hue}), oklch(0.48 0.16 ${hue}))` }}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
@@ -80,61 +76,19 @@ 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 }} />
|
||||
<span className={`badge ${meta.badgeCls}`}>
|
||||
<span className="bdot" />
|
||||
{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>
|
||||
<span className={`badge ${active ? 'green' : 'gray'}`}>
|
||||
<span className="bdot" />
|
||||
{active ? 'فعال' : 'غیرفعال'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,33 +100,42 @@ function ChangeRoleModal({ user, onClose, onSave, loading }: {
|
||||
}) {
|
||||
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 className="overlay" onClick={onClose}>
|
||||
<div className="modal" style={{ maxWidth: 400 }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<h2 style={{ fontSize: 16 }}>تغییر نقش</h2>
|
||||
<button className="mini-btn" onClick={onClose}>
|
||||
<XMarkIcon style={{ width: 18, height: 18 }} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => onSave(selected)} disabled={loading} className="cp-btn-primary flex-1">
|
||||
<div className="modal-body">
|
||||
<p className="muted" style={{ fontSize: 13, marginBottom: 16 }}>{user.name || user.mobile_number}</p>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{Object.entries(ROLE_META).map(([key, meta]) => (
|
||||
<label
|
||||
key={key}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '11px 14px',
|
||||
borderRadius: 'var(--r)', cursor: 'pointer',
|
||||
border: `2px solid ${selected === key ? 'var(--primary)' : 'var(--border)'}`,
|
||||
background: selected === key ? 'var(--primary-soft)' : 'var(--surface-2)',
|
||||
transition: '.15s',
|
||||
}}
|
||||
>
|
||||
<input type="radio" name="role" value={key} checked={selected === key}
|
||||
onChange={() => setSelected(key)} style={{ accentColor: 'var(--primary)', width: 16, height: 16 }} />
|
||||
<span className={`badge ${meta.badgeCls}`} style={{ pointerEvents: 'none' }}>
|
||||
<span className="bdot" />{meta.label}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-foot" style={{ justifyContent: 'flex-end' }}>
|
||||
<button className="btn ghost sm" onClick={onClose} disabled={loading}>انصراف</button>
|
||||
<button className="btn primary sm" onClick={() => onSave(selected)} disabled={loading}>
|
||||
{loading ? 'در حال ذخیره...' : 'ذخیره'}
|
||||
</button>
|
||||
<button onClick={onClose} disabled={loading} className="cp-btn-secondary px-5">لغو</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,16 +145,14 @@ function ChangeRoleModal({ user, onClose, onSave, loading }: {
|
||||
// ── 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' },
|
||||
{ key: '', label: 'همه نقشها' },
|
||||
{ key: 'admin', label: 'ادمین' },
|
||||
{ key: 'doctor', label: 'پزشک' },
|
||||
{ key: 'secretary', label: 'منشی' },
|
||||
{ key: 'clinic', label: 'کلینیک' },
|
||||
{ key: 'patient', label: 'بیمار' },
|
||||
];
|
||||
|
||||
const LIMIT_OPTIONS = [10, 25, 50, 100];
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UsersPage() {
|
||||
@@ -199,37 +160,23 @@ export default function UsersPage() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit, setLimit] = useState(25);
|
||||
const [limit] = 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);
|
||||
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]);
|
||||
useEffect(() => { setPage(1); setSelected([]); }, [role, status]);
|
||||
|
||||
// ── Queries ──
|
||||
|
||||
@@ -240,9 +187,9 @@ export default function UsersPage() {
|
||||
});
|
||||
|
||||
const usersQ = useQuery({
|
||||
queryKey: ['users', page, limit, search, role, status, sort],
|
||||
queryKey: ['users', page, limit, search, role, status],
|
||||
queryFn: () => {
|
||||
const p = new URLSearchParams({ page: String(page), limit: String(limit), sort });
|
||||
const p = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) p.set('search', search);
|
||||
if (role) p.set('role', role);
|
||||
if (status) p.set('status', status);
|
||||
@@ -291,12 +238,12 @@ export default function UsersPage() {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
|
||||
// ── Bulk actions ──
|
||||
// ── Bulk ──
|
||||
|
||||
const allSelected = items.length > 0 && items.every(u => selected.includes(u.uuid));
|
||||
const toggleAll = () => setSelected(allSelected ? [] : items.map(u => u.uuid));
|
||||
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]);
|
||||
setSelected((prev) => prev.includes(uuid) ? prev.filter((id) => id !== uuid) : [...prev, uuid]);
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
setBulkDeleting(true);
|
||||
@@ -312,313 +259,178 @@ export default function UsersPage() {
|
||||
qc.invalidateQueries({ queryKey: ['users-stats'] });
|
||||
};
|
||||
|
||||
// ── KPI cards ──
|
||||
// ── KPI data ──
|
||||
|
||||
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' },
|
||||
{ label: 'کل کاربران', value: stats?.total, bg: 'var(--surface-3)', color: 'var(--text-2)' },
|
||||
{ label: 'فعال', value: stats?.active, bg: 'var(--success-bg)', color: 'var(--success)' },
|
||||
{ label: 'غیرفعال', value: stats?.inactive, bg: 'var(--surface-3)', color: 'var(--text-3)' },
|
||||
{ label: 'ادمینها', value: stats?.admins, bg: 'var(--violet-bg)', color: 'var(--violet)' },
|
||||
{ label: 'پزشکان', value: stats?.doctors, bg: 'var(--info-bg)', color: 'var(--info)' },
|
||||
{ label: 'بیماران', value: stats?.patients, bg: 'var(--warning-bg)', color: 'var(--warning)' },
|
||||
];
|
||||
|
||||
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 className="fade-in">
|
||||
{/* Header */}
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<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>
|
||||
<h1 className="section-title">کاربران</h1>
|
||||
<div className="muted" style={{ fontSize: 13, marginTop: 3 }}>مدیریت کاربران، نقشها و سطح دسترسی</div>
|
||||
</div>
|
||||
<button className="cp-btn-primary shrink-0">
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<button className="btn primary sm">
|
||||
<PlusIcon style={{ width: 15, height: 15 }} />
|
||||
افزودن کاربر
|
||||
</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} />)}
|
||||
{/* KPI cards */}
|
||||
<div className="stat-grid">
|
||||
{kpiCards.map((c) => (
|
||||
<div key={c.label} className="stat">
|
||||
<div className="ico" style={{ background: c.bg, color: c.color }}>
|
||||
<svg style={{ width: 20, height: 20 }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="lbl">{c.label}</div>
|
||||
<div className="val">
|
||||
{c.value === undefined
|
||||
? <span className="skeleton" style={{ display: 'inline-block', width: 48, height: 26, borderRadius: 4 }} />
|
||||
: formatNumber(c.value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Main Card ──────────────────────────────────────────── */}
|
||||
<div className="cp-card overflow-hidden">
|
||||
|
||||
{/* Main card */}
|
||||
<div className="card">
|
||||
{/* 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" />
|
||||
<div className="card-pad" style={{ paddingBottom: 0 }}>
|
||||
<div className="toolbar">
|
||||
<div className="field" style={{ minWidth: 240 }}>
|
||||
<MagnifyingGlassIcon style={{ width: 17, height: 17 }} />
|
||||
<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' : ''}`} />
|
||||
<div className="field">
|
||||
<select value={role} onChange={(e) => setRole(e.target.value)}>
|
||||
{ROLE_TABS.map((t) => (
|
||||
<option key={t.key} value={t.key}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="seg">
|
||||
<button className={!status ? 'on' : ''} onClick={() => setStatus('')}>همه</button>
|
||||
<button className={status === '1' ? 'on' : ''} onClick={() => setStatus('1')}>فعال</button>
|
||||
<button className={status === '0' ? 'on' : ''} onClick={() => setStatus('0')}>غیرفعال</button>
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<button className="btn ghost sm" onClick={() => usersQ.refetch()} disabled={usersQ.isFetching} title="بهروزرسانی">
|
||||
<ArrowPathIcon style={{ width: 15, height: 15, animation: usersQ.isFetching ? 'spin 1s linear infinite' : undefined }} />
|
||||
</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 */}
|
||||
{/* Bulk 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">
|
||||
<div style={{
|
||||
padding: '10px 16px', background: 'var(--primary-soft)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
display: 'flex', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<span style={{ fontSize: 13, color: 'var(--primary-700)', fontWeight: 600 }}>
|
||||
{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 style={{ display: 'flex', gap: 8, marginRight: 'auto' }}>
|
||||
<button className="btn danger sm" onClick={() => setConfirmBulkDel(true)}>حذف انتخابشدهها</button>
|
||||
<button className="btn ghost sm" onClick={() => setSelected([])}>لغو</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<div className="table-wrap">
|
||||
<table className="t">
|
||||
<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">
|
||||
<tr>
|
||||
<th style={{ width: 42 }}>
|
||||
<input type="checkbox" checked={allSelected} onChange={toggleAll}
|
||||
className="rounded border-slate-300 dark:border-gray-600 text-primary-600 cursor-pointer" />
|
||||
style={{ accentColor: 'var(--primary)', 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" />
|
||||
<th>کاربر</th>
|
||||
<th>شماره موبایل</th>
|
||||
<th>نقش</th>
|
||||
<th>تاریخ عضویت</th>
|
||||
<th>وضعیت</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-gray-700/40">
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{usersQ.isLoading && Array.from({ length: 8 }).map((_, i) => (
|
||||
<tbody>
|
||||
{usersQ.isLoading && Array.from({ length: 7 }).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>
|
||||
{Array.from({ length: 7 }).map((_, j) => (
|
||||
<td key={j}>
|
||||
<div className="skeleton" style={{ height: 14, borderRadius: 6, width: j === 1 ? '70%' : '55%' }} />
|
||||
</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 colSpan={7}>
|
||||
<div className="empty">هیچ کاربری یافت نشد</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()}>
|
||||
<tr key={user.uuid}>
|
||||
<td>
|
||||
<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" />
|
||||
style={{ accentColor: 'var(--primary)', 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>
|
||||
<td>
|
||||
<div className="cell-user">
|
||||
<UserAvatar name={user.name} id={user.id} />
|
||||
<div>
|
||||
<b style={{ cursor: 'pointer' }} onClick={() => navigate(`/admin/users/${user.uuid}`)}>
|
||||
{user.name || '—'}
|
||||
</b>
|
||||
<br /><small>#{user.id}</small>
|
||||
</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 style={{ fontFamily: 'monospace', direction: 'ltr', textAlign: 'right' }}>
|
||||
{user.mobile_number}
|
||||
</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" />
|
||||
<td><RoleBadge roles={user.roles} /></td>
|
||||
<td className="muted">{formatDate(user.created_at)}</td>
|
||||
<td><ActiveBadge active={user.is_active} /></td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button className="mini-btn" title="مشاهده"
|
||||
onClick={() => navigate(`/admin/users/${user.uuid}`)}>
|
||||
<EyeIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
<button className="mini-btn" title="تغییر نقش"
|
||||
onClick={() => setRoleTarget(user)}>
|
||||
<ShieldCheckIcon style={{ width: 16, height: 16 }} />
|
||||
</button>
|
||||
<button className="mini-btn" title={user.is_active ? 'غیرفعال کردن' : 'فعالسازی'}
|
||||
onClick={() => toggleStatusMut.mutate(user.uuid)} disabled={toggleStatusMut.isPending}>
|
||||
{user.is_active
|
||||
? <XCircleIcon style={{ width: 16, height: 16 }} />
|
||||
: <CheckCircleIcon style={{ width: 16, height: 16 }} />}
|
||||
</button>
|
||||
<button className="mini-btn danger" title="حذف" onClick={() => setDeleteTarget(user)}>
|
||||
<TrashIcon style={{ width: 16, height: 16 }} />
|
||||
</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>
|
||||
@@ -627,13 +439,10 @@ export default function UsersPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="px-4">
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
<Pagination page={page} total={total} limit={limit} onPageChange={setPage} />
|
||||
</div>
|
||||
|
||||
{/* Delete confirm */}
|
||||
{/* Dialogs */}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title="حذف کاربر"
|
||||
@@ -645,11 +454,10 @@ export default function UsersPage() {
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
{/* Bulk delete confirm */}
|
||||
<ConfirmDialog
|
||||
open={confirmBulkDel}
|
||||
title="حذف گروهی"
|
||||
message={`آیا از حذف ${formatNumber(selected.length)} کاربر انتخابشده اطمینان دارید؟ این عمل قابل بازگشت نیست.`}
|
||||
message={`آیا از حذف ${formatNumber(selected.length)} کاربر انتخابشده اطمینان دارید؟`}
|
||||
confirmLabel={`حذف ${formatNumber(selected.length)} کاربر`}
|
||||
danger
|
||||
loading={bulkDeleting}
|
||||
@@ -657,7 +465,6 @@ export default function UsersPage() {
|
||||
onCancel={() => setConfirmBulkDel(false)}
|
||||
/>
|
||||
|
||||
{/* Role change modal */}
|
||||
{roleTarget && (
|
||||
<ChangeRoleModal
|
||||
user={roleTarget}
|
||||
|
||||
Reference in New Issue
Block a user