feat: métricas completas, export Excel domicilios con filtros, hora llegada/salida visible
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 => ({'<':'<','>':'>','&':'&','"':'"'}[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
@@ -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} Total: ${tot} Completados: ${com} 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> 50–79%</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;background:#e53935;border-radius:2px"></span> <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 => ({'<':'<','>':'>','&':'&','"':'"'}[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>
|
||||
|
||||
Reference in New Issue
Block a user