feat: Implement a comprehensive audit log system with a dedicated dashboard page and backend API.
This commit is contained in:
73
frontend/src/lib/api/dashboard/a76/audit_log.ts
Normal file
73
frontend/src/lib/api/dashboard/a76/audit_log.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
343
frontend/src/routes/dashboard/bitacora/+page.svelte
Normal file
343
frontend/src/routes/dashboard/bitacora/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user