Files
whatsapp/lab_reportes.php
T

883 lines
47 KiB
PHP
Raw 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
/**
* Reportes y Trazabilidad — Módulo Administrativo de Laboratorio
*/
require_once 'config/config.php';
if (!isUserLoggedIn()) {
header('Location: login.php');
exit;
}
if (isEnfermero()) { header('Location: enfermero_portal.php'); exit; }
// ── Exportación CSV directa (sin cabeceras HTML) ──────────────────────────
$tipo = $_GET['export'] ?? '';
if ($tipo) {
require_once 'classes/Database.php';
$db = Database::getInstance();
$desde = $_GET['desde'] ?? date('Y-m-01');
$hasta = $_GET['hasta'] ?? date('Y-m-d');
switch ($tipo) {
case 'ordenes':
$rows = $db->fetchAll(
"SELECT o.id, p.nombre_completo AS paciente, p.numero_documento, p.eps,
o.estado, o.medico_nombre, o.fecha_orden, o.examenes_solicitados,
o.requiere_ayuno, o.horas_ayuno, o.created_at,
ar.full_name AS revisada_por, aa.full_name AS autorizada_por
FROM lab_ordenes_medicas o
JOIN lab_pacientes p ON p.id = o.paciente_id
LEFT JOIN admin_users ar ON ar.id = o.revisada_por
LEFT JOIN admin_users aa ON aa.id = o.autorizada_por
WHERE DATE(o.created_at) BETWEEN ? AND ?
ORDER BY o.created_at DESC",
[$desde, $hasta]
);
$filename = "ordenes_$desde\_$hasta.csv";
$headers = ['ID','Paciente','Documento','EPS','Estado','Médico','Fecha Orden','Exámenes','Ayuno','H.Ayuno','Creada','Revisada por','Autorizada por'];
break;
case 'domicilios':
// Soporta filtros extra enviados desde lab_domicilios.php
$sqlWhere = 'DATE(d.fecha_programada) BETWEEN ? AND ?';
$sqlParams = [$desde, $hasta];
// Si viene fecha única (filtro de día), tiene prioridad sobre desde/hasta
if (!empty($_GET['fecha'])) {
$sqlWhere = 'd.fecha_programada = ?';
$sqlParams = [$_GET['fecha']];
}
if (!empty($_GET['estado'])) {
$sqlWhere .= ' AND d.estado = ?';
$sqlParams[] = $_GET['estado'];
}
if (!empty($_GET['enfermera_id'])) {
$sqlWhere .= ' AND a.enfermera_id = ?';
$sqlParams[] = (int)$_GET['enfermera_id'];
}
$rows = $db->fetchAll(
"SELECT d.id,
p.nombre_completo AS paciente,
p.numero_documento,
p.telefono AS tel_paciente,
p.eps,
d.fecha_programada,
d.hora_programada,
d.hora_llegada,
d.hora_salida,
CASE
WHEN d.hora_llegada IS NOT NULL AND d.hora_salida IS NOT NULL
THEN CONCAT(TIMESTAMPDIFF(MINUTE, d.hora_llegada, d.hora_salida), ' min')
ELSE ''
END AS duracion_domicilio,
d.estado,
COALESCE(e.nombre_completo,'Sin asignar') AS enfermera,
d.tipo_servicio,
d.tipo_cliente,
COALESCE(d.seguro_nombre,'') AS seguro,
d.autorizacion,
d.examenes_solicitados,
d.direccion,
d.barrio,
d.ciudad,
COALESCE(d.valor_domicilio,0) AS valor_domicilio,
COALESCE(d.valor_copago,0) AS valor_copago,
COALESCE(d.copago_laboratorio,0) AS copago_laboratorio,
d.pago_estado,
d.pago_modo,
COALESCE(d.pago_monto,0) AS monto_recibido,
d.observaciones,
d.created_at,
COALESCE(nc.antecedentes,'') AS antecedentes,
COALESCE(nc.medicamentos,'') AS medicamentos,
COALESCE(nc.acudiente_nombre,'') AS acudiente_nombre,
COALESCE(nc.acudiente_documento,'') AS acudiente_documento,
COALESCE(
(SELECT GROUP_CONCAT(CONCAT(COALESCE(nl.titulo,''), ': ', COALESCE(nl.cuerpo,''))
ORDER BY nl.created_at ASC SEPARATOR ' | ')
FROM lab_domicilio_notas nl
WHERE nl.domicilio_id = d.id AND nl.tipo = 'libre'),
'') AS notas_libres
FROM lab_domicilios d
JOIN lab_pacientes p ON p.id = d.paciente_id
LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id AND a.estado NOT IN ('liberada')
LEFT JOIN lab_enfermeras e ON e.id = a.enfermera_id
LEFT JOIN lab_domicilio_notas nc ON nc.domicilio_id = d.id AND nc.tipo = 'clinica'
WHERE $sqlWhere
ORDER BY d.fecha_programada ASC, d.hora_programada ASC",
$sqlParams
);
$rangoLabel = !empty($_GET['fecha']) ? $_GET['fecha'] : "{$desde}_{$hasta}";
$filename = "domicilios_{$rangoLabel}.csv";
$headers = [
'ID','Paciente','Documento','Teléfono','EPS',
'Fecha','Hora programada','Hora llegada','Hora salida','Duración',
'Estado','Enfermera','Tipo servicio','Tipo cliente','Seguro','Autorización',
'Exámenes','Dirección','Barrio','Ciudad',
'Valor domicilio','Valor copago','Copago lab',
'Pago estado','Pago modo','Monto recibido',
'Observaciones','Registrado',
'Antecedentes','Medicamentos','Acudiente nombre','Acudiente documento','Notas libres',
];
break;
case 'pacientes':
$rows = $db->fetchAll(
"SELECT p.id, p.nombre_completo, p.tipo_documento, p.numero_documento,
p.telefono, p.email, p.eps, p.ciudad, p.barrio, p.is_active,
COUNT(o.id) AS total_ordenes, p.created_at
FROM lab_pacientes p
LEFT JOIN lab_ordenes_medicas o ON o.paciente_id = p.id
GROUP BY p.id
ORDER BY p.nombre_completo",
[]
);
$filename = "pacientes_" . date('Y-m-d') . ".csv";
$headers = ['ID','Nombre','Tipo Doc.','Documento','Teléfono','Email','EPS','Ciudad','Barrio','Activo','Total Órdenes','Registrado'];
break;
default:
http_response_code(400);
exit('Tipo de exportación no válido');
}
header('Content-Type: text/csv; charset=utf-8');
header("Content-Disposition: attachment; filename=\"$filename\"");
header('Pragma: no-cache');
$f = fopen('php://output', 'w');
fputs($f, "\xEF\xBB\xBF"); // BOM UTF-8 para Excel
fputcsv($f, $headers);
foreach ($rows as $row) {
fputcsv($f, array_values($row));
}
fclose($f);
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>Reportes — 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-item { font-size:.82rem; padding:6px 0; border-bottom:1px solid #f3f3f3; }
.log-item:last-child { border-bottom:none; }
.badge-modulo { font-size:.7rem; }
.kpi-card { border-radius:12px; padding:16px 14px; text-align:center; }
.kpi-num { font-size:1.9rem; font-weight:700; line-height:1.1; }
.kpi-lbl { font-size:.78rem; color:#6c757d; margin-top:4px; }
.bar-row { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
.bar-label { min-width:110px; font-size:.8rem; text-align:right; white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
.bar-track { flex:1; background:#f1f3f5; border-radius:4px; height:16px; }
.bar-fill { height:100%; border-radius:4px; transition:width .4s; }
.bar-val { min-width:36px; font-size:.8rem; font-weight:600; text-align:right; }
.trend-row { display:flex; align-items:flex-end; gap:3px; height:60px; }
.trend-bar { flex:1; border-radius:3px 3px 0 0; min-width:6px; transition:height .4s; }
.trend-bar:hover { opacity:.8; }
.ranking-row { display:flex; align-items:center; gap:10px; padding:6px 0; border-bottom:1px solid #f5f5f5; }
.ranking-row:last-child { border-bottom:none; }
</style>
</head>
<body>
<nav class="sidebar">
<div class="sidebar-header"><h4><i class="fas fa-flask"></i> Módulo Lab</h4></div>
<ul class="sidebar-menu">
<li><a href="lab_dashboard.php" class="nav-link"><i class="fas fa-tachometer-alt"></i> Dashboard</a></li>
<li><a href="lab_ordenes.php" class="nav-link"><i class="fas fa-file-medical"></i> Órdenes</a></li>
<li><a href="lab_pacientes.php" class="nav-link"><i class="fas fa-users"></i> Pacientes</a></li>
<li><a href="lab_domicilios.php" class="nav-link"><i class="fas fa-house-medical"></i> Domicilios</a></li>
<li><a href="lab_enfermeras.php" class="nav-link"><i class="fas fa-user-nurse"></i> Enfermeras</a></li>
<li><a href="lab_formularios.php" class="nav-link"><i class="fas fa-wpforms"></i> Formularios</a></li>
<li><a href="lab_reportes.php" class="nav-link active"><i class="fas fa-chart-bar"></i> Reportes</a></li>
<li><a href="lab_configuracion.php" class="nav-link"><i class="fas fa-sliders-h"></i> Configuración</a></li>
<li><a href="lab_usuarios.php" class="nav-link"><i class="fas fa-users-cog"></i> Usuarios</a></li>
<li class="sidebar-divider"></li>
<li><a href="index.php" class="nav-link"><i class="fas fa-arrow-left"></i> Volver al Bot</a></li>
<li><a href="logout.php" class="nav-link logout-link" onclick="return confirm('¿Cerrar sesión?')"><i class="fas fa-sign-out-alt"></i> Cerrar Sesión</a></li>
</ul>
</nav>
<main class="main-content">
<header class="content-header d-flex align-items-center justify-content-between">
<div>
<h1><i class="fas fa-chart-bar text-primary"></i> Reportes y Métricas</h1>
<small class="text-muted"><?= htmlspecialchars($adminNombre) ?></small>
</div>
</header>
<div class="container-fluid py-3">
<!-- ── Rango de fechas + exportaciones ──────────────────────────── -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-body d-flex flex-wrap gap-3 align-items-end">
<div>
<label class="form-label small text-muted text-uppercase">Desde</label>
<input type="date" id="rpt-desde" class="form-control form-control-sm" value="<?= date('Y-m-01') ?>">
</div>
<div>
<label class="form-label small text-muted text-uppercase">Hasta</label>
<input type="date" id="rpt-hasta" class="form-control form-control-sm" value="<?= date('Y-m-d') ?>">
</div>
<button class="btn btn-primary btn-sm" onclick="cargarTodo()">
<i class="fas fa-sync-alt me-1"></i> Actualizar
</button>
<div class="ms-auto d-flex gap-2 flex-wrap">
<button class="btn btn-sm btn-outline-success" onclick="exportar('domicilios')">
<i class="fas fa-file-excel me-1"></i> Excel Domicilios
</button>
<button class="btn btn-sm btn-outline-warning" onclick="exportar('ordenes')">
<i class="fas fa-file-excel me-1"></i> Excel Órdenes
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="exportar('pacientes')">
<i class="fas fa-file-excel me-1"></i> Excel Pacientes
</button>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- Reporte domicilios por enfermero -->
<!-- ══════════════════════════════════════════════════════════════ -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 d-flex flex-wrap gap-2 align-items-end">
<h6 class="fw-semibold mb-0 me-auto">
<i class="fas fa-user-nurse me-2 text-success"></i>Domicilios por enfermero
</h6>
<div class="d-flex flex-wrap gap-2 align-items-end">
<div>
<label class="form-label small text-muted text-uppercase mb-1">Año</label>
<input type="number" id="enf-anio" class="form-control form-control-sm"
value="<?= date('Y') ?>" min="2020" max="2099" style="width:90px">
</div>
<div>
<label class="form-label small text-muted text-uppercase mb-1">Mes</label>
<select id="enf-mes" class="form-select form-select-sm" style="width:100px">
<option value="">Todos</option>
<option value="1">Enero</option><option value="2">Febrero</option>
<option value="3">Marzo</option><option value="4">Abril</option>
<option value="5">Mayo</option><option value="6">Junio</option>
<option value="7">Julio</option><option value="8">Agosto</option>
<option value="9">Septiembre</option><option value="10">Octubre</option>
<option value="11">Noviembre</option><option value="12">Diciembre</option>
</select>
</div>
<div>
<label class="form-label small text-muted text-uppercase mb-1">Enfermero/a</label>
<select id="enf-selector" class="form-select form-select-sm" style="min-width:160px">
<option value="">Todos</option>
</select>
</div>
<button class="btn btn-primary btn-sm" onclick="cargarReporteEnfermero()">
<i class="fas fa-sync-alt me-1"></i>Filtrar
</button>
<button class="btn btn-sm btn-outline-success" onclick="exportarReporteEnfermero()">
<i class="fas fa-file-excel me-1"></i>Excel
</button>
</div>
</div>
<div class="card-body p-0">
<div id="enf-tabla-wrap" style="overflow-x:auto">
<div class="text-center py-4 text-muted">
<i class="fas fa-spinner fa-spin"></i> Cargando…
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- KPIs principales -->
<!-- ══════════════════════════════════════════════════════════════ -->
<div class="row g-3 mb-3" id="kpis-row">
<div class="col-6 col-md-3">
<div class="kpi-card" style="background:#fff8e1;border:1px solid #ffe082">
<div class="kpi-num text-warning" id="kpi-total">—</div>
<div class="kpi-lbl">Total domicilios</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="kpi-card" style="background:#e8f5e9;border:1px solid #a5d6a7">
<div class="kpi-num text-success" id="kpi-tasa">—</div>
<div class="kpi-lbl">Tasa de éxito</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="kpi-card" style="background:#e3f2fd;border:1px solid #90caf9">
<div class="kpi-num text-primary" id="kpi-duracion">—</div>
<div class="kpi-lbl">Duración promedio</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="kpi-card" style="background:#f3e5f5;border:1px solid #ce93d8">
<div class="kpi-num" style="color:#7b1fa2" id="kpi-cobrado">—</div>
<div class="kpi-lbl">Total cobrado</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- Fila 1: Tendencia por día + Tasa de éxito -->
<!-- ══════════════════════════════════════════════════════════════ -->
<div class="row g-3 mb-3">
<div class="col-lg-8">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0 d-flex justify-content-between align-items-center">
<h6 class="fw-semibold mb-0"><i class="fas fa-chart-bar me-2 text-primary"></i>Domicilios por día</h6>
<small class="text-muted" id="trend-subtitulo"></small>
</div>
<div class="card-body" id="trend-chart">
<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0">
<h6 class="fw-semibold mb-0"><i class="fas fa-chart-pie me-2 text-success"></i>Estado de domicilios</h6>
</div>
<div class="card-body" id="dom-estado-chart">
<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- Fila 2: Tiempo en domicilio + Hora pico -->
<!-- ══════════════════════════════════════════════════════════════ -->
<div class="row g-3 mb-3">
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0">
<h6 class="fw-semibold mb-0"><i class="fas fa-stopwatch me-2 text-info"></i>Tiempo en domicilio</h6>
</div>
<div class="card-body" id="tiempos-card">
<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0">
<h6 class="fw-semibold mb-0"><i class="fas fa-clock me-2 text-warning"></i>Horas pico del día</h6>
</div>
<div class="card-body" id="hora-chart">
<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0">
<h6 class="fw-semibold mb-0"><i class="fas fa-file-medical me-2 text-warning"></i>Órdenes por estado</h6>
</div>
<div class="card-body" id="ordenes-estado-chart">
<div class="text-center py-3 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- Fila 3: Ranking enfermeras + Financiero + Zonas -->
<!-- ══════════════════════════════════════════════════════════════ -->
<div class="row g-3 mb-3">
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0">
<h6 class="fw-semibold mb-0"><i class="fas fa-user-nurse me-2 text-success"></i>Ranking enfermeras</h6>
</div>
<div class="card-body" id="ranking-enf">
<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0">
<h6 class="fw-semibold mb-0"><i class="fas fa-dollar-sign me-2" style="color:#7b1fa2"></i>Resumen financiero</h6>
</div>
<div class="card-body" id="financiero-card">
<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0">
<h6 class="fw-semibold mb-0"><i class="fas fa-map-marker-alt me-2 text-danger"></i>Top zonas</h6>
</div>
<div class="card-body" id="zonas-chart">
<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- Fila 4: Top exámenes + Tipo cliente + Log actividad -->
<!-- ══════════════════════════════════════════════════════════════ -->
<div class="row g-3">
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0">
<h6 class="fw-semibold mb-0"><i class="fas fa-vials me-2 text-info"></i>Top exámenes solicitados</h6>
</div>
<div class="card-body" id="examenes-chart">
<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0">
<h6 class="fw-semibold mb-0"><i class="fas fa-shield-alt me-2 text-primary"></i>Tipo de cliente</h6>
</div>
<div class="card-body" id="tipo-cliente-chart">
<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-white border-0 d-flex align-items-center justify-content-between">
<h6 class="fw-semibold mb-0"><i class="fas fa-history me-2 text-secondary"></i>Log de actividad</h6>
<select id="log-modulo" class="form-select form-select-sm w-auto" onchange="cargarActividad(1)">
<option value="">Todos</option>
<option value="ordenes">Órdenes</option>
<option value="domicilios">Domicilios</option>
<option value="pacientes">Pacientes</option>
<option value="enfermeras">Enfermeras</option>
</select>
</div>
<div class="card-body pb-0" id="log-body" style="max-height:380px;overflow-y:auto">
<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
</div>
<div class="card-footer bg-white border-0" id="log-paginacion"></div>
</div>
</div>
</div>
</div><!-- /container -->
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const COLOR_EST = {
pendiente:'warning', en_revision:'info', autorizada:'success', rechazada:'danger',
en_domicilio:'primary', completada:'secondary', completado:'success',
programado:'secondary', confirmado:'info', en_camino:'primary', cancelado:'danger', reprogramado:'dark',
};
const MODULO_COLOR = { ordenes:'warning', domicilios:'success', pacientes:'primary', enfermeras:'info', asignaciones:'secondary' };
let logPag = 1;
// ── Reporte por enfermero ───────────────────────────────────────────────────
async function cargarReporteEnfermero() {
const wrap = document.getElementById('enf-tabla-wrap');
wrap.innerHTML = '<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i></div>';
const anio = document.getElementById('enf-anio').value;
const mes = document.getElementById('enf-mes').value;
const enf = document.getElementById('enf-selector').value;
const params = new URLSearchParams();
if (anio) params.set('anio', anio);
if (mes) params.set('mes', mes);
if (enf) params.set('enfermera_id', enf);
try {
const r = await fetch(`api/lab/get_reporte_enfermero.php?${params}`);
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Error');
// Poblar selector de enfermeras (sólo la primera vez)
const sel = document.getElementById('enf-selector');
if (sel.options.length === 1 && d.enfermeras?.length) {
d.enfermeras.forEach(e => {
const opt = document.createElement('option');
opt.value = e.id;
opt.textContent = e.nombre_completo;
sel.appendChild(opt);
});
}
if (!d.filas?.length) {
wrap.innerHTML = '<p class="text-muted text-center py-3 small">Sin datos para los filtros seleccionados.</p>';
return;
}
wrap.innerHTML = `
<table class="table table-sm table-hover mb-0" style="font-size:.85rem">
<thead class="table-light">
<tr>
<th>Enfermero/a</th>
<th class="text-center">Período</th>
<th class="text-center">Total</th>
<th class="text-center text-success">Completados</th>
<th class="text-center text-danger">Cancelados</th>
<th class="text-center text-warning">En proceso</th>
<th class="text-center">Tasa éxito</th>
</tr>
</thead>
<tbody>
${d.filas.map(f => {
const color = f.tasa_pct >= 80 ? '#43a047' : f.tasa_pct >= 50 ? '#fb8c00' : '#e53935';
return `<tr>
<td class="fw-semibold">${esc(f.enfermera)}</td>
<td class="text-center text-muted">${esc(f.mes_nombre)} ${f.anio}</td>
<td class="text-center">${f.total}</td>
<td class="text-center text-success">${f.completados}</td>
<td class="text-center text-danger">${f.cancelados}</td>
<td class="text-center text-warning">${f.en_proceso}</td>
<td class="text-center">
<span class="fw-bold" style="color:${color}">${f.tasa_pct}%</span>
<div style="width:60px;height:5px;background:#f0f0f0;border-radius:3px;margin:2px auto 0">
<div style="width:${f.tasa_pct}%;height:100%;background:${color};border-radius:3px"></div>
</div>
</td>
</tr>`;
}).join('')}
</tbody>
</table>`;
} catch (e) {
wrap.innerHTML = `<div class="alert alert-danger m-3 small">${esc(e.message)}</div>`;
}
}
function exportarReporteEnfermero() {
const anio = document.getElementById('enf-anio').value;
const mes = document.getElementById('enf-mes').value;
const enf = document.getElementById('enf-selector').value;
const params = new URLSearchParams({ export: 'domicilios', desde: `${anio || '2020'}-${mes ? String(mes).padStart(2,'0') : '01'}-01` });
if (mes) params.set('hasta', `${anio || new Date().getFullYear()}-${String(mes).padStart(2,'0')}-31`);
else params.set('hasta', `${anio || new Date().getFullYear()}-12-31`);
if (enf) params.set('enfermera_id', enf);
window.open(`lab_reportes.php?${params}`, '_blank');
}
// ── Carga todo en paralelo ──────────────────────────────────────────────────
async function cargarTodo() {
const desde = fecha('rpt-desde');
const hasta = fecha('rpt-hasta');
await Promise.all([
cargarMetricas(desde, hasta),
cargarResumenOrdenes(desde, hasta),
cargarActividad(1),
cargarReporteEnfermero(),
]);
}
// ── Métricas de domicilios ──────────────────────────────────────────────────
async function cargarMetricas(desde, hasta) {
const r = await fetch(`api/lab/get_metricas.php?desde=${desde}&hasta=${hasta}`);
const d = await r.json();
if (!d.success) return;
const m = d; // jsonOk() hace array_merge → datos en nivel raíz
// ── KPIs ──
const kpi = m.kpi || {};
const total = parseInt(kpi.total) || 0;
const comp = parseInt(kpi.completados) || 0;
const canc = parseInt(kpi.cancelados) || 0;
const tasa = total ? Math.round(comp / total * 100) : 0;
document.getElementById('kpi-total').textContent = total;
document.getElementById('kpi-tasa').textContent = tasa + '%';
// Duración
const t = m.tiempos || {};
const prom = parseFloat(t.promedio_min) || 0;
document.getElementById('kpi-duracion').textContent = prom ? fmtMin(prom) : '—';
// Financiero KPI
const fin = m.financiero || {};
document.getElementById('kpi-cobrado').textContent = fmtMoney(fin.cobrado);
// ── Tendencia por día ──
renderTrend(m.por_dia || []);
// ── Estado domicilios ──
const estMap = {};
(m.kpi ? [] : []).forEach(() => {});
// Reconstruir desde por_dia
if (m.kpi) {
estMap.completado = comp;
estMap.cancelado = canc;
estMap.reprogramado = parseInt(kpi.reprogramados) || 0;
estMap.en_curso = parseInt(kpi.en_curso) || 0;
}
renderBarras('dom-estado-chart', estMap);
// ── Tiempos ──
renderTiempos(t, m.tiempo_agendamiento || {});
// ── Hora pico ──
renderHoras(m.por_hora || []);
// ── Ranking enfermeras ──
renderRanking(m.ranking_enfermeras || []);
// ── Financiero ──
renderFinanciero(fin, m.por_modo_pago || []);
// ── Zonas ──
renderBarrasSimples('zonas-chart', (m.top_zonas||[]).map(z => [z.zona, z.n]), '#e53935');
// ── Exámenes ──
renderBarrasSimples('examenes-chart', (m.top_examenes||[]).map(e => [e.examen, e.n]), '#0288d1');
// ── Tipo cliente ──
const tcMap = {};
(m.por_tipo_cliente || []).forEach(tc => {
const k = tc.tipo === 'seguro' && tc.seguro ? `seguro: ${tc.seguro}` : tc.tipo;
tcMap[k] = (tcMap[k] || 0) + parseInt(tc.n);
});
renderBarrasSimples('tipo-cliente-chart', Object.entries(tcMap), '#6d4c41');
}
// ── Órdenes (usa get_stats existente) ──────────────────────────────────────
async function cargarResumenOrdenes(desde, hasta) {
const r = await fetch(`api/lab/get_stats.php?desde=${desde}&hasta=${hasta}`);
const d = await r.json();
if (!d.success) return;
renderBarras('ordenes-estado-chart', d.ordenes?.por_estado || {});
}
// ── Render helpers ──────────────────────────────────────────────────────────
function renderTrend(dias) {
const el = document.getElementById('trend-chart');
if (!dias.length) { el.innerHTML = '<p class="text-muted small text-center py-3">Sin datos en el período</p>'; return; }
const maxN = Math.max(...dias.map(d => parseInt(d.total) || 0)) || 1;
document.getElementById('trend-subtitulo').textContent =
`${dias.length} día(s) · total ${dias.reduce((s,d) => s + parseInt(d.total||0), 0)}`;
el.innerHTML = `
<div style="overflow-x:auto">
<div style="display:flex;align-items:flex-end;gap:4px;min-height:90px;padding-bottom:20px;min-width:${Math.max(dias.length*26, 200)}px">
${dias.map(d => {
const tot = parseInt(d.total) || 0;
const com = parseInt(d.completados) || 0;
const can = parseInt(d.cancelados) || 0;
const h = Math.max(Math.round((tot / maxN) * 80), 4);
const pct = tot ? Math.round(com/tot*100) : 0;
return `<div style="flex:1;min-width:22px;display:flex;flex-direction:column;align-items:center;gap:2px"
title="${d.dia}&#10;Total: ${tot}&#10;Completados: ${com}&#10;Cancelados: ${can}">
<div style="font-size:.65rem;color:#666">${tot}</div>
<div style="height:${h}px;width:100%;border-radius:3px 3px 0 0;
background:${pct>=80?'#43a047':pct>=50?'#fb8c00':'#e53935'};opacity:.85"></div>
<div style="font-size:.6rem;color:#aaa;writing-mode:vertical-rl;transform:rotate(180deg);white-space:nowrap">
${d.dia?.slice(5) || ''}
</div>
</div>`;
}).join('')}
</div>
</div>
<div class="d-flex gap-3 justify-content-center mt-1" style="font-size:.75rem">
<span><span style="display:inline-block;width:10px;height:10px;background:#43a047;border-radius:2px"></span> ≥80% éxito</span>
<span><span style="display:inline-block;width:10px;height:10px;background:#fb8c00;border-radius:2px"></span> 5079%</span>
<span><span style="display:inline-block;width:10px;height:10px;background:#e53935;border-radius:2px"></span> &lt;50%</span>
</div>`;
}
function renderTiempos(t, ta) {
const el = document.getElementById('tiempos-card');
const prom = parseFloat(t.promedio_min);
const dias = parseFloat(ta.dias_promedio);
el.innerHTML = `
<div class="row g-2 text-center mb-3">
<div class="col-4">
<div class="fw-bold fs-5 text-info">${isNaN(prom) ? '—' : fmtMin(prom)}</div>
<div class="small text-muted">Promedio</div>
</div>
<div class="col-4">
<div class="fw-bold fs-5 text-success">${t.min_min != null ? fmtMin(t.min_min) : '—'}</div>
<div class="small text-muted">Mínimo</div>
</div>
<div class="col-4">
<div class="fw-bold fs-5 text-warning">${t.max_min != null ? fmtMin(t.max_min) : '—'}</div>
<div class="small text-muted">Máximo</div>
</div>
</div>
<div class="small text-muted text-center mb-2">
<i class="fas fa-info-circle me-1"></i>${t.con_tiempos || 0} domicilio(s) con hora llegada/salida registrada
</div>
<hr class="my-2">
<div class="text-center">
<div class="fw-bold fs-5 text-primary">${isNaN(dias) ? '—' : dias + ' días'}</div>
<div class="small text-muted">Promedio agendamiento → realización</div>
</div>`;
}
function renderHoras(horas) {
const el = document.getElementById('hora-chart');
if (!horas.length) { el.innerHTML = '<p class="text-muted small text-center py-3">Sin datos</p>'; return; }
const maxH = Math.max(...horas.map(h => parseInt(h.n) || 0)) || 1;
const allHours = Array.from({length:24}, (_, i) => i);
el.innerHTML = `
<div style="display:flex;align-items:flex-end;gap:2px;height:70px;margin-bottom:4px">
${allHours.map(h => {
const found = horas.find(r => parseInt(r.hora) === h);
const n = found ? parseInt(found.n) : 0;
const height = n ? Math.max(Math.round((n/maxH)*60), 4) : 2;
return `<div style="flex:1;height:${height}px;background:${n?'#1976d2':'#e0e0e0'};border-radius:2px 2px 0 0;opacity:.85"
title="${h}:00 — ${n} domicilio(s)"></div>`;
}).join('')}
</div>
<div style="display:flex;justify-content:space-between;font-size:.65rem;color:#aaa">
<span>00h</span><span>06h</span><span>12h</span><span>18h</span><span>23h</span>
</div>
<p class="text-muted small text-center mt-2">Pasa el cursor sobre las barras para ver detalle</p>`;
}
function renderRanking(enfs) {
const el = document.getElementById('ranking-enf');
if (!enfs.length) { el.innerHTML = '<p class="text-muted small text-center py-3">Sin datos</p>'; return; }
el.innerHTML = enfs.map((e, i) => {
const tasa = parseFloat(e.tasa_pct) || 0;
const color = tasa >= 80 ? '#43a047' : tasa >= 50 ? '#fb8c00' : '#e53935';
return `<div class="ranking-row">
<div style="font-size:.85rem;font-weight:700;color:#999;min-width:18px">${i+1}</div>
<div class="flex-grow-1 overflow-hidden">
<div class="fw-semibold small text-truncate">${esc(e.nombre_completo)}</div>
<div class="text-muted" style="font-size:.75rem">${e.asignados} asignados · ${e.completados} ✅ · ${e.cancelados} ❌</div>
</div>
<div class="text-end">
<div class="fw-bold small" style="color:${color}">${tasa}%</div>
<div style="width:50px;height:5px;background:#f0f0f0;border-radius:3px;margin-top:2px">
<div style="width:${tasa}%;height:100%;background:${color};border-radius:3px"></div>
</div>
</div>
</div>`;
}).join('');
}
function renderFinanciero(fin, modos) {
const el = document.getElementById('financiero-card');
const bruto = parseFloat(fin.total_bruto) || 0;
const cobrado = parseFloat(fin.cobrado) || 0;
const pendiente= parseFloat(fin.pendiente) || 0;
const exento = parseFloat(fin.exento) || 0;
const pctCob = bruto ? Math.round(cobrado/bruto*100) : 0;
el.innerHTML = `
<div class="row g-2 text-center mb-3">
<div class="col-6">
<div class="fw-bold" style="color:#7b1fa2">${fmtMoney(cobrado)}</div>
<div class="small text-muted">Cobrado (${fin.n_pagados||0})</div>
</div>
<div class="col-6">
<div class="fw-bold text-warning">${fmtMoney(pendiente)}</div>
<div class="small text-muted">Pendiente (${fin.n_pendientes||0})</div>
</div>
</div>
<div style="background:#f3e5f5;border-radius:6px;padding:4px 8px;margin-bottom:10px;font-size:.8rem;text-align:center">
${pctCob}% cobrado de ${fmtMoney(bruto)} total facturado
</div>
${modos.length ? `<div class="small text-muted text-uppercase mb-1">Por modo de pago</div>
${modos.map(m => `
<div class="d-flex justify-content-between small mb-1">
<span>${esc(m.modo)}</span>
<span class="fw-semibold">${m.n} · ${fmtMoney(m.total)}</span>
</div>`).join('')}` : ''}`;
}
function renderBarras(elId, datos) {
const el = document.getElementById(elId);
if (!el) return;
const entries = Object.entries(datos).filter(([,v]) => v > 0);
const total = entries.reduce((a,[,b]) => a + (parseInt(b)||0), 0) || 1;
if (!entries.length) { el.innerHTML = '<p class="text-muted small text-center">Sin datos</p>'; return; }
el.innerHTML = entries.map(([estado, n]) => `
<div class="bar-row">
<div class="bar-label"><span class="badge bg-${COLOR_EST[estado]||'secondary'}">${esc(estado)}</span></div>
<div class="bar-track">
<div class="bar-fill bg-${COLOR_EST[estado]||'secondary'}" style="width:${Math.max((parseInt(n)/total)*100,2)}%"></div>
</div>
<div class="bar-val">${n}</div>
</div>`).join('');
}
function renderBarrasSimples(elId, entries, color) {
const el = document.getElementById(elId);
if (!el) return;
if (!entries.length) { el.innerHTML = '<p class="text-muted small text-center py-3">Sin datos</p>'; return; }
const max = Math.max(...entries.map(([,v]) => parseInt(v)||0)) || 1;
el.innerHTML = entries.slice(0, 10).map(([label, n]) => `
<div class="bar-row">
<div class="bar-label text-truncate" title="${esc(label)}">${esc(label)}</div>
<div class="bar-track">
<div class="bar-fill" style="width:${Math.max((parseInt(n)/max)*100,2)}%;background:${color};opacity:.8"></div>
</div>
<div class="bar-val">${n}</div>
</div>`).join('');
}
// ── Log de actividad ────────────────────────────────────────────────────────
async function cargarActividad(pag = 1) {
logPag = pag;
const params = new URLSearchParams({
page: pag, limit: 25,
modulo: document.getElementById('log-modulo').value,
desde: fecha('rpt-desde'), hasta: fecha('rpt-hasta'),
});
const r = await fetch(`api/lab/get_actividad.php?${params}`);
const d = await r.json();
const el = document.getElementById('log-body');
if (!d.data?.length) {
el.innerHTML = '<p class="text-muted text-center py-3 small">Sin actividad en este período</p>';
document.getElementById('log-paginacion').innerHTML = '';
return;
}
el.innerHTML = d.data.map(a => `
<div class="log-item">
<span class="badge bg-${MODULO_COLOR[a.modulo]||'secondary'} badge-modulo">${esc(a.modulo)}</span>
<span class="fw-semibold small ms-1">${esc(a.admin_nombre||a.admin_username||'Sistema')}</span>
<div class="text-muted" style="font-size:.78rem">${esc(a.accion)} ${a.entidad_id?`#${a.entidad_id}`:''}</div>
<div class="text-muted" style="font-size:.74rem">${formatFecha(a.created_at)}</div>
</div>`).join('');
const { paginas, total } = d;
const pagEl = document.getElementById('log-paginacion');
pagEl.innerHTML = paginas > 1
? `<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">${total} eventos</small>
<div class="btn-group btn-group-sm">
${pag>1?`<button class="btn btn-outline-secondary" onclick="cargarActividad(${pag-1})"><i class="fas fa-chevron-left"></i></button>`:''}
<button class="btn btn-secondary disabled">${pag}/${paginas}</button>
${pag<paginas?`<button class="btn btn-outline-secondary" onclick="cargarActividad(${pag+1})"><i class="fas fa-chevron-right"></i></button>`:''}
</div></div>`
: `<small class="text-muted">${total} evento(s)</small>`;
}
// ── Exportar ────────────────────────────────────────────────────────────────
function exportar(tipo) {
window.open(`lab_reportes.php?export=${tipo}&desde=${fecha('rpt-desde')}&hasta=${fecha('rpt-hasta')}`, '_blank');
}
// ── Utils ────────────────────────────────────────────────────────────────────
const esc = s => String(s||'').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
const fecha = id => document.getElementById(id)?.value || '';
const formatFecha = s => s ? new Date(s).toLocaleString('es-CO', {day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}) : '—';
function fmtMin(m) {
m = parseFloat(m);
if (isNaN(m)) return '—';
if (m < 60) return Math.round(m) + ' min';
return Math.floor(m/60) + 'h ' + Math.round(m%60) + 'min';
}
function fmtMoney(v) {
v = parseFloat(v) || 0;
return '$' + v.toLocaleString('es-CO', {maximumFractionDigits:0});
}
cargarTodo();
</script>
<script src="assets/js/lab-sidebar.js"></script>
</body>
</html>