feat(admin): add user search and sorting
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { User, UserRole } from '../../types';
|
||||
import { AccountStatus } from '../../services/auth/auth.constants';
|
||||
import { databaseService } from '../../services/database';
|
||||
@@ -15,12 +15,19 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
||||
const [error, setError] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortField, setSortField] = useState<
|
||||
'createdAt' | 'status' | 'role' | 'username'
|
||||
>('createdAt');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
const loadUsers = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const users = await databaseService.getAllUsers();
|
||||
setUsers(users);
|
||||
@@ -107,6 +114,88 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
||||
: 'text-blue-600 bg-blue-100';
|
||||
};
|
||||
|
||||
const statusPriority: Record<AccountStatus, number> = {
|
||||
[AccountStatus.ACTIVE]: 0,
|
||||
[AccountStatus.PENDING]: 1,
|
||||
[AccountStatus.SUSPENDED]: 2,
|
||||
};
|
||||
|
||||
const rolePriority: Record<UserRole, number> = {
|
||||
[UserRole.ADMIN]: 0,
|
||||
[UserRole.USER]: 1,
|
||||
};
|
||||
|
||||
const getStatusPriority = (status?: AccountStatus) =>
|
||||
statusPriority[status as AccountStatus] ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const getRolePriority = (role?: UserRole) =>
|
||||
rolePriority[role as UserRole] ?? Number.MAX_SAFE_INTEGER;
|
||||
|
||||
const sortedUsers = useMemo(() => {
|
||||
const copy = [...users];
|
||||
copy.sort((a, b) => {
|
||||
let result = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case 'status':
|
||||
result = getStatusPriority(a.status) - getStatusPriority(b.status);
|
||||
break;
|
||||
case 'role':
|
||||
result = getRolePriority(a.role) - getRolePriority(b.role);
|
||||
break;
|
||||
case 'username':
|
||||
result = (a.username || '').localeCompare(
|
||||
b.username || '',
|
||||
undefined,
|
||||
{
|
||||
sensitivity: 'base',
|
||||
}
|
||||
);
|
||||
break;
|
||||
case 'createdAt':
|
||||
default: {
|
||||
const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
result = timeA - timeB;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (result === 0) {
|
||||
result = (a.username || '').localeCompare(b.username || '', undefined, {
|
||||
sensitivity: 'base',
|
||||
});
|
||||
}
|
||||
|
||||
if (result === 0) {
|
||||
result = (a.email || '').localeCompare(b.email || '', undefined, {
|
||||
sensitivity: 'base',
|
||||
});
|
||||
}
|
||||
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
});
|
||||
|
||||
return copy;
|
||||
}, [users, sortField, sortDirection]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
if (!searchTerm.trim()) {
|
||||
return sortedUsers;
|
||||
}
|
||||
|
||||
const query = searchTerm.trim().toLowerCase();
|
||||
return sortedUsers.filter(user => {
|
||||
const username = user.username?.toLowerCase() ?? '';
|
||||
const email = user.email?.toLowerCase() ?? '';
|
||||
return username.includes(query) || email.includes(query);
|
||||
});
|
||||
}, [sortedUsers, searchTerm]);
|
||||
|
||||
const visibleUsersLabel = searchTerm.trim()
|
||||
? `${filteredUsers.length} of ${users.length} users`
|
||||
: `${filteredUsers.length} users`;
|
||||
|
||||
if (currentUser?.role !== UserRole.ADMIN) {
|
||||
return (
|
||||
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'>
|
||||
@@ -173,7 +262,7 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
||||
<div className='space-y-4'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100'>
|
||||
User Management ({users.length} users)
|
||||
User Management ({visibleUsersLabel})
|
||||
</h3>
|
||||
<button
|
||||
onClick={loadUsers}
|
||||
@@ -184,6 +273,67 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
||||
</div>
|
||||
|
||||
<div className='overflow-x-auto'>
|
||||
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-4'>
|
||||
<div className='w-full sm:w-72'>
|
||||
<label htmlFor='admin-search' className='sr-only'>
|
||||
Search users
|
||||
</label>
|
||||
<input
|
||||
id='admin-search'
|
||||
type='search'
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
placeholder='Search by username or email'
|
||||
className='w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<label
|
||||
htmlFor='admin-sort'
|
||||
className='text-sm text-slate-600 dark:text-slate-300'
|
||||
>
|
||||
Sort by
|
||||
</label>
|
||||
<select
|
||||
id='admin-sort'
|
||||
value={sortField}
|
||||
onChange={e =>
|
||||
setSortField(
|
||||
e.target.value as
|
||||
| 'createdAt'
|
||||
| 'status'
|
||||
| 'role'
|
||||
| 'username'
|
||||
)
|
||||
}
|
||||
className='px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 bg-white dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100'
|
||||
>
|
||||
<option value='createdAt'>Created Date</option>
|
||||
<option value='status'>Status</option>
|
||||
<option value='role'>Role</option>
|
||||
<option value='username'>Name</option>
|
||||
</select>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setSortDirection(prev =>
|
||||
prev === 'asc' ? 'desc' : 'asc'
|
||||
)
|
||||
}
|
||||
className='px-3 py-2 text-sm border border-slate-300 rounded-md bg-white hover:bg-slate-100 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100 dark:hover:bg-slate-600'
|
||||
aria-label={`Toggle sort direction. Currently ${
|
||||
sortDirection === 'asc' ? 'ascending' : 'descending'
|
||||
}`}
|
||||
>
|
||||
{sortDirection === 'asc' ? 'Asc ↑' : 'Desc ↓'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<p className='text-center text-sm text-slate-600 dark:text-slate-300 py-6'>
|
||||
No users match the current filters.
|
||||
</p>
|
||||
) : (
|
||||
<table className='min-w-full bg-white dark:bg-slate-700 rounded-lg overflow-hidden'>
|
||||
<thead className='bg-slate-50 dark:bg-slate-600'>
|
||||
<tr>
|
||||
@@ -208,7 +358,7 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='divide-y divide-slate-200 dark:divide-slate-600'>
|
||||
{users.map(user => (
|
||||
{filteredUsers.map(user => (
|
||||
<tr
|
||||
key={user._id}
|
||||
className='hover:bg-slate-50 dark:hover:bg-slate-600'
|
||||
@@ -307,6 +457,7 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user