Files
dh-inspeccion-v2/web-v2/src/pages/AuditPage.tsx
T

75 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import type { FormEvent } from 'react';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../components/Feedback';
import { Icon } from '../components/Icon';
import { getAuditEvent, listAudit } from '../lib/api';
import type { AuditEventDetail, AuditEventSummary, PageMeta } from '../lib/api';
import { actionLabel, formatDate } from '../lib/format';
interface AuditFilters {
search: string;
action: string;
source: string;
from: string;
to: string;
}
const emptyFilters: AuditFilters = { search: '', action: '', source: '', from: '', to: '' };
export function AuditPage() {
const [events, setEvents] = useState<AuditEventSummary[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [draft, setDraft] = useState<AuditFilters>(emptyFilters);
const [filters, setFilters] = useState<AuditFilters>(emptyFilters);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [detail, setDetail] = useState<AuditEventDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => {
setLoading(true); setError('');
listAudit({
page, pageSize: 25, search: filters.search, action: filters.action,
source: filters.source,
from: filters.from ? `${filters.from}T00:00:00.000Z` : undefined,
to: filters.to ? `${filters.to}T23:59:59.999Z` : undefined,
}).then((response) => { setEvents(response.data); setMeta(response.meta); })
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [filters, page]);
const apply = (event: FormEvent) => { event.preventDefault(); setPage(1); setFilters({ ...draft }); };
const clear = () => { setDraft(emptyFilters); setFilters(emptyFilters); setPage(1); };
const set = (key: keyof AuditFilters, value: string) => setDraft((current) => ({ ...current, [key]: value }));
const openDetail = async (id: string) => {
setDetail(null); setDetailLoading(true); setError('');
try { setDetail(await getAuditEvent(id)); }
catch (requestError) { setError(errorMessage(requestError)); }
finally { setDetailLoading(false); }
};
return <section>
<div className="page-heading"><div><span className="eyebrow">TRAZABILIDAD</span><h1>Auditoría</h1><p>Registro central de accesos y cambios administrativos.</p></div><span className="count-pill large">{meta.total} eventos</span></div>
<form className="audit-filters panel" onSubmit={apply}>
<label className="search-field"><Icon name="search" /><input value={draft.search} onChange={(event) => set('search', event.target.value)} placeholder="Usuario, entidad, acción o request ID" /></label>
<label className="field compact-field"><span>Acción</span><input value={draft.action} onChange={(event) => set('action', event.target.value)} placeholder="Ej. USER_UPDATED" /></label>
<label className="field compact-field"><span>Origen</span><SearchableSelect value={draft.source} onChange={(event) => set('source', event.target.value)}><option value="">Todos</option><option>WEB</option><option>ANDROID</option><option>SYSTEM</option><option>IMPORT</option></SearchableSelect></label>
<label className="field compact-field"><span>Desde</span><input type="date" value={draft.from} onChange={(event) => set('from', event.target.value)} /></label>
<label className="field compact-field"><span>Hasta</span><input type="date" value={draft.to} onChange={(event) => set('to', event.target.value)} /></label>
<div className="filter-actions"><button className="button text" type="button" onClick={clear}>Limpiar</button><button className="button primary">Aplicar filtros</button></div>
</form>
{error && <Alert>{error}</Alert>}
{loading ? <LoadingBlock label="Consultando auditoría…" /> : events.length === 0 ? <EmptyState title="Sin eventos" text="No hay registros para los filtros seleccionados." /> : <div className="table-panel audit-table"><div className="table-scroll"><table><thead><tr><th>Fecha y hora</th><th>Usuario</th><th>Acción</th><th>Entidad</th><th>Origen</th><th>Request ID</th><th /></tr></thead><tbody>{events.map((event) => <tr key={event.id}><td>{formatDate(event.occurredAt)}</td><td><strong>{event.actorUsername ?? 'Sistema'}</strong><small className="cell-subtext">{event.ip ?? 'IP no registrada'}</small></td><td><span className={`event-badge ${event.action.includes('FAILED') || event.action.includes('REUSE') ? 'warning' : ''}`}>{actionLabel(event.action)}</span><small className="cell-subtext code-text">{event.action}</small></td><td>{event.entityType ?? '—'}<small className="cell-subtext code-text">{event.entityId ?? ''}</small></td><td><span className="tag">{event.source}</span></td><td><code>{event.requestId ? `${event.requestId.slice(0, 12)}…` : '—'}</code></td><td><button className="icon-button" onClick={() => openDetail(event.id)} aria-label="Ver detalle"><Icon name="chevron" /></button></td></tr>)}</tbody></table></div><div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>Página {page} de {Math.max(meta.totalPages, 1)}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div></div>}
{(detailLoading || detail) && <div className="modal-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget && !detailLoading) setDetail(null); }}><aside className="detail-drawer" role="dialog" aria-modal="true" aria-label="Detalle de auditoría">{detailLoading ? <LoadingBlock label="Cargando detalle…" /> : detail && <><div className="drawer-heading"><div><span className="eyebrow">EVENTO DE AUDITORÍA</span><h2>{actionLabel(detail.action)}</h2></div><button className="icon-button" onClick={() => setDetail(null)} aria-label="Cerrar">×</button></div><dl className="detail-list"><div><dt>Fecha</dt><dd>{formatDate(detail.occurredAt)}</dd></div><div><dt>Usuario</dt><dd>{detail.actorUsername ?? 'Sistema'}</dd></div><div><dt>Origen / IP</dt><dd>{detail.source} · {detail.ip ?? '—'}</dd></div><div><dt>Entidad</dt><dd>{detail.entityType ?? '—'} {detail.entityId ? ${detail.entityId}` : ''}</dd></div><div><dt>Request ID</dt><dd><code>{detail.requestId ?? '—'}</code></dd></div><div><dt>User agent</dt><dd>{detail.userAgent ?? '—'}</dd></div></dl><JsonDetail title="Datos anteriores" value={detail.beforeData} /><JsonDetail title="Datos posteriores" value={detail.afterData} /><JsonDetail title="Metadatos" value={detail.metadata} /></>}</aside></div>}
</section>;
}
function JsonDetail({ title, value }: { title: string; value: Record<string, unknown> | null }) {
if (!value) return null;
return <details className="json-detail" open><summary>{title}</summary><pre>{JSON.stringify(value, null, 2)}</pre></details>;
}