feat: métricas completas, export Excel domicilios con filtros, hora llegada/salida visible

This commit is contained in:
Lizandro Guarnizo
2026-03-25 11:48:24 -05:00
parent 75aac68d65
commit 4ba890a0d5
5 changed files with 743 additions and 114 deletions
+187
View File
@@ -0,0 +1,187 @@
<?php
/**
* GET /api/lab/get_metricas.php
* Estadísticas de período para el módulo de reportes.
* Parámetros: desde (Y-m-d), hasta (Y-m-d)
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('GET');
try {
$db = Database::getInstance();
$desde = $_GET['desde'] ?? date('Y-m-01');
$hasta = $_GET['hasta'] ?? date('Y-m-d');
// ── 1. Domicilios por día ────────────────────────────────────────────────
$porDia = $db->fetchAll(
"SELECT DATE(fecha_programada) AS dia,
COUNT(*) AS total,
SUM(estado = 'completado') AS completados,
SUM(estado = 'cancelado') AS cancelados,
SUM(estado = 'reprogramado') AS reprogramados
FROM lab_domicilios
WHERE fecha_programada BETWEEN ? AND ?
GROUP BY DATE(fecha_programada)
ORDER BY dia ASC",
[$desde, $hasta]
);
// ── 2. KPIs de tasa de éxito ────────────────────────────────────────────
$kpi = $db->fetch(
"SELECT COUNT(*) AS total,
SUM(estado = 'completado') AS completados,
SUM(estado = 'cancelado') AS cancelados,
SUM(estado = 'reprogramado') AS reprogramados,
SUM(estado NOT IN ('completado','cancelado','reprogramado')) AS en_curso
FROM lab_domicilios
WHERE fecha_programada BETWEEN ? AND ?",
[$desde, $hasta]
) ?: [];
// ── 3. Tiempo promedio en domicilio ──────────────────────────────────────
$tiempos = $db->fetch(
"SELECT ROUND(AVG(TIMESTAMPDIFF(MINUTE, hora_llegada, hora_salida)), 1) AS promedio_min,
MIN(TIMESTAMPDIFF(MINUTE, hora_llegada, hora_salida)) AS min_min,
MAX(TIMESTAMPDIFF(MINUTE, hora_llegada, hora_salida)) AS max_min,
COUNT(*) AS con_tiempos
FROM lab_domicilios
WHERE fecha_programada BETWEEN ? AND ?
AND hora_llegada IS NOT NULL AND hora_salida IS NOT NULL
AND hora_salida > hora_llegada",
[$desde, $hasta]
) ?: [];
// ── 4. Tiempo promedio desde agendamiento hasta realización ─────────────
$tiempoAgen = $db->fetch(
"SELECT ROUND(AVG(DATEDIFF(fecha_programada, DATE(created_at))), 1) AS dias_promedio,
MIN(DATEDIFF(fecha_programada, DATE(created_at))) AS dias_min,
MAX(DATEDIFF(fecha_programada, DATE(created_at))) AS dias_max
FROM lab_domicilios
WHERE fecha_programada BETWEEN ? AND ?
AND estado = 'completado'",
[$desde, $hasta]
) ?: [];
// ── 5. Ranking de enfermeras ────────────────────────────────────────────
$rankingEnf = $db->fetchAll(
"SELECT e.id, e.nombre_completo,
COUNT(a.id) AS asignados,
SUM(d.estado = 'completado') AS completados,
SUM(d.estado = 'cancelado') AS cancelados,
ROUND(SUM(d.estado='completado') / NULLIF(COUNT(a.id),0) * 100, 1) AS tasa_pct
FROM lab_asignaciones a
JOIN lab_enfermeras e ON e.id = a.enfermera_id
JOIN lab_domicilios d ON d.id = a.domicilio_id
WHERE d.fecha_programada BETWEEN ? AND ?
GROUP BY e.id, e.nombre_completo
ORDER BY completados DESC, asignados DESC",
[$desde, $hasta]
);
// ── 6. Financiero ────────────────────────────────────────────────────────
$financiero = $db->fetch(
"SELECT SUM(COALESCE(valor_domicilio,0) + COALESCE(valor_copago,0)) AS total_bruto,
SUM(CASE WHEN pago_estado='pagado' THEN COALESCE(valor_domicilio,0)+COALESCE(valor_copago,0) ELSE 0 END) AS cobrado,
SUM(CASE WHEN pago_estado='pendiente' THEN COALESCE(valor_domicilio,0)+COALESCE(valor_copago,0) ELSE 0 END) AS pendiente,
SUM(CASE WHEN pago_estado='exento' THEN COALESCE(valor_domicilio,0)+COALESCE(valor_copago,0) ELSE 0 END) AS exento,
COUNT(CASE WHEN pago_estado='pagado' THEN 1 END) AS n_pagados,
COUNT(CASE WHEN pago_estado='pendiente' THEN 1 END) AS n_pendientes,
COUNT(CASE WHEN pago_estado='exento' THEN 1 END) AS n_exentos
FROM lab_domicilios
WHERE fecha_programada BETWEEN ? AND ?",
[$desde, $hasta]
) ?: [];
// ── 7. Por modo de pago ─────────────────────────────────────────────────
$porModoPago = $db->fetchAll(
"SELECT COALESCE(pago_modo, 'no_especificado') AS modo,
COUNT(*) AS n,
SUM(COALESCE(valor_domicilio,0)+COALESCE(valor_copago,0)) AS total
FROM lab_domicilios
WHERE pago_estado = 'pagado'
AND fecha_programada BETWEEN ? AND ?
GROUP BY pago_modo
ORDER BY n DESC",
[$desde, $hasta]
);
// ── 8. Top zonas (barrio / ciudad) ──────────────────────────────────────
$topZonas = $db->fetchAll(
"SELECT COALESCE(NULLIF(TRIM(barrio),''), NULLIF(TRIM(ciudad),''), 'Sin datos') AS zona,
COUNT(*) AS n
FROM lab_domicilios
WHERE fecha_programada BETWEEN ? AND ?
GROUP BY zona
ORDER BY n DESC
LIMIT 10",
[$desde, $hasta]
);
// ── 9. Domicilios por tipo de cliente ────────────────────────────────────
$porTipoCliente = $db->fetchAll(
"SELECT COALESCE(tipo_cliente,'particular') AS tipo,
COALESCE(seguro_nombre,'') AS seguro,
COUNT(*) AS n
FROM lab_domicilios
WHERE fecha_programada BETWEEN ? AND ?
GROUP BY tipo, seguro
ORDER BY n DESC",
[$desde, $hasta]
);
// ── 10. Top exámenes solicitados (simple word-frequency) ─────────────────
// Traemos todos los valores y hacemos el conteo en PHP para no depender de
// funciones de texto avanzadas de MySQL que podrían variar según servidor.
$examRows = $db->fetchAll(
"SELECT examenes_solicitados FROM lab_domicilios
WHERE fecha_programada BETWEEN ? AND ?
AND examenes_solicitados IS NOT NULL AND examenes_solicitados != ''",
[$desde, $hasta]
);
$examConteo = [];
foreach ($examRows as $row) {
// Dividir por coma, punto y coma o salto de línea
$items = preg_split('/[,;\n]+/', $row['examenes_solicitados'] ?? '');
foreach ($items as $item) {
$item = trim($item);
if (strlen($item) > 2) {
$k = mb_strtolower($item);
$examConteo[$k] = ($examConteo[$k] ?? 0) + 1;
}
}
}
arsort($examConteo);
$topExamenes = array_slice(
array_map(fn($k, $v) => ['examen' => $k, 'n' => $v], array_keys($examConteo), $examConteo),
0, 10
);
// ── 11. Horas del día con más agendamientos ─────────────────────────────
$porHora = $db->fetchAll(
"SELECT HOUR(hora_programada) AS hora, COUNT(*) AS n
FROM lab_domicilios
WHERE fecha_programada BETWEEN ? AND ?
AND hora_programada IS NOT NULL
GROUP BY HOUR(hora_programada)
ORDER BY hora ASC",
[$desde, $hasta]
);
jsonOk([
'por_dia' => $porDia,
'kpi' => $kpi,
'tiempos' => $tiempos,
'tiempo_agendamiento' => $tiempoAgen,
'ranking_enfermeras' => $rankingEnf,
'financiero' => $financiero,
'por_modo_pago' => $porModoPago,
'top_zonas' => $topZonas,
'por_tipo_cliente' => $porTipoCliente,
'top_examenes' => $topExamenes,
'por_hora' => $porHora,
]);
} catch (Exception $e) {
error_log('[get_metricas] ' . $e->getMessage());
jsonError($e->getMessage(), 500);
}
+2
View File
@@ -115,6 +115,8 @@ class Enfermera {
d.pago_modo,
d.pago_monto,
d.pago_notas,
d.hora_llegada,
d.hora_salida,
p.id AS paciente_id,
p.nombre_completo AS paciente_nombre,
p.telefono AS paciente_telefono,
+14
View File
@@ -531,6 +531,20 @@ const portal = {
<!-- Cobro y pago -->
${cobroHtml}
<!-- Horas de llegada / salida (auto-registradas) -->
${(item.hora_llegada || item.hora_salida) ? `
<div class="d-flex gap-3 small mt-1 mb-2">
${item.hora_llegada ? `<span class="text-success"><i class="fas fa-sign-in-alt me-1"></i>Llegada: <strong>${item.hora_llegada.slice(0,5)}</strong></span>` : ''}
${item.hora_salida ? `<span class="text-danger"><i class="fas fa-sign-out-alt me-1"></i>Salida: <strong>${item.hora_salida.slice(0,5)}</strong></span>` : ''}
${item.hora_llegada && item.hora_salida ? (() => {
const [h1,m1] = item.hora_llegada.slice(0,5).split(':').map(Number);
const [h2,m2] = item.hora_salida.slice(0,5).split(':').map(Number);
const mins = (h2*60+m2)-(h1*60+m1);
if (mins > 0) return `<span class="text-info"><i class="fas fa-stopwatch me-1"></i>${mins>=60?Math.floor(mins/60)+'h '+mins%60+'min':mins+' min'}</span>`;
return '';
})() : ''}
</div>` : ''}
<!-- Órdenes médicas (multi-archivo) -->
${renderOrdenesEnfermero(item)}
<!-- Servicios extra registrados -->
+42
View File
@@ -104,6 +104,11 @@ $domIdParam = (int)($_GET['id'] ?? 0);
<i class="fas fa-calendar-day me-1"></i> Hoy
</button>
</div>
<div class="col-md-2">
<button class="btn btn-sm btn-success w-100" onclick="exportarDomicilios()">
<i class="fas fa-file-excel me-1"></i> Excel
</button>
</div>
</div>
<!-- Resumen del día -->
@@ -288,6 +293,18 @@ $domIdParam = (int)($_GET['id'] ?? 0);
<input type="time" class="form-control" name="hora_programada" id="dom-hora">
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label class="form-label"><i class="fas fa-sign-in-alt text-success me-1"></i>Hora llegada</label>
<input type="time" class="form-control" name="hora_llegada" id="dom-hora-llegada">
<div class="form-text">Registrada automáticamente al iniciar el domicilio</div>
</div>
<div class="col-md-4">
<label class="form-label"><i class="fas fa-sign-out-alt text-danger me-1"></i>Hora salida</label>
<input type="time" class="form-control" name="hora_salida" id="dom-hora-salida">
<div class="form-text">Registrada automáticamente al completar</div>
</div>
</div>
<!-- ── Cobros ── -->
<h6 class="text-muted text-uppercase small fw-semibold border-bottom pb-1 mb-3">
@@ -655,6 +672,10 @@ async function verDomicilio(id) {
<dt class="col-5 text-muted">Barrio</dt><dd class="col-7">${esc(dom.barrio||'—')}</dd>
<dt class="col-5 text-muted">Indicaciones</dt><dd class="col-7">${esc(dom.indicaciones_dir||'—')}</dd>
<dt class="col-5 text-muted">Fecha</dt><dd class="col-7">${esc(dom.fecha_programada)} ${esc(dom.hora_programada||'')}</dd>
${(dom.hora_llegada || dom.hora_salida) ? `
<dt class="col-5 text-muted">Hora llegada</dt><dd class="col-7 text-success fw-semibold">${dom.hora_llegada ? dom.hora_llegada.slice(0,5) : '—'}</dd>
<dt class="col-5 text-muted">Hora salida</dt><dd class="col-7 text-danger fw-semibold">${dom.hora_salida ? dom.hora_salida.slice(0,5) : '—'}</dd>
${dom.hora_llegada && dom.hora_salida ? `<dt class="col-5 text-muted">Duración</dt><dd class="col-7 fw-semibold text-info">${calcDuracion(dom.hora_llegada, dom.hora_salida)}</dd>` : ''}` : ''}
<dt class="col-5 text-muted">Servicio</dt><dd class="col-7">${esc(dom.tipo_servicio||'—')}</dd>
<dt class="col-5 text-muted">Tipo cliente</dt><dd class="col-7">
${dom.tipo_cliente === 'seguro'
@@ -855,6 +876,8 @@ async function editarDomicilio(id) {
document.getElementById('dom-indic').value = dom.indicaciones_dir || '';
document.getElementById('dom-fecha').value = dom.fecha_programada || '';
document.getElementById('dom-hora').value = (dom.hora_programada || '').slice(0, 5);
document.getElementById('dom-hora-llegada').value = (dom.hora_llegada || '').slice(0, 5);
document.getElementById('dom-hora-salida').value = (dom.hora_salida || '').slice(0, 5);
document.getElementById('dom-tipo-cliente').value = dom.tipo_cliente || 'particular';
toggleSeguro(dom.tipo_cliente || 'particular');
document.getElementById('dom-seguro-nombre').value = dom.seguro_nombre || '';
@@ -969,8 +992,27 @@ function verHoy() {
cargarLista();
}
function exportarDomicilios() {
const fecha = document.getElementById('filtro-fecha').value;
const estado = document.getElementById('filtro-estado').value;
const enfId = document.getElementById('filtro-enfermera').value;
let url = `lab_reportes.php?export=domicilios`;
if (fecha) url += `&fecha=${encodeURIComponent(fecha)}`;
if (estado) url += `&estado=${encodeURIComponent(estado)}`;
if (enfId) url += `&enfermera_id=${encodeURIComponent(enfId)}`;
window.open(url, '_blank');
}
// ── Utils ──────────────────────────────────────────────────────────────────
const esc = s => String(s||'').replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
function calcDuracion(hl, hs) {
if (!hl || !hs) return '—';
const [h1, m1] = hl.slice(0,5).split(':').map(Number);
const [h2, m2] = hs.slice(0,5).split(':').map(Number);
const mins = (h2 * 60 + m2) - (h1 * 60 + m1);
if (mins <= 0) return '—';
return mins >= 60 ? `${Math.floor(mins/60)}h ${mins%60}min` : `${mins} min`;
}
/**
* Renderiza el bloque de órdenes médicas (multi-archivo) en el detalle de domicilio.
+498 -114
View File
@@ -39,21 +39,74 @@ if ($tipo) {
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.telefono AS tel_paciente,
d.direccion, d.barrio, d.ciudad, d.fecha_programada, d.hora_programada,
d.estado, COALESCE(e.nombre_completo,'Sin asignar') AS enfermera,
d.tipo_servicio, d.created_at
"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
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
WHERE DATE(d.fecha_programada) BETWEEN ? AND ?
ORDER BY d.fecha_programada, d.hora_programada",
[$desde, $hasta]
WHERE $sqlWhere
ORDER BY d.fecha_programada ASC, d.hora_programada ASC",
$sqlParams
);
$filename = "domicilios_$desde\_$hasta.csv";
$headers = ['ID','Paciente','Teléfono','Dirección','Barrio','Ciudad','Fecha','Hora','Estado','Enfermera','Tipo Servicio','Registrado'];
$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',
];
break;
case 'pacientes':
@@ -105,6 +158,19 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
.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>
@@ -130,14 +196,14 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
<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 Trazabilidad</h1>
<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 ──────────────────────────────────────────── -->
<!-- ── 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>
@@ -152,33 +218,102 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
<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('ordenes')">
<i class="fas fa-file-csv me-1"></i> Exportar Órdenes
<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-primary" onclick="exportar('domicilios')">
<i class="fas fa-file-csv me-1"></i> Exportar Domicilios
<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-csv me-1"></i> Exportar Pacientes
<i class="fas fa-file-excel me-1"></i> Excel Pacientes
</button>
</div>
</div>
</div>
<div class="row g-3">
<!-- ── Resumen estadístico ──────────────────────────────────── -->
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- 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 mb-3">
<div class="card-header bg-white border-0">
<h6 class="fw-semibold mb-0"><i class="fas fa-chart-pie me-2 text-primary"></i>Resumen del período</h6>
<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="resumen-stats">
<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin me-2"></i>Cargando...</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>
<!-- Órdenes por estado -->
<div class="card border-0 shadow-sm mb-3">
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- 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>
@@ -186,34 +321,82 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
<div class="text-center py-3 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
</div>
<!-- Domicilios por estado -->
<div class="card border-0 shadow-sm">
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- 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-house-medical me-2 text-success"></i>Domicilios por estado</h6>
<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="domicilios-estado-chart">
<div class="text-center py-3 text-muted"><i class="fas fa-spinner fa-spin"></i></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>
<!-- ── Log de actividad ─────────────────────────────────────── -->
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- 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-info"></i>Log de actividad</h6>
<div>
<select id="log-modulo" class="form-select form-select-sm" onchange="cargarActividad(1)">
<option value="">Todos los módulos</option>
<option value="ordenes">Órdenes</option>
<option value="domicilios">Domicilios</option>
<option value="pacientes">Pacientes</option>
<option value="enfermeras">Enfermeras</option>
</select>
</div>
<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:520px;overflow-y:auto">
<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>
@@ -226,124 +409,325 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const COLOR = { pendiente:'warning', en_revision:'info', autorizada:'success', rechazada:'danger', en_domicilio:'primary', completada:'secondary', programado:'secondary', confirmado:'info', en_camino:'primary', cancelado:'danger' };
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;
// ── Carga todo en paralelo ──────────────────────────────────────────────────
async function cargarTodo() {
await Promise.all([cargarResumen(), cargarActividad(1)]);
const desde = fecha('rpt-desde');
const hasta = fecha('rpt-hasta');
await Promise.all([
cargarMetricas(desde, hasta),
cargarResumenOrdenes(desde, hasta),
cargarActividad(1),
]);
}
async function cargarResumen() {
const r = await fetch(`api/lab/get_stats.php?desde=${fecha('rpt-desde')}&hasta=${fecha('rpt-hasta')}`);
// ── 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.data;
// Resumen general
const g = d.generales || {};
document.getElementById('resumen-stats').innerHTML = `
<div class="row g-3">
<div class="col-6 col-md-3 text-center">
<div class="fs-4 fw-bold text-warning">${d.ordenes?.total_periodo||g.total_ordenes||0}</div>
<div class="small text-muted">Órdenes</div>
// ── 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 class="col-6 col-md-3 text-center">
<div class="fs-4 fw-bold text-success">${d.ordenes?.autorizadas_periodo||0}</div>
<div class="small text-muted">Autorizadas</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-6 col-md-3 text-center">
<div class="fs-4 fw-bold text-primary">${d.domicilios?.total_periodo||g.total_domicilios||0}</div>
<div class="small text-muted">Domicilios</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-6 col-md-3 text-center">
<div class="fs-4 fw-bold text-info">${g.total_pacientes||0}</div>
<div class="small text-muted">Pacientes</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('');
}
// Barras de órdenes por estado
renderBarras('ordenes-estado-chart', d.ordenes?.por_estado || {});
// Barras de domicilios por estado
renderBarras('domicilios-estado-chart', d.domicilios?.por_estado || {});
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);
const total = Object.values(datos).reduce((a,b) => a + (b||0), 0) || 1;
if (!Object.keys(datos).length) {
el.innerHTML = '<p class="text-muted small text-center">Sin datos</p>'; return;
}
el.innerHTML = Object.entries(datos).map(([estado, n]) => `
<div class="d-flex align-items-center gap-2 mb-2">
<div class="text-end" style="min-width:90px">
<span class="badge bg-${COLOR[estado]||'secondary'}">${esc(estado)}</span>
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="flex-grow-1 bg-light rounded" style="height:16px">
<div class="rounded h-100 bg-${COLOR[estado]||'secondary'}" style="width:${Math.max((n/total)*100,2)}%;transition:width .3s"></div>
</div>
<div style="min-width:28px" class="text-end fw-bold small">${n}</div>
</div>
`).join('');
<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 modulo = document.getElementById('log-modulo').value;
const params = new URLSearchParams({
page: pag, limit: 30,
modulo,
desde: fecha('rpt-desde'),
hasta: fecha('rpt-hasta'),
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 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">
<div class="d-flex align-items-center gap-2">
<span class="badge bg-${MODULO_COLOR[a.modulo]||'secondary'} badge-modulo">${esc(a.modulo)}</span>
<span class="fw-semibold small">${esc(a.admin_nombre||a.admin_username||'Sistema')}</span>
</div>
<div class="text-muted" style="font-size:.78rem">${esc(a.accion)} ${a.entidad_id ? `#${a.entidad_id}` : ''}</div>
<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('');
</div>`).join('');
// Paginación mínima
const { paginas, total } = d;
const pagEl = document.getElementById('log-paginacion');
if (paginas > 1) {
pagEl.innerHTML = `
<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>`;
} else {
pagEl.innerHTML = `<small class="text-muted">${total} evento(s)</small>`;
}
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) {
const url = `lab_reportes.php?export=${tipo}&desde=${fecha('rpt-desde')}&hasta=${fecha('rpt-hasta')}`;
window.open(url, '_blank');
window.open(`lab_reportes.php?export=${tipo}&desde=${fecha('rpt-desde')}&hasta=${fecha('rpt-hasta')}`, '_blank');
}
// ── Utils ──────────────────────────────────────────────────────────────────
// ── 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>