Add search, filter, and sort to User Management table
- Search bar filters users by name, email, or team assignment - Group filter chips: All, Admin, Standard_User, Leadership, Read_Only, No Teams - Sortable column headers (User, Group, Teams, Status, Last Login) - Shows filtered count (e.g., '5 of 12 users') - 'No Teams' filter quickly identifies users needing team assignment
This commit is contained in:
@@ -9,7 +9,8 @@
|
|||||||
// - JSX that uses the imported lucide-react icons (X, Plus, Edit2, Trash2, etc.)
|
// - JSX that uses the imported lucide-react icons (X, Plus, Edit2, Trash2, etc.)
|
||||||
// - The ConfirmModal integration for delete/group-change confirmations
|
// - The ConfirmModal integration for delete/group-change confirmations
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { X, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, User, Mail, Shield, Eye } from 'lucide-react';
|
// ⚠️ CONVENTION: 'Search' icon is used below but missing from this import — add it to avoid a ReferenceError
|
||||||
|
import { X, Plus, Edit2, Trash2, Loader, AlertCircle, CheckCircle, User, Mail, Shield, Eye, Search } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import ConfirmModal from './ConfirmModal';
|
import ConfirmModal from './ConfirmModal';
|
||||||
|
|
||||||
@@ -188,6 +189,10 @@ export default function UserManagement({ onClose }) {
|
|||||||
const [formError, setFormError] = useState('');
|
const [formError, setFormError] = useState('');
|
||||||
const [formSuccess, setFormSuccess] = useState('');
|
const [formSuccess, setFormSuccess] = useState('');
|
||||||
const [pendingConfirm, setPendingConfirm] = useState(null);
|
const [pendingConfirm, setPendingConfirm] = useState(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [groupFilter, setGroupFilter] = useState('');
|
||||||
|
const [sortField, setSortField] = useState('username');
|
||||||
|
const [sortDir, setSortDir] = useState('asc');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
@@ -208,6 +213,45 @@ export default function UserManagement({ onClose }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Filtered and sorted user list
|
||||||
|
const filteredUsers = users
|
||||||
|
.filter(u => {
|
||||||
|
// Search filter
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
const matchesName = u.username?.toLowerCase().includes(term);
|
||||||
|
const matchesEmail = u.email?.toLowerCase().includes(term);
|
||||||
|
const matchesTeam = u.teams?.some(t => t.toLowerCase().includes(term));
|
||||||
|
if (!matchesName && !matchesEmail && !matchesTeam) return false;
|
||||||
|
}
|
||||||
|
// Group filter
|
||||||
|
if (groupFilter === 'no-teams') return !u.teams || u.teams.length === 0;
|
||||||
|
if (groupFilter && u.group !== groupFilter) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
let aVal, bVal;
|
||||||
|
switch (sortField) {
|
||||||
|
case 'username': aVal = a.username || ''; bVal = b.username || ''; break;
|
||||||
|
case 'group': aVal = a.group || ''; bVal = b.group || ''; break;
|
||||||
|
case 'teams': aVal = (a.teams || []).join(','); bVal = (b.teams || []).join(','); break;
|
||||||
|
case 'status': aVal = a.is_active ? 'a' : 'z'; bVal = b.is_active ? 'a' : 'z'; break;
|
||||||
|
case 'last_login': aVal = a.last_login || ''; bVal = b.last_login || ''; break;
|
||||||
|
default: aVal = a.username || ''; bVal = b.username || '';
|
||||||
|
}
|
||||||
|
const cmp = String(aVal).localeCompare(String(bVal));
|
||||||
|
return sortDir === 'asc' ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSort = (field) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDir('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const doSubmit = async () => {
|
const doSubmit = async () => {
|
||||||
setFormError('');
|
setFormError('');
|
||||||
setFormSuccess('');
|
setFormSuccess('');
|
||||||
@@ -581,6 +625,51 @@ export default function UserManagement({ onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Search, Filters & Sort Controls */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||||
|
{/* Search bar */}
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Search style={{ position: 'absolute', left: '0.75rem', top: '50%', transform: 'translateY(-50%)', width: '14px', height: '14px', color: '#475569' }} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name, email, or team..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '0.5rem 0.75rem 0.5rem 2.25rem',
|
||||||
|
background: 'rgba(15, 23, 42, 0.6)',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
|
borderRadius: '0.375rem', color: '#E2E8F0',
|
||||||
|
fontSize: '0.8rem', fontFamily: 'monospace', outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Group filter chips */}
|
||||||
|
<div style={{ display: 'flex', gap: '0.375rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<span style={{ fontSize: '0.7rem', color: '#64748B', marginRight: '0.25rem' }}>Filter:</span>
|
||||||
|
{['', 'Admin', 'Standard_User', 'Leadership', 'Read_Only', 'no-teams'].map(g => (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
onClick={() => setGroupFilter(g)}
|
||||||
|
style={{
|
||||||
|
padding: '0.2rem 0.6rem', borderRadius: '0.25rem',
|
||||||
|
fontSize: '0.7rem', fontFamily: 'monospace', fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: groupFilter === g ? '1px solid #0EA5E9' : '1px solid rgba(14,165,233,0.2)',
|
||||||
|
background: groupFilter === g ? 'rgba(14,165,233,0.15)' : 'transparent',
|
||||||
|
color: groupFilter === g ? '#0EA5E9' : '#94A3B8',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{g === '' ? 'All' : g === 'no-teams' ? 'No Teams' : g.replace('_', ' ')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: '0.7rem', color: '#64748B' }}>
|
||||||
|
{filteredUsers.length} of {users.length} users
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* User Table */}
|
{/* User Table */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
<div style={{ textAlign: 'center', padding: '3rem 0' }}>
|
||||||
@@ -597,16 +686,16 @@ export default function UserManagement({ onClose }) {
|
|||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={styles.th}>User</th>
|
<th style={{ ...styles.th, cursor: 'pointer' }} onClick={() => handleSort('username')}>User {sortField === 'username' ? (sortDir === 'asc' ? '▲' : '▼') : ''}</th>
|
||||||
<th style={styles.th}>Group</th>
|
<th style={{ ...styles.th, cursor: 'pointer' }} onClick={() => handleSort('group')}>Group {sortField === 'group' ? (sortDir === 'asc' ? '▲' : '▼') : ''}</th>
|
||||||
<th style={styles.th}>Teams</th>
|
<th style={{ ...styles.th, cursor: 'pointer' }} onClick={() => handleSort('teams')}>Teams {sortField === 'teams' ? (sortDir === 'asc' ? '▲' : '▼') : ''}</th>
|
||||||
<th style={styles.th}>Status</th>
|
<th style={{ ...styles.th, cursor: 'pointer' }} onClick={() => handleSort('status')}>Status {sortField === 'status' ? (sortDir === 'asc' ? '▲' : '▼') : ''}</th>
|
||||||
<th style={styles.th}>Last Login</th>
|
<th style={{ ...styles.th, cursor: 'pointer' }} onClick={() => handleSort('last_login')}>Last Login {sortField === 'last_login' ? (sortDir === 'asc' ? '▲' : '▼') : ''}</th>
|
||||||
<th style={styles.thRight}>Actions</th>
|
<th style={styles.thRight}>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map((user) => (
|
{filteredUsers.map((user) => (
|
||||||
<tr key={user.id}
|
<tr key={user.id}
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'}
|
onMouseEnter={e => e.currentTarget.style.background = 'rgba(14,165,233,0.05)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||||
|
|||||||
Reference in New Issue
Block a user