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,116 @@
"""
Audit Log Events
"""
from sqlalchemy import event, inspect
from sqlalchemy.orm import Session
from .services.service import AuditService
from core.context import get_user_context
def register_audit_listeners(models_to_audit):
"""
Register SQLAlchemy listeners for given models
"""
for model in models_to_audit:
event.listen(model, "after_insert", after_insert_listener)
event.listen(model, "after_update", after_update_listener)
event.listen(model, "after_delete", after_delete_listener)
def _get_current_username():
try:
context = get_user_context()
if context:
# Token usually has 'preferred_username' or 'name' or 'sub'
return context.get("preferred_username") or context.get("email") or context.get("sub") or "System"
except:
pass
return "System"
def after_insert_listener(mapper, connection, target):
"""
Listener for INSERT operations
"""
table_name = target.__tablename__
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
username = _get_current_username()
company_id = getattr(target, "company_id", None)
# Create a session bound to the connection
session = Session(bind=connection)
try:
AuditService.log_crud_operation(
db=session,
table_name=table_name,
operation_type="CREATE",
record_data=record_data,
username=username,
record_id=str(getattr(target, "id", "")),
company_id=company_id
)
except Exception as e:
print(f"Error logging insert: {e}")
finally:
session.close()
def after_update_listener(mapper, connection, target):
"""
Listener for UPDATE operations
"""
table_name = target.__tablename__
state = inspect(target)
changes = {}
old_values = {}
new_values = {}
for attr in state.attrs:
hist = attr.history
if hist.has_changes():
changes[attr.key] = hist.added[0] if hist.added else None
old_values[attr.key] = hist.deleted[0] if hist.deleted else None
new_values[attr.key] = hist.added[0] if hist.added else None
if not changes:
return
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
username = _get_current_username()
session = Session(bind=connection)
try:
AuditService.log_crud_operation(
db=session,
table_name=table_name,
operation_type="UPDATE",
record_data=record_data,
username=username,
record_id=str(getattr(target, "id", "")),
old_values=old_values,
new_values=new_values
)
except Exception as e:
print(f"Error logging update: {e}")
finally:
session.close()
def after_delete_listener(mapper, connection, target):
"""
Listener for DELETE operations
"""
table_name = target.__tablename__
record_data = {c.name: getattr(target, c.name) for c in mapper.columns}
username = _get_current_username()
session = Session(bind=connection)
try:
AuditService.log_crud_operation(
db=session,
table_name=table_name,
operation_type="DELETE",
record_data=record_data,
username=username,
record_id=str(getattr(target, "id", ""))
)
except Exception as e:
print(f"Error logging delete: {e}")
finally:
session.close()

View File

@@ -0,0 +1,22 @@
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from core.security import verify_token
from core.context import set_user_context
class UserContextMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.split(" ")[1]
try:
# verify_token might raise exception if invalid, we catch it to not block request
# but we won't have user context
user_info = verify_token(token)
set_user_context(user_info)
except Exception:
# Log error or ignore
pass
response = await call_next(request)
return response

View File

@@ -0,0 +1,56 @@
"""
Audit Log Models
"""
from sqlalchemy import Column, Integer, String, Date, Time, DateTime, Text, Index, func
from sqlalchemy.dialects.postgresql import JSONB, ARRAY
from core.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
# Primary Key
spec_id = Column(Integer, primary_key=True, autoincrement=True)
# Legacy Display Columns (English names as requested)
reference = Column(String(100), nullable=False, index=True) # Legacy: Referencia
procedure = Column(String(100), nullable=False, index=True) # Legacy: Procedimiento
movement = Column(String(255), nullable=False) # Legacy: Movimiento
username = Column(String(100), nullable=False, index=True) # Legacy: Usuario
date = Column(Date, nullable=False, index=True) # Legacy: Fecha
time = Column(Time, nullable=False) # Legacy: Hora
# Technical Columns
timestamp = Column(DateTime(timezone=True), nullable=False, index=True) # Combined for queries
system = Column(String(20), nullable=False, index=True, default="SCAF")
company_id = Column(Integer, nullable=True, index=True)
tenant_id = Column(Integer, nullable=True, index=True)
# Traceability
table_name = Column(String(100), nullable=True, index=True)
record_id = Column(String(255), nullable=True, index=True)
operation_type = Column(String(20), nullable=True, index=True) # CREATE, UPDATE, DELETE, LOGIN
# Data Changes
old_values = Column(JSONB, nullable=True)
new_values = Column(JSONB, nullable=True)
changed_fields = Column(ARRAY(String), nullable=True)
# Request Context
ip_address = Column(String(45), nullable=True)
user_agent = Column(Text, nullable=True)
endpoint = Column(String(500), nullable=True)
request_method = Column(String(10), nullable=True)
session_id = Column(String(50), nullable=True, index=True)
execution_time_ms = Column(Integer, nullable=True)
# Metadata
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Composite Indexes for common filters
__table_args__ = (
Index('idx_audit_username_date', 'username', 'date'),
Index('idx_audit_procedure_date', 'procedure', 'date'),
Index('idx_audit_system_timestamp', 'system', 'timestamp'),
Index('idx_audit_table_record', 'table_name', 'record_id'),
)

