This commit is contained in:
Lizandro Guarnizo
2026-04-18 23:41:01 -05:00
parent 876e935a66
commit 318a1742cc
37 changed files with 8838 additions and 89 deletions
+374
View File
@@ -0,0 +1,374 @@
<?php
/**
* modules/turnero/views/dashboard.php
* Panel de resumen diario del Turnero.
* Carga datos vía fetch a get_dashboard.php (permite cambiar fecha sin recargar).
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dashboard Turnero</title>
<link rel="stylesheet" href="<?= BASE_URL ?>assets/css/bootstrap.min.css">
<link rel="stylesheet" href="<?= BASE_URL ?>assets/css/fontawesome.min.css">
<style>
body { background:#f1f5f9; }
.page-header {
background:#fff; border-bottom:1px solid #e2e8f0;
padding:14px 24px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;
}
.page-header h1 { font-size:1.18rem; font-weight:700; color:#1e293b; margin:0; flex:1; }
.page-header .back-btn { color:#64748b; text-decoration:none; font-size:.9rem; }
.content-wrap { max-width:1100px; margin:24px auto; padding:0 16px 48px; }
/* ── KPI cards ── */
.kpi-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:14px; margin-bottom:24px; }
.kpi-card {
background:#fff; border:1px solid #e2e8f0; border-radius:12px;
padding:16px 18px; text-align:center;
}
.kpi-card .kpi-val { font-size:2rem; font-weight:800; line-height:1.1; }
.kpi-card .kpi-lbl { font-size:.72rem; text-transform:uppercase; letter-spacing:.07em; color:#64748b; margin-top:4px; }
.kpi-card .kpi-sub { font-size:.75rem; color:#94a3b8; margin-top:2px; }
/* ── Charts / tables section ── */
.section-card { background:#fff; border:1px solid #e2e8f0; border-radius:12px; padding:20px; margin-bottom:20px; }
.section-title { font-size:.72rem; font-weight:700; text-transform:uppercase; letter-spacing:.08em; color:#64748b; margin:0 0 14px; }
/* ── Priority bar ── */
.pri-row { display:flex; align-items:center; gap:8px; margin-bottom:8px; }
.pri-dot { width:10px; height:10px; border-radius:50%; flex-shrink:0; }
.pri-name { width:160px; font-size:.82rem; color:#334155; }
.pri-bar-wrap { flex:1; background:#f1f5f9; border-radius:99px; height:10px; overflow:hidden; }
.pri-bar-fill { height:100%; border-radius:99px; transition:width .5s; }
.pri-nums { font-size:.78rem; color:#64748b; white-space:nowrap; }
/* ── Lugar cards ── */
.lugar-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(200px,1fr)); gap:12px; }
.lugar-card { border:1px solid #e2e8f0; border-radius:10px; padding:14px; }
.lugar-card .lc-name { font-weight:600; font-size:.9rem; color:#1e293b; margin-bottom:8px; }
.lugar-card .lc-row { display:flex; justify-content:space-between; font-size:.8rem; color:#475569; margin-bottom:4px; }
.lugar-card .lc-row span:last-child { font-weight:600; color:#0f172a; }
/* ── Tabla de turnos ── */
.estado-badge { font-size:.65rem; padding:2px 7px; border-radius:99px; font-weight:600; white-space:nowrap; }
.eb-espera { background:#fef9c3; color:#92400e; }
.eb-en_recepcion { background:#dbeafe; color:#1e40af; }
.eb-en_espera_lugar { background:#fde68a; color:#92400e; }
.eb-en_servicio { background:#dcfce7; color:#166534; }
.eb-finalizado { background:#f1f5f9; color:#475569; }
.eb-ausente { background:#fee2e2; color:#991b1b; }
.eb-cancelado { background:#f1f5f9; color:#9ca3af; }
/* ── Sesión badge ── */
.sesion-open { background:#dcfce7; color:#15803d; }
.sesion-closed { background:#f1f5f9; color:#64748b; }
/* ── Spinner / empty ── */
#loadingOverlay { min-height:180px; display:flex; align-items:center; justify-content:center; }
.empty-msg { color:#94a3b8; font-size:.9rem; text-align:center; padding:40px 0; }
</style>
</head>
<body>
<div class="page-header">
<a href="<?= BASE_URL ?>modules/turnero/views/recepcion.php" class="back-btn">
<i class="fas fa-arrow-left me-1"></i>Volver
</a>
<h1><i class="fas fa-chart-bar me-2 text-primary"></i>Dashboard Turnero</h1>
<!-- Selector de fecha -->
<div class="d-flex align-items-center gap-2">
<label class="small fw-semibold text-muted mb-0">Fecha:</label>
<input type="date" id="fechaInput" class="form-control form-control-sm" style="width:150px"
value="<?= date('Y-m-d') ?>" max="<?= date('Y-m-d') ?>">
</div>
<button class="btn btn-outline-success btn-sm" id="btnExport" title="Exportar CSV del día">
<i class="fas fa-file-csv me-1"></i>Exportar CSV
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="cargarDatos()" title="Refrescar">
<i class="fas fa-sync-alt"></i>
</button>
<a href="<?= BASE_URL ?>modules/turnero/views/configuracion.php" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-cogs"></i>
</a>
</div>
<div class="content-wrap">
<!-- Estado sesión -->
<div id="sesionInfo" class="mb-3 small text-muted"></div>
<!-- KPIs -->
<div class="kpi-grid" id="kpiGrid">
<div id="loadingOverlay" class="col-span-full">
<div class="text-center text-muted"><i class="fas fa-spinner fa-spin me-2"></i>Cargando...</div>
</div>
</div>
<!-- Consentimientos (solo si hay) -->
<div class="section-card" id="sectionConsent" style="display:none">
<div class="section-title"><i class="fas fa-file-signature me-1"></i>Consentimientos del día</div>
<div id="consentKpis" class="d-flex gap-4 flex-wrap"></div>
</div>
<!-- Desglose por prioridad -->
<div class="section-card" id="sectionPrioridad" style="display:none">
<div class="section-title"><i class="fas fa-sort-amount-up me-1"></i>Por prioridad</div>
<div id="barrasPrioridad"></div>
</div>
<!-- Desglose por lugar -->
<div class="section-card" id="sectionLugar" style="display:none">
<div class="section-title"><i class="fas fa-map-marker-alt me-1"></i>Por lugar / estación</div>
<div class="lugar-grid" id="lugarGrid"></div>
</div>
<!-- Tabla detalle de turnos -->
<div class="section-card">
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
<div class="section-title mb-0"><i class="fas fa-list me-1"></i>Detalle de turnos</div>
<input type="text" id="filtroBusqueda" class="form-control form-control-sm"
style="max-width:220px" placeholder="Buscar paciente / código...">
</div>
<div class="table-responsive">
<table class="table table-sm table-hover small align-middle" id="tablaTurnos">
<thead class="table-light">
<tr>
<th>Código</th>
<th>Prioridad</th>
<th>Paciente</th>
<th>Lugar</th>
<th>Estado</th>
<th>Espera</th>
<th>Servicio</th>
<th>Entrada</th>
</tr>
</thead>
<tbody id="tbodyTurnos">
<tr><td colspan="8" class="empty-msg">Cargando...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<script src="<?= BASE_URL ?>assets/js/bootstrap.bundle.min.js"></script>
<script>
/* ═══════════════════════════════════════════════════════════
Dashboard Turnero
═══════════════════════════════════════════════════════════ */
const API = '<?= BASE_URL ?>modules/turnero/api/';
const BASE_WA = '<?= BASE_URL ?>';
let _turnos = []; // cache para búsqueda local
let _autoReloadId = null;
// ── Entrada ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
cargarDatos();
document.getElementById('fechaInput').addEventListener('change', cargarDatos);
document.getElementById('filtroBusqueda').addEventListener('input', filtrarTabla);
document.getElementById('btnExport').addEventListener('click', exportarCSV);
// Auto-refresh cada 30s solo si la fecha es hoy
_autoReloadId = setInterval(() => {
const hoy = new Date().toISOString().slice(0, 10);
if (document.getElementById('fechaInput').value === hoy) cargarDatos();
}, 30000);
});
// ── Carga principal ──────────────────────────────────────────
async function cargarDatos() {
const fecha = document.getElementById('fechaInput').value;
document.getElementById('kpiGrid').innerHTML =
'<div id="loadingOverlay"><div class="text-center text-muted"><i class="fas fa-spinner fa-spin me-2"></i>Cargando...</div></div>';
document.getElementById('sesionInfo').textContent = '';
try {
const res = await fetch(`${API}get_dashboard.php?fecha=${fecha}`);
const json = await res.json();
if (!json.ok) { mostrarError(json.error || 'Error'); return; }
renderDashboard(json);
} catch (e) {
mostrarError('Error de conexión');
}
}
function mostrarError(msg) {
document.getElementById('kpiGrid').innerHTML =
`<div class="empty-msg text-danger"><i class="fas fa-exclamation-circle me-1"></i>${escHtml(msg)}</div>`;
}
// ── Render ───────────────────────────────────────────────────
function renderDashboard(json) {
const { sesion, resumen, por_prioridad, por_lugar, consent_stats, turnos } = json;
// ── Sesión info ────────────────────────────────────────
const si = document.getElementById('sesionInfo');
if (!sesion) {
si.innerHTML = '<span class="badge bg-secondary-subtle text-secondary">Sin sesión para este día</span>';
} else {
const cls = sesion.abierta ? 'bg-success-subtle text-success' : 'bg-secondary-subtle text-secondary';
const est = sesion.abierta ? 'Abierta' : 'Cerrada';
si.innerHTML = `<span class="badge ${cls} me-2">${est}</span>
<span>Inicio: <strong>${sesion.inicio || '—'}</strong></span>
${sesion.fin ? ` &bull; Cierre: <strong>${sesion.fin}</strong>` : ''}`;
}
// ── KPIs ───────────────────────────────────────────────
const total = parseInt(resumen.total || 0);
const atendidos = parseInt(resumen.atendidos || 0);
const enEspera = parseInt(resumen.en_espera || 0);
const ausentes = parseInt(resumen.ausentes || 0);
const cancelados= parseInt(resumen.cancelados || 0);
const tEspera = resumen.tiempo_espera_promedio_min;
const tServicio = resumen.tiempo_servicio_promedio_min;
const pct = total > 0 ? Math.round(atendidos / total * 100) : 0;
document.getElementById('kpiGrid').innerHTML = `
${kpiCard(total, '📋 Total turnos', '')}
${kpiCard(atendidos,'✅ Atendidos', pct + '%')}
${kpiCard(enEspera, '⏳ En espera', '')}
${kpiCard(ausentes, '🚫 Ausentes', '')}
${kpiCard(cancelados,'❌ Cancelados', '')}
${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera prom.', 'hasta recepción')}
${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Servicio prom.', 'en lugar')}
`;
// ── Consentimientos ────────────────────────────────────
const totalConsent = Object.values(consent_stats).reduce((a,b)=>a+(+b),0);
const secCons = document.getElementById('sectionConsent');
if (totalConsent > 0) {
secCons.style.display = '';
const labels = { pendiente:'Pendientes', enviado:'Enviados', visto:'Vistos', firmado:'Firmados', rechazado:'Rechazados' };
const colors = { pendiente:'#fef9c3', enviado:'#dbeafe', visto:'#e0f2fe', firmado:'#dcfce7', rechazado:'#fee2e2' };
const tcolors= { pendiente:'#92400e', enviado:'#1e40af', visto:'#0369a1', firmado:'#166534', rechazado:'#991b1b' };
document.getElementById('consentKpis').innerHTML =
Object.entries(consent_stats).map(([est, cnt]) =>
`<div style="text-align:center">
<div style="font-size:1.5rem;font-weight:800;color:${tcolors[est]||'#0f172a'}">${cnt}</div>
<div style="font-size:.7rem;color:#64748b;text-transform:uppercase;letter-spacing:.05em">${labels[est]||est}</div>
</div>`
).join('');
} else {
secCons.style.display = 'none';
}
// ── Barras por prioridad ───────────────────────────────
const secPri = document.getElementById('sectionPrioridad');
if (por_prioridad.length) {
secPri.style.display = '';
const maxTotal = Math.max(...por_prioridad.map(p => +p.total), 1);
document.getElementById('barrasPrioridad').innerHTML = por_prioridad.map(p => {
const pct2 = Math.round(+p.total / maxTotal * 100);
return `<div class="pri-row">
<div class="pri-dot" style="background:${p.color}"></div>
<div class="pri-name">${escHtml(p.codigo + ' ' + p.nombre)}</div>
<div class="pri-bar-wrap">
<div class="pri-bar-fill" style="width:${pct2}%;background:${p.color}"></div>
</div>
<div class="pri-nums">${p.total} (✅${p.atendidos} ⏳${p.pendientes} 🚫${p.ausentes})</div>
</div>`;
}).join('');
} else {
secPri.style.display = 'none';
}
// ── Tarjetas por lugar ─────────────────────────────────
const secLug = document.getElementById('sectionLugar');
if (por_lugar.length) {
secLug.style.display = '';
document.getElementById('lugarGrid').innerHTML = por_lugar.map(l => `
<div class="lugar-card">
<div class="lc-name"><i class="fas fa-map-marker-alt text-primary me-1"></i>${escHtml(l.lugar)}</div>
<div class="lc-row"><span>Total</span><span>${l.total}</span></div>
<div class="lc-row"><span>Atendidos</span><span>${l.atendidos}</span></div>
<div class="lc-row"><span>En espera</span><span>${l.en_espera}</span></div>
<div class="lc-row"><span>T. servicio prom.</span><span>${l.tiempo_servicio_promedio_min !== null ? l.tiempo_servicio_promedio_min + ' min' : '—'}</span></div>
</div>`).join('');
} else {
secLug.style.display = 'none';
}
// ── Tabla de turnos ────────────────────────────────────
_turnos = turnos;
renderTabla(turnos);
}
function kpiCard(val, lbl, sub) {
return `<div class="kpi-card">
<div class="kpi-val">${val !== null && val !== undefined ? escHtml(String(val)) : '—'}</div>
<div class="kpi-lbl">${lbl}</div>
${sub ? `<div class="kpi-sub">${escHtml(sub)}</div>` : ''}
</div>`;
}
// ── Tabla detalle ─────────────────────────────────────────────
function renderTabla(turnos) {
const tbody = document.getElementById('tbodyTurnos');
if (!turnos.length) {
tbody.innerHTML = '<tr><td colspan="8" class="empty-msg">Sin turnos para este día</td></tr>';
return;
}
const mapEstado = {
espera: 'eb-espera',
en_recepcion: 'eb-en_recepcion',
en_espera_lugar: 'eb-en_espera_lugar',
en_servicio: 'eb-en_servicio',
finalizado: 'eb-finalizado',
ausente: 'eb-ausente',
cancelado: 'eb-cancelado',
};
const labelEstado = {
espera: 'Espera', en_recepcion: 'Recepción', en_espera_lugar: 'Esp. lugar',
en_servicio: 'En servicio', finalizado: 'Finalizado', ausente: 'Ausente', cancelado: 'Cancelado',
};
tbody.innerHTML = turnos.map(t => {
const nombrePac = escHtml(t.paciente_bd || t.paciente_nombre || '—');
const lugar = escHtml(t.lugar_nombre || '—');
const estadoCls = mapEstado[t.estado] || 'eb-finalizado';
const estadoLbl = labelEstado[t.estado] || t.estado;
const hora = t.creado_at ? new Date(t.creado_at.replace(' ', 'T')).toLocaleTimeString('es-CO', {hour:'2-digit',minute:'2-digit'}) : '—';
const espMin = t.espera_min !== null ? t.espera_min + ' m' : '—';
const srvMin = t.servicio_min !== null ? t.servicio_min + ' m' : '—';
return `<tr>
<td><span class="fw-bold" style="color:${escHtml(t.prioridad_color || '#333')}">${escHtml(t.codigo)}</span></td>
<td><span class="estado-badge" style="background:${escHtml(t.prioridad_color+'22'||'#eee')};color:${escHtml(t.prioridad_color||'#333')}">${escHtml(t.prioridad_codigo)}</span></td>
<td>${nombrePac}</td>
<td>${lugar}</td>
<td><span class="estado-badge ${estadoCls}">${estadoLbl}</span></td>
<td>${espMin}</td>
<td>${srvMin}</td>
<td>${hora}</td>
</tr>`;
}).join('');
}
function filtrarTabla() {
const q = document.getElementById('filtroBusqueda').value.toLowerCase().trim();
if (!q) { renderTabla(_turnos); return; }
renderTabla(_turnos.filter(t =>
(t.codigo || '').toLowerCase().includes(q) ||
(t.paciente_bd || '').toLowerCase().includes(q) ||
(t.paciente_nombre || '').toLowerCase().includes(q)
));
}
// ── Exportar CSV ──────────────────────────────────────────────
function exportarCSV() {
const fecha = document.getElementById('fechaInput').value;
window.location.href = `${API}export_csv.php?fecha=${fecha}`;
}
// ── Helper ────────────────────────────────────────────────────
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(str ?? '')));
return d.innerHTML;
}
</script>
</body>
</html>