- login.php: detectar dispositivo por IP al autenticar y redirigir al lugar asignado - dashboard.php / lugar.php / recepcion.php: bloquear acceso por dispositivo (IP→lugar) - lugar.php: modal firma profesional pre-guardada; compact 2-col en iframe; botón WA solo cuando el formulario requiere firma del paciente - ver_formulario_enviado.php: modo compact tablet, auto-save borrador (merge), firma profesional en 1 clic, Muestras Prolongadas (countdown + Finalizar), auto_profesional pre-fill desde sesión, fecha→fecha_hoy en campos de toma/recepción - get_consentimientos.php: añadir requiere_firma_paciente para controlar botón WA - save_firma_profesional.php: API para guardar firma pre-configurada del profesional - migrations: turnero_dispositivos, admin_users.firma_svg, clon VIH F-LAB-05 para turnero Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
622 lines
31 KiB
PHP
622 lines
31 KiB
PHP
<?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 APP_ROOT . '/config/config.php';
|
||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||
|
||
// Tablet asignada → no tiene acceso al dashboard, ir a su lugar
|
||
if (!empty($_SESSION['turnero_dispositivo'])) {
|
||
$_d = $_SESSION['turnero_dispositivo'];
|
||
header('Location: ' . BASE_URL . ($_d['tipo'] === 'recepcion'
|
||
? 'erp.php?m=turnero&v=recepcion&desk_id=' . $_d['lugar_id']
|
||
: 'erp.php?m=turnero&v=lugar&lugar_id=' . $_d['lugar_id']));
|
||
exit;
|
||
}
|
||
|
||
$_iaActiva = false;
|
||
try {
|
||
$_cfgIA = Database::getInstance()->fetch("SELECT valor FROM lab_config WHERE clave = 'ia_activa'");
|
||
$_iaActiva = ($_cfgIA['valor'] ?? '0') === '1';
|
||
} catch (\Throwable $_) {}
|
||
|
||
Layout::open('Dashboard Turnero', 'fas fa-chart-bar');
|
||
?>
|
||
<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; }
|
||
.row-expandible { cursor:pointer; transition:background .15s; }
|
||
.row-expandible:hover { background: color-mix(in srgb, var(--brand,#1565c0) 5%, #fff) !important; }
|
||
.row-expandible.det-open { background: color-mix(in srgb, var(--brand,#1565c0) 8%, #fff) !important; }
|
||
.det-row { border-top:none !important; }
|
||
.det-row td { padding:0 !important; border-top:none !important; }
|
||
.det-inner { padding:12px 16px 14px; border-top:2px solid color-mix(in srgb, var(--brand,#1565c0) 20%, #e2e8f0); background:#f8fafc; }
|
||
|
||
/* ── 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; }
|
||
|
||
/* ── Panel IA ── */
|
||
#aiPanel {
|
||
position:fixed; bottom:0; right:24px; width:360px; z-index:1080;
|
||
box-shadow:0 -4px 32px rgba(0,0,0,.18); border-radius:16px 16px 0 0;
|
||
background:#fff; border:1px solid #e2e8f0; border-bottom:none;
|
||
transform:translateY(100%); transition:transform .3s ease;
|
||
display:flex; flex-direction:column; max-height:520px;
|
||
}
|
||
#aiPanel.open { transform:translateY(0); }
|
||
.ai-header {
|
||
background:linear-gradient(135deg, var(--brand-dark,#0d47a1), var(--brand,#1565c0));
|
||
color:#fff; padding:12px 16px; border-radius:16px 16px 0 0;
|
||
display:flex; align-items:center; gap:8px; flex-shrink:0;
|
||
}
|
||
.ai-header .ai-title { flex:1; font-weight:700; font-size:.92rem; }
|
||
.ai-header .btn-close { filter:invert(1) brightness(2); }
|
||
.ai-messages { flex:1; overflow-y:auto; padding:12px 14px; display:flex; flex-direction:column; gap:8px; }
|
||
.ai-msg { max-width:88%; padding:8px 12px; border-radius:12px; font-size:.83rem; line-height:1.45; }
|
||
.ai-msg.user { background:var(--brand,#1565c0); color:#fff; align-self:flex-end; border-radius:12px 12px 2px 12px; }
|
||
.ai-msg.bot { background:#f1f5f9; color:#1e293b; align-self:flex-start; border-radius:12px 12px 12px 2px; }
|
||
.ai-msg.bot.typing { color:#94a3b8; font-style:italic; }
|
||
.ai-footer { padding:10px 12px; border-top:1px solid #e2e8f0; display:flex; gap:8px; flex-shrink:0; }
|
||
.ai-footer input { flex:1; border:1px solid #e2e8f0; border-radius:8px; padding:7px 12px; font-size:.85rem; outline:none; }
|
||
.ai-footer input:focus { border-color:var(--brand,#1565c0); }
|
||
.ai-footer .btn-send { background:var(--brand,#1565c0); color:#fff; border:none; border-radius:8px; padding:7px 14px; cursor:pointer; font-size:.85rem; }
|
||
#btnAI {
|
||
position:fixed; bottom:24px; right:24px; z-index:1079;
|
||
background:linear-gradient(135deg, var(--brand-dark,#0d47a1), var(--brand,#1565c0));
|
||
color:#fff; border:none; border-radius:50%; width:52px; height:52px;
|
||
font-size:1.3rem; cursor:pointer; box-shadow:0 4px 18px rgba(0,0,0,.22);
|
||
transition:transform .2s;
|
||
}
|
||
#btnAI:hover { transform:scale(1.08); }
|
||
#btnAI.hidden { display:none; }
|
||
@media(max-width:600px) { #aiPanel { width:100%; right:0; border-radius:16px 16px 0 0; } }
|
||
</style>
|
||
|
||
<?php
|
||
$_dashRole = $_SESSION['admin_user']['role'] ?? 'admin';
|
||
$_dashIsAdmin = in_array($_dashRole, ['superadmin', 'admin', 'supervisor'], true);
|
||
$_dashIsRecep = $_dashRole === 'recepcionista';
|
||
$_dashIsBacte = $_dashRole === 'bacteriologo';
|
||
|
||
$_dashReceps = [];
|
||
$_dashMuestras = [];
|
||
try {
|
||
$_pdo = Database::getInstance()->getConnection();
|
||
if ($_dashIsAdmin || $_dashIsRecep)
|
||
$_dashReceps = $_pdo->query("SELECT id, nombre FROM turnero_lugares WHERE activo=1 AND tipo='recepcion' ORDER BY sort_order")->fetchAll(PDO::FETCH_ASSOC);
|
||
if ($_dashIsAdmin || $_dashIsBacte)
|
||
$_dashMuestras = $_pdo->query("SELECT id, nombre FROM turnero_lugares WHERE activo=1 AND tipo='muestras' ORDER BY sort_order")->fetchAll(PDO::FETCH_ASSOC);
|
||
} catch (\Throwable $_) {}
|
||
?>
|
||
<div class="page-header">
|
||
<h1><i class="fas fa-chart-bar me-2 text-primary"></i>Dashboard Turnero</h1>
|
||
|
||
<!-- Accesos rápidos: dropdown por grupo, filtrado por rol -->
|
||
<?php if (!empty($_dashReceps)): ?>
|
||
<div class="dropdown">
|
||
<button class="btn btn-sm btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||
<i class="fas fa-concierge-bell me-1"></i>Recepción
|
||
</button>
|
||
<ul class="dropdown-menu shadow-sm">
|
||
<?php foreach ($_dashReceps as $_r): ?>
|
||
<li><a class="dropdown-item" href="<?= BASE_URL ?>erp.php?m=turnero&v=recepcion&desk_id=<?= (int)$_r['id'] ?>">
|
||
<i class="fas fa-chair me-1 text-primary"></i><?= htmlspecialchars($_r['nombre'], ENT_QUOTES) ?>
|
||
</a></li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if (!empty($_dashMuestras)): ?>
|
||
<div class="dropdown">
|
||
<button class="btn btn-sm btn-outline-success dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
||
<i class="fas fa-flask me-1"></i>Toma de Muestras
|
||
</button>
|
||
<ul class="dropdown-menu shadow-sm">
|
||
<?php foreach ($_dashMuestras as $_m): ?>
|
||
<li><a class="dropdown-item" href="<?= BASE_URL ?>erp.php?m=turnero&v=lugar&lugar_id=<?= (int)$_m['id'] ?>">
|
||
<i class="fas fa-vial me-1 text-success"></i><?= htmlspecialchars($_m['nombre'], ENT_QUOTES) ?>
|
||
</a></li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<!-- Selector de fecha -->
|
||
<div class="d-flex align-items-center gap-2 ms-auto">
|
||
<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 ?>erp.php?m=turnero&v=configuracion" 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:260px" placeholder="Buscar paciente, código o cédula…">
|
||
</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>Recepción</th>
|
||
<th>Lugar / Bacteriólogo</th>
|
||
<th>Exámenes</th>
|
||
<th>Estado</th>
|
||
<th>Espera</th>
|
||
<th>Serv.</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tbodyTurnos">
|
||
<tr><td colspan="10" class="empty-msg">Cargando...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<?php if ($_iaActiva): ?>
|
||
<!-- ── Botón flotante IA ── -->
|
||
<button id="btnAI" title="Asistente IA" onclick="toggleAI()">
|
||
<i class="fas fa-robot"></i>
|
||
</button>
|
||
|
||
<!-- ── Panel IA ── -->
|
||
<div id="aiPanel">
|
||
<div class="ai-header">
|
||
<i class="fas fa-robot"></i>
|
||
<span class="ai-title">Asistente del Turnero</span>
|
||
<button class="btn-close" onclick="toggleAI()"></button>
|
||
</div>
|
||
<div class="ai-messages" id="aiMessages">
|
||
<div class="ai-msg bot">¡Hola! Puedo responder preguntas sobre el estado del turnero de hoy. ¿Qué necesitas saber?</div>
|
||
</div>
|
||
<div class="ai-footer">
|
||
<input type="text" id="aiInput" placeholder="¿Cuántos pacientes en espera?…" maxlength="400"
|
||
onkeydown="if(event.key==='Enter')enviarIA()">
|
||
<button class="btn-send" onclick="enviarIA()"><i class="fas fa-paper-plane"></i></button>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<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 ? ` • 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 docPac = t.paciente_doc ? `<div class="text-muted" style="font-size:.7rem">${escHtml(t.paciente_tipo_doc||'CC')} ${escHtml(t.paciente_doc)}</div>` : '';
|
||
const estadoCls = mapEstado[t.estado] || 'eb-finalizado';
|
||
const estadoLbl = labelEstado[t.estado] || t.estado;
|
||
const espMin = t.espera_min !== null ? t.espera_min + ' m' : '—';
|
||
const srvMin = t.servicio_min !== null ? t.servicio_min + ' m' : '—';
|
||
const rowId = `dash-det-${t.id}`;
|
||
|
||
// Recepción
|
||
const desk = escHtml(t.recepcion_desk_nombre || '—');
|
||
const recep = escHtml(t.atendido_recepcion_nombre || '—');
|
||
const recepCell = `<div>${desk}</div><div class="text-muted" style="font-size:.7rem">${recep}</div>`;
|
||
|
||
// Lugar + bacteriólogo
|
||
const lugar = escHtml(t.lugar_nombre || '—');
|
||
const bacterio = escHtml(t.atendido_lugar_nombre || '—');
|
||
const lugarCell = `<div>${lugar}</div><div class="text-muted" style="font-size:.7rem">${bacterio}</div>`;
|
||
|
||
// Exámenes
|
||
const examsHtml = (t.examenes && t.examenes.length)
|
||
? t.examenes.map(e => `<span class="badge" style="background:#dcfce7;color:#166534;font-size:.6rem;margin:1px">${escHtml(e.nombre||e.codigo||'')}</span>`).join('')
|
||
: '<span class="text-muted small">—</span>';
|
||
|
||
return `<tr class="row-expandible" onclick="toggleDashDetalle('${rowId}', this)">
|
||
<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}${docPac}</td>
|
||
<td>${recepCell}</td>
|
||
<td>${lugarCell}</td>
|
||
<td style="max-width:120px">${examsHtml}</td>
|
||
<td><span class="estado-badge ${estadoCls}">${estadoLbl}</span></td>
|
||
<td>${espMin}</td>
|
||
<td>${srvMin}</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 id="${rowId}" class="det-row" style="display:none">
|
||
<td colspan="10">
|
||
<div class="det-inner">${renderDashDetalle(t)}</div>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function toggleDashDetalle(rowId, tr) {
|
||
const det = document.getElementById(rowId);
|
||
if (!det) return;
|
||
const open = det.style.display !== 'none';
|
||
det.style.display = open ? 'none' : '';
|
||
tr.classList.toggle('det-open', !open);
|
||
// rotar ícono
|
||
const turnoId = rowId.replace('dash-det-', '');
|
||
const ico = document.getElementById(`ico-${turnoId}`);
|
||
if (ico) ico.style.transform = open ? 'rotate(0deg)' : 'rotate(90deg)';
|
||
}
|
||
|
||
function renderDashDetalle(t) {
|
||
const e = s => escHtml(s || '—');
|
||
const fmt = dt => dt ? new Date(dt.replace(' ','T')).toLocaleString('es-CO',{dateStyle:'short',timeStyle:'short'}) : '—';
|
||
|
||
const chips = [
|
||
{ lbl:'Recepción (desk)', val: e(t.recepcion_desk_nombre) },
|
||
{ lbl:'Atendió recepción', val: e(t.atendido_recepcion_nombre) },
|
||
{ lbl:'Atendió muestras', val: e(t.atendido_lugar_nombre) },
|
||
{ lbl:'Inicio recepción', val: fmt(t.inicio_recepcion_at) },
|
||
{ lbl:'Fin recepción', val: fmt(t.fin_recepcion_at) },
|
||
{ lbl:'Inicio servicio', val: fmt(t.inicio_lugar_at) },
|
||
{ lbl:'Fin servicio', val: fmt(t.fin_lugar_at) },
|
||
];
|
||
|
||
let grid = `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px;margin-bottom:10px">
|
||
${chips.map(c=>`<div style="background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:7px 11px">
|
||
<div style="font-size:.63rem;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8">${c.lbl}</div>
|
||
<div style="font-size:.82rem;font-weight:600;color:#1e293b">${c.val}</div>
|
||
</div>`).join('')}
|
||
</div>`;
|
||
|
||
// Notas
|
||
if (t.notas) {
|
||
grid += `<div style="margin-bottom:8px;font-size:.82rem"><span style="font-size:.67rem;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8">📝 Notas: </span>${e(t.notas)}</div>`;
|
||
}
|
||
|
||
// Exámenes
|
||
if (t.examenes && t.examenes.length) {
|
||
grid += `<div style="margin-bottom:8px">
|
||
<span style="font-size:.67rem;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8"><i class="fas fa-vials me-1"></i>Exámenes (${t.examenes.length}): </span>
|
||
${t.examenes.map(ex=>`<span class="badge" style="background:#dcfce7;color:#166534;margin:1px 2px">${e(ex.nombre)}</span>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
// Comentarios
|
||
if (t.comentarios && t.comentarios.length) {
|
||
grid += `<div style="border-top:1px solid #e2e8f0;padding-top:8px">
|
||
<div style="font-size:.67rem;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8;margin-bottom:5px"><i class="fas fa-comments me-1"></i>Comentarios</div>
|
||
${t.comentarios.map(c=>`<div style="font-size:.8rem;padding:4px 0;border-bottom:1px solid #f1f5f9">
|
||
<strong>${e(c.usuario_nombre)}</strong>
|
||
<span style="color:#94a3b8;font-size:.72rem;margin-left:6px">${fmt(c.creado_at)}</span>
|
||
<div style="color:#475569">${e(c.comentario)}</div>
|
||
</div>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
return grid;
|
||
}
|
||
|
||
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) ||
|
||
(t.paciente_doc || '').toLowerCase().includes(q)
|
||
));
|
||
}
|
||
|
||
// ── Asistente IA ──────────────────────────────────────────────
|
||
let _aiOpen = false;
|
||
|
||
function toggleAI() {
|
||
_aiOpen = !_aiOpen;
|
||
document.getElementById('aiPanel').classList.toggle('open', _aiOpen);
|
||
document.getElementById('btnAI').classList.toggle('hidden', _aiOpen);
|
||
if (_aiOpen) document.getElementById('aiInput').focus();
|
||
}
|
||
|
||
async function enviarIA() {
|
||
const input = document.getElementById('aiInput');
|
||
const pregunta = input.value.trim();
|
||
if (!pregunta) return;
|
||
|
||
const msgs = document.getElementById('aiMessages');
|
||
msgs.insertAdjacentHTML('beforeend',
|
||
`<div class="ai-msg user">${escHtml(pregunta)}</div>`);
|
||
const typing = document.createElement('div');
|
||
typing.className = 'ai-msg bot typing';
|
||
typing.textContent = 'Pensando…';
|
||
msgs.appendChild(typing);
|
||
msgs.scrollTop = msgs.scrollHeight;
|
||
input.value = '';
|
||
input.disabled = true;
|
||
|
||
try {
|
||
const res = await fetch(`${API}ai_chat.php`, {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({ pregunta }),
|
||
});
|
||
const d = await res.json();
|
||
typing.className = 'ai-msg bot';
|
||
typing.textContent = d.ok ? d.respuesta : (d.error || 'Error al conectar con la IA');
|
||
} catch(_) {
|
||
typing.className = 'ai-msg bot';
|
||
typing.textContent = 'Error de conexión';
|
||
} finally {
|
||
input.disabled = false;
|
||
input.focus();
|
||
msgs.scrollTop = msgs.scrollHeight;
|
||
}
|
||
}
|
||
|
||
// ── 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>
|
||
<?php Layout::close(); ?>
|