Files
SKEEN-Proyecto/frontend/src/components/AuditoriaPanel.tsx

210 lines
7.8 KiB
TypeScript

import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { History, Search, X } from 'lucide-react';
import { Card, Button, Input, Select, EmptyState, Skeleton, toast } from './ui';
import { odooApi, type ConfigAuditRow } from '../services/odoo';
const MODULOS = [
{ value: '', label: 'Todos los módulos' },
{ value: 'comisiones', label: 'Comisiones' },
{ value: 'tipo_cambio', label: 'Tipo de cambio' },
{ value: 'metas', label: 'Metas' },
{ value: 'horarios', label: 'Horarios' },
{ value: 'roles', label: 'Roles y permisos' },
{ value: 'usuarios', label: 'Usuarios' },
{ value: 'catalogos', label: 'Catálogos' },
];
const PAGE_SIZE = 25;
const fmtFecha = (iso: string | null) => {
if (!iso) return '—';
const [d, h] = iso.split(' ');
const [y, m, day] = d.split('-');
return `${day}/${m}/${y} ${h || ''}`.trim();
};
const recorta = (v: string, n = 60) => (v && v.length > n ? `${v.slice(0, n)}…` : v || '—');
/** Bitácora de cambios de configuración: quién cambió qué y con qué valores. */
const AuditoriaPanel: FC = () => {
const [rows, setRows] = useState<ConfigAuditRow[]>([]);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [modulo, setModulo] = useState('');
const [usuario, setUsuario] = useState('');
const [entidad, setEntidad] = useState('');
const [desde, setDesde] = useState('');
const [hasta, setHasta] = useState('');
const load = useCallback(async () => {
try {
setLoading(true);
const res = await odooApi.getConfigAudit({
module: modulo || undefined,
user: usuario || undefined,
entity: entidad || undefined,
date_from: desde || undefined,
date_to: hasta || undefined,
page,
page_size: PAGE_SIZE,
});
if (res.status === 'success') {
setRows(res.registros);
setTotal(res.total);
setTotalPages(res.total_pages || 1);
}
} catch {
toast.error('No se pudo cargar la auditoría');
} finally {
setLoading(false);
}
}, [modulo, usuario, entidad, desde, hasta, page]);
useEffect(() => {
const t = setTimeout(load, 250);
return () => clearTimeout(t);
}, [load]);
useEffect(() => {
setPage(1);
}, [modulo, usuario, entidad, desde, hasta]);
const hayFiltros = modulo || usuario || entidad || desde || hasta;
const limpiar = () => {
setModulo('');
setUsuario('');
setEntidad('');
setDesde('');
setHasta('');
};
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-end gap-3 flex-wrap">
<Select
label="Módulo"
options={MODULOS}
value={modulo}
onChange={(e) => setModulo(e.target.value)}
className="sm:max-w-[190px]"
/>
<div className="relative sm:max-w-[200px] w-full">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<Input
label="Usuario"
placeholder="login o nombre"
value={usuario}
onChange={(e) => setUsuario(e.target.value)}
className="pl-9"
/>
</div>
<Input
label="Entidad"
placeholder="médico, usuario, TC..."
value={entidad}
onChange={(e) => setEntidad(e.target.value)}
className="sm:max-w-[190px]"
/>
<Input
label="Desde"
type="date"
value={desde}
onChange={(e) => setDesde(e.target.value)}
className="sm:max-w-[160px]"
/>
<Input
label="Hasta"
type="date"
value={hasta}
onChange={(e) => setHasta(e.target.value)}
className="sm:max-w-[160px]"
/>
{hayFiltros && (
<Button variant="ghost" size="sm" onClick={limpiar} className="sm:mb-0.5">
<X size={14} className="mr-1" /> Limpiar
</Button>
)}
</div>
<Card>
<Card.Body>
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<h3 className="font-heading text-lg text-theme-heading inline-flex items-center gap-2">
<History size={18} /> Cambios de configuración
</h3>
<p className="text-xs text-theme-muted">
{total} {total === 1 ? 'registro' : 'registros'} · página {page} de {totalPages || 1}
</p>
</div>
{loading ? (
<Skeleton count={6} className="h-10 w-full" />
) : rows.length === 0 ? (
<EmptyState
title="Sin cambios registrados"
subtitle={hayFiltros ? 'Ningún registro coincide con los filtros.' : 'Aún no se ha modificado configuración sensible.'}
icon={<History size={28} />}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-theme-bg text-theme-muted text-xs uppercase">
<tr>
<th className="text-left px-3 py-2 whitespace-nowrap">Fecha</th>
<th className="text-left px-3 py-2">Usuario</th>
<th className="text-left px-3 py-2">Módulo</th>
<th className="text-left px-3 py-2">Entidad</th>
<th className="text-left px-3 py-2">Campo</th>
<th className="text-left px-3 py-2">Antes</th>
<th className="text-left px-3 py-2">Después</th>
</tr>
</thead>
<tbody className="divide-y divide-theme-border">
{rows.map((r) => (
<tr key={r.id} className="hover:bg-theme-bg align-top">
<td className="px-3 py-2 whitespace-nowrap text-theme-muted">{fmtFecha(r.date)}</td>
<td className="px-3 py-2">
<p className="font-medium text-theme-heading">{r.user_name || r.user_login || '—'}</p>
<p className="text-[11px] text-theme-muted">{r.user_login}{r.user_role ? ` · ${r.user_role}` : ''}</p>
</td>
<td className="px-3 py-2">
<span className="inline-flex rounded-full bg-theme-accent-bg px-2 py-0.5 text-[11px] font-medium text-theme-heading">
{r.module_label}
</span>
</td>
<td className="px-3 py-2">
<p className="text-theme-heading">{r.entity_label || r.entity || '—'}</p>
{r.entity_label && r.entity && <p className="text-[11px] text-theme-muted">{r.entity}</p>}
</td>
<td className="px-3 py-2 text-theme-muted">{r.field || '—'}</td>
<td className="px-3 py-2 text-theme-muted" title={r.old_value}>{recorta(r.old_value)}</td>
<td className="px-3 py-2 text-theme-heading font-medium" title={r.new_value}>{recorta(r.new_value)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="flex items-center justify-end gap-2 mt-4 pt-3 border-t border-theme-border">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
Anterior
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Siguiente
</Button>
</div>
)}
</Card.Body>
</Card>
</div>
);
};
export default AuditoriaPanel;