View File

@@ -0,0 +1,93 @@
"""
Audit Log Router
"""
from typing import List, Optional
from datetime import date
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import or_, desc, distinct
from core.database import get_core_db
from core.security import get_current_user # Assuming this exists
from .models import AuditLog
from .schemas import AuditLogListResponse, AuditLogResponse, AuditLogDetailResponse
router = APIRouter()
@router.get("/bitacora", response_model=AuditLogListResponse)
async def get_bitacora(
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=100),
search: Optional[str] = None,
username: Optional[str] = None,
procedure: Optional[str] = None,
reference: Optional[str] = None,
date_from: Optional[date] = None,
date_to: Optional[date] = None,
db: Session = Depends(get_core_db)
):
"""
Get legacy audit log (Bitácora)
"""
query = db.query(AuditLog)
# Filters
if date_from:
query = query.filter(AuditLog.date >= date_from)
if date_to:
query = query.filter(AuditLog.date <= date_to)
if username:
query = query.filter(AuditLog.username.ilike(f"%{username}%"))
if procedure:
# Exact match for dropdown filter usually better, but let's allow partial if manual
# Legacy UI sends exact strings usually
query = query.filter(AuditLog.procedure == procedure)
if reference:
query = query.filter(AuditLog.reference.ilike(f"%{reference}%"))
if search:
# General search across main columns
search_filter = or_(
AuditLog.reference.ilike(f"%{search}%"),
AuditLog.procedure.ilike(f"%{search}%"),
AuditLog.movement.ilike(f"%{search}%"),
AuditLog.username.ilike(f"%{search}%")
)
query = query.filter(search_filter)
total = query.count()
# Sort by ID desc (newest first) -> Legacy usually shows newest first or spec_id desc
logs = query.order_by(desc(AuditLog.spec_id))\
.offset((page - 1) * page_size)\
.limit(page_size)\
.all()
return {
"data": logs,
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/bitacora/procedimientos", response_model=List[str])
async def get_procedures(db: Session = Depends(get_core_db)):
"""
Get distinct list of procedures for filters
"""
results = db.query(distinct(AuditLog.procedure))\
.order_by(AuditLog.procedure)\
.all()
# verify if result is tuple
return [r[0] for r in results if r[0]]
@router.get("/bitacora/{spec_id}/detalle", response_model=AuditLogDetailResponse)
async def get_audit_detail(spec_id: int, db: Session = Depends(get_core_db)):
"""
Get full detail of a log entry
"""
log = db.query(AuditLog).filter(AuditLog.spec_id == spec_id).first()
if not log:
raise HTTPException(status_code=404, detail="Log entry not found")
return log

View File

@@ -0,0 +1,52 @@
"""
Audit Log Schemas
"""
from typing import Optional, List, Any, Dict
from datetime import date as date_type, time as time_type, datetime
from pydantic import BaseModel, Field
# --- Response Schemas ---
class AuditLogResponse(BaseModel):
"""
Standard response showing the Legacy columns
"""
spec_id: int
reference: str
procedure: str
movement: str
username: str
date: date_type
time: time_type
# Modern extras
timestamp: datetime
system: str
operation_type: Optional[str] = None
table_name: Optional[str] = None
record_id: Optional[str] = None
class Config:
from_attributes = True
class AuditLogDetailResponse(AuditLogResponse):
"""
Detailed response including changed values
"""
old_values: Optional[Dict[str, Any]] = None
new_values: Optional[Dict[str, Any]] = None
changed_fields: Optional[List[str]] = None
ip_address: Optional[str] = None
execution_time_ms: Optional[int] = None
# --- List Response ---
class AuditLogListResponse(BaseModel):
"""
Paginated response
"""
data: List[AuditLogResponse]
total: int
page: int
page_size: int

View File

@@ -0,0 +1,148 @@
"""
Audit Log Core Logic: Reference Generation and Mapping
"""
from typing import Optional, Dict, Any, Tuple
class ReferenceGenerator:
"""
Generates legacy-style references (e.g., FUSE0-040-10)
"""
@staticmethod
def generate_invoice_reference(invoice_data: Dict[str, Any]) -> str:
"""
Format: {SYSTEM}-{CUSTOMS}-{YEAR}
Example: FUSE0-040-10
"""
# Default values
system = "FUSE0"
customs = "000"
year = "00"
# Try to extract system (invoice_type usually holds this key)
if invoice_data.get("invoice_type"):
system = str(invoice_data["invoice_type"])
# Try to extract customs (need to look into nested compliance_mx if available, or just use default)
# Since this receives a dictionary from the mapper, we might not have deep nested relations resolved
# We'll try to do our best with available data
# Try to get year from invoice_date
if invoice_data.get("invoice_date"):
try:
# invoice_date can be a date object or string
d = invoice_data["invoice_date"]
if hasattr(d, "year"):
y = d.year
else:
# Assume string YYYY-MM-DD
y = int(str(d)[:4])
year = str(y)[-2:]
except:
pass
return f"{system}-{customs}-{year}"
@staticmethod
def generate_invoice_item_reference(invoice_ref: str, item_data: Dict[str, Any]) -> str:
"""
Format: {INVOICE_REF}-{ITEM_PART}
Example: FUSE0-040-10-FUS035
"""
part_number = item_data.get("part_number", "ITEM")
return f"{invoice_ref}-{part_number}"
@staticmethod
def generate_pedimento_reference(pedimento_data: Dict[str, Any]) -> str:
"""
Format: {LICENSE}-{CUSTOMS}{YEAR}{NUMBER}
Example: 0756C-040010315
"""
license = str(pedimento_data.get("license", "0000")).strip()
customs = str(pedimento_data.get("customs_office", "000")).zfill(3)
year = str(pedimento_data.get("year", "00")).zfill(2)
number = str(pedimento_data.get("pedimento_number", "0000000")).zfill(7)
return f"{license}-{customs}{year}{number}"
class AuditMapper:
"""
Maps table names and operations to English Procedures and Movements
"""
# Map table names to Legacy Procedures (English)
TABLE_TO_PROCEDURE = {
# Invoices
"invoice_header": "IMPORT INVOICE BROWSE", # BROWSEOFACIMP
"invoice_sales_details": "IMPORT INVOICE UPDATE", # UPDATEOFACIMP
# Exports would be similar but we start with general
# Pedimentos
"pedimentos": "PEDIMENTO BROWSE", # BROWSEPEDIMEN
# System
"users": "SYSTEM SCAF",
"sessions": "SYSTEM SCAF",
# General fallbacks
"clients_and_providers": "CATALOGS",
"items": "CATALOGS",
}
# Map (Table, Operation) to Legacy Movements (English)
OPERATION_TO_MOVEMENT = {
("invoice_header", "CREATE"): "ADD IMPORT_INVOICE",
("invoice_header", "UPDATE"): "EDIT IMPORT_INVOICE",
("invoice_header", "DELETE"): "DELETE IMPORT_INVOICE",
("invoice_sales_details", "CREATE"): "ADD IMPORT_INVOICE_ITEM",
("invoice_sales_details", "UPDATE"): "EDIT IMPORT_INVOICE_ITEM",
("invoice_sales_details", "DELETE"): "DELETE IMPORT_INVOICE_ITEM",
("pedimentos", "CREATE"): "ADD PEDIMENTO",
("pedimentos", "UPDATE"): "EDIT PEDIMENTO",
("pedimentos", "DELETE"): "DELETE PEDIMENTO",
("auth", "LOGIN"): "SYSTEM LOGIN",
("auth", "LOGOUT"): "SYSTEM LOGOUT",
}
@staticmethod
def map_to_legacy_format(
table_name: str,
record_id: str,
operation_type: str,
username: str,
system: str = "SCAF"
) -> Dict[str, Any]:
"""
Returns dictionary with keys: reference, procedure, movement, username, system
"""
# Determine Procedure
procedure = AuditMapper.TABLE_TO_PROCEDURE.get(
table_name,
table_name.upper().replace("_", " ") # Fallback
)
# Determine Movement
movement_key = (table_name, operation_type)
movement = AuditMapper.OPERATION_TO_MOVEMENT.get(
movement_key,
f"{operation_type} {table_name.upper()}"
)
# Determine Reference Base
if operation_type in ["LOGIN", "LOGOUT"]:
reference = operation_type
else:
reference = record_id or "NO-REF"
return {
"reference": reference,
"procedure": procedure,
"movement": movement,
"username": username,
"system": system
}

View File

@@ -0,0 +1,162 @@
"""
Audit Log Service
"""
from datetime import datetime
import pytz
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from ..models import AuditLog
from .core import AuditMapper, ReferenceGenerator
from core.security import verify_token # keep if needed or simpler just remove if unused
# We don't need security import here anymore as context is passed explicitly or handled by events
class AuditService:
@staticmethod
def create_audit_log(
db: Session,
reference: str,
procedure: str,
movement: str,
username: str,
system: str = "SCAF",
# Extra context
table_name: Optional[str] = None,
record_id: Optional[str] = None,
operation_type: Optional[str] = None,
old_values: Optional[Dict] = None,
new_values: Optional[Dict] = None,
changed_fields: Optional[List[str]] = None,
# HTTP Context
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
endpoint: Optional[str] = None,
request_method: Optional[str] = None,
session_id: Optional[str] = None,
company_id: Optional[int] = None,
tenant_id: Optional[int] = None,
) -> AuditLog:
"""
Low-level creation of an Audit Log entry
"""
# Timezone handling set to Mexico City as requested implicitly by legacy format example
tz = pytz.timezone('America/Mexico_City')
now = datetime.now(tz)
log = AuditLog(
reference=reference,
procedure=procedure,
movement=movement,
username=username,
date=now.date(),
time=now.time(),
timestamp=now,
system=system,
table_name=table_name,
record_id=record_id,
operation_type=operation_type,
old_values=old_values,
new_values=new_values,
changed_fields=changed_fields,
ip_address=ip_address,
user_agent=user_agent,
endpoint=endpoint,
request_method=request_method,
session_id=session_id,
company_id=company_id,
tenant_id=tenant_id
)
db.add(log)
db.commit()
db.refresh(log)
return log
@staticmethod
def log_crud_operation(
db: Session,
table_name: str,
operation_type: str,
record_data: Dict[str, Any],
username: str,
record_id: Optional[str] = None,
old_values: Optional[Dict] = None,
new_values: Optional[Dict] = None,
# Context
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
company_id: Optional[int] = None
):
"""
High-level wrapper to log CRUD operations automatically mapping to Legacy format
"""
# 1. Map to Legacy Base Format
legacy_data = AuditMapper.map_to_legacy_format(
table_name=table_name,
record_id=record_id,
operation_type=operation_type,
username=username
)
# 2. Refine Reference based on specific table logic
reference = legacy_data["reference"]
if table_name == "invoice_header":
generated_ref = ReferenceGenerator.generate_invoice_reference(record_data)
# Use generated ref only if meaningful, else keep default
if generated_ref != "FUSE0-000-00":
reference = generated_ref
elif table_name == "pedimentos":
reference = ReferenceGenerator.generate_pedimento_reference(record_data)
elif table_name == "clients_and_providers":
reference = record_data.get("rfc") or reference
elif table_name == "parts":
reference = record_data.get("part_number") or reference
elif table_name == "companies":
reference = record_data.get("rfc") or reference
# 3. Detect Changed Fields (for Update)
changed_fields = None
if operation_type == "UPDATE" and old_values and new_values:
changed_fields = [
k for k in new_values.keys()
if old_values.get(k) != new_values.get(k)
]
# 4. Create Log
return AuditService.create_audit_log(
db=db,
reference=reference,
procedure=legacy_data["procedure"],
movement=legacy_data["movement"],
username=username,
system=legacy_data["system"],
table_name=table_name,
record_id=record_id,
operation_type=operation_type,
old_values=old_values,
new_values=new_values,
changed_fields=changed_fields,
ip_address=ip_address,
user_agent=user_agent,
company_id=company_id
)
@staticmethod
def log_login(db: Session, username: str, ip_address: str = None):
return AuditService.create_audit_log(
db=db,
reference="LOGIN",
procedure="SYSTEM SCAF",
movement="SYSTEM LOGIN",
username=username,
operation_type="LOGIN",
ip_address=ip_address
)

View File

@@ -145,4 +145,8 @@ router.include_router(
aviso_consolidado_export_router,
prefix="/a76/reports/exportacion/aviso_consolidado",
tags=["a76 / reports"]
)
)
# Registrar router de bitácora
from .audit_log.router import router as audit_log_router
router.include_router(audit_log_router, prefix="/a76/audit-log", tags=["Audit Log"])

