up
This commit is contained in:
+63
-11
@@ -167,18 +167,70 @@ 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,
|
||||
'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,
|
||||
'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,
|
||||
'traslado' => $traslado,
|
||||
'traslado_por_enfermera' => $trasladoPorEnfermera,
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
|
||||
@@ -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>
|
||||
· 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;
|
||||
|
||||
Reference in New Issue
Block a user