feat: Implement a comprehensive audit log system with a dedicated dashboard page and backend API.

This commit is contained in:
Galindo97
2026-02-10 14:07:20 -06:00
parent 6b88429cc6
commit f90e20469a
14 changed files with 1163 additions and 7 deletions

View File

@@ -0,0 +1,73 @@
import { api } from '$lib/api';
const BASE_PATH = '/v1/a76/audit-log';
export interface AuditLog {
spec_id: number;
reference: string;
procedure: string; // "Procedimiento"
movement: string; // "Movimiento"
username: string;
date: string; // "YYYY-MM-DD"
time: string; // "HH:MM:SS"
timestamp: string; // ISO
system: string;
operation_type?: string;
table_name?: string;
old_values?: any;
new_values?: any;
}
export interface AuditLogResponse {
data: AuditLog[];
total: number;
page: number;
page_size: number;
}
export interface AuditLogParams {
page?: number;
page_size?: number;
search?: string;
username?: string;
procedure?: string;
reference?: string;
date_from?: string;
date_to?: string;
}
export const AuditLogAPI = {
getLogs: async (params: AuditLogParams = {}): Promise<AuditLogResponse> => {
const query = new URLSearchParams();
if (params.page) query.append('page', params.page.toString());
if (params.page_size) query.append('page_size', params.page_size.toString());
if (params.search) query.append('search', params.search);
if (params.username) query.append('username', params.username);
if (params.procedure) query.append('procedure', params.procedure);
if (params.reference) query.append('reference', params.reference);
if (params.date_from) query.append('date_from', params.date_from);
if (params.date_to) query.append('date_to', params.date_to);
const response = await api.get<AuditLogResponse>(`${BASE_PATH}/bitacora?${query.toString()}`);
if (response.error || !response.data) {
throw new Error(response.error || 'Failed to fetch audit logs');
}
return response.data;
},
getProcedures: async (): Promise<string[]> => {
const response = await api.get<string[]>(`${BASE_PATH}/bitacora/procedimientos`);
if (response.error || !response.data) {
throw new Error(response.error || 'Failed to fetch procedures');
}
return response.data;
},
getDetail: async (specId: number): Promise<AuditLog> => {
const response = await api.get<AuditLog>(`${BASE_PATH}/bitacora/${specId}/detalle`);
if (response.error || !response.data) {
throw new Error(response.error || 'Failed to fetch audit log detail');
}
return response.data;
}
};

View File

