214 lines
9.6 KiB
TypeScript
214 lines
9.6 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import { Users as UsersIcon, CheckCircle, Search, Edit3, Check, X } from 'lucide-react';
|
||
import api from '../services/api';
|
||
import Skeleton from '../components/ui/Skeleton';
|
||
import Spinner from '../components/ui/Spinner';
|
||
import Pagination from '../components/ui/Pagination';
|
||
|
||
export interface UserRecord {
|
||
[x: string]: string;
|
||
id: string;
|
||
firstName?: string;
|
||
lastName?: string;
|
||
mobile?: string;
|
||
email?: string;
|
||
role?: string;
|
||
createdAt?: string;
|
||
}
|
||
|
||
export default function Users() {
|
||
const [users, setUsers] = useState<UserRecord[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
|
||
// Queries
|
||
const [page, setPage] = useState(1);
|
||
const [search, setSearch] = useState('');
|
||
const [role, setRole] = useState('');
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
|
||
// Edit State
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const [editRole, setEditRole] = useState<string>('');
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
|
||
const fetchUsers = useCallback(async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const params = new URLSearchParams();
|
||
params.append('page', page.toString());
|
||
if (search) params.append('search', search);
|
||
if (role) params.append('role', role);
|
||
|
||
const response = await api.get(`/admin/users?${params.toString()}`);
|
||
if (response.data?.success) {
|
||
setUsers(response.data.data);
|
||
setTotalPages(response.data.meta?.lastPage || 1);
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to fetch users', err);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [page, search, role]);
|
||
|
||
useEffect(() => {
|
||
const delayDebounceFn = setTimeout(() => {
|
||
fetchUsers();
|
||
}, 500);
|
||
|
||
return () => clearTimeout(delayDebounceFn);
|
||
}, [fetchUsers]);
|
||
|
||
const handleEditClick = (user: UserRecord) => {
|
||
setEditingId(user.id);
|
||
setEditRole(user.role || 'User_PetOwner');
|
||
};
|
||
|
||
const handleSaveEdit = async (id: string) => {
|
||
try {
|
||
setIsSaving(true);
|
||
await api.put(`/admin/users/${id}/role`, { role: editRole });
|
||
setEditingId(null);
|
||
fetchUsers();
|
||
} catch (err) {
|
||
console.error('Failed to update user role', err);
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
|
||
<UsersIcon className="w-6 h-6 text-purple-600" />
|
||
مدیریت کاربران
|
||
</h2>
|
||
<p className="text-gray-500 font-medium mt-1">مشاهده و تایید حسابهای کاربری و کلینیکها</p>
|
||
</div>
|
||
|
||
<div className="flex flex-col sm:flex-row items-center gap-3 w-full sm:w-auto">
|
||
<select
|
||
className="bg-white border border-gray-200 text-gray-700 px-4 py-2 rounded-xl flex items-center gap-2 font-bold hover:bg-gray-50 transition-all outline-none w-full sm:w-auto"
|
||
value={role}
|
||
onChange={(e) => { setRole(e.target.value); setPage(1); }}
|
||
>
|
||
<option value="">همه نقشها</option>
|
||
<option value="B2B">همکار (B2B)</option>
|
||
<option value="User_PetOwner">مشتری عادی</option>
|
||
<option value="ADMIN">ادمین</option>
|
||
</select>
|
||
<div className="relative w-full sm:w-64">
|
||
<Search className="w-5 h-5 text-gray-400 absolute right-3 top-1/2 -translate-y-1/2" />
|
||
<input
|
||
type="text"
|
||
placeholder="جستجو کاربر (نام، ایمیل، موبایل)..."
|
||
className="pl-4 pr-10 py-2 border border-gray-200 rounded-xl outline-none focus:ring-2 focus:ring-purple-500 w-full font-medium"
|
||
dir="rtl"
|
||
value={search}
|
||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-right font-vazir">
|
||
<thead className="bg-gray-50 text-gray-500 text-sm border-b border-gray-200">
|
||
<tr>
|
||
<th className="py-4 px-6 font-bold">نام کاربر / کلینیک</th>
|
||
<th className="py-4 px-6 font-bold">ایمیل</th>
|
||
<th className="py-4 px-6 font-bold">نوع حساب</th>
|
||
<th className="py-4 px-6 font-bold">تاریخ عضویت</th>
|
||
<th className="py-4 px-6 font-bold">وضعیت</th>
|
||
<th className="py-4 px-6 font-bold text-center">عملیات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{isLoading ? (
|
||
Array.from({ length: 4 }).map((_, i) => (
|
||
<tr key={i}>
|
||
<td className="py-4 px-6"><Skeleton className="h-6 w-32" /></td>
|
||
<td className="py-4 px-6"><Skeleton className="h-6 w-40" /></td>
|
||
<td className="py-4 px-6"><Skeleton className="h-6 w-24" /></td>
|
||
<td className="py-4 px-6"><Skeleton className="h-6 w-20" /></td>
|
||
<td className="py-4 px-6"><Skeleton className="h-6 w-24" /></td>
|
||
<td className="py-4 px-6"><Skeleton className="h-8 w-20 mx-auto" /></td>
|
||
</tr>
|
||
))
|
||
) : (
|
||
users.map((user) => {
|
||
const isEditing = editingId === user.id;
|
||
return (
|
||
<tr key={user.id} className="hover:bg-gray-50/50 transition-colors">
|
||
<td className="py-4 px-6 font-bold text-gray-900">{user.firstName} {user.lastName}</td>
|
||
<td className="py-4 px-6 text-gray-500" dir="ltr">{user.email}</td>
|
||
<td className="py-4 px-6">
|
||
{isEditing ? (
|
||
<select
|
||
className="border border-purple-300 rounded p-1 text-xs font-bold"
|
||
value={editRole}
|
||
onChange={(e) => setEditRole(e.target.value)}
|
||
>
|
||
<option value="User_PetOwner">مشتری عادی</option>
|
||
<option value="User_Wholesale">خریدار عمده (تاییدشده)</option>
|
||
<option value="User_B2B">همکار (B2B)</option>
|
||
<option value="ADMIN">ادمین</option>
|
||
</select>
|
||
) : (
|
||
<span className={`px-3 py-1 rounded-full text-xs font-bold ${user.role === 'User_Wholesale' ? 'bg-amber-100 text-amber-800' :
|
||
user.role?.includes('B2B') ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
|
||
}`}>
|
||
{user.role === 'User_Wholesale' ? 'خریدار عمده' :
|
||
user.role?.includes('B2B') ? 'همکار (B2B)' :
|
||
(user.role === 'ADMIN' ? 'مدیر سیستم' : 'مشتری عادی')}
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="py-4 px-6 text-gray-500 text-sm font-medium">{new Date(user.createdAt).toLocaleDateString('fa-IR')}</td>
|
||
<td className="py-4 px-6">
|
||
{user.status === 'active' ? (
|
||
<span className="flex items-center gap-1.5 text-green-600 font-bold text-sm">
|
||
<CheckCircle className="w-4 h-4" />
|
||
تایید شده
|
||
</span>
|
||
) : (
|
||
<span className="flex items-center gap-1.5 text-orange-500 font-bold text-sm">
|
||
<div className="w-2 h-2 rounded-full bg-orange-500"></div>
|
||
در انتظار تایید
|
||
</span>
|
||
)}
|
||
</td>
|
||
<td className="py-4 px-6 text-center">
|
||
{isEditing ? (
|
||
<div className="flex justify-center gap-2">
|
||
<button onClick={() => handleSaveEdit(user.id)} className="text-green-600 hover:bg-green-50 p-1.5 rounded-lg transition-colors" disabled={isSaving}>
|
||
{isSaving ? <Spinner size="sm" /> : <Check className="w-5 h-5" />}
|
||
</button>
|
||
<button onClick={() => setEditingId(null)} className="text-gray-500 hover:bg-gray-50 p-1.5 rounded-lg transition-colors" disabled={isSaving}>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="flex justify-center gap-2">
|
||
<button onClick={() => handleEditClick(user)} className="text-blue-600 hover:bg-blue-50 p-1.5 rounded-lg transition-colors" title="ویرایش نقش">
|
||
<Edit3 className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|