Files
whatsapp/lab_reportes.php
T
2026-04-09 16:38:03 -05:00

1195 lines
65 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;
case 'pagos':
$pModo = in_array($_GET['modo'] ?? '', ['dia','mes','rango']) ? $_GET['modo'] : 'dia';
$pWheres = [];
$pParams = [];
switch ($pModo) {
case 'mes':
$pAnio = isset($_GET['anio']) && ctype_digit($_GET['anio']) ? (int)$_GET['anio'] : (int)date('Y');
$pMes = isset($_GET['mes']) && ctype_digit($_GET['mes']) ? (int)$_GET['mes'] : (int)date('n');
if ($pMes < 1 || $pMes > 12) $pMes = (int)date('n');
$pWheres[] = 'YEAR(d.fecha_programada) = ?'; $pParams[] = $pAnio;
$pWheres[] = 'MONTH(d.fecha_programada) = ?'; $pParams[] = $pMes;
$rangoNombre = sprintf('%04d-%02d', $pAnio, $pMes);
break;
case 'rango':
$pDesde = $_GET['desde'] ?? date('Y-m-01');
$pHasta = $_GET['hasta'] ?? date('Y-m-d');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $pDesde)) $pDesde = date('Y-m-01');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $pHasta)) $pHasta = date('Y-m-d');
$pWheres[] = 'DATE(d.fecha_programada) BETWEEN ? AND ?';
$pParams[] = $pDesde; $pParams[] = $pHasta;
$rangoNombre = "{$pDesde}_{$pHasta}";
break;
default: // dia
$pFecha = $_GET['fecha'] ?? date('Y-m-d');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $pFecha)) $pFecha = date('Y-m-d');
$pWheres[] = 'DATE(d.fecha_programada) = ?';
$pParams[] = $pFecha;
$rangoNombre = $pFecha;
}
if (!empty($_GET['enfermera_id']) && ctype_digit($_GET['enfermera_id'])) {
$pWheres[] = 'a.enfermera_id = ?';
$pParams[] = (int)$_GET['enfermera_id'];
}
$pWhere = $pWheres ? 'WHERE ' . implode(' AND ', $pWheres) : '';
$rows = $db->fetchAll(
"SELECT d.id, d.fecha_programada, d.pago_fecha,
p.nombre_completo AS paciente, p.numero_documento,
COALESCE(e.nombre_completo,'Sin asignar') AS enfermera,
d.tipo_cliente, COALESCE(d.seguro_nombre,'') AS seguro,
d.estado AS dom_estado,
COALESCE(d.valor_domicilio,0) AS valor_domicilio,
COALESCE(d.pago_estado,'pendiente') AS pago_estado,
COALESCE(d.pago_modo,'') AS pago_modo,
COALESCE(d.pago_monto,0) AS pago_monto,
COALESCE(d.pago_notas,'') AS pago_notas
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
$pWhere
ORDER BY d.fecha_programada ASC, d.id ASC",
$pParams
);
$filename = "reporte_pagos_{$rangoNombre}.csv";
$headers = [
'ID','Fecha programada','Fecha pago','Paciente','Documento',
'Enfermero/a','Tipo cliente','Seguro','Estado domicilio',
'Valor domicilio','Estado pago','Modo pago','Monto recibido','Notas pago',
];
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_actividad.php" class="nav-link"><i class="fas fa-history"></i> Actividad</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 de Pagos recibidos -->
<!-- ══════════════════════════════════════════════════════════════ -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 pb-0">
<h6 class="fw-semibold mb-2">
<i class="fas fa-money-bill-wave me-2 text-success"></i>Informe de pagos recibidos en domicilios
</h6>
<div class="d-flex flex-wrap gap-2 align-items-end pb-2">
<div>
<label class="form-label small text-muted text-uppercase mb-1">Período</label>
<select id="pago-modo" class="form-select form-select-sm" onchange="pagoToggleModo()" style="min-width:110px">
<option value="dia">Por día</option>
<option value="mes">Por mes</option>
<option value="rango">Rango fechas</option>
</select>
</div>
<!-- Día -->
<div id="pago-wrap-dia">
<label class="form-label small text-muted text-uppercase mb-1">Fecha</label>
<input type="date" id="pago-fecha" class="form-control form-control-sm" value="<?= date('Y-m-d') ?>">
</div>
<!-- Mes -->
<div id="pago-wrap-anio" style="display:none">
<label class="form-label small text-muted text-uppercase mb-1">Año</label>
<input type="number" id="pago-anio" class="form-control form-control-sm" value="<?= date('Y') ?>" min="2020" max="2099" style="width:88px">
</div>
<div id="pago-wrap-mes" style="display:none">
<label class="form-label small text-muted text-uppercase mb-1">Mes</label>
<select id="pago-mes" class="form-select form-select-sm" style="width:120px">
<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>
<!-- Rango -->
<div id="pago-wrap-desde" style="display:none">
<label class="form-label small text-muted text-uppercase mb-1">Desde</label>
<input type="date" id="pago-desde" class="form-control form-control-sm" value="<?= date('Y-m-01') ?>">
</div>
<div id="pago-wrap-hasta" style="display:none">
<label class="form-label small text-muted text-uppercase mb-1">Hasta</label>
<input type="date" id="pago-hasta" class="form-control form-control-sm" value="<?= date('Y-m-d') ?>">
</div>
<!-- Enfermero -->
<div>
<label class="form-label small text-muted text-uppercase mb-1">Enfermero/a</label>
<select id="pago-enfermera" class="form-select form-select-sm" style="min-width:160px">
<option value="">Todos</option>
</select>
</div>
<button class="btn btn-primary btn-sm" onclick="cargarReportePagos()">
<i class="fas fa-sync-alt me-1"></i>Ver informe
</button>
<button class="btn btn-sm btn-outline-success" onclick="exportarReportePagos()">
<i class="fas fa-file-excel me-1"></i>Excel
</button>
</div>
</div>
<!-- Resumen KPIs -->
<div id="pago-resumen" class="px-3 py-2 border-bottom bg-light" style="display:none">
<div class="row g-2 text-center">
<div class="col-6 col-md-3">
<div class="fw-bold fs-5 text-secondary" id="pr-total">—</div>
<div class="small text-muted">Total domicilios</div>
</div>
<div class="col-6 col-md-3">
<div class="fw-bold fs-5 text-success" id="pr-pagados">—</div>
<div class="small text-muted">Pagados</div>
</div>
<div class="col-6 col-md-3">
<div class="fw-bold fs-5 text-warning" id="pr-pendientes">—</div>
<div class="small text-muted">Pendientes</div>
</div>
<div class="col-6 col-md-3">
<div class="fw-bold fs-5" style="color:#7b1fa2" id="pr-monto">—</div>
<div class="small text-muted">Total recibido</div>
</div>
</div>
<div id="pr-modos" class="d-flex flex-wrap gap-3 justify-content-center mt-2" style="font-size:.82rem"></div>
</div>
<!-- Tabla -->
<div class="card-body p-0">
<div id="pago-tabla-wrap" style="overflow-x:auto">
<div class="text-center py-4 text-muted small">
<i class="fas fa-info-circle me-1"></i>Selecciona los filtros y haz clic en <strong>Ver informe</strong>
</div>
</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');
}
// ── Reporte de Pagos ───────────────────────────────────────────────────────
function pagoToggleModo() {
const modo = document.getElementById('pago-modo').value;
document.getElementById('pago-wrap-dia').style.display = modo === 'dia' ? '' : 'none';
document.getElementById('pago-wrap-anio').style.display = modo === 'mes' ? '' : 'none';
document.getElementById('pago-wrap-mes').style.display = modo === 'mes' ? '' : 'none';
document.getElementById('pago-wrap-desde').style.display = modo === 'rango' ? '' : 'none';
document.getElementById('pago-wrap-hasta').style.display = modo === 'rango' ? '' : 'none';
}
// Seleccionar el mes actual al cargar
(function initPagoMes() {
const mes = new Date().getMonth() + 1;
const sel = document.getElementById('pago-mes');
if (sel) sel.value = mes;
})();
async function cargarReportePagos() {
const wrap = document.getElementById('pago-tabla-wrap');
wrap.innerHTML = '<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i> Cargando…</div>';
document.getElementById('pago-resumen').style.display = 'none';
const params = _pagoParams();
try {
const r = await fetch(`api/lab/get_reporte_pagos.php?${params}`);
const d = await r.json();
if (!d.success) throw new Error(d.error || 'Error al cargar');
// Poblar selector de enfermeras (solo la primera vez)
const sel = document.getElementById('pago-enfermera');
if (sel.options.length === 1 && d.enfermeras?.length) {
d.enfermeras.forEach(e => {
const o = document.createElement('option');
o.value = e.id;
o.textContent = e.nombre_completo;
sel.appendChild(o);
});
}
// Resumen KPIs
const rs = d.resumen || {};
document.getElementById('pr-total').textContent = rs.total ?? 0;
document.getElementById('pr-pagados').textContent = rs.pagados ?? 0;
document.getElementById('pr-pendientes').textContent = rs.pendientes ?? 0;
document.getElementById('pr-monto').textContent = fmtMoney(rs.monto_total);
const modosEl = document.getElementById('pr-modos');
const MODO_LABEL = { efectivo:'Efectivo', transferencia:'Transferencia', otro:'Otro', sin_registrar:'Sin modo' };
const modos = Object.entries(rs.por_modo || {});
modosEl.innerHTML = modos.length
? modos.map(([k, v]) =>
`<span class="badge bg-secondary">${esc(MODO_LABEL[k] || k)}: ${fmtMoney(v)}</span>`
).join('')
: '';
document.getElementById('pago-resumen').style.display = '';
// Tabla de detalle
if (!d.filas?.length) {
wrap.innerHTML = '<p class="text-muted text-center py-3 small">Sin domicilios para los filtros seleccionados.</p>';
return;
}
const PAGO_BADGE = { pagado:'success', pendiente:'warning', exento:'secondary' };
const DOM_BADGE = { completado:'success', cancelado:'danger', programado:'secondary',
confirmado:'info', en_camino:'primary', reprogramado:'dark' };
wrap.innerHTML = `
<table class="table table-sm table-hover mb-0" style="font-size:.83rem">
<thead class="table-light sticky-top">
<tr>
<th class="ps-3">#</th>
<th>Fecha prog.</th>
<th>Paciente</th>
<th>Documento</th>
<th>Enfermero/a</th>
<th>Tipo cliente</th>
<th class="text-end">Valor domicilio</th>
<th class="text-center">Estado pago</th>
<th class="text-center">Modo pago</th>
<th class="text-end">Monto recibido</th>
<th>Fecha pago</th>
<th>Estado domicilio</th>
<th>Notas pago</th>
</tr>
</thead>
<tbody>
${d.filas.map(f => {
const pagoEst = f.pago_estado || 'pendiente';
const domEst = f.dom_estado || '';
const cliente = f.tipo_cliente === 'seguro' && f.seguro_nombre
? `Seguro: ${esc(f.seguro_nombre)}`
: esc(f.tipo_cliente || 'particular');
return `<tr class="${pagoEst === 'pagado' ? '' : 'opacity-75'}">
<td class="ps-3 text-muted">${f.id}</td>
<td class="text-nowrap">${esc(f.fecha_programada?.slice(0,10) || '—')}</td>
<td class="fw-semibold text-nowrap">${esc(f.paciente)}</td>
<td class="text-muted">${esc(f.numero_documento || '—')}</td>
<td class="text-nowrap">${esc(f.enfermera)}</td>
<td class="small">${cliente}</td>
<td class="text-end">${fmtMoney(f.valor_domicilio)}</td>
<td class="text-center">
<span class="badge bg-${PAGO_BADGE[pagoEst] || 'secondary'}">${esc(pagoEst)}</span>
</td>
<td class="text-center text-muted small">${esc(f.pago_modo) || '—'}</td>
<td class="text-end fw-semibold ${pagoEst === 'pagado' ? 'text-success' : 'text-muted'}">
${pagoEst === 'pagado' ? fmtMoney(f.pago_monto) : '—'}
</td>
<td class="text-nowrap text-muted small">${f.pago_fecha ? esc(f.pago_fecha.slice(0,16)) : '—'}</td>
<td>
<span class="badge bg-${DOM_BADGE[domEst] || 'secondary'}">${esc(domEst)}</span>
</td>
<td class="text-muted small" style="max-width:160px;white-space:normal">${esc(f.pago_notas) || ''}</td>
</tr>`;
}).join('')}
</tbody>
<tfoot class="table-light fw-semibold">
<tr>
<td colspan="6" class="ps-3">Total</td>
<td class="text-end">${fmtMoney(d.filas.reduce((s,f) => s + parseFloat(f.valor_domicilio||0), 0))}</td>
<td></td><td></td>
<td class="text-end text-success">${fmtMoney(rs.monto_total)}</td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>`;
} catch (e) {
wrap.innerHTML = `<div class="alert alert-danger m-3 small">${esc(e.message)}</div>`;
}
}
function exportarReportePagos() {
const p = _pagoParams();
p.set('export', 'pagos');
window.open(`lab_reportes.php?${p}`, '_blank');
}
function _pagoParams() {
const modo = document.getElementById('pago-modo').value;
const enf = document.getElementById('pago-enfermera').value;
const p = new URLSearchParams({ modo });
if (modo === 'dia') {
p.set('fecha', document.getElementById('pago-fecha').value || '<?= date('Y-m-d') ?>');
} else if (modo === 'mes') {
p.set('anio', document.getElementById('pago-anio').value || '<?= date('Y') ?>');
p.set('mes', document.getElementById('pago-mes').value || '<?= date('n') ?>');
} else {
p.set('desde', document.getElementById('pago-desde').value || '<?= date('Y-m-01') ?>');
p.set('hasta', document.getElementById('pago-hasta').value || '<?= date('Y-m-d') ?>');
}
if (enf) p.set('enfermera_id', enf);
return p;
}
// ── 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(),
cargarReportePagos(),
]);
}
// ── 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>