Files
whatsapp/modules/turnero/views/dashboard.php
T
Lizandro GuarnizoandClaude Opus 5 51b22c4dfc Dashboard: que cada indicador diga qué está midiendo
Las etiquetas eran tan cortas que se prestaban para entender otra cosa.
«Servicio prom. en lugar» y «Total prom. puerta a puerta» parecían medir lo
mismo, y no cuadraban entre sí: 16,3 de espera más 7,4 de servicio no dan los
36,6 del total.

Sí cuadran, pero el recorrido tiene cuatro tramos y el tablero solo mostraba
dos. Los otros dos —6,4 min dentro de recepción y 8,4 esperando el puesto—
suman casi quince minutos que no aparecían por ningún lado.

Cada casilla lleva ahora una línea que explica de dónde sale el número, y el
total dice explícitamente por qué es mayor que la suma de las anteriores. Las
etiquetas pasan a lenguaje llano: «Espera para recepción» en vez de «Espera
prom.», «No se presentaron» en vez de «Ausentes».

En la de tiempo en el puesto se aclara que los protocolos prolongados cuentan
hasta la primera toma, para que nadie lea ese número como si midiera lo que
dura una curva completa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:23:59 -05:00

1059 lines
55 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
$_liaTokensUsados = 0;
try {
$_db = Database::getInstance();
$_cfgIA = $_db->fetch("SELECT valor FROM lab_config WHERE clave = 'ia_activa'");
$_iaActiva = ($_cfgIA['valor'] ?? '0') === '1';
$_liaTok = $_db->fetch("SELECT valor FROM lab_config WHERE clave = 'lia_tokens_usados'");
$_liaTokensUsados = (int)($_liaTok['valor'] ?? 0);
} 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; }
.kpi-ayuda { font-size:.62rem; color:#94a3b8; line-height:1.3; margin-top:5px;
border-top:1px solid #f1f5f9; padding-top:5px; }
/* ── 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; }
/* ── Origen de cada nota ── */
.com-tipo { font-size:.62rem; font-weight:700; letter-spacing:.04em; padding:1px 7px;
border-radius:10px; text-transform:uppercase; margin-right:6px; }
.com-tipo.recepcion { background:#dbeafe; color:#1d4ed8; }
.com-tipo.muestras { background:#dcfce7; color:#15803d; }
.com-tipo.general { background:#fef3c7; color:#92400e; }
/* ── 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 LIA ── */
#aiPanel {
position:fixed; bottom:0; right:24px; width:400px; z-index:1080;
box-shadow:0 -8px 40px rgba(0,0,0,.22); border-radius:20px 20px 0 0;
background:#fff; border:1px solid #e2e8f0; border-bottom:none;
transform:translateY(100%); transition:transform .35s cubic-bezier(.4,0,.2,1);
display:flex; flex-direction:column; max-height:580px;
}
#aiPanel.open { transform:translateY(0); }
#aiPanel.minimized .ai-body { display:none; }
#aiPanel.minimized { max-height:none; }
.ai-header {
background:linear-gradient(135deg,#0d47a1,#1565c0,#0288d1);
color:#fff; padding:14px 18px; border-radius:20px 20px 0 0;
display:flex; align-items:center; gap:12px; flex-shrink:0; cursor:pointer; user-select:none;
}
.lia-logo {
width:44px; height:44px; border-radius:50%;
background:rgba(255,255,255,.15); border:2px solid rgba(255,255,255,.4);
display:flex; align-items:center; justify-content:center;
font-size:1rem; font-weight:900; letter-spacing:-1px; flex-shrink:0;
transition:transform .3s;
}
.lia-logo:hover { transform:scale(1.08); }
.lia-logo .lia-i { color:#7dd3fc; }
.lia-logo.speaking { animation:lia-pulse 1s infinite; }
@keyframes lia-pulse { 0%,100%{box-shadow:0 0 0 0 rgba(255,255,255,.4)} 50%{box-shadow:0 0 0 8px rgba(255,255,255,0)} }
.ai-title { font-weight:800; font-size:1.05rem; letter-spacing:.5px; }
.ai-header .btn-close { filter:invert(1) brightness(2); opacity:.7; }
.ai-header .btn-close:hover { opacity:1; }
.btn-tts { background:rgba(255,255,255,.15); border:1px solid rgba(255,255,255,.3); color:rgba(255,255,255,.7); cursor:pointer; padding:5px 9px; border-radius:8px; font-size:.88rem; transition:all .2s; }
.btn-tts.active { background:rgba(255,255,255,.25); color:#fff; }
.ai-body { display:flex; flex-direction:column; flex:1; overflow:hidden; }
.ai-quick { display:flex; gap:7px; flex-wrap:wrap; padding:10px 14px; border-bottom:1px solid #f1f5f9; flex-shrink:0; background:#fafbff; }
.ai-chip { background:#fff; border:1.5px solid #e2e8f0; border-radius:99px; padding:6px 13px; font-size:.82rem; cursor:pointer; color:#334155; white-space:nowrap; transition:all .15s; font-weight:500; }
.ai-chip:hover { background:#eff6ff; border-color:#93c5fd; color:#1d4ed8; }
.ai-messages { flex:1; overflow-y:auto; padding:14px 16px; display:flex; flex-direction:column; gap:10px; }
.ai-msg { max-width:92%; padding:10px 14px; border-radius:16px; font-size:.9rem; line-height:1.55; animation:msg-in .2s ease; }
@keyframes msg-in { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:none} }
.ai-msg.user { background:linear-gradient(135deg,#1565c0,#0288d1); color:#fff; align-self:flex-end; border-radius:16px 16px 2px 16px; }
.ai-msg.bot { background:#f1f5f9; color:#1e293b; align-self:flex-start; border-radius:16px 16px 16px 2px; }
.ai-msg.bot.typing::after { content:'●●●'; animation:dots 1.2s infinite; letter-spacing:2px; }
@keyframes dots { 0%,100%{opacity:.3} 50%{opacity:1} }
.ai-msg.bot strong { color:#1565c0; }
.ai-msg.bot ul { margin:4px 0 4px 16px; padding:0; }
.lia-wave { display:flex; align-items:center; justify-content:center; gap:4px; height:32px; }
.lia-wave span { width:4px; border-radius:2px; background:#1565c0; animation:wave .8s infinite ease-in-out; }
.lia-wave span:nth-child(1){height:8px;animation-delay:0s}
.lia-wave span:nth-child(2){height:16px;animation-delay:.1s}
.lia-wave span:nth-child(3){height:24px;animation-delay:.2s}
.lia-wave span:nth-child(4){height:16px;animation-delay:.3s}
.lia-wave span:nth-child(5){height:8px;animation-delay:.4s}
@keyframes wave { 0%,100%{transform:scaleY(.5);opacity:.6} 50%{transform:scaleY(1);opacity:1} }
.lia-tokens { padding:6px 14px; background:#f8faff; border-top:1px solid #f1f5f9; flex-shrink:0; }
.lia-tokens-bar { height:5px; border-radius:3px; background:#e2e8f0; overflow:hidden; margin-bottom:3px; }
.lia-tokens-fill { height:100%; border-radius:3px; background:linear-gradient(90deg,#1565c0,#0288d1); transition:width .6s ease; }
.lia-tokens-fill.warn { background:linear-gradient(90deg,#f59e0b,#ef4444); }
.lia-tokens-label { font-size:.68rem; color:#94a3b8; display:flex; justify-content:space-between; }
.ai-footer { padding:10px 14px; border-top:1px solid #f1f5f9; display:flex; gap:8px; flex-shrink:0; background:#fafbff; }
.ai-footer input { flex:1; border:1.5px solid #e2e8f0; border-radius:10px; padding:9px 14px; font-size:.9rem; outline:none; background:#fff; }
.ai-footer input:focus { border-color:#1565c0; }
.btn-mic { background:#fff; border:1.5px solid #e2e8f0; border-radius:10px; padding:9px 13px; cursor:pointer; color:#64748b; font-size:.88rem; transition:all .2s; min-width:42px; }
.btn-mic.listening { background:#fee2e2; color:#dc2626; border-color:#fca5a5; }
.ai-footer .btn-send { background:linear-gradient(135deg,#1565c0,#0288d1); color:#fff; border:none; border-radius:10px; padding:9px 16px; cursor:pointer; font-size:.88rem; min-width:42px; }
#btnAI {
position:fixed; bottom:24px; right:24px; z-index:1079;
background:linear-gradient(135deg,#0d47a1,#1565c0,#0288d1);
color:#fff; border:none; border-radius:50%; width:58px; height:58px;
font-size:.78rem; font-weight:900; letter-spacing:-.5px;
cursor:pointer; box-shadow:0 4px 20px rgba(21,101,192,.45);
transition:transform .2s,box-shadow .2s;
display:flex; align-items:center; justify-content:center;
}
#btnAI:hover { transform:scale(1.1); box-shadow:0 6px 28px rgba(21,101,192,.55); }
#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>CSV
</button>
<button class="btn btn-outline-success btn-sm" id="btnExcel" title="Exportar Excel del día">
<i class="fas fa-file-excel me-1"></i>Excel
</button>
<button class="btn btn-outline-success btn-sm" id="btnWAInforme" title="Enviar informe por WhatsApp" data-bs-toggle="modal" data-bs-target="#modalWAInforme">
<i class="fab fa-whatsapp me-1"></i>Informe WA
</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>
<!-- Proyección de ventas -->
<div class="section-card" id="sectionProyeccion" style="display:none">
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="section-title mb-0"><i class="fas fa-chart-line me-1"></i>Facturación del día</div>
<span id="proyeccionResumen" class="small text-muted"></span>
</div>
<canvas id="chartProyeccion" height="90"></canvas>
</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>
<!-- Desglose por recepcionista -->
<div class="section-card" id="sectionRecepcionista" style="display:none">
<div class="section-title"><i class="fas fa-user-check me-1"></i>Por recepcionista</div>
<div class="lugar-grid" id="recepcionistaGrid"></div>
</div>
<!-- Desglose por bacteriólogo -->
<div class="section-card" id="sectionBacteriologo" style="display:none">
<div class="section-title"><i class="fas fa-user-nurse me-1"></i>Por bacteriólogo</div>
<div class="lugar-grid" id="bacteriologoGrid"></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>Total</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="Abrir LIA - Asistente Inteligente" onclick="toggleAI()">
L<span style="color:#7dd3fc">I</span>A
</button>
<!-- ── Modal informe WA ── -->
<div class="modal fade" id="modalWAInforme" tabindex="-1">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header"><h5 class="modal-title"><i class="fab fa-whatsapp me-2 text-success"></i>Enviar informe por WA</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label small fw-semibold">Número WhatsApp (con código país)</label>
<input type="tel" id="waInformePhone" class="form-control" placeholder="573001234567">
<div class="form-text">Ej: 573001234567 (57 = Colombia)</div>
<div id="waInformeMsg" class="mt-2 small"></div>
</div>
<div class="modal-footer">
<button class="btn btn-success" onclick="enviarInformeWA()"><i class="fab fa-whatsapp me-1"></i>Enviar</button>
</div>
</div>
</div>
</div>
<!-- ── Panel IA ── -->
<div id="aiPanel">
<div class="ai-header" onclick="minimizarIA(event)">
<div class="lia-logo" id="liaLogo">
<span>L</span><span class="lia-i">I</span><span>A</span>
</div>
<div style="flex:1;line-height:1.1">
<div class="ai-title">LIA</div>
<div style="font-size:.68rem;opacity:.75;font-weight:400">Asistente Inteligente del Lab.</div>
</div>
<button id="btnTTS" class="btn-tts" onclick="event.stopPropagation();toggleTTS()" title="Activar/silenciar voz">
<i class="fas fa-volume-up"></i>
</button>
<button class="btn-tts" onclick="event.stopPropagation();minimizarIA(event)" title="Minimizar" id="btnMin">
<i class="fas fa-minus" id="iconMin"></i>
</button>
<button class="btn-close" onclick="event.stopPropagation();toggleAI()"></button>
</div>
<div class="ai-body" id="aiBody">
<div class="ai-quick" id="aiQuick">
<button class="ai-chip" onclick="enviarRapido('¿Cuántos pacientes atendidos hoy y cuántos faltan?')">📊 Resumen</button>
<button class="ai-chip" onclick="enviarRapido('¿Cuánto se ha facturado hoy?')">💰 Facturación</button>
<button class="ai-chip" onclick="enviarRapido('¿Cuáles son los tiempos promedio de hoy?')">⏱ Tiempos</button>
<button class="ai-chip" onclick="enviarRapido('¿Cuántos pacientes están en espera ahora mismo?')">⏳ En espera</button>
</div>
<div class="ai-messages" id="aiMessages"></div>
<div class="lia-tokens" id="liaTokensBar">
<div class="lia-tokens-bar"><div class="lia-tokens-fill" id="liaFill" style="width:0%"></div></div>
<div class="lia-tokens-label">
<span id="liaTokensUsed">0 tokens usados</span>
<span id="liaTokensLeft">1.000.000 restantes</span>
</div>
</div>
<div id="liaListening" style="display:none;text-align:center;padding:10px 0">
<div class="lia-wave"><span></span><span></span><span></span><span></span><span></span></div>
<div style="font-size:.75rem;color:#64748b;margin-top:4px">Escuchando…</div>
</div>
<div class="ai-footer">
<input type="text" id="aiInput" placeholder="Escribe o habla con LIA…" maxlength="400"
onkeydown="if(event.key==='Enter')enviarIA()">
<button class="btn-mic" id="btnMic" onclick="iniciarVoz()" title="Hablar"><i class="fas fa-microphone"></i></button>
<button class="btn-send" onclick="enviarIA()"><i class="fas fa-paper-plane"></i></button>
</div>
</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;
let _liaHistorial = []; // últimos intercambios con LIA (contexto de seguimiento)
// ── Entrada ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
cargarDatos();
document.getElementById('fechaInput').addEventListener('change', () => {
cargarDatos();
cargarProyeccion(document.getElementById('fechaInput').value);
});
document.getElementById('filtroBusqueda').addEventListener('input', filtrarTabla);
document.getElementById('btnExport').addEventListener('click', exportarCSV);
document.getElementById('btnExcel').addEventListener('click', exportarExcel);
cargarProyeccion(document.getElementById('fechaInput').value);
// 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,
por_recepcionista = [], por_bacteriologo = [] } = 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 tTotal = resumen.tiempo_total_promedio_min;
const pct = total > 0 ? Math.round(atendidos / total * 100) : 0;
document.getElementById('kpiGrid').innerHTML = `
${kpiCard(total, '📋 Turnos del día', '',
'Todos los turnos generados hoy, sin importar cómo terminaron.')}
${kpiCard(atendidos, '✅ Atendidos', pct + ' % del día',
'Ya terminaron su atención, más los que están siendo atendidos en este momento.')}
${kpiCard(enEspera, '⏳ Esperando', '',
'Todavía no los llaman, o ya salieron de recepción y esperan el puesto de toma.')}
${kpiCard(ausentes, '🚫 No se presentaron', '',
'Se les llamó y no llegaron. Recepción registra el motivo al marcarlos.')}
${kpiCard(cancelados, '❌ Cancelados', '',
'Turnos anulados antes de atenderlos.')}
${kpiCard(tEspera !== null ? tEspera + ' min' : '—', '⏱ Espera para recepción', 'sacar el turno → lo llaman',
'Cuánto aguarda el paciente sentado desde que saca el turno hasta que recepción lo atiende.')}
${kpiCard(tServicio !== null ? tServicio + ' min' : '—', '⏱ Tiempo en el puesto', 'toma de muestras',
'Lo que dura la toma. En protocolos prolongados (curvas) se cuenta hasta la primera toma: las horas de espera del examen no son trabajo del puesto.')}
${kpiCard(tTotal !== null ? tTotal + ' min' : '—', '⏱ Total en el laboratorio', 'llegada → salida',
'Todo el recorrido: espera + recepción + espera del puesto + toma. Por eso es mayor que la suma de las dos casillas anteriores.')}
`;
// ── 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';
}
// ── Tarjetas por persona ───────────────────────────────
renderPersonas('sectionRecepcionista', 'recepcionistaGrid', por_recepcionista, [
['Turnos', p => p.total],
['Atendidos', p => p.atendidos],
['Ausentes', p => p.ausentes],
['T. recepción prom.', p => p.tiempo_promedio_min !== null ? p.tiempo_promedio_min + ' min' : '—'],
]);
renderPersonas('sectionBacteriologo', 'bacteriologoGrid', por_bacteriologo, [
['Turnos', p => p.total],
['Finalizados', p => p.finalizados],
['En servicio', p => p.en_servicio],
['T. servicio prom.', p => p.tiempo_promedio_min !== null ? p.tiempo_promedio_min + ' min' : '—'],
]);
// ── Tabla de turnos ────────────────────────────────────
_turnos = turnos;
renderTabla(turnos);
}
// Tarjetas "quién atendió a cuántos". Reutiliza el grid de lugares para que
// las tres secciones se vean iguales.
function renderPersonas(sectionId, gridId, filas, columnas) {
const sec = document.getElementById(sectionId);
if (!filas || !filas.length) { sec.style.display = 'none'; return; }
sec.style.display = '';
document.getElementById(gridId).innerHTML = filas.map(p => `
<div class="lugar-card">
<div class="lc-name"><i class="fas fa-user text-primary me-1"></i>${escHtml(p.nombre || '—')}</div>
${columnas.map(([lbl, val]) =>
`<div class="lc-row"><span>${lbl}</span><span>${escHtml(String(val(p) ?? '—'))}</span></div>`
).join('')}
</div>`).join('');
}
function kpiCard(val, lbl, sub, ayuda) {
// `ayuda` explica de dónde sale el número. Las etiquetas cortas se prestan
// para malentendidos —«servicio» no es lo mismo que «tiempo en el
// laboratorio»— y quien lee el tablero no tiene por qué adivinarlo.
const t = ayuda ? ` title="${escHtml(ayuda)}"` : '';
return `<div class="kpi-card"${t}>
<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>` : ''}
${ayuda ? `<div class="kpi-ayuda">${escHtml(ayuda)}</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 totMin = t.total_min !== null ? t.total_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>${totMin}</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>Notas y observaciones (${t.comentarios.length})</div>
${t.comentarios.map(c=>`<div style="font-size:.8rem;padding:4px 0;border-bottom:1px solid #f1f5f9">
<span class="com-tipo ${e(c.tipo||'general')}">${({recepcion:'Recepción',muestras:'Toma de muestras',general:'General'})[c.tipo]||c.tipo}</span>
<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;
// ── LIA helpers ──────────────────────────────────────────────
let _aiMinimized = false;
let _ttsOn = false;
let _recog = null;
let _chartProy = null;
const _LIA_TOKENS_USADOS = <?= (int)$_liaTokensUsados ?>;
const _LIA_TOKENS_MAX = 1_000_000;
function _actualizarTokens(usados, max) {
const pct = Math.min(100, (usados / max) * 100);
const left = max - usados;
const fill = document.getElementById('liaFill');
const fmt = n => Intl.NumberFormat('es-CO').format(n);
fill.style.width = pct + '%';
fill.className = 'lia-tokens-fill' + (pct > 80 ? ' warn' : '');
document.getElementById('liaTokensUsed').textContent = fmt(usados) + ' tokens usados';
document.getElementById('liaTokensLeft').textContent = fmt(left) + ' restantes';
if (left <= 0) _bloquearLIA();
}
function _bloquearLIA() {
document.getElementById('aiInput').disabled = true;
document.getElementById('btnMic').disabled = true;
document.getElementById('btnMic').style.opacity = '.4';
const msgs = document.getElementById('aiMessages');
const el = document.createElement('div');
el.className = 'ai-msg bot';
el.innerHTML = '⚠️ <strong>Se agotaron los tokens de LIA.</strong><br>Solicita tokens a soporte para continuar.';
msgs.appendChild(el);
msgs.scrollTop = msgs.scrollHeight;
hablarIA('Se agotaron los tokens de LIA. Solicita tokens a soporte para continuar.');
}
function mdToHtml(md) {
let s = md
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
.replace(/^\* (.+)$/gm,'<li>$1</li>');
s = s.replace(/((?:<li>[^]*?<\/li>\n?)+)/g,
m => `<ul style="margin:4px 0 4px 14px;padding:0;list-style:disc">${m.replace(/\n/g,'')}</ul>`);
return s.replace(/\n/g,'<br>');
}
function _stripMd(text) {
return text.replace(/\*\*(.+?)\*\*/g,'$1').replace(/^\* /gm,'').replace(/\n/g,' ');
}
function _vozFemenina() {
const vs = window.speechSynthesis.getVoices();
return vs.find(v => v.lang.startsWith('es') && /female|mujer|paulina|sabina|monica|helena|lucia|penelope|conchita|paloma|maria|carmen/i.test(v.name))
|| vs.find(v => v.lang.startsWith('es') && !v.name.toLowerCase().includes('male'))
|| vs.find(v => v.lang.startsWith('es'))
|| null;
}
function hablarIA(text) {
if (!_ttsOn || !window.speechSynthesis) return;
window.speechSynthesis.cancel();
const utt = new SpeechSynthesisUtterance(_stripMd(text));
utt.lang = 'es-CO'; utt.rate = 1.05;
const v = _vozFemenina(); if (v) utt.voice = v;
window.speechSynthesis.speak(utt);
}
function toggleTTS() {
_ttsOn = !_ttsOn;
document.getElementById('btnTTS').classList.toggle('active', _ttsOn);
if (!_ttsOn) window.speechSynthesis?.cancel();
}
function iniciarVoz(autoEnviar = true) {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { alert('Tu navegador no soporta reconocimiento de voz (usa Chrome)'); return; }
const btn = document.getElementById('btnMic');
if (_recog) { _recog.stop(); return; }
_recog = new SR();
_recog.lang = 'es-CO'; _recog.interimResults = false;
btn.classList.add('listening');
document.getElementById('liaListening').style.display = 'block';
_recog.onresult = e => {
const texto = e.results[0][0].transcript;
document.getElementById('aiInput').value = texto;
if (autoEnviar) enviarIA();
};
_recog.onend = () => {
btn.classList.remove('listening');
document.getElementById('liaListening').style.display = 'none';
_recog = null;
};
_recog.onerror= () => {
btn.classList.remove('listening');
document.getElementById('liaListening').style.display = 'none';
_recog = null;
};
_recog.start();
}
function enviarRapido(pregunta) {
document.getElementById('aiInput').value = pregunta;
document.getElementById('aiQuick').style.display = 'none';
enviarIA();
}
function minimizarIA(e) {
if (e && e.target && e.target.closest && e.target.closest('button:not(#btnMin)')) return;
_aiMinimized = !_aiMinimized;
document.getElementById('aiPanel').classList.toggle('minimized', _aiMinimized);
const icon = document.getElementById('iconMin');
if (icon) icon.className = _aiMinimized ? 'fas fa-chevron-up' : 'fas fa-minus';
}
function _liaGreet() {
const hoy = new Date().toISOString().slice(0,10);
const llave = 'lia_greeted_' + hoy;
if (localStorage.getItem(llave)) return;
localStorage.setItem(llave, '1');
const msg = 'Hola, soy LIA. ¿En qué te puedo asistir hoy?';
const msgs = document.getElementById('aiMessages');
const el = document.createElement('div');
el.className = 'ai-msg bot';
el.textContent = msg;
msgs.appendChild(el);
msgs.scrollTop = msgs.scrollHeight;
document.getElementById('liaLogo').classList.add('speaking');
hablarIA(msg);
// Auto-escuchar 3 segundos después del saludo
setTimeout(() => {
document.getElementById('liaLogo').classList.remove('speaking');
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (SR) {
const btn = document.getElementById('btnMic');
btn.classList.add('listening');
document.getElementById('liaListening').style.display = 'block';
const r = new SR();
r.lang = 'es-CO'; r.interimResults = false;
let oyo = false;
r.onresult = e => {
oyo = true;
document.getElementById('aiInput').value = e.results[0][0].transcript;
enviarIA();
};
r.onend = () => {
btn.classList.remove('listening');
document.getElementById('liaListening').style.display = 'none';
if (!oyo) {
// no habló — mostrar hint
const h = document.createElement('div');
h.className = 'ai-msg bot';
h.innerHTML = 'Toca 🎤 para hablar o escribe tu pregunta.';
document.getElementById('aiMessages').appendChild(h);
document.getElementById('aiMessages').scrollTop = 99999;
}
};
r.onerror = () => { btn.classList.remove('listening'); document.getElementById('liaListening').style.display = 'none'; };
setTimeout(() => r.start(), 3000); // esperar que el TTS termine
setTimeout(() => { try { r.stop(); } catch(_){} }, 9000); // max 6s de escucha
}
}, 100);
}
function toggleAI() {
_aiOpen = !_aiOpen;
if (!_aiOpen) { _aiMinimized = false; document.getElementById('aiPanel').classList.remove('minimized'); }
document.getElementById('aiPanel').classList.toggle('open', _aiOpen);
document.getElementById('btnAI').classList.toggle('hidden', _aiOpen);
if (_aiOpen && !_aiMinimized) {
document.getElementById('aiInput').focus();
setTimeout(_liaGreet, 400);
_actualizarTokens(_LIA_TOKENS_USADOS, _LIA_TOKENS_MAX);
}
}
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, historial: _liaHistorial }),
});
const d = await res.json();
typing.className = 'ai-msg bot';
if (d.ok) {
typing.innerHTML = mdToHtml(d.respuesta);
hablarIA(d.respuesta);
// Guardar el intercambio para que las preguntas de seguimiento tengan contexto
_liaHistorial.push({ rol: 'user', texto: pregunta });
_liaHistorial.push({ rol: 'model', texto: d.respuesta });
if (_liaHistorial.length > 8) _liaHistorial = _liaHistorial.slice(-8);
if (d.tokens_max) _actualizarTokens(d.tokens_usados, d.tokens_max);
} else {
typing.textContent = d.error || 'Error al conectar con la IA';
if (d.status === 402 || (d.error && d.error.includes('agotaron'))) _bloquearLIA();
}
} catch(_) {
typing.className = 'ai-msg bot';
typing.textContent = 'Error de conexión';
} finally {
input.disabled = false;
input.focus();
msgs.scrollTop = msgs.scrollHeight;
}
}
// ── Exportar CSV / Excel ──────────────────────────────────────
function exportarCSV() {
const fecha = document.getElementById('fechaInput').value;
window.location.href = `${API}export_csv.php?fecha=${fecha}`;
}
function exportarExcel() {
const fecha = document.getElementById('fechaInput').value;
window.location.href = `${API}export_excel.php?fecha=${fecha}`;
}
// ── Informe por WhatsApp ──────────────────────────────────────
async function enviarInformeWA() {
const phone = (document.getElementById('waInformePhone').value || '').replace(/\D/g,'');
const msg = document.getElementById('waInformeMsg');
if (!phone || phone.length < 7) { msg.innerHTML = '<span class="text-danger">Número inválido</span>'; return; }
msg.innerHTML = '<span class="text-muted"><i class="fas fa-spinner fa-spin me-1"></i>Enviando…</span>';
try {
const res = await fetch(`${API}enviar_informe_dia.php`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ phone })
});
const d = await res.json();
msg.innerHTML = d.ok
? `<span class="text-success"><i class="fas fa-check me-1"></i>${d.mensaje}</span>`
: `<span class="text-danger">${d.error}</span>`;
} catch(_) { msg.innerHTML = '<span class="text-danger">Error de conexión</span>'; }
}
// ── Gráfica proyección ─────────────────────────────────────────
async function cargarProyeccion(fecha) {
const sec = document.getElementById('sectionProyeccion');
try {
const r = await fetch(`${API}get_proyeccion.php?fecha=${fecha}`);
const d = await r.json();
if (!d.ok || !d.labels.length) { sec.style.display = 'none'; return; }
sec.style.display = '';
const fmt = v => v == null ? null : '$' + Intl.NumberFormat('es-CO').format(v);
document.getElementById('proyeccionResumen').textContent =
`Facturado: ${fmt(d.total_real)} (${d.turnos_finalizados} finalizados)`
+ ` · En proceso: ${fmt(d.total_en_proceso)} (${d.turnos_en_proceso})`
+ ` · Total estimado: ${fmt(d.total_proyectado)}`;
const ctx = document.getElementById('chartProyeccion').getContext('2d');
if (_chartProy) _chartProy.destroy();
_chartProy = new Chart(ctx, {
type: 'bar',
data: {
labels: d.labels,
datasets: [
{
label: 'Facturado',
data: d.real,
backgroundColor: 'rgba(21,101,192,.75)',
borderRadius: 6,
},
{
label: 'En proceso',
data: d.proyectado,
backgroundColor: 'rgba(245,158,11,.55)',
borderRadius: 6,
}
]
},
options: {
responsive: true,
plugins: {
legend: { position:'top', labels:{ font:{size:12} } },
tooltip: {
callbacks: { label: ctx => ' $' + Intl.NumberFormat('es-CO').format(ctx.raw ?? 0) }
}
},
scales: {
y: {
ticks: { callback: v => '$' + Intl.NumberFormat('es-CO',{notation:'compact'}).format(v) }
}
}
}
});
} catch(_) { sec.style.display = 'none'; }
}
// ── Helper ───────────────────────────────────────────────────
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(str ?? '')));
return d.innerHTML;
}
</script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<?php Layout::close(); ?>