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 { User, UserRole } from '../../types';
|
||||||
import { AccountStatus } from '../../services/auth/auth.constants';
|
import { AccountStatus } from '../../services/auth/auth.constants';
|
||||||
import { databaseService } from '../../services/database';
|
import { databaseService } from '../../services/database';
|
||||||
@@ -15,12 +15,19 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
const [newPassword, setNewPassword] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
try {
|
try {
|
||||||
const users = await databaseService.getAllUsers();
|
const users = await databaseService.getAllUsers();
|
||||||
setUsers(users);
|
setUsers(users);
|
||||||
@@ -107,6 +114,88 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
: 'text-blue-600 bg-blue-100';
|
: '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) {
|
if (currentUser?.role !== UserRole.ADMIN) {
|
||||||
return (
|
return (
|
||||||
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'>
|
<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='space-y-4'>
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center'>
|
||||||
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100'>
|
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100'>
|
||||||
User Management ({users.length} users)
|
User Management ({visibleUsersLabel})
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={loadUsers}
|
onClick={loadUsers}
|
||||||
@@ -184,6 +273,67 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='overflow-x-auto'>
|
<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'>
|
<table className='min-w-full bg-white dark:bg-slate-700 rounded-lg overflow-hidden'>
|
||||||
<thead className='bg-slate-50 dark:bg-slate-600'>
|
<thead className='bg-slate-50 dark:bg-slate-600'>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -208,7 +358,7 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className='divide-y divide-slate-200 dark:divide-slate-600'>
|
<tbody className='divide-y divide-slate-200 dark:divide-slate-600'>
|
||||||
{users.map(user => (
|
{filteredUsers.map(user => (
|
||||||
<tr
|
<tr
|
||||||
key={user._id}
|
key={user._id}
|
||||||
className='hover:bg-slate-50 dark:hover:bg-slate-600'
|
className='hover:bg-slate-50 dark:hover:bg-slate-600'
|
||||||
@@ -307,6 +457,7 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user