Files
mercadodevida/project/apps/admin/src/app/(dashboard)/audit/page.tsx
2026-08-19 07:54:36 +02:00

130 lines
6.1 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.

'use client';
import { useState, useEffect, useCallback } from 'react';
import { auditApi, type AuditEntry } from '@/lib/api-client';
const PAGE_SIZE = 50;
const ACTION_COLORS: Record<string, string> = {
'admin.mfa.enroll': 'bg-purple-100 text-purple-700',
'admin.mfa.status': 'bg-purple-100 text-purple-700',
'admin.mfa.challenge': 'bg-purple-100 text-purple-700',
'auth.login': 'bg-blue-100 text-blue-700',
'auth.logout': 'bg-gray-100 text-gray-600',
'product.created': 'bg-green-100 text-green-700',
'product.updated': 'bg-green-100 text-green-700',
'product.deleted': 'bg-red-100 text-red-700',
'order.placed': 'bg-indigo-100 text-indigo-700',
'order.state_changed': 'bg-indigo-100 text-indigo-700',
};
function colorForAction(action: string): string {
return ACTION_COLORS[action] ?? 'bg-gray-100 text-gray-600';
}
export default function AuditLogPage() {
const [items, setItems] = useState<AuditEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [filter, setFilter] = useState('');
const [debounced, setDebounced] = useState('');
const [page, setPage] = useState(0);
const [total, setTotal] = useState(0);
useEffect(() => {
const t = setTimeout(() => setDebounced(filter), 400);
return () => clearTimeout(t);
}, [filter]);
useEffect(() => { setPage(0); }, [debounced]);
const load = useCallback(async () => {
setLoading(true); setError('');
try {
const data = await auditApi.list({ action: debounced || undefined, limit: PAGE_SIZE, offset: page * PAGE_SIZE });
setItems(data.items ?? []);
setTotal(data.total ?? 0);
} catch (e) { setError(e instanceof Error ? e.message : 'Error'); }
finally { setLoading(false); }
}, [page, debounced]);
useEffect(() => { load(); }, [load]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Log de auditoría</h1>
<p className="text-sm text-gray-500 mt-0.5">
{total > 0 ? `${total} entrada${total !== 1 ? 's' : ''}` : 'Sin entradas'}
</p>
</div>
<div className="max-w-xs">
<div className="relative">
<input type="text" placeholder="Filtrar por acción..." value={filter}
onChange={e => setFilter(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-[#2D6A4F] outline-none" />
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">🔍</span>
</div>
</div>
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-16 text-gray-400 text-sm">Cargando...</div>
) : error ? (
<div className="flex items-center justify-center py-16 text-red-500 text-sm">{error}</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400 text-sm gap-2">
<span className="text-3xl">📋</span><span>Sin entradas de auditoría</span>
</div>
) : (
<>
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Fecha</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Acción</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Objetivo</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Actor</th>
<th className="text-left px-6 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">Detalles</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{items.map(entry => (
<tr key={entry.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
{new Date(entry.createdAt).toLocaleString('es-ES', { dateStyle: 'short', timeStyle: 'short' })}
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorForAction(entry.action)}`}>
{entry.action}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">{entry.target}</td>
<td className="px-6 py-4 text-sm text-gray-400 font-mono">{entry.actorId?.slice(0, 8) ?? '—'}</td>
<td className="px-6 py-4 text-xs text-gray-400 font-mono max-w-xs truncate">
{Object.keys(entry.metadata ?? {}).length > 0 ? JSON.stringify(entry.metadata) : '—'}
</td>
</tr>
))}
</tbody>
</table>
{total > PAGE_SIZE && (
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200">
<span className="text-sm text-gray-500">
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)} de {total}
</span>
<div className="flex gap-2">
<button disabled={page === 0} onClick={() => setPage(p => p - 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Anterior</button>
<button disabled={(page + 1) * PAGE_SIZE >= total} onClick={() => setPage(p => p + 1)}
className="px-4 py-2 text-sm border border-gray-300 rounded-xl disabled:opacity-40 hover:bg-gray-50 transition-colors">Siguiente</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}