10
backend/core/context.py Normal file
View File

@@ -0,0 +1,10 @@
from contextvars import ContextVar
from typing import Optional, Dict, Any
_user_context: ContextVar[Optional[Dict[str, Any]]] = ContextVar("user_context", default=None)
def get_user_context() -> Optional[Dict[str, Any]]:
return _user_context.get()
def set_user_context(user: Dict[str, Any]) -> None:
_user_context.set(user)

View File

@@ -108,11 +108,86 @@ if settings.DEBUG:
app.add_middleware(LicenseValidationMiddleware)
app.add_middleware(TenantMiddleware)
# Middleware de Contexto de Usuario (Audit Log)
from api.v1.modules.a76.audit_log.middleware import UserContextMiddleware
app.add_middleware(UserContextMiddleware)
# Importar modelos para Audit Log
from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos
from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceSalesDetails
from api.v1.modules.a76.audit_log.events import register_audit_listeners
# Core Modules
from api.v1.modules.a76.clients_and_providers.models import ClientProvider
from api.v1.modules.a76.customs_brokers.models import CustomsBroker
from api.v1.modules.a76.parts.models import Part
from api.v1.modules.a76.items.models import Item
from api.v1.modules.a76.general_catalogs.company.models import Company
# Reference Data
from api.v1.modules.public.reference_data.countries.models import Country
from api.v1.modules.public.reference_data.currency_types.models import CurrencyType
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
from api.v1.modules.public.reference_data.customs_warehouses.models import CustomsWarehouse
from api.v1.modules.public.reference_data.incoterms.models import Incoterm
from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType
from api.v1.modules.public.reference_data.material_types.models import MaterialType
from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento
from api.v1.modules.public.reference_data.sectors.models import Sector
from api.v1.modules.public.reference_data.states.models import State
from api.v1.modules.public.reference_data.transport_modes.models import TransportMode
from api.v1.modules.public.reference_data.transport_types.models import TransportType
from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod
from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure
from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate
from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier
# Registrar Listeners de Auditoría
@app.on_event("startup")
def register_audit():
register_audit_listeners([
# Core Transactions
Pedimentos,
InvoiceHeader,
InvoiceSalesDetails,
Item,
# Sidebar Core Modules
ClientProvider,
CustomsBroker,
Part,
Company,
# Reference Data
Country,
CurrencyType,
CustomsSection,
CustomsWarehouse,
Incoterm,
InvoiceType,
MaterialType,
PaymentMethod,
PedimentoCode,
RegimenPedimento,
Sector,
State,
TransportMode,
TransportType,
ValuationMethod,
UnitOfMeasure,
ExchangeRate,
Identifier
])
# Crear directorio de uploads si no existe y montar archivos estáticos
uploads_dir = Path("/app/uploads")
uploads_dir = Path("uploads").resolve()
uploads_dir.mkdir(parents=True, exist_ok=True)
app.mount("/api/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
# Registrar routers
app.include_router(api_v1_router, prefix="/api/v1")

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>