2026-01-06 10:34:06 -03:00
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
2025-12-23 15:12:57 -03:00
|
|
|
import api from '../../services/api';
|
2026-01-06 10:34:06 -03:00
|
|
|
import { History, User as UserIcon, CheckCircle, XCircle, FileText, Search, Calendar, Users, AlertCircle, Info } from 'lucide-react';
|
|
|
|
|
import clsx from 'clsx';
|
2025-12-23 15:12:57 -03:00
|
|
|
|
2026-01-05 10:30:04 -03:00
|
|
|
interface AuditLog {
|
|
|
|
|
id: number;
|
|
|
|
|
action: string;
|
|
|
|
|
username: string;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
details: string;
|
2026-01-06 10:34:06 -03:00
|
|
|
userId: number;
|
2026-01-05 10:30:04 -03:00
|
|
|
}
|
|
|
|
|
|
2025-12-23 15:12:57 -03:00
|
|
|
export default function AuditTimeline() {
|
2026-01-06 10:34:06 -03:00
|
|
|
const todayStr = new Date().toISOString().split('T')[0];
|
2026-01-05 10:30:04 -03:00
|
|
|
const [logs, setLogs] = useState<AuditLog[]>([]);
|
2026-01-06 10:34:06 -03:00
|
|
|
const [users, setUsers] = useState<{ id: number, username: string }[]>([]);
|
2025-12-23 15:12:57 -03:00
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
2026-01-06 10:34:06 -03:00
|
|
|
// ESTADO DE FILTROS
|
|
|
|
|
const [filters, setFilters] = useState({
|
|
|
|
|
from: todayStr,
|
|
|
|
|
to: todayStr,
|
|
|
|
|
userId: ""
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const loadInitialData = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await api.get('/users');
|
|
|
|
|
setUsers(res.data);
|
|
|
|
|
} catch (e) { console.error(e); }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const loadLogs = useCallback(async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const res = await api.get('/reports/audit', {
|
|
|
|
|
params: {
|
|
|
|
|
from: filters.from,
|
|
|
|
|
to: filters.to,
|
|
|
|
|
userId: filters.userId || null
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-12-23 15:12:57 -03:00
|
|
|
setLogs(res.data);
|
2026-01-06 10:34:06 -03:00
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e);
|
|
|
|
|
} finally {
|
2025-12-23 15:12:57 -03:00
|
|
|
setLoading(false);
|
2026-01-06 10:34:06 -03:00
|
|
|
}
|
|
|
|
|
}, [filters]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => { loadInitialData(); }, []);
|
|
|
|
|
useEffect(() => { loadLogs(); }, [loadLogs]);
|
2025-12-23 15:12:57 -03:00
|
|
|
|
2026-01-06 10:34:06 -03:00
|
|
|
const translateDetails = (details: string) => {
|
|
|
|
|
if (!details) return details;
|
|
|
|
|
return details
|
|
|
|
|
.replace(/Published/g, 'Publicado')
|
|
|
|
|
.replace(/Pending/g, 'Pendiente')
|
|
|
|
|
.replace(/Rejected/g, 'Rechazado')
|
|
|
|
|
.replace(/Draft/g, 'Borrador')
|
|
|
|
|
.replace(/True/g, 'Sí')
|
|
|
|
|
.replace(/False/g, 'No')
|
|
|
|
|
.replace(/Total:/g, 'Total:')
|
|
|
|
|
.replace(/Featured:/g, 'Destacado:')
|
|
|
|
|
.replace(/El usuario (\d+) cambió el estado del aviso #(\d+) a/g, 'Se cambió el estado del aviso #$2 a')
|
|
|
|
|
.replace(/Aviso creado por usuario autenticado/g, 'Nuevo aviso creado');
|
2025-12-23 15:12:57 -03:00
|
|
|
};
|
|
|
|
|
|
2026-01-06 10:34:06 -03:00
|
|
|
const getLogMeta = (action: string) => {
|
|
|
|
|
switch (action) {
|
|
|
|
|
case 'APPROVE_LISTING':
|
|
|
|
|
case 'Published':
|
|
|
|
|
case 'Aprobar':
|
|
|
|
|
return { icon: <CheckCircle className="text-emerald-500" size={16} />, color: 'bg-emerald-50 text-emerald-700 border-emerald-100', label: 'Aprobación' };
|
|
|
|
|
|
|
|
|
|
case 'REJECT_LISTING':
|
|
|
|
|
case 'Rejected':
|
|
|
|
|
case 'Rechazar':
|
|
|
|
|
return { icon: <XCircle className="text-rose-500" size={16} />, color: 'bg-rose-50 text-rose-700 border-rose-100', label: 'Rechazo' };
|
|
|
|
|
|
|
|
|
|
case 'CREATE_LISTING':
|
|
|
|
|
case 'CREATE_USER':
|
|
|
|
|
case 'CREATE_COUPON':
|
|
|
|
|
case 'CREATE_CATEGORY':
|
|
|
|
|
return { icon: <FileText className="text-blue-500" size={16} />, color: 'bg-blue-50 text-blue-700 border-blue-100', label: 'Creación' };
|
|
|
|
|
|
|
|
|
|
case 'UPDATE_USER':
|
|
|
|
|
case 'UPDATE_CLIENT':
|
|
|
|
|
case 'UPDATE_CATEGORY':
|
|
|
|
|
case 'SAVE_PRICING':
|
|
|
|
|
case 'UPDATE_PROMOTION':
|
|
|
|
|
return { icon: <Search className="text-amber-500" size={16} />, color: 'bg-amber-50 text-amber-700 border-amber-100', label: 'Actualización' };
|
|
|
|
|
|
|
|
|
|
case 'DELETE_USER':
|
|
|
|
|
case 'DELETE_COUPON':
|
|
|
|
|
case 'DELETE_CATEGORY':
|
|
|
|
|
case 'DELETE_PROMOTION':
|
|
|
|
|
return { icon: <XCircle className="text-slate-600" size={16} />, color: 'bg-slate-100 text-slate-700 border-slate-200', label: 'Eliminación' };
|
|
|
|
|
|
|
|
|
|
case 'LOGIN':
|
|
|
|
|
return { icon: <Users className="text-indigo-500" size={16} />, color: 'bg-indigo-50 text-indigo-700 border-indigo-100', label: 'Acceso' };
|
|
|
|
|
|
|
|
|
|
case 'CONFIRM_PAYMENT':
|
|
|
|
|
return { icon: <CheckCircle className="text-cyan-500" size={16} />, color: 'bg-cyan-50 text-cyan-700 border-cyan-100', label: 'Pago' };
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return { icon: <Info className="text-slate-400" size={16} />, color: 'bg-slate-50 text-slate-500 border-slate-200', label: action.replace('_', ' ') };
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-23 15:12:57 -03:00
|
|
|
return (
|
2026-01-06 10:34:06 -03:00
|
|
|
<div className="space-y-6 max-w-6xl mx-auto">
|
|
|
|
|
{/* HEADER PREMIUM */}
|
|
|
|
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="text-3xl font-black text-slate-800 uppercase tracking-tighter flex items-center gap-3">
|
|
|
|
|
<History size={32} className="text-blue-600" /> Auditoría de Sistema
|
|
|
|
|
</h2>
|
|
|
|
|
<p className="text-slate-500 font-medium text-sm mt-1">Trazabilidad completa de acciones y cambios de estado</p>
|
|
|
|
|
</div>
|
2025-12-23 15:12:57 -03:00
|
|
|
</div>
|
|
|
|
|
|
2026-01-06 10:34:06 -03:00
|
|
|
{/* BARRA DE FILTROS */}
|
|
|
|
|
<div className="bg-white p-6 rounded-[2rem] border border-slate-200 shadow-xl shadow-slate-200/50 flex flex-wrap items-end gap-6">
|
|
|
|
|
<div className="flex-1 min-w-[200px]">
|
|
|
|
|
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-2">
|
|
|
|
|
<Users size={12} /> Usuario Responsable
|
|
|
|
|
</label>
|
|
|
|
|
<select
|
|
|
|
|
className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl px-4 py-2.5 font-bold text-sm outline-none focus:border-blue-500 transition-all appearance-none"
|
|
|
|
|
value={filters.userId}
|
|
|
|
|
onChange={e => setFilters({ ...filters, userId: e.target.value })}
|
|
|
|
|
>
|
|
|
|
|
<option value="">TODOS LOS USUARIOS</option>
|
|
|
|
|
{users.map(u => <option key={u.id} value={u.id}>{u.username.toUpperCase()}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col md:flex-row gap-4 flex-[2]">
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-2">
|
|
|
|
|
<Calendar size={12} /> Desde
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="date"
|
|
|
|
|
className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl px-4 py-2 text-sm font-bold outline-none focus:border-blue-500"
|
|
|
|
|
value={filters.from}
|
|
|
|
|
onChange={e => setFilters({ ...filters, from: e.target.value })}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<label className="block text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 flex items-center gap-2">
|
|
|
|
|
<Calendar size={12} /> Hasta
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="date"
|
|
|
|
|
className="w-full bg-slate-50 border-2 border-slate-100 rounded-xl px-4 py-2 text-sm font-bold outline-none focus:border-blue-500"
|
|
|
|
|
value={filters.to}
|
|
|
|
|
onChange={e => setFilters({ ...filters, to: e.target.value })}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2025-12-23 15:12:57 -03:00
|
|
|
</div>
|
|
|
|
|
|
2026-01-06 10:34:06 -03:00
|
|
|
<button
|
|
|
|
|
onClick={loadLogs}
|
|
|
|
|
className="bg-slate-900 text-white p-3 rounded-xl hover:bg-black transition-all shadow-lg shadow-slate-200"
|
|
|
|
|
>
|
|
|
|
|
<Search size={20} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* LISTA DE EVENTOS TIPO TIMELINE */}
|
|
|
|
|
<div className="relative">
|
|
|
|
|
{/* Línea vertical de fondo para el timeline */}
|
|
|
|
|
<div className="absolute left-12 top-0 bottom-0 w-0.5 bg-slate-100 hidden md:block"></div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
2025-12-23 15:12:57 -03:00
|
|
|
{loading ? (
|
2026-01-06 10:34:06 -03:00
|
|
|
<div className="py-20 text-center flex flex-col items-center gap-4">
|
|
|
|
|
<div className="w-12 h-12 border-4 border-slate-100 border-t-blue-600 rounded-full animate-spin"></div>
|
|
|
|
|
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">Sincronizando logs...</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : logs.length === 0 ? (
|
|
|
|
|
<div className="bg-slate-50 rounded-[2rem] border border-dashed border-slate-200 p-20 text-center text-slate-400">
|
|
|
|
|
<AlertCircle size={40} className="mx-auto mb-4 opacity-20" />
|
|
|
|
|
<p className="font-bold uppercase text-xs tracking-widest">No hay eventos para el período seleccionado</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : logs.map((log, idx) => {
|
|
|
|
|
const meta = getLogMeta(log.action);
|
|
|
|
|
return (
|
|
|
|
|
<div key={log.id} className="relative group animate-fade-in" style={{ animationDelay: `${idx * 50}ms` }}>
|
|
|
|
|
<div className="flex flex-col md:flex-row items-start md:items-center gap-6">
|
|
|
|
|
|
|
|
|
|
{/* HORA Y PUNTO (Timeline) */}
|
|
|
|
|
<div className="hidden md:flex flex-col items-end w-24 flex-shrink-0">
|
|
|
|
|
<span className="text-xs font-black text-slate-900">{new Date(log.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
|
|
|
|
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-tighter">{new Date(log.createdAt).toLocaleDateString()}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* ICONO CENTRAL BUBBLE */}
|
|
|
|
|
<div className={clsx(
|
|
|
|
|
"w-10 h-10 rounded-full flex items-center justify-center border-4 border-white shadow-sm z-10 transition-transform group-hover:scale-110",
|
|
|
|
|
meta.color.split(' ')[0]
|
|
|
|
|
)}>
|
|
|
|
|
{meta.icon}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* CARD DE CONTENIDO */}
|
|
|
|
|
<div className="flex-1 bg-white p-5 rounded-[1.5rem] border border-slate-100 shadow-sm group-hover:shadow-md group-hover:border-blue-100 transition-all">
|
|
|
|
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 mb-2">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="text-[10px] font-black text-blue-600 uppercase tracking-widest mb-0.5">{meta.label}</span>
|
|
|
|
|
<span className="text-sm font-black text-slate-800 uppercase tracking-tight flex items-center gap-2">
|
|
|
|
|
<UserIcon size={14} className="text-slate-300" /> {log.username}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="px-3 py-1 bg-slate-50 rounded-full text-[9px] font-black text-slate-400 uppercase tracking-wider border border-slate-100">
|
|
|
|
|
#{log.id}
|
|
|
|
|
</span>
|
|
|
|
|
<div className="md:hidden text-[10px] font-bold text-slate-400">
|
|
|
|
|
{new Date(log.createdAt).toLocaleString()}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p className="text-sm font-medium text-slate-600 leading-relaxed bg-slate-50/50 p-3 rounded-xl border border-slate-50 italic">
|
|
|
|
|
{translateDetails(log.details)}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-12-23 15:12:57 -03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-06 10:34:06 -03:00
|
|
|
);
|
|
|
|
|
})}
|
2025-12-23 15:12:57 -03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-01-06 10:34:06 -03:00
|
|
|
</div >
|
2025-12-23 15:12:57 -03:00
|
|
|
);
|
|
|
|
|
}
|