audit logic
This commit is contained in:
460
src/pages/AuditoriaPage.tsx
Normal file
460
src/pages/AuditoriaPage.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Search, Filter, RefreshCw, Download, Eye } from "lucide-react";
|
||||
import {
|
||||
getAuditLogs,
|
||||
type AuditLog,
|
||||
type AuditAction,
|
||||
} from "../api/audit";
|
||||
|
||||
export default function AuditoriaPage() {
|
||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedAction, setSelectedAction] = useState<AuditAction | "">("");
|
||||
const [selectedTable, setSelectedTable] = useState("");
|
||||
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const limit = 50;
|
||||
|
||||
const actions: AuditAction[] = [
|
||||
"CREATE",
|
||||
"UPDATE",
|
||||
"DELETE",
|
||||
"LOGIN",
|
||||
"LOGOUT",
|
||||
"READ",
|
||||
"EXPORT",
|
||||
"BULK_UPLOAD",
|
||||
"STATUS_CHANGE",
|
||||
"PERMISSION_CHANGE",
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuditLogs();
|
||||
}, [currentPage, selectedAction, selectedTable]);
|
||||
|
||||
const fetchAuditLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const filters: any = {
|
||||
page: currentPage,
|
||||
limit,
|
||||
};
|
||||
|
||||
if (selectedAction) filters.action = selectedAction;
|
||||
if (selectedTable) filters.tableName = selectedTable;
|
||||
|
||||
const response = await getAuditLogs(filters);
|
||||
setLogs(response.data);
|
||||
setTotal(response.pagination.total);
|
||||
setTotalPages(response.pagination.totalPages);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch audit logs:", error);
|
||||
setLogs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
setCurrentPage(1);
|
||||
fetchAuditLogs();
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setSelectedAction("");
|
||||
setSelectedTable("");
|
||||
setSearch("");
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleViewDetails = (log: AuditLog) => {
|
||||
setSelectedLog(log);
|
||||
setShowDetails(true);
|
||||
};
|
||||
|
||||
const getActionColor = (action: AuditAction) => {
|
||||
const colors: Record<AuditAction, string> = {
|
||||
CREATE: "bg-green-100 text-green-800",
|
||||
UPDATE: "bg-blue-100 text-blue-800",
|
||||
DELETE: "bg-red-100 text-red-800",
|
||||
LOGIN: "bg-purple-100 text-purple-800",
|
||||
LOGOUT: "bg-gray-100 text-gray-800",
|
||||
READ: "bg-cyan-100 text-cyan-800",
|
||||
EXPORT: "bg-yellow-100 text-yellow-800",
|
||||
BULK_UPLOAD: "bg-orange-100 text-orange-800",
|
||||
STATUS_CHANGE: "bg-indigo-100 text-indigo-800",
|
||||
PERMISSION_CHANGE: "bg-pink-100 text-pink-800",
|
||||
};
|
||||
return colors[action] || "bg-gray-100 text-gray-800";
|
||||
};
|
||||
|
||||
const filteredLogs = logs.filter((log) => {
|
||||
if (!search) return true;
|
||||
const searchLower = search.toLowerCase();
|
||||
return (
|
||||
log.user_email.toLowerCase().includes(searchLower) ||
|
||||
log.user_name.toLowerCase().includes(searchLower) ||
|
||||
log.table_name.toLowerCase().includes(searchLower) ||
|
||||
log.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
const uniqueTables = Array.from(new Set(logs.map((log) => log.table_name)));
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 border-r border-gray-200 bg-white p-4">
|
||||
<h2 className="text-lg font-semibold mb-4">Filtros</h2>
|
||||
|
||||
{/* Action Filter */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Acción
|
||||
</label>
|
||||
<select
|
||||
value={selectedAction}
|
||||
onChange={(e) => setSelectedAction(e.target.value as AuditAction | "")}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Todas las acciones</option>
|
||||
{actions.map((action) => (
|
||||
<option key={action} value={action}>
|
||||
{action}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Table Filter */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tabla
|
||||
</label>
|
||||
<select
|
||||
value={selectedTable}
|
||||
onChange={(e) => setSelectedTable(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Todas las tablas</option>
|
||||
{uniqueTables.map((table) => (
|
||||
<option key={table} value={table}>
|
||||
{table}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Clear Filters */}
|
||||
<button
|
||||
onClick={handleClearFilters}
|
||||
className="w-full bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-2 rounded-md text-sm"
|
||||
>
|
||||
Limpiar filtros
|
||||
</button>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-2">
|
||||
Estadísticas
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>Total de registros: {total}</p>
|
||||
<p>Página actual: {currentPage} de {totalPages}</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Auditoría del Sistema
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-4 py-2 rounded-md flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar por usuario, email, tabla o descripción..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
Cargando registros...
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
No se encontraron registros de auditoría
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Fecha/Hora
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Usuario
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Acción
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Tabla
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Descripción
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Estado
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredLogs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-900 whitespace-nowrap">
|
||||
{new Date(log.created_at).toLocaleString("es-MX")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<div className="font-medium text-gray-900">
|
||||
{log.user_name}
|
||||
</div>
|
||||
<div className="text-gray-500">{log.user_email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getActionColor(
|
||||
log.action
|
||||
)}`}
|
||||
>
|
||||
{log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">
|
||||
{log.table_name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
{log.description || "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
log.success
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{log.success ? "Éxito" : "Fallo"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => handleViewDetails(log)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<span className="px-4 py-2">
|
||||
Página {currentPage} de {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Siguiente
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Details Modal */}
|
||||
{showDetails && selectedLog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-auto m-4">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold">Detalles del Registro</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
ID
|
||||
</label>
|
||||
<p className="text-sm text-gray-900 font-mono">
|
||||
{selectedLog.id}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Fecha/Hora
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">
|
||||
{new Date(selectedLog.created_at).toLocaleString("es-MX")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Usuario
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">{selectedLog.user_name}</p>
|
||||
<p className="text-xs text-gray-500">{selectedLog.user_email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Acción
|
||||
</label>
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getActionColor(
|
||||
selectedLog.action
|
||||
)}`}
|
||||
>
|
||||
{selectedLog.action}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Tabla
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">{selectedLog.table_name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Record ID
|
||||
</label>
|
||||
<p className="text-sm text-gray-900 font-mono">
|
||||
{selectedLog.record_id || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
IP Address
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">
|
||||
{selectedLog.ip_address || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Estado
|
||||
</label>
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
selectedLog.success
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{selectedLog.success ? "Éxito" : "Fallo"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedLog.description && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Descripción
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">{selectedLog.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.old_values && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Valores Anteriores
|
||||
</label>
|
||||
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(selectedLog.old_values, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.new_values && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Valores Nuevos
|
||||
</label>
|
||||
<pre className="bg-gray-50 p-3 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(selectedLog.new_values, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog.error_message && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-red-700 mb-2">
|
||||
Mensaje de Error
|
||||
</label>
|
||||
<p className="text-sm text-red-900 bg-red-50 p-3 rounded">
|
||||
{selectedLog.error_message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6 border-t border-gray-200 flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowDetails(false)}
|
||||
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 rounded-md"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user