feat(tiempos): agregar tiempo total puerta a puerta en historial y dashboard

- APIs: total_min = creado_at → fin_lugar_at (solo finalizados)
- Dashboard: KPI card 'Total prom. puerta a puerta' + columna Total en tabla
- Historial: columna Total en tabla

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-30 18:14:13 -05:00
co-authored by Claude Sonnet 4.6
parent c357f18b61
commit 44b737c253
4 changed files with 24 additions and 5 deletions
+11 -2
View File
@@ -73,7 +73,15 @@ $stmtRes = $pdo->prepare(
THEN TIMESTAMPDIFF(SECOND, inicio_lugar_at, fin_lugar_at) / 60.0 THEN TIMESTAMPDIFF(SECOND, inicio_lugar_at, fin_lugar_at) / 60.0
END END
), 1 ), 1
) AS tiempo_servicio_promedio_min ) AS tiempo_servicio_promedio_min,
ROUND(
AVG(
CASE
WHEN fin_lugar_at IS NOT NULL AND creado_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, creado_at, fin_lugar_at) / 60.0
END
), 1
) AS tiempo_total_promedio_min
FROM turnero_turnos FROM turnero_turnos
WHERE sesion_id = ?" WHERE sesion_id = ?"
); );
@@ -133,7 +141,8 @@ $stmtTurnos = $pdo->prepare(
ul.full_name AS atendido_lugar_nombre, ul.full_name AS atendido_lugar_nombre,
ts.observaciones AS notas, ts.observaciones AS notas,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, NOW())) / 60.0, 1) AS espera_min, ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, NOW())) / 60.0, 1) AS espera_min,
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1) AS servicio_min ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1) AS servicio_min,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, t.fin_lugar_at) / 60.0, 1) AS total_min
FROM turnero_turnos t FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id JOIN turnero_prioridades p ON p.id = t.prioridad_id
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
+3 -1
View File
@@ -116,7 +116,9 @@ $sqlTurnos = "
AS espera_min, AS espera_min,
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1) COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1)
AS servicio_min AS servicio_min,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, t.fin_lugar_at) / 60.0, 1)
AS total_min
FROM turnero_turnos t FROM turnero_turnos t
JOIN turnero_sesiones s ON s.id = t.sesion_id JOIN turnero_sesiones s ON s.id = t.sesion_id
JOIN turnero_prioridades p ON p.id = t.prioridad_id JOIN turnero_prioridades p ON p.id = t.prioridad_id
+5
View File
@@ -240,6 +240,7 @@ try {
<th>Estado</th> <th>Estado</th>
<th>Espera</th> <th>Espera</th>
<th>Serv.</th> <th>Serv.</th>
<th>Total</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -344,6 +345,7 @@ function renderDashboard(json) {
const cancelados= parseInt(resumen.cancelados || 0); const cancelados= parseInt(resumen.cancelados || 0);
const tEspera = resumen.tiempo_espera_promedio_min; const tEspera = resumen.tiempo_espera_promedio_min;
const tServicio = resumen.tiempo_servicio_promedio_min; const tServicio = resumen.tiempo_servicio_promedio_min;
const tTotal = resumen.tiempo_total_promedio_min;
const pct = total > 0 ? Math.round(atendidos / total * 100) : 0; const pct = total > 0 ? Math.round(atendidos / total * 100) : 0;
document.getElementById('kpiGrid').innerHTML = ` document.getElementById('kpiGrid').innerHTML = `
@@ -354,6 +356,7 @@ function renderDashboard(json) {
${kpiCard(cancelados,'❌ Cancelados', '')} ${kpiCard(cancelados,'❌ Cancelados', '')}
${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera prom.', 'hasta recepción')} ${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera prom.', 'hasta recepción')}
${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Servicio prom.', 'en lugar')} ${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Servicio prom.', 'en lugar')}
${kpiCard(tTotal !== null ? tTotal + ' min' : '—', '⏱ Total prom.', 'puerta a puerta')}
`; `;
// ── Consentimientos ──────────────────────────────────── // ── Consentimientos ────────────────────────────────────
@@ -451,6 +454,7 @@ function renderTabla(turnos) {
const estadoLbl = labelEstado[t.estado] || t.estado; const estadoLbl = labelEstado[t.estado] || t.estado;
const espMin = t.espera_min !== null ? t.espera_min + ' m' : '—'; const espMin = t.espera_min !== null ? t.espera_min + ' m' : '—';
const srvMin = t.servicio_min !== null ? t.servicio_min + ' m' : '—'; const srvMin = t.servicio_min !== null ? t.servicio_min + ' m' : '—';
const totMin = t.total_min !== null ? t.total_min + ' m' : '—';
const rowId = `dash-det-${t.id}`; const rowId = `dash-det-${t.id}`;
// Recepción // Recepción
@@ -478,6 +482,7 @@ function renderTabla(turnos) {
<td><span class="estado-badge ${estadoCls}">${estadoLbl}</span></td> <td><span class="estado-badge ${estadoCls}">${estadoLbl}</span></td>
<td>${espMin}</td> <td>${espMin}</td>
<td>${srvMin}</td> <td>${srvMin}</td>
<td>${totMin}</td>
<td><i class="fas fa-chevron-right" style="font-size:.65rem;color:#94a3b8;transition:transform .2s" id="ico-${t.id}"></i></td> <td><i class="fas fa-chevron-right" style="font-size:.65rem;color:#94a3b8;transition:transform .2s" id="ico-${t.id}"></i></td>
</tr> </tr>
<tr id="${rowId}" class="det-row" style="display:none"> <tr id="${rowId}" class="det-row" style="display:none">
+4 -1
View File
@@ -214,6 +214,7 @@ Layout::open('Historial de Turnos', 'fas fa-history');
<th>Estado</th> <th>Estado</th>
<th>Espera</th> <th>Espera</th>
<th>Servicio</th> <th>Servicio</th>
<th>Total</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -407,7 +408,7 @@ const ESTADO_LBL = {
function renderTabla(turnos) { function renderTabla(turnos) {
const tbody = document.getElementById('tbody'); const tbody = document.getElementById('tbody');
if (!turnos.length) { if (!turnos.length) {
tbody.innerHTML = `<tr><td colspan="11"> tbody.innerHTML = `<tr><td colspan="12">
<div class="empty-state"><i class="fas fa-search"></i>Sin resultados para los filtros aplicados</div> <div class="empty-state"><i class="fas fa-search"></i>Sin resultados para los filtros aplicados</div>
</td></tr>`; </td></tr>`;
return; return;
@@ -425,6 +426,7 @@ function renderTabla(turnos) {
: '—'; : '—';
const espMin = t.espera_min != null ? t.espera_min + ' m' : '—'; const espMin = t.espera_min != null ? t.espera_min + ' m' : '—';
const srvMin = t.servicio_min != null ? t.servicio_min + ' m' : '—'; const srvMin = t.servicio_min != null ? t.servicio_min + ' m' : '—';
const totMin = t.total_min != null ? t.total_min + ' m' : '—';
const tel = esc(t.paciente_cel || '—'); const tel = esc(t.paciente_cel || '—');
const priCls = esc(t.prioridad_color || '#888'); const priCls = esc(t.prioridad_color || '#888');
const cls = ESTADO_CLS[t.estado] || 'eb-finalizado'; const cls = ESTADO_CLS[t.estado] || 'eb-finalizado';
@@ -467,6 +469,7 @@ function renderTabla(turnos) {
<td><span class="eb ${cls}">${lbl}</span></td> <td><span class="eb ${cls}">${lbl}</span></td>
<td class="text-nowrap">${espMin}</td> <td class="text-nowrap">${espMin}</td>
<td class="text-nowrap">${srvMin}</td> <td class="text-nowrap">${srvMin}</td>
<td class="text-nowrap">${totMin}</td>
<td class="text-nowrap"> <td class="text-nowrap">
<button class="btn btn-sm btn-outline-secondary py-0 px-2 me-1" <button class="btn btn-sm btn-outline-secondary py-0 px-2 me-1"
onclick="toggleDetalle('${rowId}', this, ${t.id})" onclick="toggleDetalle('${rowId}', this, ${t.id})"