chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { SearchableSelect } from '../components/SearchableSelect';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { PermissionGate } from '../auth/PermissionGate';
|
||||
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { listUsers } from '../lib/api';
|
||||
import type { AdministrativeUser, PageMeta } from '../lib/api';
|
||||
import { formatDate, initials } from '../lib/format';
|
||||
|
||||
export function UsersPage() {
|
||||
const [urlParams, setUrlParams] = useSearchParams();
|
||||
const [users, setUsers] = useState<AdministrativeUser[]>([]);
|
||||
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 20, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [draftSearch, setDraftSearch] = useState(urlParams.get('search') ?? '');
|
||||
const search = urlParams.get('search') ?? '';
|
||||
const rawStatus = urlParams.get('status');
|
||||
const status = rawStatus === 'ACTIVE' || rawStatus === 'INACTIVE' ? rawStatus : '';
|
||||
const page = Math.max(1, Number(urlParams.get('page') ?? 1) || 1);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
listUsers({ page, pageSize: 20, search, status })
|
||||
.then((response) => { setUsers(response.data); setMeta(response.meta); })
|
||||
.catch((requestError) => setError(errorMessage(requestError)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page, search, status]);
|
||||
|
||||
const applyFilters = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const next = new URLSearchParams();
|
||||
if (draftSearch.trim()) next.set('search', draftSearch.trim());
|
||||
if (status) next.set('status', status);
|
||||
setUrlParams(next);
|
||||
};
|
||||
|
||||
const setStatus = (value: string) => {
|
||||
const next = new URLSearchParams(urlParams);
|
||||
value ? next.set('status', value) : next.delete('status');
|
||||
next.delete('page');
|
||||
setUrlParams(next);
|
||||
};
|
||||
|
||||
const setPage = (value: number) => {
|
||||
const next = new URLSearchParams(urlParams);
|
||||
value > 1 ? next.set('page', String(value)) : next.delete('page');
|
||||
setUrlParams(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Usuarios</h1><p>Gestioná accesos, estados y roles del sistema.</p></div><PermissionGate permission="users.create"><Link className="button primary" to="/admin/users/new"><Icon name="plus" />Nuevo usuario</Link></PermissionGate></div>
|
||||
|
||||
<form className="toolbar" onSubmit={applyFilters}>
|
||||
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por nombre, usuario o email" /><button type="submit">Buscar</button></label>
|
||||
<label className="select-field"><span>Estado</span><SearchableSelect value={status} onChange={(event) => setStatus(event.target.value)}><option value="">Todos</option><option value="ACTIVE">Activos</option><option value="INACTIVE">Inactivos</option></SearchableSelect></label>
|
||||
</form>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{loading ? <LoadingBlock label="Cargando usuarios…" /> : users.length === 0 ? <EmptyState title="Sin resultados" text="No encontramos usuarios con los filtros seleccionados." /> : <div className="table-panel">
|
||||
<div className="table-summary"><strong>{meta.total} usuario{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
|
||||
<div className="table-scroll"><table><thead><tr><th>Usuario</th><th>Roles</th><th>Estado</th><th>Último acceso</th><th aria-label="Acciones" /></tr></thead><tbody>
|
||||
{users.map((user) => <tr key={user.id}><td><div className="person-cell"><span className="mini-avatar">{initials(user.firstName, user.lastName)}</span><div><strong>{user.firstName} {user.lastName}</strong><small>@{user.username}{user.email ? ` · ${user.email}` : ''}</small></div></div></td><td><div className="tag-list">{user.roles.length ? user.roles.map((role) => <span className="tag" key={role.id}>{role.name}</span>) : <span className="muted">Sin rol</span>}</div></td><td><span className={`status-badge ${user.status.toLowerCase()}`}>{user.status === 'ACTIVE' ? 'Activo' : 'Inactivo'}</span>{user.mustChangePassword && <span className="inline-note">Clave temporal</span>}</td><td>{formatDate(user.lastLoginAt)}</td><td className="action-cell"><Link className="icon-button" to={`/admin/users/${user.id}`} aria-label={`Abrir ${user.username}`}><Icon name="chevron" /></Link></td></tr>)}
|
||||
</tbody></table></div>
|
||||
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}–${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
|
||||
</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user