215 lines
8.1 KiB
TypeScript
215 lines
8.1 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' },
|
|
{ value: 'pacientes', label: 'Expedientes de pacientes' },
|
|
{ value: 'agenda', label: 'Agenda' },
|
|
{ value: 'paquetes', label: 'Paquetes' },
|
|
{ value: 'monedero', label: 'Monedero' },
|
|
{ value: 'clinica', label: 'Clínica' },
|
|
];
|
|
|
|
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;
|