This commit is contained in:
Lizandro Guarnizo
2026-05-11 13:45:36 -05:00
parent f197118acb
commit 00232b3513
2 changed files with 171 additions and 11 deletions
+52
View File
@@ -167,6 +167,56 @@ try {
[$desde, $hasta]
);
// ── 12. Tiempo de traslado: desde 'en_camino' hasta 'en_domicilio' ────────
// Fuente: tabla lab_actividad_admin, accion='en_camino' y accion='en_domicilio'
// Se empareja por entidad_id (domicilio_id) y se exige que en_domicilio
// ocurra después de en_camino y dentro de un margen de 4 horas.
$traslado = $db->fetch(
"SELECT
ROUND(AVG(TIMESTAMPDIFF(MINUTE, tc.created_at, td.created_at)), 1) AS promedio_min,
MIN(TIMESTAMPDIFF(MINUTE, tc.created_at, td.created_at)) AS min_min,
MAX(TIMESTAMPDIFF(MINUTE, tc.created_at, td.created_at)) AS max_min,
COUNT(*) AS con_datos,
SUM(TIMESTAMPDIFF(MINUTE, tc.created_at, td.created_at) <= 15) AS dentro_15min,
SUM(TIMESTAMPDIFF(MINUTE, tc.created_at, td.created_at) <= 30) AS dentro_30min
FROM lab_actividad_admin tc
JOIN lab_actividad_admin td
ON td.entidad_id = tc.entidad_id
AND td.modulo = 'domicilios'
AND td.accion = 'en_domicilio'
AND td.created_at > tc.created_at
AND td.created_at <= DATE_ADD(tc.created_at, INTERVAL 4 HOUR)
JOIN lab_domicilios d ON d.id = tc.entidad_id
WHERE tc.modulo = 'domicilios'
AND tc.accion = 'en_camino'
AND d.fecha_programada BETWEEN ? AND ?",
[$desde, $hasta]
) ?: [];
// ── 13. Tiempo de traslado por enfermera (top 10 más rápidas) ─────────────
$trasladoPorEnfermera = $db->fetchAll(
"SELECT e.nombre_completo,
ROUND(AVG(TIMESTAMPDIFF(MINUTE, tc.created_at, td.created_at)), 1) AS promedio_min,
COUNT(*) AS viajes
FROM lab_actividad_admin tc
JOIN lab_actividad_admin td
ON td.entidad_id = tc.entidad_id
AND td.modulo = 'domicilios'
AND td.accion = 'en_domicilio'
AND td.created_at > tc.created_at
AND td.created_at <= DATE_ADD(tc.created_at, INTERVAL 4 HOUR)
JOIN lab_domicilios d ON d.id = tc.entidad_id
JOIN lab_asignaciones a ON a.domicilio_id = d.id AND a.estado NOT IN ('liberada')
JOIN lab_enfermeras e ON e.id = a.enfermera_id
WHERE tc.modulo = 'domicilios'
AND tc.accion = 'en_camino'
AND d.fecha_programada BETWEEN ? AND ?
GROUP BY e.id, e.nombre_completo
ORDER BY promedio_min ASC
LIMIT 10",
[$desde, $hasta]
);
jsonOk([
'por_dia' => $porDia,
'kpi' => $kpi,
@@ -179,6 +229,8 @@ try {
'por_tipo_cliente' => $porTipoCliente,
'top_examenes' => $topExamenes,
'por_hora' => $porHora,
'traslado' => $traslado,
'traslado_por_enfermera' => $trasladoPorEnfermera,
]);
} catch (Exception $e) {
+108
View File
@@ -400,6 +400,34 @@ require_once __DIR__ . '/shared/components/sidebar.php';
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- Fila 1.5: Tiempo de traslado (en_camino → en_domicilio) -->
<!-- ══════════════════════════════════════════════════════════════ -->
<div class="row g-3 mb-3">
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-0 pb-1">
<div class="d-flex align-items-start gap-2">
<i class="fas fa-route mt-1" style="color:#00897b"></i>
<div>
<h6 class="fw-semibold mb-0">Tiempo de traslado del enfermero</h6>
<div class="text-muted" style="font-size:.78rem">
Mide cuánto tarda el enfermero desde que marca
<span class="badge" style="background:#fff3e0;color:#e65100;font-size:.72rem">En camino</span>
hasta que registra
<span class="badge" style="background:#e8f5e9;color:#2e7d32;font-size:.72rem">En domicilio</span>
&nbsp;·&nbsp; Fuente: log de cambios de estado (<code style="font-size:.72rem">lab_actividad_admin</code>)
</div>
</div>
</div>
</div>
<div class="card-body" id="traslado-card">
<div class="text-center py-4 text-muted"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════════════════ -->
<!-- Fila 2: Tiempo en domicilio + Hora pico -->
<!-- ══════════════════════════════════════════════════════════════ -->
@@ -968,6 +996,9 @@ async function cargarMetricas(desde, hasta) {
tcMap[k] = (tcMap[k] || 0) + parseInt(tc.n);
});
renderBarrasSimples('tipo-cliente-chart', Object.entries(tcMap), '#6d4c41');
// ── Traslado ──
renderTraslado(m.traslado || {}, m.traslado_por_enfermera || []);
}
// ── Órdenes (usa get_stats existente) ──────────────────────────────────────
@@ -1133,6 +1164,83 @@ function renderBarras(elId, datos) {
</div>`).join('');
}
function renderTraslado(t, porEnf) {
const el = document.getElementById('traslado-card');
if (!el) return;
const prom = parseFloat(t.promedio_min);
const con = parseInt(t.con_datos) || 0;
if (!con) {
el.innerHTML = `<div class="text-center py-3 text-muted small">
<i class="fas fa-info-circle me-1"></i>
Sin datos en el período. Se necesita que el enfermero haya marcado
<strong>En camino</strong> y luego <strong>En domicilio</strong> para el mismo domicilio.
</div>`;
return;
}
const pct15 = con ? Math.round((parseInt(t.dentro_15min)||0) / con * 100) : 0;
const pct30 = con ? Math.round((parseInt(t.dentro_30min)||0) / con * 100) : 0;
el.innerHTML = `
<div class="row g-3 align-items-start">
<!-- KPIs -->
<div class="col-lg-5">
<div class="row g-2 text-center mb-2">
<div class="col-4">
<div class="fw-bold fs-4" style="color:#00897b">${isNaN(prom) ? '—' : fmtMin(prom)}</div>
<div class="small text-muted">Promedio</div>
</div>
<div class="col-4">
<div class="fw-bold fs-4 text-success">${t.min_min != null ? fmtMin(t.min_min) : '—'}</div>
<div class="small text-muted">Mínimo</div>
</div>
<div class="col-4">
<div class="fw-bold fs-4 text-warning">${t.max_min != null ? fmtMin(t.max_min) : '—'}</div>
<div class="small text-muted">Máximo</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-center mb-2" style="font-size:.82rem">
<span class="badge" style="background:#e8f5e9;color:#2e7d32">${pct15}% llegó en ≤15 min</span>
<span class="badge" style="background:#fff8e1;color:#f57f17">${pct30}% llegó en ≤30 min</span>
</div>
<div class="text-muted text-center" style="font-size:.75rem">
<i class="fas fa-database me-1"></i>${con} trayecto(s) medido(s) en el período
</div>
</div>
<!-- Tabla por enfermera -->
<div class="col-lg-7">
${porEnf.length ? `
<div class="small text-muted text-uppercase mb-1" style="font-size:.72rem">Tiempo promedio por enfermero/a</div>
<div style="overflow-x:auto">
<table class="table table-sm table-hover mb-0" style="font-size:.82rem">
<thead class="table-light"><tr>
<th>Enfermero/a</th>
<th class="text-center">Prom. traslado</th>
<th class="text-center">Viajes</th>
<th style="min-width:100px"></th>
</tr></thead>
<tbody>
${porEnf.map(e => {
const p = parseFloat(e.promedio_min);
const color = p <= 15 ? '#43a047' : p <= 30 ? '#fb8c00' : '#e53935';
const pct = Math.min(Math.round((p / 60) * 100), 100);
return `<tr>
<td class="text-truncate" style="max-width:160px" title="${esc(e.nombre_completo)}">${esc(e.nombre_completo)}</td>
<td class="text-center fw-semibold" style="color:${color}">${fmtMin(p)}</td>
<td class="text-center text-muted">${e.viajes}</td>
<td><div style="height:6px;background:#f0f0f0;border-radius:3px">
<div style="width:${pct}%;height:100%;background:${color};border-radius:3px"></div>
</div></td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>` : '<p class="text-muted small text-center py-2">Sin desglose por enfermero/a</p>'}
</div>
</div>`;
}
function renderBarrasSimples(elId, entries, color) {
const el = document.getElementById(elId);
if (!el) return;