@@ -1,5 +1,4 @@
const BASE_URL = import.meta.env.VITE_API_URL || '';
const BASE_URL = import.meta.env.VITE_API_URL || '';
export const invoicesReportsApi = {
@@ -15,7 +14,6 @@ export const invoicesReportsApi = {
const token = localStorage.getItem('access_token');
const response = await fetch(endpoint, {
method: 'POST',
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
@@ -25,14 +23,11 @@ export const invoicesReportsApi = {
if (!response.ok) throw new Error('Error al iniciar la generación');
return await response.json();
return await response.json();
},
getTaskStatus: async (taskId: string) => {
const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`;
const endpoint = `${BASE_URL}/v1/a76/reports/importacion/facturas/tasks/${taskId}`;
const token = localStorage.getItem('access_token');
const response = await fetch(endpoint, {
method: 'GET',

View File

@@ -77,6 +77,13 @@ export function getSidebarData(): SidebarData {
icon: LayoutDashboard,
items: [],
},
{
title: "Bitácora",
url: "/dashboard/bitacora",
icon: Shield,
items: [],
},
{
title: m["sidebar.reference_data.title"](),
url: "/dashboard",

View File

@@ -0,0 +1,343 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
AuditLogAPI,
type AuditLog,
type AuditLogParams
} from '$lib/api/dashboard/a76/audit_log';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Table from '$lib/components/ui/table';
import {
RefreshCw,
Search,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight
} from 'lucide-svelte';
// State
let logs: AuditLog[] = []; // $state([]) in runes mode, but using let for now as per file style
let total: number = 0;
let page: number = 1;
let pageSize: number = 50;
let loading: boolean = false;
let error: string | null = null;
let totalPages: number = 1;
// Filters
let search: string = '';
let usernameFilter: string = '';
let procedureFilter: string = '';
let dateFrom: string = '';
let dateTo: string = '';
let procedures: string[] = [];
// Selected Log for detail modal (if needed, or navigate)
let selectedLog: AuditLog | null = null;
// Infinite Scroll State
let hasMore: boolean = true;
let sentinel: HTMLElement;
async function loadLogs() {
if (loading) return;
loading = true;
error = null;
try {
const params: AuditLogParams = {
page,
page_size: pageSize,
search: search || undefined,
username: usernameFilter || undefined,
procedure: procedureFilter || undefined,
date_from: dateFrom || undefined,
date_to: dateTo || undefined
};
const response = await AuditLogAPI.getLogs(params);
if (page === 1) {
logs = response.data;
} else {
logs = [...logs, ...response.data];
}
total = response.total;
hasMore = logs.length < total;
} catch (e: any) {
error = e.message;
} finally {
loading = false;
}
}
async function loadProcedures() {
try {
procedures = await AuditLogAPI.getProcedures();
} catch (e) {
console.error(e);
}
}
// Debounce timer
let timer: ReturnType<typeof setTimeout>;
function handleFilterChange() {
page = 1;
hasMore = true;
// Reset logs immediately to avoid confusion (optional, but good for UX)
logs = [];
loadLogs();
}
function handleSearchInput() {
clearTimeout(timer);
timer = setTimeout(() => {
page = 1;
hasMore = true;
logs = [];
loadLogs();
}, 300);
}
function clearFilters() {
search = '';
usernameFilter = '';
procedureFilter = '';
dateFrom = '';
dateTo = '';
handleFilterChange();
}
function formatDate(dateStr: string): string {
if (!dateStr) return '';
const [y, m, d] = dateStr.split('-');
return `${d}/${m}/${y}`;
}
function formatTime(timeStr: string): string {
if (!timeStr) return '';
try {
const [h, m] = timeStr.split(':');
let hour = parseInt(h);
const ampm = hour >= 12 ? 'PM' : 'AM';
hour = hour % 12;
hour = hour ? hour : 12;
return `${hour.toString().padStart(2, '0')}:${m} ${ampm}`;
} catch {
return timeStr;
}
}
onMount(() => {
loadProcedures();
loadLogs();
// Intersection Observer for Infinite Scroll
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
page++;
loadLogs();
}
},
{ rootMargin: '100px' }
);
if (sentinel) {
observer.observe(sentinel);
}
return () => {
observer.disconnect();
};
});
</script>
<div class="flex h-[calc(100vh-85px)] flex-col space-y-4 overflow-hidden">
<div class="flex flex-none items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Bitácora de Movimientos</h1>
<p class="text-muted-foreground">Auditoría detallada de operaciones del sistema</p>
</div>
<div class="flex gap-2">
<Button
variant="outline"
onclick={() => {
page = 1;
hasMore = true;
logs = [];
loadLogs();
}}
disabled={loading}
>
<RefreshCw class="mr-2 h-4 w-4 {loading ? 'animate-spin' : ''}" />
Actualizar
</Button>
</div>
</div>
<!-- Filters -->
<div class="flex-none">
<Card.Root>
<Card.Header class="py-3">
<Card.Title class="text-lg">Filtros</Card.Title>
</Card.Header>
<Card.Content>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-5">
<div class="space-y-1">
<Label for="search" class="text-xs">Búsqueda General</Label>
<div class="relative">
<Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
<Input
id="search"
placeholder="Ref, Mov, Usuario..."
class="h-9 pl-8"
bind:value={search}
oninput={handleSearchInput}
/>
</div>
</div>
<div class="space-y-1">
<Label for="username" class="text-xs">Usuario</Label>
<Input
id="username"
placeholder="Filtrar por usuario"
class="h-9"
bind:value={usernameFilter}
oninput={handleSearchInput}
/>
</div>
<div class="space-y-1">
<Label for="procedure" class="text-xs">Procedimiento</Label>
<select
id="procedure"
bind:value={procedureFilter}
onchange={handleFilterChange}
class="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="">Todos</option>
{#each procedures as proc}
<option value={proc}>{proc}</option>
{/each}
</select>
</div>
<div class="space-y-1">
<Label for="dateFrom" class="text-xs">Desde</Label>
<Input
id="dateFrom"
type="date"
class="h-9"
bind:value={dateFrom}
onchange={handleFilterChange}
/>
</div>
<div class="space-y-1">
<Label for="dateTo" class="text-xs">Hasta</Label>
<Input
id="dateTo"
type="date"
class="h-9"
bind:value={dateTo}
onchange={handleFilterChange}
/>
</div>
</div>
<div class="mt-2 flex justify-end">
<Button variant="ghost" onclick={clearFilters} size="sm" class="h-8 text-xs font-normal"
>Limpiar Filtros</Button
>
</div>
</Card.Content>
</Card.Root>
</div>
<!-- Table -->
<Card.Root class="flex min-h-0 flex-1 flex-col overflow-hidden">
<Card.Header class="flex-none py-3">
<div class="flex items-center justify-between">
<div>
<Card.Title class="text-lg">Registros</Card.Title>
<Card.Description class="text-xs">Total: {total} registros encontrados</Card.Description>
</div>
</div>
</Card.Header>
<Card.Content class="flex min-h-0 flex-1 flex-col p-0 px-6 pb-6">
{#if error}
<div class="rounded-md bg-red-50 p-4 text-center text-red-500">
Error al cargar datos: {error}
</div>
{:else}
<div class="relative min-h-0 flex-1 overflow-y-auto rounded-md border bg-card shadow-inner">
<Table.Root>
<Table.Header class="sticky top-0 z-10 bg-background/95 shadow-sm backdrop-blur-sm">
<Table.Row>
<Table.Head class="w-[80px]">ID</Table.Head>
<Table.Head class="w-[180px]">Referencia</Table.Head>
<Table.Head>Procedimiento</Table.Head>
<Table.Head>Movimiento</Table.Head>
<Table.Head class="w-[150px]">Usuario</Table.Head>
<Table.Head class="w-[120px]">Fecha</Table.Head>
<Table.Head class="w-[120px]">Hora</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading && logs.length === 0}
<Table.Row>
<Table.Cell colspan={7} class="h-24 text-center text-muted-foreground italic"
>Cargando...</Table.Cell
>
</Table.Row>
{:else if logs.length === 0}
<Table.Row>
<Table.Cell colspan={7} class="h-24 text-center text-muted-foreground"
>No se encontraron registros</Table.Cell
>
</Table.Row>
{:else}
{#each logs as log}
<Table.Row class="transition-colors hover:bg-muted/50">
<Table.Cell class="text-xs font-medium text-muted-foreground"
>{log.spec_id}</Table.Cell
>
<Table.Cell class="text-sm font-bold text-blue-600 dark:text-blue-400"
>{log.reference}</Table.Cell
>
<Table.Cell class="text-sm">{log.procedure}</Table.Cell>
<Table.Cell class="text-sm">{log.movement}</Table.Cell>
<Table.Cell class="text-sm">{log.username}</Table.Cell>
<Table.Cell class="text-sm">{formatDate(log.date)}</Table.Cell>
<Table.Cell class="text-sm">{formatTime(log.time)}</Table.Cell>
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
<!-- Infinite Scroll Sentinel (Inside the scrollable container) -->
<div bind:this={sentinel} class="flex h-12 w-full items-center justify-center p-4">
{#if loading && logs.length > 0}
<div class="flex items-center gap-2">
<RefreshCw class="h-4 w-4 animate-spin text-primary" />
<span class="text-sm text-muted-foreground">Cargando más registros...</span>
</div>
{:else if !hasMore && logs.length > 0}
<span class="text-xs tracking-wider text-muted-foreground uppercase opacity-50"
>Fin de la bitácora</span
>
{/if}
</div>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>