Files
whatsapp/modules/turnero/views/dashboard.php
T

987 lines
50 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; }
/* ── 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 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 y proyecció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>
<!-- 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;
// ── 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 } = 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, '📋 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')}
${kpiCard(tTotal !== null ? tTotal + ' min' : '—', '⏱ Total prom.', 'puerta a puerta')}
`;
// ── 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 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>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;
// ── 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 }),
});
const d = await res.json();
typing.className = 'ai-msg bot';
if (d.ok) {
typing.innerHTML = mdToHtml(d.respuesta);
hablarIA(d.respuesta);
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 =
`Real: ${fmt(d.total_real)} · Proyectado: ${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: 'Proyectado',
data: d.proyectado,
backgroundColor: 'rgba(2,136,209,.3)',
borderRadius: 6,
borderDash: [4,4],
}
]
},
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(); ?>