316 lines
15 KiB
PHP
316 lines
15 KiB
PHP
<?php
|
||
/**
|
||
* Log de Actividad — Trazabilidad del Módulo Administrativo de Laboratorio
|
||
*/
|
||
require_once 'config/config.php';
|
||
if (!defined('APP_ROOT')) { define('APP_ROOT', __DIR__); }
|
||
|
||
if (!isUserLoggedIn()) { header('Location: login.php'); exit; }
|
||
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
|
||
|
||
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Admin';
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="es">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Log de Actividad — Módulo Lab</title>
|
||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
<link href="./assets/css/styles.css?v=12" rel="stylesheet">
|
||
<style>
|
||
.log-row td { font-size: .85rem; vertical-align: middle; }
|
||
.detalle-cell { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
||
.detalle-cell:hover { text-decoration: underline dotted; color: #0d6efd; }
|
||
.badge-accion { font-size: .72rem; }
|
||
.ctrl { height: 31px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<?php
|
||
$SIDEBAR_TITLE = 'Panel Lab';
|
||
$SIDEBAR_ICON = 'fas fa-flask';
|
||
require_once __DIR__ . '/shared/components/sidebar.php';
|
||
?>
|
||
|
||
<main class="main-content">
|
||
<header class="content-header d-flex align-items-center justify-content-between">
|
||
<div>
|
||
<h1><i class="fas fa-history text-primary"></i> Log de Actividad</h1>
|
||
<small class="text-muted"><?= htmlspecialchars($adminNombre) ?></small>
|
||
</div>
|
||
<button class="btn btn-success btn-sm" onclick="exportarCSV()">
|
||
<i class="fas fa-file-csv me-1"></i> Exportar CSV
|
||
</button>
|
||
</header>
|
||
|
||
<div class="container-fluid py-3">
|
||
|
||
<!-- Filtros -->
|
||
<div class="card border-0 shadow-sm mb-3">
|
||
<div class="card-body py-2">
|
||
<div class="row g-2 align-items-end">
|
||
<div class="col-auto">
|
||
<label class="form-label mb-0 small fw-semibold">Desde</label>
|
||
<input type="date" id="act-desde" class="form-control form-control-sm ctrl">
|
||
</div>
|
||
<div class="col-auto">
|
||
<label class="form-label mb-0 small fw-semibold">Hasta</label>
|
||
<input type="date" id="act-hasta" class="form-control form-control-sm ctrl">
|
||
</div>
|
||
<div class="col-auto">
|
||
<label class="form-label mb-0 small fw-semibold">Módulo</label>
|
||
<select id="act-modulo" class="form-select form-select-sm ctrl">
|
||
<option value="">Todos</option>
|
||
<option value="ordenes">Órdenes</option>
|
||
<option value="domicilios">Domicilios</option>
|
||
<option value="pacientes">Pacientes</option>
|
||
<option value="enfermeras">Enfermeras</option>
|
||
<option value="formularios">Formularios</option>
|
||
<option value="asignaciones">Asignaciones</option>
|
||
</select>
|
||
</div>
|
||
<div class="col-auto">
|
||
<label class="form-label mb-0 small fw-semibold">Acción</label>
|
||
<select id="act-accion" class="form-select form-select-sm ctrl">
|
||
<option value="">Todas</option>
|
||
<option value="crear">crear</option>
|
||
<option value="editar">editar</option>
|
||
<option value="eliminar">eliminar</option>
|
||
<option value="autorizar">autorizar</option>
|
||
<option value="rechazar">rechazar</option>
|
||
<option value="asignar">asignar</option>
|
||
<option value="enviar">enviar</option>
|
||
<option value="reprogramar">reprogramar</option>
|
||
<option value="cambiar_estado">cambiar_estado</option>
|
||
</select>
|
||
</div>
|
||
<div class="col">
|
||
<label class="form-label mb-0 small fw-semibold">Buscar</label>
|
||
<input type="text" id="act-buscar" class="form-control form-control-sm ctrl" placeholder="Usuario o detalle…">
|
||
</div>
|
||
<div class="col-auto d-flex gap-2">
|
||
<button class="btn btn-primary btn-sm ctrl" onclick="cargar(1)">
|
||
<i class="fas fa-search"></i>
|
||
</button>
|
||
<button class="btn btn-outline-secondary btn-sm ctrl" onclick="limpiar()">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Tabla -->
|
||
<div class="card border-0 shadow-sm">
|
||
<div class="card-body p-0">
|
||
<div id="act-spinner" class="text-center py-5 text-muted">
|
||
<i class="fas fa-spinner fa-spin fa-2x"></i>
|
||
</div>
|
||
<div id="act-wrap" class="d-none">
|
||
<table class="table table-hover table-sm mb-0">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th style="width:145px">Fecha / Hora</th>
|
||
<th>Usuario</th>
|
||
<th style="width:110px">Módulo</th>
|
||
<th style="width:120px">Acción</th>
|
||
<th style="width:70px" class="text-center">Entidad</th>
|
||
<th>Detalle</th>
|
||
<th style="width:105px">IP</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="act-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
<div id="act-empty" class="d-none text-center py-5 text-muted">
|
||
<i class="fas fa-history fa-2x mb-2 d-block opacity-25"></i>
|
||
Sin actividad para los filtros seleccionados
|
||
</div>
|
||
</div>
|
||
<div class="card-footer bg-white" id="act-footer"></div>
|
||
</div>
|
||
|
||
</div><!-- /container -->
|
||
</main>
|
||
|
||
<!-- Modal detalle -->
|
||
<div class="modal fade" id="modalDetalle" tabindex="-1">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title"><i class="fas fa-info-circle me-2 text-primary"></i>Detalle del evento</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<pre id="modal-detalle-pre" class="bg-light p-3 rounded small mb-0" style="white-space:pre-wrap;word-break:break-all;max-height:420px;overflow-y:auto"></pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||
<script>
|
||
const MODULO_COLOR = {
|
||
ordenes:'warning', domicilios:'success', pacientes:'primary',
|
||
enfermeras:'info', formularios:'secondary', asignaciones:'dark',
|
||
};
|
||
const ACCION_COLOR = {
|
||
crear:'success', editar:'primary', eliminar:'danger',
|
||
autorizar:'success', rechazar:'danger', asignar:'info',
|
||
enviar:'secondary', reprogramar:'warning', cambiar_estado:'primary',
|
||
};
|
||
|
||
let _actPag = 1;
|
||
let _actData = [];
|
||
let _debounce;
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const hoy = new Date().toISOString().slice(0,10);
|
||
document.getElementById('act-desde').value = hoy.slice(0,7) + '-01';
|
||
document.getElementById('act-hasta').value = hoy;
|
||
|
||
['act-desde','act-hasta','act-modulo','act-accion'].forEach(id =>
|
||
document.getElementById(id).addEventListener('change', () => cargar(1))
|
||
);
|
||
document.getElementById('act-buscar').addEventListener('input', () => {
|
||
clearTimeout(_debounce);
|
||
_debounce = setTimeout(() => cargar(1), 400);
|
||
});
|
||
|
||
cargar(1);
|
||
});
|
||
|
||
async function cargar(pag = 1) {
|
||
_actPag = pag;
|
||
document.getElementById('act-spinner').classList.remove('d-none');
|
||
document.getElementById('act-wrap').classList.add('d-none');
|
||
document.getElementById('act-empty').classList.add('d-none');
|
||
|
||
const params = new URLSearchParams({
|
||
desde: document.getElementById('act-desde').value,
|
||
hasta: document.getElementById('act-hasta').value,
|
||
modulo: document.getElementById('act-modulo').value,
|
||
accion: document.getElementById('act-accion').value,
|
||
buscar: document.getElementById('act-buscar').value,
|
||
page: pag,
|
||
limit: 50,
|
||
});
|
||
|
||
try {
|
||
const r = await fetch(`api/lab/get_actividad.php?${params}`);
|
||
const d = await r.json();
|
||
document.getElementById('act-spinner').classList.add('d-none');
|
||
|
||
if (!d.data?.length) {
|
||
document.getElementById('act-empty').classList.remove('d-none');
|
||
document.getElementById('act-footer').innerHTML = '';
|
||
return;
|
||
}
|
||
_actData = d.data;
|
||
renderTabla(d.data);
|
||
renderPaginacion(d.total, d.paginas, pag);
|
||
} catch(e) {
|
||
document.getElementById('act-spinner').classList.add('d-none');
|
||
document.getElementById('act-empty').classList.remove('d-none');
|
||
}
|
||
}
|
||
|
||
function renderTabla(rows) {
|
||
document.getElementById('act-wrap').classList.remove('d-none');
|
||
document.getElementById('act-tbody').innerHTML = rows.map((a, i) => {
|
||
const det = a.detalle || '';
|
||
const detShort = det.length > 70 ? det.slice(0,70) + '…' : det;
|
||
const mColor = MODULO_COLOR[a.modulo] || 'secondary';
|
||
const aColor = ACCION_COLOR[a.accion] || 'secondary';
|
||
const usuario = esc(a.admin_nombre || a.admin_username || 'Sistema');
|
||
const sub = (a.admin_username && a.admin_nombre)
|
||
? `<div class="text-muted" style="font-size:.74rem">@${esc(a.admin_username)}</div>` : '';
|
||
return `<tr class="log-row">
|
||
<td class="text-muted text-nowrap">${formatFecha(a.created_at)}</td>
|
||
<td><span class="fw-semibold">${usuario}</span>${sub}</td>
|
||
<td><span class="badge bg-${mColor} badge-accion">${esc(a.modulo)}</span></td>
|
||
<td><span class="badge bg-${aColor} bg-opacity-75 badge-accion text-dark">${esc(a.accion)}</span></td>
|
||
<td class="text-center text-muted">${a.entidad_id ? '#' + a.entidad_id : '—'}</td>
|
||
<td class="detalle-cell" onclick="verDetalle(${i})" title="Click para ver detalle completo">${esc(detShort) || '<span class="text-muted">—</span>'}</td>
|
||
<td class="text-muted small">${esc(a.ip_address || '—')}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderPaginacion(total, paginas, pag) {
|
||
const el = document.getElementById('act-footer');
|
||
if (paginas <= 1) {
|
||
el.innerHTML = `<small class="text-muted">${total} evento(s)</small>`;
|
||
return;
|
||
}
|
||
const pages = [];
|
||
for (let p = Math.max(1, pag - 2); p <= Math.min(paginas, pag + 2); p++) pages.push(p);
|
||
el.innerHTML = `
|
||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||
<small class="text-muted">${total} evento(s) — página ${pag} de ${paginas}</small>
|
||
<div class="btn-group btn-group-sm">
|
||
<button class="btn btn-outline-secondary" onclick="cargar(1)" ${pag===1?'disabled':''}>«</button>
|
||
<button class="btn btn-outline-secondary" onclick="cargar(${pag-1})" ${pag===1?'disabled':''}>‹</button>
|
||
${pages.map(p => `<button class="btn ${p===pag?'btn-primary':'btn-outline-secondary'}" onclick="cargar(${p})">${p}</button>`).join('')}
|
||
<button class="btn btn-outline-secondary" onclick="cargar(${pag+1})" ${pag>=paginas?'disabled':''}>›</button>
|
||
<button class="btn btn-outline-secondary" onclick="cargar(${paginas})" ${pag>=paginas?'disabled':''}>»</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function verDetalle(i) {
|
||
const a = _actData[i];
|
||
let det = a.detalle || '(sin detalle)';
|
||
try { det = JSON.stringify(JSON.parse(det), null, 2); } catch(e) {}
|
||
document.getElementById('modal-detalle-pre').textContent =
|
||
`Módulo: ${a.modulo}\nAcción: ${a.accion}\nEntidad: ${a.entidad_id ? '#' + a.entidad_id : '—'}\nUsuario: ${a.admin_nombre || a.admin_username || 'Sistema'}\nFecha: ${a.created_at}\nIP: ${a.ip_address || '—'}\n\nDetalle:\n${det}`;
|
||
new bootstrap.Modal(document.getElementById('modalDetalle')).show();
|
||
}
|
||
|
||
async function exportarCSV() {
|
||
const params = new URLSearchParams({
|
||
desde: document.getElementById('act-desde').value,
|
||
hasta: document.getElementById('act-hasta').value,
|
||
modulo: document.getElementById('act-modulo').value,
|
||
accion: document.getElementById('act-accion').value,
|
||
buscar: document.getElementById('act-buscar').value,
|
||
page: 1,
|
||
limit: 2000,
|
||
});
|
||
const r = await fetch(`api/lab/get_actividad.php?${params}`);
|
||
const d = await r.json();
|
||
if (!d.data?.length) { alert('Sin datos para exportar'); return; }
|
||
|
||
const cols = ['id','created_at','admin_nombre','admin_username','modulo','accion','entidad_id','detalle','ip_address'];
|
||
const rows = d.data.map(a =>
|
||
cols.map(c => '"' + String(a[c] ?? '').replace(/"/g, '""') + '"').join(',')
|
||
);
|
||
const csv = '\uFEFF' + [cols.join(','), ...rows].join('\r\n');
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `actividad_${new Date().toISOString().slice(0,10)}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function limpiar() {
|
||
const hoy = new Date().toISOString().slice(0,10);
|
||
document.getElementById('act-desde').value = hoy.slice(0,7) + '-01';
|
||
document.getElementById('act-hasta').value = hoy;
|
||
document.getElementById('act-modulo').value = '';
|
||
document.getElementById('act-accion').value = '';
|
||
document.getElementById('act-buscar').value = '';
|
||
cargar(1);
|
||
}
|
||
|
||
const esc = s => String(s||'').replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
|
||
const formatFecha = s => s ? new Date(s).toLocaleString('es-CO', {day:'2-digit',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '—';
|
||
</script>
|
||
</body>
|
||
</html>
|