Files
whatsapp/lab_actividad.php
2026-04-16 23:45:58 -05:00

316 lines
15 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[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>