feat(admin): add user search and sorting

This commit is contained in:
William Valentin
2025-09-23 10:58:01 -07:00
parent 7c712ae84b
commit e9a662d1e2

View File

@@ -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>
)}