Files
Lizandro GuarnizoandClaude Opus 5 0ec0edf245 Toma de muestras: mostrar el F-LAB-08 en los turnos de solo entrega
Cuando recepción marca "solo entrega de muestras", el puesto de toma no
mostraba ningún formulario. No era que no se generara: el backend ya lo
creaba por la vía del lugar —los ocho puestos de toma, Pediatría y
Ginecología tienen vinculado el formulario 16— y era la interfaz la que lo
escondía. En 30 días: 59 turnos de solo entrega, 34 con el F-LAB-08 ya
creado, 0 firmados.

Eran tres bloqueos, no uno:
- la sección de formularios se ocultaba por solo_muestras, y como los puestos
  de toma van en modo embebido, esa sección es la única vía para abrirlo;
- no se cargaba el formulario embebido;
- ni se refrescaba la lista cada 5 s, así que al firmar no se habría visto
  el cambio.

Se conserva la exención del bloqueo para finalizar (línea 1685): el
formulario aparece y se puede llenar, pero un turno de solo entrega no queda
atrapado si no se firma. Cerrar la cola por esto sería peor que el problema
que resuelve.

Estos turnos no traen exámenes, así que la lista solo puede contener los
formularios del puesto: no hay riesgo de que aparezcan consentimientos de
exámenes que no aplican.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 09:20:47 -05:00

3255 lines
158 KiB
PHP

<?php
/**
* Puesto Lugar / Estación de Servicio — Módulo Turnero
* Genérica: sirve para Toma de Muestras 1, 2, Rayos X, etc.
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) {
header('Location: ' . BASE_URL . 'login.php');
exit;
}
require_once __DIR__ . '/../_acceso.php';
turneroExigirRol(['bacteriologo', 'supervisor']);
// Tablet asignada → forzar su lugar por token de navegador (o IP como fallback)
$lugarForzado = 0;
try {
$_dispPdo = Database::getInstance()->getConnection();
$_dispRow = null;
// 1. Chequeo por token de navegador (cookie)
$_devToken = trim($_COOKIE['turnero_token'] ?? '');
if ($_devToken) {
$_s = $_dispPdo->prepare(
"SELECT td.lugar_id, td.nombre, tl.tipo
FROM turnero_dispositivos td
JOIN turnero_lugares tl ON tl.id = td.lugar_id
WHERE td.token = ? AND td.activo = 1 LIMIT 1"
);
$_s->execute([$_devToken]);
$_dispRow = $_s->fetch(PDO::FETCH_ASSOC) ?: null;
}
// 2. Fallback por IP
if (!$_dispRow) {
$_clientIp = trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR'] ?? '')[0]);
$_s = $_dispPdo->prepare(
"SELECT td.lugar_id, td.nombre, tl.tipo
FROM turnero_dispositivos td
JOIN turnero_lugares tl ON tl.id = td.lugar_id
WHERE td.ip = ? AND td.token IS NULL AND td.activo = 1 LIMIT 1"
);
$_s->execute([$_clientIp]);
$_dispRow = $_s->fetch(PDO::FETCH_ASSOC) ?: null;
}
if ($_dispRow) {
$_SESSION['turnero_dispositivo'] = $_dispRow;
$_forzado = (int)$_dispRow['lugar_id'];
if ($_dispRow['tipo'] === 'recepcion') {
header('Location: ' . BASE_URL . 'erp.php?m=turnero&v=recepcion&desk_id=' . $_forzado); exit;
}
$_pedido = (int)($_GET['lugar_id'] ?? 0);
// Un equipo fijo queda atado a su estación, pero algunas —Pediatría,
// Ginecología— se atienden desde cualquier puesto: se marcan como de
// acceso libre y no se redirige.
$_libre = false;
if ($_pedido && $_pedido !== $_forzado) {
$_sl = $_dispPdo->prepare(
"SELECT acceso_libre FROM turnero_lugares WHERE id = ? AND activo = 1 LIMIT 1"
);
$_sl->execute([$_pedido]);
$_libre = (bool)$_sl->fetchColumn();
}
if ($_pedido !== $_forzado && !$_libre) {
header('Location: ' . BASE_URL . 'erp.php?m=turnero&v=lugar&lugar_id=' . $_forzado); exit;
}
// El equipo sigue atado a su estación aunque esté atendiendo una de
// acceso libre: la vista muestra la pedida (sale de lugar_id), pero el
// botón de cambiar sigue oculto y no puede saltar a cualquier otra.
$lugarForzado = $_forzado;
}
} catch (\Throwable $_) {}
try {
$pdo = Database::getInstance()->getConnection();
$lugares = $pdo->query(
"SELECT id, nombre, descripcion, formulario_modo, tipo FROM turnero_lugares WHERE activo = 1 ORDER BY sort_order ASC"
)->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable) {
$lugares = [];
}
$lugarIdParam = isset($_GET['lugar_id']) ? (int)$_GET['lugar_id'] : 0;
$lugarNombre = 'Estación de Servicio';
$lugarFormModo = 'link';
$lugarTipo = 'muestras';
foreach ($lugares as $l) {
if ((int)$l['id'] === $lugarIdParam) {
$lugarNombre = $l['nombre'];
$lugarFormModo = $l['formulario_modo'] ?? 'link';
$lugarTipo = $l['tipo'] ?? 'muestras';
break;
}
}
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Operador';
$adminFirmaSvg = null;
try {
$stmt = Database::getInstance()->getConnection()
->prepare("SELECT firma_svg FROM admin_users WHERE id = ? LIMIT 1");
$stmt->execute([(int)($_SESSION['admin_user']['id'] ?? 0)]);
$adminFirmaSvg = $stmt->fetchColumn() ?: null;
} catch (\Throwable $_) {}
// Especialidades (Ginecología, Pediatría) — excluir el lugar actual
$especialidades = [];
foreach ($lugares as $_el) {
$_en = mb_strtolower($_el['nombre']);
if ((str_contains($_en, 'ginecol') || str_contains($_en, 'pediatr'))
&& (int)$_el['id'] !== $lugarIdParam) {
$especialidades[] = $_el;
}
}
$formulariosList = [];
try {
$formulariosList = Database::getInstance()->getConnection()
->query("SELECT id, nombre FROM lab_formularios WHERE is_active = 1 ORDER BY nombre ASC")
->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable $_) {}
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($lugarNombre) ?> — Turnero</title>
<script>(function(){try{var m=window.matchMedia('(prefers-color-scheme: dark)');function a(d){document.documentElement.setAttribute('data-bs-theme',d?'dark':'light');}a(m.matches);m.addEventListener('change',function(e){a(e.matches);});}catch(e){}})();</script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="<?= BASE_URL ?>assets/css/styles.css?v=15" rel="stylesheet">
<style>
/* ── Layout ─────────────────────────────────────────────── */
.lugar-layout {
display: grid;
grid-template-columns: 370px 1fr;
height: calc(100vh - 52px);
overflow: hidden;
}
@media (max-width: 680px) {
.lugar-layout { grid-template-columns: 1fr; position: relative; overflow: hidden; }
.lugar-cola { transition: transform .2s ease; }
.lugar-cola.mobile-oculta { transform: translateX(-100%); position: absolute; inset: 0; pointer-events: none; }
.lugar-ficha { position: absolute; inset: 0; background: #fff; z-index: 10;
transform: translateX(100%); transition: transform .2s ease; overflow-y: auto; }
.lugar-ficha.mobile-visible { transform: translateX(0); }
}
/* ── Cola ───────────────────────────────────────────────── */
.lugar-cola {
background: #f8fafc;
border-right: 1px solid #e2e8f0;
display: flex; flex-direction: column; overflow: hidden;
}
.lugar-cola-hdr {
padding: .85rem 1rem .65rem;
background: #fff; border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
}
.cola-hdr-row {
display: flex; align-items: center; justify-content: space-between; margin-bottom: .65rem;
}
.cola-hdr-title {
font-size: .78rem; text-transform: uppercase; letter-spacing: .08em;
color: #64748b; font-weight: 700;
}
.cola-counts { display: flex; gap: .4rem; align-items: center; }
.cola-count-chip {
font-size: .68rem; font-weight: 700; border-radius: 99px;
padding: 1px 9px; border: 1px solid;
}
.cola-count-chip.espera { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
.cola-count-chip.servicio { background: #f0fdf4; color: #166534; border-color: #bbf7d0; }
.btn-llamar-next {
width: 100%; padding: .65rem 1rem; border: none; border-radius: 10px;
background: #2563eb; color: #fff; font-weight: 700; font-size: .92rem;
cursor: pointer; display: flex; align-items: center; justify-content: center; gap: .5rem;
transition: background .15s, transform .1s;
}
.btn-llamar-next:hover { background: #1d4ed8; }
.btn-llamar-next:active { transform: scale(.98); }
.btn-llamar-next:disabled { background: #94a3b8; cursor: not-allowed; }
.cola-items { flex: 1; overflow-y: auto; padding: .5rem; }
/* ── Especialidades en cola ── */
.esp-toplinks {
display: flex; gap: .35rem; flex-wrap: wrap; flex-shrink: 0;
padding: .45rem .75rem; background: #fff;
border-bottom: 1px solid #f1f5f9;
}
.esp-toplink {
display: inline-flex; align-items: center; gap: .3rem;
padding: .2rem .6rem; border-radius: 99px; font-size: .75rem; font-weight: 600;
color: var(--esp-c, #64748b);
border: 1.5px solid color-mix(in srgb, var(--esp-c, #64748b) 30%, transparent);
background: color-mix(in srgb, var(--esp-c, #64748b) 8%, #fff);
text-decoration: none; white-space: nowrap; transition: background .12s;
}
.esp-toplink:hover { background: color-mix(in srgb, var(--esp-c) 18%, #fff); }
.esp-toplink .esp-cnt {
background: var(--esp-c); color: #fff; border-radius: 99px;
padding: 0 5px; font-size: .65rem; min-width: 16px; text-align: center;
}
#esp-panel { flex-shrink: 0; border-bottom: 2px solid #e2e8f0; }
.esp-grupo { padding: .35rem .6rem; }
.esp-grupo + .esp-grupo { border-top: 1px solid #f1f5f9; }
.esp-grupo-hdr {
display: flex; align-items: center; gap: .35rem;
font-size: .72rem; font-weight: 700; color: #475569; margin-bottom: .2rem;
}
.esp-grupo-count {
background: var(--esp-gc); color: #fff; border-radius: 99px; padding: 0 5px; font-size: .65rem;
}
.esp-grupo-ir {
margin-left: auto; font-size: .7rem; font-weight: 600;
color: var(--esp-gc); text-decoration: none;
}
.esp-grupo-ir:hover { text-decoration: underline; }
.esp-pac-row {
display: flex; align-items: center; gap: .4rem;
padding: .18rem .25rem; border-radius: 5px; font-size: .77rem;
text-decoration: none; color: #1e293b; transition: background .1s;
}
.esp-pac-row:hover { background: #f1f5f9; }
.esp-pac-orden { font-weight: 700; font-family: monospace; color: var(--esp-gc); font-size: .7rem; flex-shrink: 0; }
.esp-pac-hora { margin-left: auto; font-size: .68rem; color: #94a3b8; flex-shrink: 0; }
/* ── Sala de espera entre muestras ── */
#espera-muestras-panel { flex-shrink: 0; border-bottom: 2px solid #fde68a; background: #fffbeb; }
.espera-m-hdr {
display: flex; align-items: center; gap: .4rem;
padding: .4rem .75rem .25rem; font-size: .72rem; font-weight: 700; color: #92400e;
}
.espera-m-count {
background: #f97316; color: #fff; border-radius: 99px; padding: 0 6px; font-size: .65rem;
}
.espera-m-row {
display: flex; align-items: center; gap: .5rem;
padding: .22rem .75rem; font-size: .78rem; color: #1e293b;
border-top: 1px solid #fde68a;
}
.espera-m-orden { font-weight: 700; font-family: monospace; color: #f97316; font-size: .7rem; flex-shrink: 0; }
.espera-m-cd { font-size: .71rem; font-weight: 700; flex-shrink: 0; white-space: nowrap; }
.espera-m-cd.ok { color: #16a34a; }
.espera-m-cd.warn { color: #d97706; }
.espera-m-cd.crit { color: #dc2626; animation: parpadeo-alerta .8s infinite; }
.btn-llamar-espera {
margin-left: auto; flex-shrink: 0;
padding: .18rem .55rem; font-size: .72rem; font-weight: 700;
border: 1.5px solid #22c55e; color: #16a34a; background: #fff;
border-radius: 7px; cursor: pointer; transition: background .1s;
}
.btn-llamar-espera:hover { background: #f0fdf4; }
.cola-section-sep {
font-size: .65rem; text-transform: uppercase; letter-spacing: .06em; font-weight: 700;
color: #94a3b8; padding: .3rem .4rem .15rem; margin-top: .25rem;
}
.cola-card {
background: #fff; border: 1px solid #e2e8f0; border-radius: 11px;
padding: .65rem .9rem; margin-bottom: .3rem;
display: flex; align-items: center; gap: .55rem;
cursor: pointer; transition: border-color .15s, box-shadow .15s, background .15s;
min-height: 62px;
}
.cola-card:hover { border-color: #a5b4fc; box-shadow: 0 2px 8px rgba(99,102,241,.12); }
.cola-card.activo { border-color: #6366f1; box-shadow: 0 0 0 2px #c7d2fe; }
.cola-card.en-servicio-card { border-color: #16a34a; background: #f0fdf4; }
.cola-card.en-servicio-card:hover { border-color: #15803d; }
.cola-card.en-servicio-card.activo { box-shadow: 0 0 0 2px #bbf7d0; }
.prio-dot {
width: 36px; height: 36px; border-radius: 9px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 1rem; font-weight: 800; color: #fff;
}
.turno-info { flex: 1; min-width: 0; }
.turno-info .cod { font-size: .95rem; font-weight: 700; line-height: 1.2; }
.turno-info .pac {
font-size: .74rem; color: #64748b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.turno-info .t-badge-row { display: flex; align-items: center; gap: .3rem; margin-top: 2px; flex-wrap: wrap; }
.tiempo-chip {
font-size: .6rem; font-weight: 700; border-radius: 99px;
padding: 0 6px; line-height: 1.7;
}
.tiempo-chip.ok { background: #f0fdf4; color: #166534; }
.tiempo-chip.warn { background: #fffbeb; color: #92400e; }
.tiempo-chip.crit { background: #fef2f2; color: #991b1b; }
.muestra-chip {
font-size: .6rem; font-weight: 700; color: #ea580c;
background: #fff7ed; border-radius: 99px; padding: 0 6px; line-height: 1.7;
}
.toma-prog-chip {
font-size: .6rem; font-weight: 700; border-radius: 99px;
padding: 0 6px; line-height: 1.7; white-space: nowrap;
}
.toma-prog-chip.ok { background: #eff6ff; color: #1d4ed8; }
.toma-prog-chip.warn { background: #fffbeb; color: #92400e; }
.toma-prog-chip.crit { background: #fef2f2; color: #991b1b; animation: parpadeo-alerta 1s infinite; }
@keyframes parpadeo-alerta { 0%,100%{opacity:1} 50%{opacity:.55} }
.cola-card.toma-prog { border-color: #f97316 !important; }
.cola-card.toma-prog.crit-card { border-color: #dc2626 !important; background: #fef2f2 !important; }
.card-en-servicio-pill {
flex-shrink: 0; display: flex; flex-direction: column; align-items: center;
background: #16a34a; color: #fff; border-radius: 7px;
padding: 3px 7px; font-size: .6rem; font-weight: 800; letter-spacing: .03em;
gap: 1px; text-align: center;
}
.card-en-servicio-pill i { font-size: .75rem; }
.card-bell-btn {
flex-shrink: 0; background: #2563eb; border: none; color: #fff;
border-radius: 10px; width: 44px; height: 44px;
display: flex; align-items: center; justify-content: center;
font-size: .95rem; cursor: pointer; transition: background .12s;
}
.card-bell-btn:hover { background: #1d4ed8; }
.cola-vacia { text-align: center; padding: 2.5rem 1rem; color: #94a3b8; font-size: .88rem; }
/* ── Ficha ──────────────────────────────────────────────── */
.lugar-ficha { overflow-y: auto; padding: 0; background: #fff; display: flex; flex-direction: column; }
.ficha-inner { flex: 1; padding: 1.2rem 1.4rem 1rem; }
.ficha-placeholder {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: #94a3b8; text-align: center; padding: 3rem 1rem;
}
/* Mobile top bar (back button) */
.ficha-mobile-topbar {
display: none; align-items: center; gap: .6rem;
padding: .55rem 1rem; background: #f8fafc;
border-bottom: 1px solid #e2e8f0; flex-shrink: 0;
position: sticky; top: 0; z-index: 20;
}
@media (max-width: 680px) {
.ficha-mobile-topbar { display: flex; }
}
.ficha-mobile-topbar .btn-back {
background: none; border: none; padding: 4px 6px;
color: #2563eb; font-size: .88rem; font-weight: 600;
cursor: pointer; display: flex; align-items: center; gap: .3rem;
}
.ficha-mobile-topbar .m-cod { font-size: .78rem; color: #64748b; font-weight: 600; }
/* ── Ficha header ───────────────────────────────────────── */
.ficha-turno-hdr {
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 13px;
padding: .9rem 1.1rem; margin-bottom: 1rem;
}
.fth-top {
display: flex; align-items: center; gap: .6rem; margin-bottom: .5rem;
}
.prio-dot-sm {
width: 30px; height: 30px; border-radius: 7px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: .82rem; font-weight: 800; color: #fff;
}
.fth-meta { flex: 1; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.fth-cod { font-size: .82rem; font-weight: 800; color: #334155; }
.fth-prio { font-size: .75rem; color: #64748b; }
.fth-pac-nom {
font-size: 1.35rem; font-weight: 800; color: #1e293b; line-height: 1.2;
letter-spacing: -.01em;
}
.fth-pac-nom.loading {
height: 22px; width: 60%; border-radius: 6px;
background: linear-gradient(90deg,#f0f0f0 25%,#e8e8e8 50%,#f0f0f0 75%);
background-size: 200% 100%; animation: skel-wave 1.4s infinite;
}
@keyframes skel-wave {
0% { background-position: 200% 0 }
100% { background-position: -200% 0 }
}
/* ── Secciones ──────────────────────────────────────────── */
.ficha-sec {
background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 12px; padding: 1.2rem; margin-bottom: .85rem;
}
.ficha-sec-hdr {
display: flex; align-items: center; gap: .4rem;
font-size: .78rem; text-transform: uppercase; letter-spacing: .08em;
color: #475569; font-weight: 700; margin-bottom: .7rem;
}
.ficha-sec-hdr i { font-size: .85rem; }
/* ── Datos paciente ── */
.pac-dato { display: flex; gap: .5rem; align-items: baseline; margin-bottom: .25rem; }
.pac-dato .lbl { font-size: .72rem; color: #94a3b8; min-width: 78px; flex-shrink: 0; }
.pac-dato .val { font-size: .87rem; color: #1e293b; font-weight: 500; }
/* ── Exámenes ── */
.exam-pill {
display: inline-flex; align-items: center; gap: .3rem;
background: #eff6ff; color: #1d4ed8; border-radius: 20px;
padding: .22rem .7rem; font-size: .78rem; font-weight: 600; margin: .15rem;
border: 1px solid #bfdbfe;
}
/* ── Consentimientos ── */
.consent-row {
display: flex; align-items: center; gap: .6rem;
padding: .5rem .7rem; border-radius: 10px; margin-bottom: .35rem;
font-size: .84rem; border: 1px solid transparent; min-height: 46px;
}
.consent-row.firmado { background: #f0fdf4; color: #166534; border-color: #bbf7d0; }
.consent-row.cerrado_anticipado { background: #fef9c3; color: #78350f; border-color: #fde68a; }
.consent-row.rechazado { background: #f8fafc; color: #64748b; border-color: #e2e8f0; }
.consent-row.enviado { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; }
.consent-row.pendiente { background: #fffbeb; color: #92400e; border-color: #fde68a; }
.consent-row.visto { background: #faf5ff; color: #6b21a8; border-color: #ddd6fe; }
.consent-row.en_progreso { background: #fff7ed; color: #9a3412; border-color: #fed7aa; }
.consent-row.en_progreso.alerta { background: #fef2f2; color: #991b1b; border-color: #fca5a5;
animation: parpadeo-alerta 1s infinite; }
@keyframes parpadeo-alerta { 0%,100%{opacity:1} 50%{opacity:.6} }
.toma-progreso { font-size:.67rem; background:#ea580c; color:#fff;
border-radius:99px; padding:1px 7px; font-weight:700; white-space:nowrap; }
.toma-countdown { font-size:.67rem; color:inherit; opacity:.75; white-space:nowrap; }
.consent-row .nom-form { flex: 1; font-weight: 500; }
.consent-row .c-badge { font-size: .67rem; padding: 1px 8px; border-radius: 99px;
border: 1px solid currentColor; font-weight: 700; opacity: .85; }
.consent-row .acciones-consent { display: flex; gap: .3rem; flex-shrink: 0; }
.consent-row .acciones-consent .btn { font-size: .72rem; padding: 2px 9px; border-radius: 7px; }
/* ── Tomas pendientes de visita anterior ── */
#sec-tomas-prev { display:none; }
.tomas-prev-hdr { font-size:.78rem; font-weight:700; color:#92400e;
background:#fff7ed; border:1px solid #fed7aa;
border-radius:8px 8px 0 0; padding:6px 12px;
display:flex; align-items:center; gap:6px; }
.tomas-prev-list { border:1px solid #fed7aa; border-top:none;
border-radius:0 0 8px 8px; overflow:hidden; }
.toma-prev-row { display:flex; align-items:center; gap:.6rem;
padding:.45rem .7rem; background:#fffbeb;
font-size:.83rem; color:#78350f; }
.toma-prev-row + .toma-prev-row { border-top:1px solid #fde68a; }
.toma-prev-row .nom-form { flex:1; font-weight:500; }
.toma-prev-row .acciones-consent { display:flex; gap:.3rem; flex-shrink:0; }
.toma-prev-row .acciones-consent .btn { font-size:.72rem; padding:2px 9px; border-radius:7px; }
/* ── Barra de acciones ── */
.ficha-acciones {
position: sticky; bottom: 0;
background: rgba(255,255,255,.97);
backdrop-filter: blur(6px);
border-top: 1px solid #e2e8f0;
padding: .8rem 1.4rem .9rem;
margin-top: auto; flex-shrink: 0;
}
.accion-consent-warn {
display: flex; align-items: center; gap: .5rem;
background: #fffbeb; border: 1px solid #fcd34d; border-radius: 9px;
padding: .5rem .8rem; font-size: .8rem; color: #92400e; font-weight: 600;
margin-bottom: .65rem;
}
.accion-consent-warn i { color: #f59e0b; flex-shrink: 0; }
.accion-primary-row { margin-bottom: .45rem; }
.btn-accion-primary {
width: 100%; padding: .8rem; border: none; border-radius: 11px;
font-size: 1rem; font-weight: 700; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: .45rem;
transition: background .15s, transform .1s, opacity .15s;
}
.btn-accion-primary:active { transform: scale(.98); }
.btn-accion-primary:disabled { opacity: .5; cursor: not-allowed; }
.btn-accion-primary.azul { background: #2563eb; color: #fff; }
.btn-accion-primary.azul:hover { background: #1d4ed8; }
.btn-accion-primary.verde { background: #16a34a; color: #fff; }
.btn-accion-primary.verde:hover { background: #15803d; }
.accion-secondary-row {
display: flex; gap: .4rem; align-items: center; flex-wrap: wrap;
}
.btn-accion-sec {
flex: 1; min-width: 80px;
padding: .55rem .5rem; border-radius: 9px;
font-size: .82rem; font-weight: 600; cursor: pointer;
display: flex; align-items: center; justify-content: center; gap: .35rem;
border: 1.5px solid; background: transparent; transition: background .12s;
}
.btn-accion-sec.warning { color: #92400e; border-color: #fcd34d; }
.btn-accion-sec.warning:hover { background: #fffbeb; }
.btn-accion-sec.info { color: #1e40af; border-color: #93c5fd; }
.btn-accion-sec.info:hover { background: #eff6ff; }
.btn-accion-sec.danger { color: #991b1b; border-color: #fca5a5; }
.btn-accion-sec.danger:hover { background: #fef2f2; }
/* ── Bloqueo banner ── */
.bloqueo-banner {
background: #fef2f2; border: 1px solid #fca5a5; border-radius: 10px;
padding: .6rem .9rem; font-size: .82rem; color: #991b1b;
display: flex; align-items: center; gap: .5rem; margin-bottom: .7rem;
}
/* ── Selector ── */
.selector-lugar-overlay {
position: fixed; inset: 0; background: #f1f5f9;
z-index: 200; display: flex; align-items: center; justify-content: center;
}
.selector-lugar-box {
background: #fff; border-radius: 20px; padding: 2.5rem;
width: min(90vw, 480px); box-shadow: 0 8px 40px rgba(0,0,0,.12); text-align: center;
}
.selector-lugar-box h2 { font-size: 1.4rem; font-weight: 700; margin-bottom: 1.5rem; }
/* ── Modal paciente ── */
#modal-pac-lugar { display:none; position:fixed; inset:0; z-index:9500;
background:rgba(0,0,0,.55); align-items:center; justify-content:center; padding:16px; }
#modal-pac-lugar.show { display:flex; }
.mpac-dialog {
background:#fff; border-radius:16px; width:min(96vw,540px);
max-height:90vh; display:flex; flex-direction:column; overflow:hidden;
box-shadow:0 8px 40px rgba(0,0,0,.22);
}
.mpac-hdr {
display:flex; align-items:center; justify-content:space-between;
padding:1.1rem 1.25rem; border-bottom:1px solid rgba(255,255,255,.15); flex-shrink:0;
background:linear-gradient(135deg,#1043a0 0%,#1565c0 100%);
}
.mpac-hdr-title { font-weight:700; font-size:.97rem; color:#fff; display:flex; align-items:center; gap:.5rem; }
.mpac-close { background:rgba(255,255,255,.15); border:none; font-size:1.1rem; color:#fff; cursor:pointer; padding:4px 8px; border-radius:6px; }
.mpac-close:hover { background:rgba(255,255,255,.25); }
.mpac-body { overflow-y:auto; padding:18px; flex:1; }
.mpac-dato { display:flex; gap:.5rem; align-items:baseline; margin-bottom:.4rem; }
.mpac-dato .lbl { font-size:.72rem; color:#94a3b8; min-width:95px; flex-shrink:0; }
.mpac-dato .val { font-size:.88rem; color:#1e293b; font-weight:500; }
.mpac-sec-title {
font-size:.7rem; text-transform:uppercase; letter-spacing:.07em;
color:#475569; font-weight:700; margin:14px 0 8px;
display:flex; align-items:center; gap:.4rem;
}
/* Historial timeline */
.mpac-timeline { padding: 0; }
.mpac-lbl { font-size:.7rem; color:#64748b; font-weight:600; display:block; margin-bottom:2px; }
.mpac-aviso { font-size:.74rem; color:#1e40af; background:#eff6ff; border:1px solid #bfdbfe;
border-radius:8px; padding:7px 10px; margin-bottom:10px; }
.mpac-lock { background:#fffbeb; border:1px solid #fde68a; border-radius:8px;
padding:9px 11px; margin:4px 0 10px; }
.mpac-lock-txt { font-size:.72rem; color:#92400e; margin-bottom:7px; line-height:1.45; }
.mpac-aviso-firmados { font-size:.74rem; color:#7f1d1d; background:#fef2f2;
border:1px solid #fecaca; border-radius:8px;
padding:8px 11px; margin-bottom:10px; line-height:1.45; }
.mtl-item {
display:flex; gap:10px; align-items:flex-start;
padding-bottom:12px; position:relative;
}
.mtl-item:not(:last-child)::before {
content:''; position:absolute; left:6px; top:14px;
width:2px; bottom:0; background:#e2e8f0;
}
.mtl-dot {
width:14px; height:14px; border-radius:50%; flex-shrink:0;
margin-top:2px; border:2px solid #fff; box-shadow:0 0 0 2px currentColor;
}
.mtl-content { flex:1; min-width:0; }
.mtl-fecha { font-size:.68rem; color:#94a3b8; }
.mtl-codigo { font-size:.82rem; font-weight:700; }
.mtl-lugar { font-size:.73rem; color:#64748b; }
.mtl-eb { font-size:.62rem; padding:1px 7px; border-radius:99px; font-weight:700; display:inline-block; margin-top:2px; }
/* ── Banner toma alerta ── */
#banner-toma-alerta {
display: none; position: fixed; top: 52px; left: 0; right: 0; z-index: 8900;
background: #dc2626; color: #fff;
padding: .65rem 1.2rem; display: none;
align-items: center; gap: .75rem; font-size: .9rem; font-weight: 600;
box-shadow: 0 4px 20px rgba(220,38,38,.45);
animation: parpadeo-alerta 1s infinite;
}
#banner-toma-alerta.show { display: flex; }
#banner-toma-alerta .btn-banner-pac {
background: rgba(255,255,255,.2); border: 1.5px solid rgba(255,255,255,.5);
color: #fff; border-radius: 8px; padding: 3px 12px; font-size: .8rem;
cursor: pointer; font-weight: 700; transition: background .12s;
}
#banner-toma-alerta .btn-banner-pac:hover { background: rgba(255,255,255,.35); }
#banner-toma-alerta .btn-banner-x {
margin-left: auto; background: none; border: none; color: rgba(255,255,255,.8);
font-size: 1.1rem; cursor: pointer; padding: 0 4px;
}
/* ── Toast ── */
.rec-toast {
position: fixed; bottom: 2rem; right: 2rem; z-index: 9999;
display: flex; align-items: center; gap: .7rem;
padding: .8rem 1.2rem; border-radius: 12px;
background: #fff; border: 1px solid #e2e8f0;
box-shadow: 0 8px 30px rgba(0,0,0,.12);
font-size: .9rem; font-weight: 500;
transform: translateY(120%); opacity: 0;
transition: transform .3s ease, opacity .3s ease;
pointer-events: none;
}
.rec-toast.show { transform: translateY(0); opacity: 1; }
.rec-toast.success { border-left: 4px solid #22c55e; }
.rec-toast.info { border-left: 4px solid #3b82f6; }
.rec-toast.warn { border-left: 4px solid #f59e0b; }
.rec-toast.error { border-left: 4px solid #ef4444; }
.rec-toast .ico { font-size: 1.2rem; }
.rec-toast.success .ico { color: #22c55e; }
.rec-toast.info .ico { color: #3b82f6; }
.rec-toast.warn .ico { color: #f59e0b; }
.rec-toast.error .ico { color: #ef4444; }
/* ── Widget muestras ────────────────────────────────────── */
.muestra-row {
display: flex; align-items: center; gap: .6rem;
padding: .5rem .7rem; border-radius: 10px; margin-bottom: .35rem;
font-size: .84rem; border: 1px solid transparent;
}
.muestra-row.pendiente { background: #fffbeb; border-color: #fde68a; }
.muestra-row.recibida { background: #f0fdf4; border-color: #bbf7d0; color: #166534; }
.muestra-row.rechazada { background: #fff7ed; border-color: #fed7aa; color: #c2410c; }
.muestra-info { flex: 1; min-width: 0; }
.muestra-label { font-weight: 600; font-size: .82rem; font-family: monospace; }
.muestra-motivo { font-size: .7rem; margin-top: 2px; opacity: .85; }
.muestra-examenes { font-size: .7rem; margin-top: 2px; opacity: .85; }
.muestra-ts { font-size: .68rem; color: #94a3b8; flex-shrink: 0; }
.btn-muestra-accion {
border: none; border-radius: 8px; padding: 3px 11px;
font-size: .75rem; font-weight: 700; cursor: pointer;
display: flex; align-items: center; gap: .25rem;
transition: opacity .12s;
}
.btn-muestra-accion:active { opacity: .75; }
.btn-muestra-accion.recibir { background: #16a34a; color: #fff; }
.btn-muestra-accion.rechazar { background: #fff; color: #c2410c; border: 1.5px solid #fed7aa; }
.muestras-summary {
font-size: .72rem; color: #64748b; margin-bottom: .5rem;
display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
}
.muestras-summary .mc { font-weight: 700; border-radius: 99px; padding: 1px 8px;
font-size: .68rem; }
.muestras-summary .mc.pend { background: #fef9c3; color: #854d0e; }
.muestras-summary .mc.rec { background: #dcfce7; color: #166534; }
.muestras-summary .mc.rech { background: #fff7ed; color: #c2410c; }
</style>
</head>
<body>
<?php
$SIDEBAR_TITLE = 'Turnero';
$SIDEBAR_ICON = 'fas fa-ticket-alt';
require_once __DIR__ . '/../../../shared/components/sidebar.php';
?>
<!-- ══ Selector inicial ══════════════════════════════════════════ -->
<div id="overlay-selector" class="selector-lugar-overlay" <?= $lugarIdParam ? 'style="display:none"' : '' ?>>
<div class="selector-lugar-box">
<i class="fas fa-map-marker-alt fa-2x text-primary mb-3"></i>
<h2>Seleccione su estación</h2>
<select id="sel-lugar-init" class="form-select mb-3">
<option value="">— Elija un lugar —</option>
<?php foreach ($lugares as $l): ?>
<option value="<?= (int)$l['id'] ?>"><?= htmlspecialchars($l['nombre']) ?></option>
<?php endforeach; ?>
</select>
<button class="btn btn-primary w-100" onclick="confirmarLugar()">
<i class="fas fa-check me-1"></i>Confirmar
</button>
<button id="btn-cancelar-selector" class="btn btn-outline-secondary w-100 mt-2"
onclick="document.getElementById('overlay-selector').style.display='none'"
style="display:none">
<i class="fas fa-times me-1"></i>Cancelar
</button>
</div>
</div>
<!-- ── Banner alerta toma progresiva ─────────────────────────────── -->
<div id="banner-toma-alerta" role="alert">
<i class="fas fa-syringe fa-lg"></i>
<span id="banner-toma-txt">¡Hora de la siguiente muestra!</span>
<button class="btn-banner-pac" id="banner-toma-btn" onclick="verPacienteToma()">Ver paciente</button>
<button class="btn-banner-x" onclick="cerrarBannerToma()" title="Cerrar"><i class="fas fa-times"></i></button>
</div>
<main class="main-content" style="padding:0">
<!-- ── Top bar ──────────────────────────────────────────────── -->
<div class="content-header d-flex align-items-center justify-content-between px-3 py-2 border-bottom bg-white"
style="position:sticky;top:0;z-index:100;min-height:52px">
<div class="d-flex align-items-center gap-2">
<i class="fas fa-map-marker-alt text-primary"></i>
<strong id="lbl-lugar-titulo"><?= htmlspecialchars($lugarNombre) ?></strong>
<span id="badge-turno-activo" class="badge bg-primary ms-1 d-none"></span>
</div>
<div class="d-flex align-items-center gap-2">
<span class="text-muted small d-none d-md-inline"><?= htmlspecialchars($adminNombre) ?></span>
<button class="btn btn-sm <?= $adminFirmaSvg ? 'btn-outline-success' : 'btn-warning' ?>"
onclick="abrirModalMiFirma()" title="<?= $adminFirmaSvg ? 'Ver / cambiar mi firma' : 'Configurar mi firma' ?>">
<i class="fas fa-signature me-1"></i><?= $adminFirmaSvg ? 'Mi firma' : 'Configurar firma' ?>
</button>
<?php if (!$lugarForzado): ?>
<button class="btn btn-sm btn-outline-secondary" onclick="cambiarLugar()">
<i class="fas fa-exchange-alt me-1"></i>Cambiar
</button>
<?php endif; ?>
</div>
</div>
<div class="lugar-layout">
<!-- ══ Cola ═══════════════════════════════════════════════ -->
<div class="lugar-cola">
<div class="lugar-cola-hdr">
<div class="cola-hdr-row">
<span class="cola-hdr-title"><i class="fas fa-list-ol me-1"></i>Cola</span>
<div class="cola-counts">
<span class="cola-count-chip espera" id="badge-count">0 espera</span>
<span class="cola-count-chip servicio d-none" id="badge-servicio">0 servicio</span>
</div>
</div>
<button class="btn-llamar-next" id="btn-llamar" onclick="llamarSiguiente()">
<i class="fas fa-bell"></i>Llamar siguiente
</button>
</div>
<?php if ($especialidades): ?>
<div class="esp-toplinks">
<?php foreach ($especialidades as $_esp):
$_esGine = mb_stripos($_esp['nombre'], 'ginecol') !== false;
$_espC = $_esGine ? '#ec4899' : '#0ea5e9';
$_espI = $_esGine ? 'fa-venus' : 'fa-child';
?>
<a href="<?= BASE_URL ?>erp.php?m=turnero&v=lugar&lugar_id=<?= (int)$_esp['id'] ?>"
class="esp-toplink" style="--esp-c:<?= $_espC ?>">
<i class="fas <?= $_espI ?>"></i>
<?= htmlspecialchars($_esp['nombre']) ?>
<span class="esp-cnt" id="esp-cnt-<?= (int)$_esp['id'] ?>">—</span>
</a>
<?php endforeach; ?>
</div>
<div id="esp-panel" style="display:none"></div>
<?php endif; ?>
<div id="espera-muestras-panel" style="display:none"></div>
<div class="cola-items" id="cola-items">
<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>
Cola vacía
</div>
</div>
</div>
<!-- ══ Ficha del turno ════════════════════════════════════ -->
<div class="lugar-ficha" id="lugar-ficha">
<!-- Barra back en móvil -->
<div class="ficha-mobile-topbar" id="ficha-mobile-topbar">
<button class="btn-back" onclick="volverACola()">
<i class="fas fa-arrow-left"></i> Cola
</button>
<span class="m-cod" id="ficha-mobile-cod"></span>
</div>
<!-- Estado vacío -->
<div class="ficha-placeholder" id="ficha-placeholder">
<i class="fas fa-stethoscope fa-3x mb-3" style="color:#cbd5e1"></i>
<p class="fw-semibold mb-1">Sin turno activo</p>
<small class="text-muted">Pulse "Llamar siguiente" o seleccione un turno de la cola</small>
</div>
<div id="ficha-turno" class="d-none" style="display:flex;flex-direction:column;flex:1">
<div class="ficha-inner">
<!-- ── Header: paciente + turno ── -->
<div class="ficha-turno-hdr">
<div class="fth-top">
<div id="ficha-prio-dot" class="prio-dot-sm" style="background:#6366f1">—</div>
<div class="fth-meta">
<span class="fth-cod" id="ficha-codigo">—</span>
<span class="fth-prio" id="ficha-prio-nombre"></span>
<span id="ficha-orden" class="d-none"
style="font-size:.72rem;background:#eff6ff;color:#1d4ed8;border:1px solid #bfdbfe;
border-radius:20px;padding:1px 10px;font-weight:700;white-space:nowrap">
<i class="fas fa-hashtag" style="font-size:.63rem"></i>
<span id="ficha-orden-val"></span>
</span>
</div>
<span class="badge bg-success-subtle text-success border border-success-subtle"
id="ficha-estado-badge">en espera</span>
<span id="badge-solo-muestras" class="d-none"
style="font-size:.7rem;background:#fff7ed;color:#c2410c;border:1px solid #fed7aa;
border-radius:20px;padding:1px 10px;font-weight:700;white-space:nowrap">
<i class="fas fa-flask me-1"></i>Solo entrega de muestras
</span>
</div>
<div id="ficha-pac-nombre-hdr" class="fth-pac-nom loading">&nbsp;</div>
</div>
<!-- ── Paciente ── -->
<div class="ficha-sec">
<div class="ficha-sec-hdr">
<i class="fas fa-user"></i>Paciente
<button id="btn-ver-paciente" class="btn btn-sm btn-outline-primary ms-auto d-none"
onclick="abrirModalPaciente()" style="font-size:.72rem;padding:2px 10px;border-radius:20px">
<i class="fas fa-eye me-1"></i>Ver
</button>
</div>
<div id="bloque-pac-info">
<div class="pac-dato"><span class="lbl">Nombre</span><span class="val" id="pac-nombre">—</span></div>
<div class="pac-dato"><span class="lbl">Documento</span><span class="val" id="pac-doc">—</span></div>
<div class="pac-dato d-none"><span class="lbl">Fecha nac.</span><span class="val" id="pac-fec">—</span></div>
<div class="pac-dato d-none"><span class="lbl">Celular</span><span class="val" id="pac-cel">—</span></div>
</div>
<div id="pac-embarazada" class="d-none mt-2">
<span class="badge text-bg-danger"><i class="fas fa-baby me-1"></i>Paciente embarazada</span>
</div>
<div id="pac-medico" class="d-none mt-2">
<div class="pac-dato">
<span class="lbl">Médico</span>
<span class="val" id="pac-medico-nombre" style="color:#4f46e5"></span>
</div>
</div>
<div id="pac-obs-recepcion" class="d-none mt-2">
<div style="background:#fffbeb;border-left:3px solid #f59e0b;border-radius:4px;padding:7px 10px;font-size:.8rem">
<div style="font-weight:600;color:#92400e;margin-bottom:2px"><i class="fas fa-comment-alt me-1"></i>Nota de recepción</div>
<div id="pac-obs-recepcion-texto" style="color:#78350f;white-space:pre-line"></div>
</div>
</div>
<div id="bloque-pac-sin" class="text-muted small">
<i class="fas fa-info-circle me-1"></i>Sin paciente vinculado
</div>
</div>
<!-- ── Exámenes ── -->
<div class="ficha-sec">
<div class="ficha-sec-hdr"><i class="fas fa-vial"></i>Exámenes solicitados</div>
<div id="lista-examenes-ficha">
<span class="text-muted small">Sin exámenes registrados</span>
</div>
</div>
<!-- ── Formulario embebido (iframe, modo link) ── -->
<div class="ficha-sec d-none" id="sec-form-embebido">
<div class="ficha-sec-hdr"><i class="fas fa-file-alt"></i>Formulario de consentimiento</div>
<div id="form-embebido-loading" class="text-muted small py-2">
<i class="fas fa-spinner fa-spin me-1"></i>Cargando formulario...
</div>
<iframe id="form-embebido-iframe" src="" frameborder="0"
style="width:100%;min-height:520px;border-radius:8px;border:1px solid #e2e8f0;display:none"
onload="this.style.display='block';document.getElementById('form-embebido-loading').style.display='none'">
</iframe>
</div>
<!-- ── Tomas progresivas pendientes de visita anterior ── -->
<div class="ficha-sec" id="sec-tomas-prev">
<div class="tomas-prev-hdr">
<i class="fas fa-hourglass-half"></i>
Tomas pendientes de visita anterior
</div>
<div class="tomas-prev-list" id="lista-tomas-prev"></div>
</div>
<!-- ── Consentimientos ── -->
<div class="ficha-sec" id="sec-consent">
<div class="ficha-sec-hdr"><i class="fas fa-file-signature"></i>Consentimientos informados</div>
<div id="bloqueo-banner" class="bloqueo-banner d-none">
<i class="fas fa-lock"></i>
<span>Hay consentimientos pendientes. No puede <strong>finalizar</strong> hasta que estén <strong>firmados</strong> o <strong>rechazados</strong>.</span>
</div>
<div id="lista-consent"></div>
<div id="sin-consent" class="text-muted small">
<i class="fas fa-check-circle text-success me-1"></i>No se requieren consentimientos
</div>
<div id="btn-anexar-wrap" class="d-none mt-2">
<button type="button" class="btn btn-outline-secondary btn-sm w-100" onclick="_abrirAnexarFormulario()">
<i class="fas fa-plus me-1"></i>Agregar formulario adicional
</button>
</div>
</div>
<!-- ── Muestras (solo estaciones tipo muestras) ── -->
<div class="ficha-sec d-none" id="sec-muestras">
<div class="ficha-sec-hdr">
<i class="fas fa-flask" style="color:#ea580c"></i>
Recepción de muestras
</div>
<div id="muestras-summary" class="muestras-summary"></div>
<div id="lista-muestras"></div>
</div>
<!-- ── Comentarios ── -->
<div class="ficha-sec" id="sec-comentarios">
<div class="ficha-sec-hdr"><i class="fas fa-comments" style="color:#8b5cf6"></i>Comentarios del seguimiento</div>
<div style="display:flex;gap:8px;margin-bottom:10px">
<textarea id="inp-comentario" class="form-control form-control-sm"
rows="2" placeholder="Añade un comentario sobre este turno…"
maxlength="500" style="resize:vertical"
oninput="draftGuardar()"></textarea>
<button class="btn btn-sm btn-primary" onclick="guardarComentario()"
style="height:fit-content;align-self:flex-end">
<i class="fas fa-paper-plane"></i>
</button>
</div>
<div id="lista-comentarios" class="text-muted small py-2">
<i class="fas fa-spinner fa-spin me-1"></i>Cargando comentarios…
</div>
</div>
</div><!-- /ficha-inner -->
<!-- ── Barra de acciones sticky ── -->
<div class="ficha-acciones">
<!-- Aviso de consentimientos pendientes -->
<div id="accion-consent-warn" class="accion-consent-warn d-none">
<i class="fas fa-exclamation-triangle"></i>
<span id="accion-warn-txt">Consentimientos pendientes</span> — No puede finalizar la atención
</div>
<!-- Botón primario -->
<div class="accion-primary-row">
<button class="btn-accion-primary azul" id="btn-iniciar" onclick="iniciarAtencion()">
<i class="fas fa-volume-up"></i>Anunciar turno
</button>
<button class="btn-accion-primary verde d-none" id="btn-finalizar" onclick="finalizarAtencion()">
<i class="fas fa-flag-checkered"></i>Finalizar atención
</button>
</div>
<!-- Botones secundarios -->
<div class="accion-secondary-row">
<button class="btn-accion-sec info d-none" id="btn-rellamar" onclick="rellamarTurno()">
<i class="fas fa-bullhorn"></i>Re-llamar
</button>
<button class="btn-accion-sec warning d-none" id="btn-regresar" onclick="regresarCola()">
<i class="fas fa-undo"></i>Regresar cola
</button>
<button class="btn-accion-sec warning d-none" id="btn-devolver" onclick="devolverRecepcion()">
<i class="fas fa-reply"></i>A recepción
</button>
<button class="btn-accion-sec d-none" id="btn-espera" onclick="_ponerEnEspera()"
style="color:#c2410c;border-color:#fed7aa">
<i class="fas fa-hourglass-half"></i>Mandar a espera
</button>
<button class="btn-accion-sec danger" id="btn-ausente" onclick="marcarAusente()">
<i class="fas fa-user-slash"></i>Ausente
</button>
</div>
</div>
</div><!-- /ficha-turno -->
</div><!-- /lugar-ficha -->
</div><!-- /lugar-layout -->
</main>
<!-- ── Modal datos + historial del paciente ─────────────────────── -->
<div id="modal-pac-lugar" onclick="if(event.target===this)cerrarModalPaciente()">
<div class="mpac-dialog">
<div class="mpac-hdr">
<span class="mpac-hdr-title"><i class="fas fa-user-circle text-primary"></i><span id="mpac-nombre-titulo">Paciente</span></span>
<button class="mpac-close" onclick="cerrarModalPaciente()"><i class="fas fa-times"></i></button>
</div>
<div class="mpac-body">
<!-- Datos básicos -->
<div class="mpac-sec-title"><i class="fas fa-id-card"></i>Información del paciente</div>
<div id="mpac-datos">
<div class="mpac-dato"><span class="lbl">Nombre</span><span class="val" id="mpac-val-nombre">—</span></div>
<div class="mpac-dato"><span class="lbl">Documento</span><span class="val" id="mpac-val-doc">—</span></div>
<div class="mpac-dato"><span class="lbl">Fecha nac.</span><span class="val" id="mpac-val-fec">—</span></div>
<div class="mpac-dato"><span class="lbl">Celular</span><span class="val" id="mpac-val-cel">—</span></div>
<div class="mpac-dato" id="mpac-row-medico" style="display:none">
<span class="lbl">Médico</span><span class="val" id="mpac-val-medico" style="color:#4f46e5">—</span>
</div>
<div id="mpac-badge-emb" class="d-none mt-1">
<span class="badge text-bg-danger"><i class="fas fa-baby me-1"></i>Paciente embarazada</span>
</div>
<div class="mt-2">
<button class="btn btn-sm btn-outline-secondary" onclick="mpacEditar(true)"
id="mpac-btn-editar" style="font-size:.74rem">
<i class="fas fa-pen me-1"></i>Corregir datos
</button>
</div>
</div>
<!-- ── Edición de la ficha ── -->
<div id="mpac-form" class="d-none">
<div class="mpac-aviso">
<i class="fas fa-info-circle me-1"></i>
Lo que corrija aquí cambia la ficha del paciente <b>en todo el sistema</b>.
</div>
<label class="mpac-lbl">Celular</label>
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-tel" placeholder="3001234567">
<label class="mpac-lbl">Dirección</label>
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-dir">
<label class="mpac-lbl">EPS</label>
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-eps">
<!-- Datos de identidad: requieren un paso deliberado -->
<div id="mpac-identidad-lock" class="mpac-lock">
<div class="mpac-lock-txt">
<i class="fas fa-lock me-1"></i>
Nombre, documento y fecha de nacimiento identifican al paciente en toda su
historia clínica.
</div>
<button class="btn btn-sm btn-outline-warning w-100" onclick="mpacDesbloquearIdentidad()"
style="font-size:.74rem">
<i class="fas fa-unlock me-1"></i>Corregir también estos datos
</button>
</div>
<div id="mpac-identidad" class="d-none">
<label class="mpac-lbl">Nombre completo</label>
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-nombre">
<div class="d-flex gap-2 mb-2">
<div style="width:90px">
<label class="mpac-lbl">Tipo</label>
<select class="form-select form-select-sm" id="mpac-in-tipodoc">
<option>CC</option><option>TI</option><option>RC</option>
<option>CE</option><option>PA</option><option>NIT</option>
</select>
</div>
<div class="flex-grow-1">
<label class="mpac-lbl">Nº documento</label>
<input type="text" class="form-control form-control-sm" id="mpac-in-doc">
</div>
</div>
<label class="mpac-lbl">Fecha de nacimiento</label>
<input type="date" class="form-control form-control-sm mb-2" id="mpac-in-fnac">
</div>
<div id="mpac-aviso-firmados" class="mpac-aviso-firmados d-none">
<i class="fas fa-exclamation-triangle me-1"></i>
Este turno ya tiene documentos firmados. Corregir la ficha
<b>no modifica lo que ya se firmó</b>: si un documento salió con datos
equivocados, hay que emitirlo de nuevo.
</div>
<div id="mpac-msg" class="small mb-2"></div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary flex-grow-1" onclick="mpacGuardarFicha()" id="mpac-btn-guardar">
<i class="fas fa-save me-1"></i>Guardar
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="mpacEditar(false)">Cancelar</button>
</div>
</div>
<!-- Historial -->
<div class="mpac-sec-title"><i class="fas fa-history" style="color:#6366f1"></i>Historial de visitas</div>
<div id="mpac-historial-content">
<div class="text-center text-muted py-3 small">
<i class="fas fa-spinner fa-spin me-1"></i>Cargando historial…
</div>
</div>
</div>
</div>
</div>
<!-- ── Modal: quitar consentimiento ───────────────────────────── -->
<div id="modal-quitar-consent" style="
display:none;position:fixed;inset:0;z-index:9100;
background:rgba(0,0,0,.55);align-items:center;justify-content:center;padding:16px">
<div style="background:#fff;border-radius:14px;box-shadow:0 8px 40px rgba(0,0,0,.25);
width:min(96vw,480px);padding:24px;display:flex;flex-direction:column;gap:14px">
<div style="font-weight:700;font-size:.95rem;color:#1e293b">
<i class="fas fa-trash me-2 text-danger"></i>Quitar consentimiento
</div>
<div style="font-size:.85rem;color:#475569">
<strong id="mqc-nombre"></strong>
</div>
<div>
<label style="font-size:.82rem;font-weight:600;color:#374151;display:block;margin-bottom:4px">
Motivo <span style="color:#dc2626">*</span>
</label>
<textarea id="mqc-motivo" rows="3" placeholder="Explique por qué se quita este consentimiento…"
style="width:100%;border:1px solid #d1d5db;border-radius:8px;padding:8px 10px;
font-size:.85rem;resize:vertical;font-family:inherit"></textarea>
<div id="mqc-error" style="color:#dc2626;font-size:.78rem;margin-top:4px;display:none"></div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button onclick="_cerrarModalQuitarConsent()"
style="padding:7px 18px;border:1px solid #d1d5db;border-radius:8px;
background:#fff;cursor:pointer;font-size:.85rem">Cancelar</button>
<button id="mqc-btn-confirmar" onclick="_confirmarQuitarConsent()"
style="padding:7px 18px;border:none;border-radius:8px;
background:#dc2626;color:#fff;cursor:pointer;font-weight:600;font-size:.85rem">
<i class="fas fa-trash me-1"></i>Quitar
</button>
</div>
</div>
</div>
<!-- ── Bar encuesta post-finalizar ─────────────────────────────── -->
<div id="bar-encuesta" style="
display:none;position:fixed;bottom:0;left:0;right:0;z-index:9100;
background:#0f4c81;color:#fff;padding:14px 20px;
display:none;align-items:center;gap:14px;flex-wrap:wrap;
box-shadow:0 -4px 20px rgba(0,0,0,.25)">
<i class="fas fa-star" style="font-size:1.2rem;color:#fbbf24;flex-shrink:0"></i>
<span id="bar-encuesta-txt" style="flex:1;font-size:.95rem;font-weight:500"></span>
<button onclick="_enviarEncuesta()" id="btn-enc-enviar"
style="background:#22c55e;color:#fff;border:none;border-radius:8px;
padding:8px 18px;font-weight:600;cursor:pointer;white-space:nowrap">
<i class="fas fa-paper-plane me-1"></i>Enviar encuesta
</button>
<button onclick="_cerrarEncuestaBar()"
style="background:rgba(255,255,255,.15);color:#fff;border:none;border-radius:8px;
padding:8px 14px;cursor:pointer;white-space:nowrap">
Omitir
</button>
</div>
<!-- ── Modal de consentimiento embebido (modo embebido) ────────── -->
<div id="modal-consentimiento" style="
display:none;position:fixed;inset:0;z-index:9000;
background:rgba(0,0,0,.55);align-items:center;justify-content:center;padding:16px">
<div style="
background:#fff;border-radius:14px;box-shadow:0 8px 40px rgba(0,0,0,.25);
width:min(96vw,860px);max-height:92vh;
display:flex;flex-direction:column;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;
padding:12px 18px;border-bottom:1px solid #e2e8f0;flex-shrink:0">
<span style="font-weight:700;font-size:.95rem;color:#1e293b">
<i class="fas fa-file-signature me-2 text-primary"></i>Formulario de consentimiento
</span>
<button id="btn-cerrar-modal-consent" onclick="cerrarModalConsentimiento()"
style="background:none;border:none;font-size:1.2rem;color:#94a3b8;cursor:pointer;padding:2px 6px"
title="Cerrar">
<i class="fas fa-times"></i>
</button>
</div>
<div id="modal-consent-loading" style="padding:32px;text-align:center;color:#64748b;flex-shrink:0">
<i class="fas fa-spinner fa-spin fa-2x mb-2 d-block"></i>Cargando formulario…
</div>
<iframe id="modal-consent-iframe" src="" frameborder="0"
style="flex:1;border:none;display:none;min-height:60vh"
onload="document.getElementById('modal-consent-loading').style.display='none';this.style.display='block'">
</iframe>
</div>
</div>
<script>
// ── Estado global ─────────────────────────────────────────────
const LUGAR_FORZADO = <?= $lugarForzado ?: 0 ?>;
let lugarId = <?= $lugarIdParam ?: 0 ?>;
let turnoActivo = null;
let tieneConsent = false;
let hayPendientes = false;
let pollingColaId = null;
let pollingConsentId = null;
let _consentTokenCache = null;
let _pacienteActivo = null;
let _tomaProgresivaActiva = false;
let hayMuestrasPendientes = false;
let _solicitudActiva = null;
let _muestrasActivas = [];
const _formulariosList = <?= json_encode($formulariosList, JSON_UNESCAPED_UNICODE) ?>;
const API = '<?= BASE_URL ?>modules/turnero/api/';
const BASE_WA = '<?= BASE_URL ?>';
const LUGAR_NOMBRE = document.getElementById('lbl-lugar-titulo')?.textContent || '<?= addslashes($lugarNombre) ?>';
const LUGAR_FORM_MODO = '<?= $lugarFormModo ?>';
const LUGAR_TIPO = '<?= $lugarTipo ?>';
const ESP_LUGARES = <?= json_encode(array_values($especialidades)) ?>;
// ── Arranque ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
if (lugarId) iniciarPuesto();
});
function iniciarPuesto() {
recuperarTurnoActivo();
cargarCola();
_cargarEspecialidades();
pollingColaId = setInterval(cargarCola, 7000);
setInterval(_cargarEspecialidades, 30000);
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
clearInterval(pollingColaId);
clearInterval(pollingConsentId);
pollingColaId = pollingConsentId = null;
} else if (lugarId) {
cargarCola();
_cargarEspecialidades();
pollingColaId = setInterval(cargarCola, 7000);
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && lugarId &&
document.getElementById('overlay-selector').style.display !== 'none') {
document.getElementById('overlay-selector').style.display = 'none';
}
});
}
async function recuperarTurnoActivo() {
try {
const res = await fetch(`${API}get_cola.php?area=lugar&lugar_id=${lugarId}`);
const json = await res.json();
if (json.ok && json.activo) await abrirFicha(json.activo);
} catch (_) {}
}
// ── Selector de lugar ─────────────────────────────────────────
function confirmarLugar() {
const sel = document.getElementById('sel-lugar-init');
const id = parseInt(sel.value);
if (!id) return mostrarError('Seleccione un lugar.');
if (LUGAR_FORZADO && id !== LUGAR_FORZADO) return mostrarError('No tiene acceso a esa estación.');
lugarId = id;
const txt = sel.options[sel.selectedIndex].text;
document.getElementById('lbl-lugar-titulo').textContent = txt;
document.getElementById('overlay-selector').style.display = 'none';
document.title = txt + ' — Turnero';
iniciarPuesto();
}
function cambiarLugar() {
clearInterval(pollingColaId);
clearInterval(pollingConsentId);
turnoActivo = null;
resetFicha();
document.getElementById('btn-cancelar-selector').style.display = '';
document.getElementById('overlay-selector').style.display = 'flex';
}
// ── Cola ──────────────────────────────────────────────────────
async function cargarCola() {
if (!lugarId) return;
try {
const res = await fetch(`${API}get_cola.php?area=lugar&lugar_id=${lugarId}`);
const json = await res.json();
if (!json.ok) return;
renderCola(json);
} catch (_) {}
}
let _colaMap = new Map();
function tiempoEspera(iso) {
if (!iso) return null;
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
if (mins < 1) return { txt: 'recién', cls: 'ok' };
if (mins < 10) return { txt: mins + ' min', cls: 'ok' };
if (mins < 25) return { txt: mins + ' min', cls: 'warn' };
return { txt: mins + ' min', cls: 'crit' };
}
function renderCola(snap) {
_renderEsperaMuestras(snap.en_espera_muestra || []);
const items = snap.cola || [];
const enEspera = items.filter(t => t.estado === 'en_espera_lugar');
const enServ = items.filter(t => t.estado === 'en_servicio');
document.getElementById('badge-count').textContent = enEspera.length + ' espera';
const badgeServ = document.getElementById('badge-servicio');
if (enServ.length) {
badgeServ.textContent = enServ.length + ' servicio';
badgeServ.classList.remove('d-none');
} else {
badgeServ.classList.add('d-none');
}
_colaMap = new Map(items.map(t => [t.id, t]));
const lista = document.getElementById('cola-items');
if (!items.length) {
lista.innerHTML = `<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>Cola vacía</div>`;
return;
}
let html = '';
if (enServ.length) {
html += `<div class="cola-section-sep"><i class="fas fa-stethoscope me-1"></i>En servicio</div>`;
html += enServ.map(t => renderColaCard(t)).join('');
}
if (enEspera.length) {
if (enServ.length) html += `<div class="cola-section-sep" style="margin-top:.4rem"><i class="fas fa-clock me-1"></i>En espera</div>`;
html += enEspera.map(t => renderColaCard(t)).join('');
}
lista.innerHTML = html;
}
function renderColaCard(t) {
const enServicio = t.estado === 'en_servicio';
const esActivo = turnoActivo?.id == t.id;
const esMuestra = t.solo_muestras == 1;
const enTomaProg = t.en_toma_progresiva == 1;
const sigTomaAt = t.siguiente_toma_at || null;
const tiempo = tiempoEspera(t.creado_at);
const tiempoBadge = tiempo
? `<span class="tiempo-chip ${tiempo.cls}">${escHtml(tiempo.txt)}</span>`
: '';
const muestraBadge = esMuestra
? `<span class="muestra-chip"><i class="fas fa-plus me-1"></i>MUESTRAS</span>`
: '';
const tomaBadge = enTomaProg
? `<span class="toma-prog-chip ok" data-toma-at="${escHtml(sigTomaAt || '')}" data-turno-id="${t.id}">
<i class="fas fa-syringe me-1"></i><span class="toma-cd-txt">${sigTomaAt ? '...' : '¡Ahora!'}</span>
</span>`
: '';
const badgeRow = (tiempoBadge || muestraBadge || tomaBadge)
? `<div class="t-badge-row">${tiempoBadge}${muestraBadge}${tomaBadge}</div>`
: '';
const rightEl = enServicio
? `<div class="card-en-servicio-pill"><i class="fas fa-stethoscope"></i>EN<br>SERV.</div>`
: `<button onclick="event.stopPropagation();llamarTurnoEspecifico(${t.id})"
title="Anunciar turno ${escHtml(t.codigo)}" class="card-bell-btn">
<i class="fas fa-bell"></i>
</button>`;
let bg = esMuestra ? 'border:2px solid #ea580c!important;background:#fff7ed!important' : '';
const prioBg = esMuestra ? '#ea580c' : (t.prioridad_color || '#6366f1');
const prioLbl = esMuestra ? '<i class="fas fa-plus"></i>' : escHtml(t.prioridad_codigo || '?');
const tomaClass = enTomaProg ? ' toma-prog' : '';
return `<div class="cola-card ${enServicio ? 'en-servicio-card' : ''} ${esActivo ? 'activo' : ''}${tomaClass}"
style="${bg}" onclick="seleccionarSinLlamar(${t.id})">
<div class="prio-dot" style="background:${prioBg}">${prioLbl}</div>
<div class="turno-info">
<div class="cod">${escHtml(t.codigo)}</div>
<div class="pac">${escHtml(t.paciente_nombre || 'Paciente')}</div>
${badgeRow}
</div>
${rightEl}
</div>`;
}
// ── Seleccionar sin llamar ────────────────────────────────────
async function seleccionarSinLlamar(turnoId) {
let t = _colaMap.get(turnoId);
if (!t) {
// Turno no está en cola (p.ej. en espera de siguiente toma) — cargar directamente
try {
const r = await fetch(`${API}get_turno.php?id=${turnoId}`);
const j = await r.json();
if (!j.ok || !j.turno) return;
t = j.turno;
} catch (_) { return; }
}
turnoActivo = t;
mostrarCabeceraTurno(t);
await cargarFichaSolicitud(t.id);
clearInterval(pollingConsentId);
// También para solo entrega de muestras: a esos turnos les corresponde el
// formulario del puesto, así que la lista tiene que refrescarse igual o al
// firmar no se vería el cambio.
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
cargarFormEmbebido(t.id);
mostrarFichaMobile();
}
// ── Llamar ────────────────────────────────────────────────────
async function llamarSiguiente() { await _llamar({}); }
async function llamarTurnoEspecifico(turnoId) { await _llamar({ turno_id: turnoId }); }
async function _llamar(extra) {
if (!lugarId) { mostrarError('Primero seleccione un lugar.'); return; }
const btn = document.getElementById('btn-llamar');
btn.disabled = true;
mostrarToast('Consultando siguiente turno…', 'info', 1500);
try {
const res = await fetch(API + 'llamar_turno.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ area: 'lugar', lugar_id: lugarId, ...extra }),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
if (!json.turno) { mostrarToast('Cola vacía — no hay turnos en espera.', 'warn', 3000); return; }
mostrarLlamando(json.turno.codigo);
await abrirFicha(json.turno);
cargarCola();
} catch (err) {
mostrarError(err.message);
} finally {
btn.disabled = false;
}
}
// ── Abrir ficha ───────────────────────────────────────────────
function _esSoloEntrega(turno) {
return turno.solo_muestras == 1 || turno.prioridad_codigo === 'F';
}
async function abrirFicha(turno) {
turnoActivo = turno;
mostrarCabeceraTurno(turno);
await cargarFichaSolicitud(turno.id);
clearInterval(pollingConsentId);
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
cargarFormEmbebido(turno.id);
mostrarFichaMobile();
}
function mostrarCabeceraTurno(turno) {
document.getElementById('ficha-placeholder').classList.add('d-none');
const fichaEl = document.getElementById('ficha-turno');
fichaEl.classList.remove('d-none');
fichaEl.style.display = 'flex';
document.getElementById('ficha-codigo').textContent = turno.codigo;
document.getElementById('ficha-prio-nombre').textContent = turno.prioridad_nombre || '';
const dot = document.getElementById('ficha-prio-dot');
dot.textContent = turno.prioridad_codigo || '—';
dot.style.background = turno.prioridad_color || '#6366f1';
const badgeCod = document.getElementById('badge-turno-activo');
badgeCod.textContent = turno.codigo;
badgeCod.classList.remove('d-none');
const nomHdr = document.getElementById('ficha-pac-nombre-hdr');
nomHdr.textContent = '';
nomHdr.classList.add('loading');
document.getElementById('ficha-mobile-cod').textContent = turno.codigo;
actualizarEstadoBadge(turno.estado);
}
// ── Cargar solicitud ──────────────────────────────────────────
async function cargarFichaSolicitud(turnoId) {
try {
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}&incluir_solicitud=1`);
const json = await res.json();
if (!json.ok) return;
const sol = json.solicitud;
const pac = json.paciente;
_solicitudActiva = sol || null;
// Número de orden (F-... azul, D-... verde)
const ordenEl = document.getElementById('ficha-orden');
const ordenValEl = document.getElementById('ficha-orden-val');
if (sol && sol.numero_orden) {
ordenValEl.textContent = sol.numero_orden;
const isDom = sol.numero_orden.startsWith('D-');
ordenEl.style.background = isDom ? '#f0fdf4' : '#eff6ff';
ordenEl.style.color = isDom ? '#15803d' : '#1d4ed8';
ordenEl.style.borderColor = isDom ? '#86efac' : '#bfdbfe';
ordenEl.classList.remove('d-none');
} else {
ordenEl.classList.add('d-none');
}
const exams = json.examenes || [];
const consts = json.consentimientos || [];
// Nombre en el header
const nomHdr = document.getElementById('ficha-pac-nombre-hdr');
nomHdr.classList.remove('loading');
nomHdr.textContent = pac ? (pac.nombre_completo || pac.full_name || '—') : '—';
// Badge solo entrega de muestras
const esSoloMuestras = !!(sol && sol.solo_muestras == 1);
document.getElementById('badge-solo-muestras')?.classList.toggle('d-none', !esSoloMuestras);
// La sección de formularios ya NO se esconde por ser solo entrega de
// muestras. Esos turnos no traen exámenes, pero sí les corresponde el
// formulario del puesto (F-LAB-08, Datos Toma de Muestras): el backend
// se lo venía creando y la interfaz lo ocultaba, así que se acumulaban
// creados y sin firmar. Quién se muestra lo decide renderConsentimientos
// según lo que realmente haya que llenar.
const secConsent = document.getElementById('sec-consent');
const secForm = document.getElementById('sec-form-embebido');
if (secConsent) secConsent.classList.remove('d-none');
if (secForm) secForm.classList.add('d-none');
// Embarazada
const badgeEmb = document.getElementById('pac-embarazada');
if (badgeEmb) badgeEmb.classList.toggle('d-none', !(sol && sol.embarazada == 1));
// Médico
const medDiv = document.getElementById('pac-medico');
if (medDiv) {
const tieneMedico = sol && sol.medico_nombre;
medDiv.classList.toggle('d-none', !tieneMedico);
if (tieneMedico) {
const esp = sol.medico_especialidad ? ' · ' + sol.medico_especialidad : '';
document.getElementById('pac-medico-nombre').textContent = sol.medico_nombre + esp;
}
}
// Nota de recepción
const obsEl = document.getElementById('pac-obs-recepcion');
if (obsEl) {
const obs = sol?.observaciones?.trim();
obsEl.classList.toggle('d-none', !obs);
if (obs) document.getElementById('pac-obs-recepcion-texto').textContent = obs;
}
if (pac) {
_pacienteActivo = pac;
document.getElementById('bloque-pac-info').style.display = '';
document.getElementById('bloque-pac-sin').classList.add('d-none');
document.getElementById('pac-nombre').textContent =
pac.nombre_completo || pac.full_name || '—';
document.getElementById('pac-doc').textContent =
((pac.tipo_documento || '') + ' ' + (pac.numero_documento || pac.documento || '')).trim() || '—';
document.getElementById('pac-fec').textContent = pac.fecha_nacimiento || '—';
document.getElementById('pac-cel').textContent = pac.telefono || pac.celular || '—';
document.getElementById('btn-ver-paciente').classList.remove('d-none');
} else {
_pacienteActivo = null;
document.getElementById('bloque-pac-info').style.display = 'none';
document.getElementById('bloque-pac-sin').classList.remove('d-none');
document.getElementById('btn-ver-paciente').classList.add('d-none');
}
// Exámenes
const listaEx = document.getElementById('lista-examenes-ficha');
if (exams.length) {
listaEx.innerHTML = exams.map(e =>
`<span class="exam-pill"><i class="fas fa-vial"></i>${escHtml(e.codigo)} ${escHtml(e.nombre)}</span>`
).join('');
} else {
listaEx.innerHTML = '<span class="text-muted small">Sin exámenes registrados</span>';
}
renderConsentimientos(consts);
renderMuestras(json.muestras || []);
cargarComentarios(turnoId);
draftRestaurar(turnoId);
cargarTomasPrevias(turnoId);
} catch (_) {}
}
// ── Consentimientos ───────────────────────────────────────────
const CONSENT_IC = {
firmado:'fa-check-circle', rechazado:'fa-ban',
enviado:'fa-envelope', visto:'fa-eye', pendiente:'fa-clock',
en_progreso:'fa-hourglass-half',
cerrado_anticipado:'fa-exclamation-triangle',
};
const CONSENT_LBL = {
firmado:'Firmado', rechazado:'Rechazado', enviado:'Enviado', visto:'Visto', pendiente:'Pendiente',
en_progreso:'En progreso', cerrado_anticipado:'Cerrado anticipadamente',
};
// ── Countdown en tarjetas de cola (toma progresiva) ──────────
const _tomaAlertados = new Set();
let _bannerTurnoId = null;
function _playAlarmaLugar() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
// 3 pulsos: grave-agudo, gain máximo, 0.3s cada tono
[440, 880, 440, 880, 440, 880].forEach(function(freq, i) {
const osc = ctx.createOscillator(), gain = ctx.createGain();
osc.connect(gain); gain.connect(ctx.destination);
osc.frequency.value = freq;
const t0 = ctx.currentTime + i * 0.32;
gain.gain.setValueAtTime(1.0, t0);
gain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.28);
osc.start(t0); osc.stop(t0 + 0.28);
});
} catch(e) {}
}
let _alarmaInterval = null;
function verPacienteToma() {
cerrarBannerToma();
if (_bannerTurnoId) seleccionarSinLlamar(_bannerTurnoId);
}
function cerrarBannerToma() {
const b = document.getElementById('banner-toma-alerta');
if (b) b.classList.remove('show');
clearInterval(_alarmaInterval);
_alarmaInterval = null;
}
setInterval(function() {
const ahora = Date.now();
document.querySelectorAll('.toma-prog-chip[data-toma-at]').forEach(function(chip) {
const at = chip.dataset.tomaAt;
const turnoId = chip.dataset.turnoId ? parseInt(chip.dataset.turnoId) : null;
const txt = chip.querySelector('.toma-cd-txt');
if (!at || !txt) return;
const diff = Math.floor((new Date(at.replace(' ','T')).getTime() - ahora) / 1000);
const card = chip.closest('.cola-card');
if (diff <= 0) {
txt.textContent = '¡Ahora!';
chip.className = 'toma-prog-chip crit';
if (card) card.classList.add('crit-card');
// Alerta única por turno
if (turnoId && !_tomaAlertados.has(turnoId)) {
_tomaAlertados.add(turnoId);
_bannerTurnoId = turnoId;
const pacNombre = card?.querySelector('.pac')?.textContent || 'Paciente';
const banner = document.getElementById('banner-toma-alerta');
const bannerTxt = document.getElementById('banner-toma-txt');
if (banner && bannerTxt) {
bannerTxt.textContent = '¡Hora de la siguiente muestra! — ' + pacNombre;
banner.classList.add('show');
}
_playAlarmaLugar();
if (navigator.vibrate) navigator.vibrate([400, 150, 400, 150, 400]);
clearInterval(_alarmaInterval);
_alarmaInterval = setInterval(function() {
const b = document.getElementById('banner-toma-alerta');
if (b && b.classList.contains('show')) {
_playAlarmaLugar();
if (navigator.vibrate) navigator.vibrate([400, 150, 400, 150, 400]);
} else {
clearInterval(_alarmaInterval);
_alarmaInterval = null;
}
}, 30000);
}
} else {
// Si el tiempo se actualizó (nueva toma programada), resetear la alerta
if (turnoId) _tomaAlertados.delete(turnoId);
const m = Math.floor(diff / 60), s = diff % 60;
txt.textContent = m + ':' + String(s).padStart(2,'0');
chip.className = diff < 120 ? 'toma-prog-chip warn' : 'toma-prog-chip ok';
if (card) card.classList.remove('crit-card');
}
});
}, 1000);
// ── Tomas progresivas: actualiza countdowns cada segundo ──────
let _tomaTimers = {}; // consent_id → { siguiente_toma_at, es_alerta }
setInterval(() => {
const ahora = Date.now();
for (const [cid, info] of Object.entries(_tomaTimers)) {
const row = document.querySelector(`[data-consent-id="${cid}"]`);
if (!row) continue;
const cdEl = row.querySelector('.toma-countdown');
if (!cdEl) continue;
if (!info.siguiente_toma_at) { cdEl.textContent = ''; continue; }
const diff = Math.floor((info.siguiente_toma_at - ahora) / 1000);
if (diff <= 0) {
cdEl.textContent = '¡Hora de toma!';
row.classList.add('alerta');
if (!info.alertado) {
info.alertado = true;
_playAlarmaLugar();
}
} else {
row.classList.remove('alerta');
const m = Math.floor(diff / 60), s = diff % 60;
cdEl.textContent = `próx. toma en ${m}:${String(s).padStart(2,'0')}`;
}
}
}, 1000);
function _syncBtnFinalizar() {
const btnFin = document.getElementById('btn-finalizar');
const warnStrip = document.getElementById('accion-consent-warn');
const warnTxt = document.getElementById('accion-warn-txt');
const bloqueado = hayPendientes || hayMuestrasPendientes;
if (btnFin) btnFin.disabled = bloqueado;
if (!warnStrip || !warnTxt) return;
if (bloqueado) {
const msgs = [];
if (hayPendientes) msgs.push('consentimientos pendientes');
if (hayMuestrasPendientes) msgs.push('muestras sin decisión');
warnTxt.textContent = msgs.join(' · ');
warnStrip.classList.remove('d-none');
} else {
warnStrip.classList.add('d-none');
}
}
function renderConsentimientos(lista) {
// Mostrar consentimientos de examen (origen null) + los de esta estación
lista = lista.filter(c => !c.origen_lugar_id || c.origen_lugar_id == lugarId);
tieneConsent = lista.length > 0;
hayPendientes = !_esSoloEntrega(turnoActivo) &&
lista.some(c => !['firmado','rechazado','cerrado_anticipado'].includes(c.estado));
// en_progreso bloquea finalizar pero no muestra aviso de "pendiente sin acción"
const pendCount = lista.filter(c => ['pendiente','enviado','visto'].includes(c.estado)).length;
const listEl = document.getElementById('lista-consent');
const sinEl = document.getElementById('sin-consent');
const banner = document.getElementById('bloqueo-banner');
const btnFin = document.getElementById('btn-finalizar');
_syncBtnFinalizar();
if (!tieneConsent) {
listEl.innerHTML = '';
sinEl.classList.remove('d-none');
banner.classList.add('d-none');
return;
}
sinEl.classList.add('d-none');
banner.classList.toggle('d-none', !hayPendientes);
// Actualizar mapa de timers para tomas progresivas (preservar alertado si ya sonó)
const _prevTimers = _tomaTimers;
_tomaTimers = {};
lista.forEach(c => {
if (c.es_toma_progresiva && c.estado === 'en_progreso') {
const prev = _prevTimers[c.id];
const newAt = c.siguiente_toma_at ? new Date(c.siguiente_toma_at.replace(' ', 'T')).getTime() : null;
// Si la siguiente_toma_at cambió (firmó una toma), resetear alertado para el nuevo tiempo
const sameTime = prev && prev.siguiente_toma_at === newAt;
_tomaTimers[c.id] = {
siguiente_toma_at: newAt,
alertado: sameTime ? (prev.alertado || false) : false,
};
}
});
// Mostrar botón "Mandar a espera" si hay toma progresiva activa y estado en_servicio
const btnEsp = document.getElementById('btn-espera');
if (btnEsp) {
const hasProg = Object.keys(_tomaTimers).length > 0;
btnEsp.classList.toggle('d-none', !(hasProg && turnoActivo?.estado === 'en_servicio'));
}
listEl.innerHTML = lista.map(c => {
const ya = ['firmado','rechazado','cerrado_anticipado'].includes(c.estado);
const ico = CONSENT_IC[c.estado] || 'fa-clock';
const label = CONSENT_LBL[c.estado] || c.estado;
const token = escHtml(c.token || '');
const nomJs = JSON.stringify(c.formulario_nombre || 'Consentimiento');
const idJs = parseInt(c.id) || 0;
const tId = parseInt(c.turno_id) || (turnoActivo?.id) || 0;
const fId = parseInt(c.formulario_id) || 0;
// Badge de progreso para tomas progresivas
let progresoBadge = '';
if (c.es_toma_progresiva && c.tomas_total > 0) {
progresoBadge = `<span class="toma-progreso">${c.tomas_firmadas}/${c.tomas_total}</span>`;
}
const countdownEl = (c.es_toma_progresiva && c.estado === 'en_progreso')
? `<span class="toma-countdown"></span>` : '';
// Botón ver
const btnVer = (ya && c.token)
? `<button class="btn btn-outline-secondary" title="Ver firmado"
onclick="_abrirModalConToken('${token}')">
<i class="fas fa-eye"></i> Ver
</button>` : '';
// Acción de firma según modo
let btnAccion = '';
const puedeFirmar = !ya || (c.es_toma_progresiva && c.estado === 'en_progreso');
if (puedeFirmar && c.token) {
if (c.es_toma_progresiva) {
// Toma progresiva: siempre abre el modal embebido para firmar la siguiente toma
btnAccion = `<button class="btn btn-primary" title="Registrar siguiente toma"
onclick="abrirTomaProgresiva('${token}', ${tId}, ${fId})">
<i class="fas fa-flask me-1"></i>Siguiente toma
</button>`;
} else if (LUGAR_FORM_MODO === 'embebido') {
const lblAccion = c.requiere_firma_paciente ? 'Firmar' : 'Completar';
btnAccion = `<button class="btn btn-primary" title="Abrir formulario"
onclick="abrirModalConsentimientoPorToken('${token}')">
<i class="fas fa-pen me-1"></i>${lblAccion}
</button>`;
} else {
btnAccion = `<button class="btn btn-outline-primary" title="Firmar aquí"
onclick="abrirFirmaPresencial('${token}', ${idJs}, ${nomJs.replace(/"/g,'&quot;')})">
<i class="fas fa-signature"></i> Firmar
</button>
${c.requiere_firma_paciente ? `<button class="btn btn-outline-success" title="Reenviar por WhatsApp"
onclick="reenviarConsentimiento(${tId})">
<i class="fab fa-whatsapp"></i> WA
</button>` : ''}`;
}
}
const btnQuitar = `<button class="btn btn-outline-danger" title="Quitar consentimiento"
onclick="_abrirModalQuitarConsent(${idJs}, ${nomJs.replace(/"/g,'&quot;')})">
<i class="fas fa-trash"></i>
</button>`;
const btnOlvidar = (ya && c.token && c.estado !== 'rechazado')
? `<button class="btn btn-outline-warning" title="Olvidar firma — permite re-firmar"
onclick="_olvidarConsentimiento('${token}', ${nomJs.replace(/"/g,'&quot;')})">
<i class="fas fa-undo"></i>
</button>` : '';
const motivoRow = (c.estado === 'cerrado_anticipado' && c.cierre_anticipado?.motivo)
? `<div style="font-size:.75rem;color:#92400e;background:#fef3c7;border-radius:4px;padding:3px 8px;margin-top:3px;width:100%">
<i class="fas fa-comment-alt me-1"></i>${escHtml(c.cierre_anticipado.motivo)}
</div>` : '';
return `<div class="consent-row ${c.estado}" data-consent-id="${c.id}" style="flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:.4rem;width:100%">
<i class="fas ${ico}"></i>
<span class="nom-form">${escHtml(c.formulario_nombre || 'Consentimiento')}</span>
${progresoBadge}
${countdownEl}
<span class="c-badge">${label}</span>
<div class="acciones-consent">${btnAccion}${btnVer}${btnOlvidar}${btnQuitar}</div>
</div>
${motivoRow}
</div>`;
}).join('');
}
// ── Olvidar (resetear) consentimiento ────────────────────────
async function _olvidarConsentimiento(token, nombre) {
if (!confirm(`¿Seguro que deseas descartar la firma de "${nombre}"?\nEl paciente podrá volver a firmarlo.`)) return;
try {
const r = await fetch(API + 'resetear_consentimiento.php', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ token })
});
const j = await r.json();
if (!j.ok) { mostrarError(j.error || 'Error al resetear'); return; }
if (turnoActivo?.id) actualizarConsentimientos(turnoActivo.id);
} catch(e) { mostrarError(e.message); }
}
// ── Quitar consentimiento ─────────────────────────────────────
let _mqcConsentId = null;
function _abrirModalQuitarConsent(consentId, formNombre) {
_mqcConsentId = consentId;
document.getElementById('mqc-nombre').textContent = formNombre;
document.getElementById('mqc-motivo').value = '';
document.getElementById('mqc-error').style.display = 'none';
document.getElementById('modal-quitar-consent').style.display = 'flex';
setTimeout(() => document.getElementById('mqc-motivo').focus(), 50);
}
function _cerrarModalQuitarConsent() {
document.getElementById('modal-quitar-consent').style.display = 'none';
_mqcConsentId = null;
}
async function _confirmarQuitarConsent() {
const motivo = document.getElementById('mqc-motivo').value.trim();
const errEl = document.getElementById('mqc-error');
if (!motivo) { errEl.textContent = 'El motivo es obligatorio.'; errEl.style.display = ''; return; }
const btn = document.getElementById('mqc-btn-confirmar');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Quitando…';
try {
const res = await fetch(`${API}quitar_consentimiento.php`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({consent_id: _mqcConsentId, motivo})
});
const json = await res.json();
if (!json.ok) { errEl.textContent = json.error || 'Error al quitar.'; errEl.style.display = ''; }
else {
_cerrarModalQuitarConsent();
if (turnoActivo?.id) actualizarConsentimientos(turnoActivo.id);
}
} catch(e) { errEl.textContent = 'Error de red.'; errEl.style.display = ''; }
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-trash me-1"></i>Quitar';
}
async function actualizarConsentimientos(turnoId) {
if (!turnoId) return;
try {
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}`);
const json = await res.json();
if (!json.ok) return;
renderConsentimientos(json.consentimientos || []);
} catch (_) {}
}
// ── Tomas progresivas pendientes de visita anterior ──────────
async function cargarTomasPrevias(turnoId) {
const sec = document.getElementById('sec-tomas-prev');
const list = document.getElementById('lista-tomas-prev');
sec.style.display = 'none';
list.innerHTML = '';
if (!turnoId) return;
try {
const res = await fetch(`${API}get_tomas_pendientes_paciente.php?turno_id=${turnoId}`);
const json = await res.json();
if (!json.ok || !json.tomas?.length) return;
list.innerHTML = json.tomas.map(t => {
const token = escHtml(t.token || '');
const fNom = escHtml(t.formulario_nombre || 'Toma');
const turno = escHtml(t.turno_codigo || '');
const fecha = t.turno_fecha ? escHtml(t.turno_fecha.slice(0,10)) : '';
const prog = t.tomas_total > 0
? `<span class="toma-progreso">${t.tomas_firmadas}/${t.tomas_total}</span>` : '';
return `<div class="toma-prev-row" data-consent-id="${t.id}" data-turno-id="${t.turno_id}">
<i class="fas fa-hourglass-half"></i>
<span class="nom-form">${fNom}</span>
${prog}
<span class="c-badge" style="font-size:.65rem;opacity:.7">Turno ${turno} · ${fecha}</span>
<div class="acciones-consent">
<button class="btn btn-primary" onclick="abrirTomaProgresiva('${token}',${t.turno_id},0)">
<i class="fas fa-flask me-1"></i>Continuar
</button>
<button class="btn btn-outline-secondary" title="Paciente no quiere continuar"
onclick="cancelarTomaPrevia(${t.id}, this)">
<i class="fas fa-ban me-1"></i>Cancelar
</button>
</div>
</div>`;
}).join('');
sec.style.display = '';
} catch (e) { mostrarError('No se pudieron cargar tomas previas: ' + e.message); }
}
async function cancelarTomaPrevia(consentId, btn) {
if (!confirm('¿El paciente no quiere continuar la toma? Se marcará como cancelada.')) return;
btn.disabled = true;
const row = document.querySelector(`.toma-prev-row[data-consent-id="${consentId}"]`);
const oldTId = row ? parseInt(row.dataset.turnoId) : 0;
try {
const res = await fetch(`${API}cancelar_toma_pendiente.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consentimiento_id: consentId, turno_id: turnoActivo?.id }),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); btn.disabled = false; return; }
if (oldTId) _tomaAlertados.delete(oldTId);
if (row) row.remove();
const list = document.getElementById('lista-tomas-prev');
if (list && !list.children.length) document.getElementById('sec-tomas-prev').style.display = 'none';
} catch (e) { mostrarError(e.message); btn.disabled = false; }
}
async function reenviarConsentimiento(turnoId) {
try {
const res = await fetch(API + 'send_consentimiento.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoId }),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
await actualizarConsentimientos(turnoId);
} catch (err) {
mostrarError(err.message);
}
}
async function abrirFirmaPresencial(token, consentId, nombreForm) {
try {
const res = await fetch(API + 'crear_envio_presencial.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ consent_token: token }),
});
const json = await res.json();
if (!json.ok || !json.form_token) throw new Error(json.error || 'Error al crear envío');
window.open(BASE_WA + 'form_cliente.php?t=' + encodeURIComponent(json.form_token), '_blank');
} catch (err) {
alert('No se pudo abrir el formulario: ' + err.message);
}
}
// ── Modal de consentimiento embebido ─────────────────────────
function _abrirModalConToken(token) {
const modal = document.getElementById('modal-consentimiento');
const iframe = document.getElementById('modal-consent-iframe');
const load = document.getElementById('modal-consent-loading');
iframe.style.display = 'none';
iframe.src = '';
load.style.display = '';
modal.style.display = 'flex';
setTimeout(() => {
iframe.src = `${BASE_WA}ver_formulario_enviado.php?token=${encodeURIComponent(token)}&embed=1&compact=1&zoom=1.1`;
}, 80);
}
function abrirModalConsentimientoPorToken(token) {
_abrirModalConToken(token);
}
// ── Toma progresiva: abre el modal con modo especial ──────────
function abrirTomaProgresiva(token, turnoId, formularioId) {
cerrarBannerToma();
_tomaAlertados.delete(turnoId);
const modal = document.getElementById('modal-consentimiento');
const iframe = document.getElementById('modal-consent-iframe');
const load = document.getElementById('modal-consent-loading');
iframe.style.display = 'none';
iframe.src = '';
load.style.display = '';
modal.style.display = 'flex';
_tomaProgresivaActiva = true;
_setBtnCerrarConsent(true);
setTimeout(() => {
iframe.src = `${BASE_WA}ver_formulario_enviado.php?token=${encodeURIComponent(token)}&embed=1&compact=1&modo=toma_progresiva&zoom=1.1`;
}, 80);
}
function _setBtnCerrarConsent(habilitado) {
const btn = document.getElementById('btn-cerrar-modal-consent');
if (!btn) return;
btn.disabled = !habilitado;
btn.style.opacity = habilitado ? '' : '.25';
btn.title = habilitado ? 'Cerrar' : 'Cerrando...';
}
function cerrarModalConsentimiento(forzar) {
_tomaProgresivaActiva = false;
_setBtnCerrarConsent(true);
const modal = document.getElementById('modal-consentimiento');
const iframe = document.getElementById('modal-consent-iframe');
modal.style.display = 'none';
iframe.src = '';
iframe.style.display = 'none';
document.getElementById('modal-consent-loading').style.display = '';
if (turnoActivo) actualizarConsentimientos(turnoActivo.id);
}
var _mpParentCdTimer = null;
function _mpParentNotificar(lbl) {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('⏰ Toma de muestras', { body: lbl || '¡Siguiente muestra!', icon: '/favicon.ico', requireInteraction: true });
}
}
function _mpParentCancelCd() {
if (_mpParentCdTimer) { clearInterval(_mpParentCdTimer); _mpParentCdTimer = null; }
}
window.addEventListener('message', function(e) {
// El formulario embebido pide corregir la ficha del paciente: se abre el
// modal directamente en modo edición, sin cerrar el formulario.
if (e.data && e.data.type === 'corregirPaciente') {
if (!_pacienteActivo) return;
abrirModalPaciente();
mpacEditar(true);
return;
}
if (e.data && e.data.type === 'tomaProgresivaIniciada') {
_tomaProgresivaActiva = true;
if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission();
// Countdown en el padre para que sobreviva si el modal se cierra
_mpParentCancelCd();
if (e.data.targetMs) {
var _pTarget = e.data.targetMs, _pLabel = e.data.label || '';
_mpParentCdTimer = setInterval(function() {
if (Date.now() >= _pTarget) {
_mpParentCancelCd();
_mpParentNotificar(_pLabel);
}
}, 5000);
}
}
if (e.data && e.data.type === 'tomaProgresivaAlerta') {
// El iframe ya disparó la alerta — cancelar el del padre para no duplicar
_mpParentCancelCd();
_mpParentNotificar(e.data.label || '');
}
if (e.data && e.data.type === 'turneroFirmado') {
_mpParentCancelCd();
_tomaProgresivaActiva = false;
_setBtnCerrarConsent(true);
cerrarModalConsentimiento(true);
cerrarBannerToma();
_tomaAlertados.clear();
mostrarToast('Tomas completadas ✓', 'success', 3000);
if (turnoActivo) {
actualizarConsentimientos(turnoActivo.id);
cargarTomasPrevias(turnoActivo.id); // limpia fila completada del turno anterior
}
}
});
// ── Formulario embebido (iframe, modo link) ───────────────────
async function cargarFormEmbebido(turnoId) {
if (LUGAR_FORM_MODO === 'embebido') return; // token llega vía renderConsentimientos → modal
const sec = document.getElementById('sec-form-embebido');
const iframe = document.getElementById('form-embebido-iframe');
const load = document.getElementById('form-embebido-loading');
if (!sec || !iframe) return;
sec.classList.remove('d-none');
iframe.style.display = 'none';
load.style.display = '';
try {
const res = await fetch(`${API}get_consent_token.php?turno_id=${turnoId}&lugar_id=${lugarId}`);
const json = await res.json();
if (json.ok && json.token) {
iframe.src = `${BASE_WA}ver_formulario_enviado.php?token=${encodeURIComponent(json.token)}`;
} else {
load.innerHTML = '<span class="text-danger small"><i class="fas fa-exclamation-triangle me-1"></i>' + escHtml(json.error || 'No hay formulario configurado') + '</span>';
}
} catch (e) {
load.innerHTML = '<span class="text-danger small"><i class="fas fa-exclamation-triangle me-1"></i>Error al cargar formulario</span>';
}
}
// ── Acciones del turno ────────────────────────────────────────
async function iniciarAtencion() {
if (!turnoActivo) return;
await _llamar({ turno_id: turnoActivo.id });
// Si _llamar no actualizó el estado (fallo silencioso), recuperar desde el servidor
if (turnoActivo && turnoActivo.estado !== 'en_servicio') {
await recuperarTurnoActivo();
}
}
async function finalizarAtencion() {
if (!turnoActivo) return;
if (hayPendientes) { mostrarError('Debe firmar todos los consentimientos antes de finalizar.'); return; }
if (hayMuestrasPendientes) { mostrarError('Debe marcar cada muestra como recibida o pendiente antes de finalizar.'); return; }
if (!confirm(`¿Finalizar atención del turno ${turnoActivo.codigo}?`)) return;
const _t = { id: turnoActivo.id, paciente_nombre: turnoActivo.paciente_nombre };
const ok = await cambiarEstadoTurno('finalizado');
if (ok) _ofrecerEncuesta(_t);
}
async function marcarAusente() {
if (!turnoActivo) return;
if (!confirm(`¿Marcar turno ${turnoActivo.codigo} como AUSENTE?`)) return;
await cambiarEstadoTurno('ausente');
}
async function regresarCola() {
if (!turnoActivo) return;
if (!confirm(`¿Regresar turno ${turnoActivo.codigo} a la cola?`)) return;
try {
const res = await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: 'en_espera_lugar', lugar_id: lugarId }),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
resetFicha();
cargarCola();
} catch (err) {
mostrarError(err.message);
}
}
async function devolverRecepcion() {
if (!turnoActivo) return;
if (!confirm(`¿Devolver turno ${turnoActivo.codigo} a recepción?`)) return;
try {
const res = await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: 'en_recepcion' }),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
mostrarToast('Turno devuelto a recepción', 'info', 3000);
resetFicha();
cargarCola();
} catch (err) {
mostrarError(err.message);
}
}
async function cambiarEstadoTurno(nuevoEstado) {
try {
const res = await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: nuevoEstado }),
});
const json = await res.json();
if (!json.ok) {
// Si el estado cambió por otro operador, refrescar UI en lugar de mostrar error técnico
if (res.status === 422) {
mostrarError('El estado del turno cambió desde otro puesto. Actualizando…');
await cargarCola();
await recuperarTurnoActivo();
} else {
mostrarError(json.error);
}
return false;
}
resetFicha();
cargarCola();
return true;
} catch (err) {
mostrarError(err.message);
return false;
}
}
// ── Encuesta post-finalizar ───────────────────────────────────
let _encuestaTurnoId = null, _encuestaTimer = null;
function _ofrecerEncuesta(turno) {
_encuestaTurnoId = turno.id;
const bar = document.getElementById('bar-encuesta');
document.getElementById('bar-encuesta-txt').textContent =
`¿Enviar encuesta de satisfacción a ${turno.paciente_nombre || 'el paciente'}?`;
document.getElementById('btn-enc-enviar').disabled = false;
document.getElementById('btn-enc-enviar').innerHTML = '<i class="fas fa-paper-plane me-1"></i>Enviar encuesta';
bar.style.display = 'flex';
clearTimeout(_encuestaTimer);
_encuestaTimer = setTimeout(_cerrarEncuestaBar, 12000);
}
async function _enviarEncuesta() {
if (!_encuestaTurnoId) return;
const btn = document.getElementById('btn-enc-enviar');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Enviando…';
try {
const res = await fetch(API + 'enviar_encuesta.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: _encuestaTurnoId }),
});
const json = await res.json();
_cerrarEncuestaBar();
if (json.ok) mostrarToast('Encuesta enviada ✓', 'success', 3000);
else mostrarToast('No se pudo enviar: ' + (json.error || 'error'), 'warning', 4000);
} catch (e) {
_cerrarEncuestaBar();
mostrarToast('Error al enviar encuesta', 'warning', 3000);
}
}
function _cerrarEncuestaBar() {
clearTimeout(_encuestaTimer);
document.getElementById('bar-encuesta').style.display = 'none';
_encuestaTurnoId = null;
}
// ── Reset ficha ───────────────────────────────────────────────
function resetFicha() {
clearInterval(pollingConsentId);
turnoActivo = null;
tieneConsent = false;
hayPendientes = false;
hayMuestrasPendientes = false;
_consentTokenCache = null;
_pacienteActivo = null;
_solicitudActiva = null;
_muestrasActivas = [];
const secMuestras = document.getElementById('sec-muestras');
if (secMuestras) secMuestras.classList.add('d-none');
// Dejar la sección visible para la siguiente ficha
document.getElementById('sec-consent')?.classList.remove('d-none');
document.getElementById('badge-solo-muestras')?.classList.add('d-none');
document.getElementById('ficha-orden').classList.add('d-none');
const obsRec = document.getElementById('pac-obs-recepcion');
if (obsRec) obsRec.classList.add('d-none');
cerrarModalPaciente();
document.getElementById('btn-ver-paciente').classList.add('d-none');
cerrarModalConsentimiento();
const iframe = document.getElementById('form-embebido-iframe');
const sec = document.getElementById('sec-form-embebido');
const load = document.getElementById('form-embebido-loading');
if (iframe) { iframe.src = ''; iframe.style.display = 'none'; }
if (sec) { sec.classList.add('d-none'); }
if (load) { load.style.display = ''; load.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Cargando formulario...'; }
const fichaEl = document.getElementById('ficha-turno');
fichaEl.classList.add('d-none');
fichaEl.style.display = 'none';
document.getElementById('ficha-placeholder').classList.remove('d-none');
document.getElementById('badge-turno-activo').classList.add('d-none');
const nomHdr = document.getElementById('ficha-pac-nombre-hdr');
if (nomHdr) { nomHdr.textContent = ''; nomHdr.classList.remove('loading'); }
const warn = document.getElementById('accion-consent-warn');
if (warn) warn.classList.add('d-none');
const inpCom = document.getElementById('inp-comentario');
if (inpCom) { inpCom.value = ''; inpCom.style.borderColor = ''; inpCom.title = ''; }
document.getElementById('btn-iniciar').classList.remove('d-none');
document.getElementById('btn-iniciar').disabled = false;
document.getElementById('btn-finalizar').classList.add('d-none');
document.getElementById('btn-regresar').classList.add('d-none');
document.getElementById('btn-rellamar').classList.add('d-none');
document.getElementById('btn-devolver').classList.add('d-none');
document.getElementById('btn-anexar-wrap')?.classList.add('d-none');
volverACola();
}
// ── Anexar formulario ─────────────────────────────────────────
function _abrirAnexarFormulario() {
const sel = document.getElementById('sel-anexar-form');
sel.innerHTML = '<option value="">— Seleccionar formulario —</option>';
_formulariosList.forEach(f => {
const o = document.createElement('option');
o.value = f.id; o.textContent = f.nombre;
sel.appendChild(o);
});
document.getElementById('anexar-form-msg').style.display = 'none';
document.getElementById('btn-guardar-anexar').disabled = false;
document.getElementById('modal-anexar-form').style.display = 'flex';
}
function _cerrarAnexarFormulario() {
document.getElementById('modal-anexar-form').style.display = 'none';
}
async function _guardarAnexarFormulario() {
const fId = parseInt(document.getElementById('sel-anexar-form').value);
const msg = document.getElementById('anexar-form-msg');
if (!fId) { msg.textContent = 'Selecciona un formulario.'; msg.style.display=''; return; }
const btn = document.getElementById('btn-guardar-anexar');
btn.disabled = true;
try {
const r = await fetch('modules/turnero/api/anexar_formulario.php', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ turno_id: turnoActivo.id, formulario_id: fId })
});
const j = await r.json();
if (!j.ok) { msg.textContent = j.error || 'Error al agregar.'; msg.style.display=''; btn.disabled=false; return; }
_cerrarAnexarFormulario();
actualizarConsentimientos(turnoActivo.id);
} catch(e) { msg.textContent = e.message; msg.style.display=''; btn.disabled=false; }
}
// ── Mobile ────────────────────────────────────────────────────
function esMobile() { return window.innerWidth <= 680; }
function mostrarFichaMobile() {
if (!esMobile()) return;
document.getElementById('lugar-ficha').classList.add('mobile-visible');
document.querySelector('.lugar-cola').classList.add('mobile-oculta');
}
function volverACola() {
document.getElementById('lugar-ficha').classList.remove('mobile-visible');
document.querySelector('.lugar-cola').classList.remove('mobile-oculta');
}
// ── Estado badge + botones ────────────────────────────────────
function actualizarEstadoBadge(estado) {
const el = document.getElementById('ficha-estado-badge');
const mapa = {
'en_espera_lugar': ['bg-warning-subtle text-warning border border-warning-subtle', 'En espera'],
'en_servicio': ['bg-success-subtle text-success border border-success-subtle', 'En servicio'],
'finalizado': ['bg-secondary-subtle text-secondary border', 'Finalizado'],
'ausente': ['bg-danger-subtle text-danger border border-danger-subtle', 'Ausente'],
};
const [cls, lbl] = mapa[estado] || ['bg-secondary-subtle text-secondary border', estado];
el.className = 'badge ' + cls;
el.textContent = lbl;
const btnIni = document.getElementById('btn-iniciar');
const btnFin = document.getElementById('btn-finalizar');
const btnReg = document.getElementById('btn-regresar');
const btnRellamar = document.getElementById('btn-rellamar');
const btnDev = document.getElementById('btn-devolver');
const btnEsp = document.getElementById('btn-espera');
const btnAnexar = document.getElementById('btn-anexar-wrap');
if (estado === 'en_espera_lugar') {
btnIni.classList.remove('d-none'); btnIni.disabled = false;
btnFin.classList.add('d-none');
btnReg.classList.add('d-none');
btnRellamar.classList.add('d-none');
btnDev.classList.remove('d-none');
if (btnEsp) btnEsp.classList.add('d-none');
if (btnAnexar) btnAnexar.classList.add('d-none');
} else if (estado === 'en_servicio') {
btnIni.classList.add('d-none');
btnFin.classList.remove('d-none');
btnReg.classList.remove('d-none');
btnRellamar.classList.remove('d-none');
btnDev.classList.remove('d-none');
// btn-espera: solo si hay toma progresiva activa (sincronizado desde renderConsentimientos)
if (btnEsp) btnEsp.classList.add('d-none');
if (btnAnexar) btnAnexar.classList.remove('d-none');
}
}
async function rellamarTurno() {
if (!turnoActivo) return;
const btn = document.getElementById('btn-rellamar');
btn.disabled = true;
try {
const res = await fetch(API + 'llamar_turno.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ area: 'lugar', lugar_id: lugarId, turno_id: turnoActivo.id }),
});
const json = await res.json();
if (!json.ok) mostrarError(json.error);
else if (json.turno) mostrarLlamando(json.turno.codigo);
} catch (err) {
mostrarError(err.message);
} finally {
btn.disabled = false;
}
}
// ── Comentarios ───────────────────────────────────────────────
async function cargarComentarios(turnoId) {
if (!turnoId) return;
const cont = document.getElementById('lista-comentarios');
cont.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Cargando…';
try {
const res = await fetch(`${API}comentarios.php?turno_id=${turnoId}`);
const json = await res.json();
if (!json.ok || !json.comentarios) { cont.innerHTML = '<small class="text-muted">Sin comentarios</small>'; return; }
if (!json.comentarios.length) { cont.innerHTML = '<small class="text-muted">Sin comentarios aún</small>'; return; }
cont.innerHTML = json.comentarios.map(c => `
<div style="background:#f8fafc;border-left:3px solid #8b5cf6;padding:8px 10px;margin-bottom:8px;border-radius:4px">
<div style="font-size:.7rem;color:#94a3b8;margin-bottom:2px">${escHtml(c.usuario_nombre)} • ${new Date(c.creado_at).toLocaleString()}</div>
<div style="font-size:.82rem;color:#334155">${escHtml(c.comentario)}</div>
</div>
`).join('');
} catch (_) {
cont.innerHTML = '<small class="text-danger">Error al cargar comentarios</small>';
}
}
async function guardarComentario() {
if (!turnoActivo) return;
const inp = document.getElementById('inp-comentario');
const txt = (inp.value || '').trim();
if (!txt) { mostrarError('El comentario no puede estar vacío'); return; }
try {
const res = await fetch(API + 'comentarios.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, comentario: txt, tipo: 'muestras' }),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error || 'Error al guardar'); return; }
inp.value = '';
draftBorrar();
await cargarComentarios(turnoActivo.id);
mostrarToast('Comentario guardado', 'success', 2000);
} catch (err) {
mostrarError(err.message);
}
}
// ── Borrador de comentario (localStorage) ─────────────────────
function _draftKey() { return turnoActivo ? 'toma_draft_' + turnoActivo.id : null; }
function draftGuardar() {
const key = _draftKey();
if (!key) return;
const val = (document.getElementById('inp-comentario')?.value || '').trim();
if (val) localStorage.setItem(key, val);
else localStorage.removeItem(key);
}
function draftRestaurar(turnoId) {
const key = 'toma_draft_' + turnoId;
const val = localStorage.getItem(key);
const inp = document.getElementById('inp-comentario');
if (!inp) return;
if (val) {
inp.value = val;
inp.style.borderColor = '#f59e0b';
inp.title = 'Borrador restaurado — no guardado aún';
} else {
inp.value = '';
inp.style.borderColor = '';
inp.title = '';
}
}
function draftBorrar() {
const key = _draftKey();
if (key) localStorage.removeItem(key);
const inp = document.getElementById('inp-comentario');
if (inp) { inp.style.borderColor = ''; inp.title = ''; }
}
// ── Modal paciente ────────────────────────────────────────────
function abrirModalPaciente() {
if (!_pacienteActivo) return;
const pac = _pacienteActivo;
const sol = _solicitudActiva;
document.getElementById('mpac-nombre-titulo').textContent =
pac.nombre_completo || pac.full_name || 'Paciente';
document.getElementById('mpac-val-nombre').textContent =
pac.nombre_completo || pac.full_name || '—';
document.getElementById('mpac-val-doc').textContent =
((pac.tipo_documento || '') + ' ' + (pac.numero_documento || pac.documento || '')).trim() || '—';
document.getElementById('mpac-val-fec').textContent = pac.fecha_nacimiento || '—';
document.getElementById('mpac-val-cel').textContent = pac.telefono || pac.celular || '—';
const rowMed = document.getElementById('mpac-row-medico');
if (sol && sol.medico_nombre) {
rowMed.style.display = '';
const esp = sol.medico_especialidad ? ' · ' + sol.medico_especialidad : '';
document.getElementById('mpac-val-medico').textContent = sol.medico_nombre + esp;
} else {
rowMed.style.display = 'none';
}
const badgeEmb = document.getElementById('mpac-badge-emb');
badgeEmb.classList.toggle('d-none', !(sol && sol.embarazada == 1));
document.getElementById('mpac-historial-content').innerHTML =
'<div class="text-center text-muted py-3 small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando historial…</div>';
document.getElementById('modal-pac-lugar').classList.add('show');
cargarHistorialModal(pac.id);
}
function cerrarModalPaciente() {
document.getElementById('modal-pac-lugar').classList.remove('show');
mpacEditar(false);
}
/* ── Corrección de la ficha del paciente desde la estación ──
Los campos del formulario son vinculados: leen de la ficha. Por eso se
corrige la ficha y no el documento — así el dato queda bien en el turnero,
los domicilios y las órdenes, no solo en la hoja que se está llenando. */
function mpacEditar(abrir) {
document.getElementById('mpac-form').classList.toggle('d-none', !abrir);
document.getElementById('mpac-datos').classList.toggle('d-none', abrir);
document.getElementById('mpac-msg').textContent = '';
if (!abrir) { mpacBloquearIdentidad(); return; }
const p = _pacienteActivo || {};
document.getElementById('mpac-in-tel').value = p.telefono || p.celular || '';
document.getElementById('mpac-in-dir').value = p.direccion || '';
document.getElementById('mpac-in-eps').value = p.eps || '';
document.getElementById('mpac-in-nombre').value = p.nombre_completo || '';
document.getElementById('mpac-in-tipodoc').value = p.tipo_documento || 'CC';
document.getElementById('mpac-in-doc').value = p.numero_documento || p.documento || '';
document.getElementById('mpac-in-fnac').value = (p.fecha_nacimiento || '').slice(0, 10);
mpacBloquearIdentidad();
// Si ya hay documentos firmados, advertir que corregir no los modifica.
// El botón "Ver firmado" solo se renderiza para consentimientos ya firmados.
const firmados = document.querySelectorAll('#lista-consent button[title="Ver firmado"]').length > 0;
document.getElementById('mpac-aviso-firmados').classList.toggle('d-none', !firmados);
}
function mpacBloquearIdentidad() {
document.getElementById('mpac-identidad').classList.add('d-none');
document.getElementById('mpac-identidad-lock').classList.remove('d-none');
}
function mpacDesbloquearIdentidad() {
if (!confirm('Nombre, documento y fecha de nacimiento identifican al paciente en toda su '
+ 'historia clínica.\n\n¿Confirma que quiere corregirlos?')) return;
document.getElementById('mpac-identidad').classList.remove('d-none');
document.getElementById('mpac-identidad-lock').classList.add('d-none');
}
async function mpacGuardarFicha() {
if (!_pacienteActivo?.id) return;
const msg = document.getElementById('mpac-msg');
const btn = document.getElementById('mpac-btn-guardar');
const val = id => document.getElementById(id).value.trim();
const cuerpo = {
id: _pacienteActivo.id,
telefono: val('mpac-in-tel'),
direccion: val('mpac-in-dir'),
eps: val('mpac-in-eps'),
};
// Los datos de identidad solo se envían si se desbloquearon
if (!document.getElementById('mpac-identidad').classList.contains('d-none')) {
cuerpo.nombre_completo = val('mpac-in-nombre');
cuerpo.tipo_documento = val('mpac-in-tipodoc');
cuerpo.numero_documento = val('mpac-in-doc');
cuerpo.fecha_nacimiento = val('mpac-in-fnac') || null;
}
btn.disabled = true;
msg.className = 'small mb-2 text-muted';
msg.textContent = 'Guardando…';
try {
const r = await fetch(`${BASE_WA}api/lab/save_paciente.php`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cuerpo),
});
const d = await r.json();
if (!d.ok) {
msg.className = 'small mb-2 text-danger';
msg.textContent = d.error || 'No se pudo guardar.';
btn.disabled = false;
return;
}
// Reflejar el cambio sin recargar: ficha del modal, tarjeta del turno
// y el formulario abierto, cuyos campos vinculados leen de la ficha.
Object.assign(_pacienteActivo, cuerpo);
msg.className = 'small mb-2 text-success';
msg.textContent = 'Ficha actualizada.';
setTimeout(() => {
mpacEditar(false);
abrirModalPaciente();
if (turnoActivo?.id) actualizarConsentimientos(turnoActivo.id);
// Recargar el formulario abierto para que sus campos vinculados
// tomen el dato corregido. Puede estar en el modal o embebido.
['modal-consent-iframe', 'form-embebido-iframe'].forEach(function (id) {
const marco = document.getElementById(id);
if (marco && marco.src) marco.src = marco.src;
});
}, 700);
} catch (_) {
msg.className = 'small mb-2 text-danger';
msg.textContent = 'Error de conexión.';
btn.disabled = false;
}
}
async function cargarHistorialModal(pacienteId) {
const cont = document.getElementById('mpac-historial-content');
try {
const res = await fetch(`${API}get_historial.php?paciente_id=${pacienteId}&per_page=10&page=1`);
const json = await res.json();
if (!json.ok || !json.turnos || !json.turnos.length) {
cont.innerHTML = '<div class="text-center text-muted py-3 small"><i class="fas fa-inbox me-1"></i>Sin visitas anteriores registradas</div>';
return;
}
cont.innerHTML = renderHistorialTimeline(json.turnos, json.total);
} catch(_) {
cont.innerHTML = '<div class="text-center text-danger py-2 small">Error al cargar historial</div>';
}
}
function renderHistorialTimeline(turnos, total) {
const ESTADO_LBL = {
espera:'Espera', en_recepcion:'Recepción', en_espera_lugar:'Esp. lugar',
en_servicio:'En servicio', finalizado:'Finalizado', ausente:'Ausente', cancelado:'Cancelado',
};
const ESTADO_STYLE = {
finalizado: 'background:#f1f5f9;color:#334155',
ausente: 'background:#fee2e2;color:#991b1b',
cancelado: 'background:#f1f5f9;color:#9ca3af',
en_servicio: 'background:#dcfce7;color:#166534',
en_espera_lugar:'background:#fde68a;color:#92400e',
en_recepcion: 'background:#dbeafe;color:#1e40af',
espera: 'background:#fef9c3;color:#78350f',
};
const totalLabel = total > turnos.length
? `<div style="font-size:.67rem;color:#94a3b8;text-align:right;padding:4px 0">Mostrando ${turnos.length} de ${total} visitas</div>` : '';
const items = turnos.map(t => {
const color = t.prioridad_color || '#6366f1';
const lugar = escHtml(t.lugar_nombre || 'Recepción');
const fecha = escHtml(t.fecha_sesion || '—');
const est = t.estado || '';
const eStyle = ESTADO_STYLE[est] || 'background:#f1f5f9;color:#334155';
const eLbl = escHtml(ESTADO_LBL[est] || est);
const mins = t.servicio_min != null
? `<span style="font-size:.65rem;color:#94a3b8;margin-left:4px">${t.servicio_min} min</span>` : '';
let examStr = '';
if (t.examenes && t.examenes.length) {
examStr = `<div style="margin-top:5px;font-size:.74rem">
<strong style="color:#64748b">Exámenes:</strong>
<span style="color:#475569">${t.examenes.map(e => escHtml(e.nombre)).join(', ')}</span>
</div>`;
}
let comStr = '';
if (t.comentarios && t.comentarios.length) {
comStr = `<div style="margin-top:5px;font-size:.71rem;border-top:1px solid #e2e8f0;padding-top:5px">
<strong style="color:#64748b">📝 Comentarios:</strong>
<div style="color:#475569;margin-top:3px">${t.comentarios.map(c =>
`<div style="margin-bottom:2px"><strong>${escHtml(c.usuario_nombre)}</strong>: ${escHtml(c.comentario)}</div>`
).join('')}</div>
</div>`;
}
return `<div class="mtl-item">
<div class="mtl-dot" style="color:${color};background:${color}"></div>
<div class="mtl-content">
<div class="mtl-fecha">${fecha}</div>
<div class="mtl-codigo" style="color:${color}">${escHtml(t.codigo)}</div>
<div class="mtl-lugar">${lugar}</div>
<span class="mtl-eb" style="${eStyle}">${eLbl}</span>${mins}
${examStr}${comStr}
</div>
</div>`;
}).join('');
return `<div class="mpac-timeline">${items}</div>${totalLabel}`;
}
// ── Muestras ──────────────────────────────────────────────────
function renderMuestras(lista) {
_muestrasActivas = lista || [];
const sec = document.getElementById('sec-muestras');
const cont = document.getElementById('lista-muestras');
const summary = document.getElementById('muestras-summary');
if (!sec) return;
// Solo mostrar en estaciones tipo "muestras"
if (LUGAR_TIPO !== 'muestras' || !lista.length) {
sec.classList.add('d-none');
return;
}
sec.classList.remove('d-none');
const nPend = lista.filter(m => m.estado === 'pendiente').length;
const nRec = lista.filter(m => m.estado === 'recibida').length;
const nRech = lista.filter(m => m.estado === 'rechazada').length;
hayMuestrasPendientes = nPend > 0;
_syncBtnFinalizar();
let chips = '';
if (nPend) chips += `<span class="mc pend">${nPend} pendiente${nPend > 1 ? 's' : ''}</span>`;
if (nRec) chips += `<span class="mc rec">${nRec} recibida${nRec > 1 ? 's' : ''}</span>`;
if (nRech) chips += `<span class="mc rech">${nRech} pendiente${nRech > 1 ? 's' : ''}</span>`;
summary.innerHTML = chips;
cont.innerHTML = lista.map(m => {
const esPrevio = !!m.es_pendiente_anterior;
// Rechazadas de una visita anterior también se pueden recibir directo (el paciente trae muestra nueva)
const esPend = m.estado === 'pendiente' || (esPrevio && m.estado === 'rechazada');
const label = escHtml(m.label || m.tipo_muestra || 'MUESTRA');
const origenTag = esPrevio
? `<span style="font-size:.68rem;background:#fef3c7;color:#92400e;border:1px solid #fde68a;
border-radius:10px;padding:1px 7px;margin-left:5px;white-space:nowrap">
<i class="fas fa-history me-1"></i>visita anterior${m.solicitud_orden_anterior ? ' · ' + escHtml(m.solicitud_orden_anterior) : ''}
</span>`
: '';
const examAntHtml = (esPrevio && m.examenes_anteriores && m.examenes_anteriores.length)
? `<div class="muestra-examenes"><i class="fas fa-vials me-1"></i>${m.examenes_anteriores.map(e => escHtml(e.nombre)).join(', ')}</div>`
: '';
const tsHtml = m.recibida_at
? `<span class="muestra-ts">${formatHora(m.recibida_at)}</span>`
: '';
const motivoHtml = m.motivo_rechazo
? `<div class="muestra-motivo">${escHtml(m.motivo_rechazo)}</div>` : '';
const acciones = esPend
? `<div style="display:flex;gap:5px;flex-shrink:0">
<button class="btn-muestra-accion recibir"
onclick="marcarMuestra(${m.id},'recibida')">
<i class="fas fa-check"></i> Recibida
</button>
<button class="btn-muestra-accion rechazar"
onclick="pedirRechazo(${m.id},'${label.replace(/'/g,'\\\'')}')" >
<i class="fas fa-clock"></i> Pendiente
</button>
</div>`
: `${tsHtml}
<button class="btn-muestra-accion"
style="background:#f1f5f9;color:#64748b;border:1px solid #e2e8f0"
onclick="marcarMuestra(${m.id},'pendiente')" title="Revertir a pendiente">
<i class="fas fa-undo"></i>
</button>`;
const ico = m.estado === 'recibida'
? '<i class="fas fa-check-circle" style="color:#16a34a;flex-shrink:0"></i>'
: m.estado === 'rechazada'
? '<i class="fas fa-hourglass-half" style="color:#c2410c;flex-shrink:0"></i>'
: '<i class="fas fa-clock" style="color:#d97706;flex-shrink:0"></i>';
return `<div class="muestra-row ${m.estado}" data-muestra-id="${m.id}">
${ico}
<div class="muestra-info">
<span class="muestra-label">${label}</span>${origenTag}
${examAntHtml}
${motivoHtml}
</div>
${acciones}
</div>`;
}).join('');
}
async function marcarMuestra(muestraId, estado, motivo = null) {
try {
const body = { muestra_id: muestraId, estado };
if (motivo) body.motivo_rechazo = motivo;
if (turnoActivo?.id) body.turno_id = turnoActivo.id;
const res = await fetch(API + 'update_muestra_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
// Actualizar estado local sin re-fetch
const m = _muestrasActivas.find(x => x.id == muestraId);
if (m) {
m.estado = estado;
m.motivo_rechazo = motivo;
m.recibida_at = estado !== 'pendiente' ? new Date().toISOString() : null;
}
renderMuestras(_muestrasActivas);
const msg = estado === 'recibida'
? 'Muestra recibida ✓'
: estado === 'rechazada' ? 'Pendiente' : 'Revertida a pendiente';
mostrarToast(msg, estado === 'recibida' ? 'success' : estado === 'rechazada' ? 'warn' : 'info', 2000);
} catch (err) {
mostrarError(err.message);
}
}
function pedirRechazo(muestraId, tipoMuestra) {
const motivo = prompt(`Nota para "${tipoMuestra}" (opcional):\n(ej: paciente la trae mañana, volumen insuficiente…)`);
if (motivo === null) return;
marcarMuestra(muestraId, 'rechazada', motivo.trim() || null);
}
function formatHora(isoStr) {
if (!isoStr) return '';
try {
return new Date(isoStr).toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' });
} catch (_) { return ''; }
}
// ── Helpers ───────────────────────────────────────────────────
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(str ?? '')));
return d.innerHTML;
}
let _toastTimer = null;
function mostrarToast(msg, type = 'info', duration = 2500) {
const el = document.getElementById('lug-toast');
const ico = document.getElementById('lug-toast-ico');
const txt = document.getElementById('lug-toast-msg');
const icons = { success:'fa-check-circle', info:'fa-info-circle', warn:'fa-exclamation-triangle', error:'fa-times-circle' };
el.className = 'rec-toast ' + type;
ico.innerHTML = '<i class="fas ' + (icons[type] || icons.info) + '"></i>';
txt.textContent = msg;
el.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.classList.remove('show'), duration);
}
function mostrarError(msg) { mostrarToast(msg, 'error', 4000); }
function mostrarLlamando(cod) { mostrarToast('Llamando turno ' + cod + '…', 'info', 2000); }
function _renderEsperaMuestras(items) {
const panel = document.getElementById('espera-muestras-panel');
if (!panel) return;
if (!items.length) { panel.style.display = 'none'; panel.innerHTML = ''; return; }
const ahora = Date.now();
let html = `<div class="espera-m-hdr">
<i class="fas fa-hourglass-half" style="color:#f97316"></i>
Esperando siguiente muestra
<span class="espera-m-count">${items.length}</span>
</div>`;
for (const p of items) {
let cdHtml = '';
if (p.siguiente_toma_at) {
const ms = new Date(p.siguiente_toma_at.replace(' ', 'T')).getTime() - ahora;
const mins = Math.ceil(ms / 60000);
if (ms <= 0) {
cdHtml = `<span class="espera-m-cd crit">¡Lista ya!</span>`;
} else if (mins <= 5) {
cdHtml = `<span class="espera-m-cd warn">en ${mins} min</span>`;
} else {
cdHtml = `<span class="espera-m-cd ok">en ${mins} min</span>`;
}
}
const chipOculto = p.siguiente_toma_at
? `<span class="toma-prog-chip" data-toma-at="${escHtml(p.siguiente_toma_at)}" data-turno-id="${p.id}" style="display:none"><span class="toma-cd-txt"></span></span>`
: '';
html += `<div class="espera-m-row">
${chipOculto}
<span class="espera-m-orden">#${escHtml(String(p.numero_orden))}</span>
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(p.paciente_nombre)}</span>
${cdHtml}
<button class="btn-llamar-espera" onclick="_llamarDesdeEspera(${(int = p.id, int)})">
<i class="fas fa-bell fa-xs me-1"></i>Llamar
</button>
</div>`;
}
panel.innerHTML = html;
panel.style.display = '';
}
async function _ponerEnEspera() {
if (!turnoActivo) return;
const btn = document.getElementById('btn-espera');
if (btn) btn.disabled = true;
try {
const res = await fetch(API + 'poner_en_espera_muestra.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id }),
});
const j = await res.json();
if (!j.ok) { mostrarError(j.error); return; }
resetFicha();
cargarCola();
} catch (e) { mostrarError(e.message); }
finally { if (btn) btn.disabled = false; }
}
async function _llamarDesdeEspera(turnoId) {
try {
const res = await fetch(API + 'llamar_desde_espera.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoId, lugar_id: lugarId }),
});
const j = await res.json();
if (!j.ok) { mostrarError(j.error); return; }
mostrarLlamando(j.turno.codigo);
await abrirFicha(j.turno);
cargarCola();
} catch (e) { mostrarError(e.message); }
}
async function _cargarEspecialidades() {
if (!ESP_LUGARES.length) return;
try {
const qs = ESP_LUGARES.map(l => `ids[]=${l.id}`).join('&');
const res = await fetch(`${API}get_especialidades.php?${qs}`);
const j = await res.json();
if (j.ok) _renderEspecialidades(j.grupos);
} catch {}
}
function _renderEspecialidades(grupos) {
const panel = document.getElementById('esp-panel');
if (!panel) return;
let html = '';
for (const g of grupos) {
const esGine = g.lugar_nombre.toLowerCase().includes('ginecol');
const color = esGine ? '#ec4899' : '#0ea5e9';
const icon = esGine ? 'fa-venus' : 'fa-child';
const url = `${BASE_WA}erp.php?m=turnero&v=lugar&lugar_id=${g.lugar_id}`;
const cnt = document.getElementById(`esp-cnt-${g.lugar_id}`);
if (cnt) cnt.textContent = g.pacientes.length;
if (!g.pacientes.length) continue;
html += `<div class="esp-grupo" style="--esp-gc:${color}">
<div class="esp-grupo-hdr">
<i class="fas ${icon}" style="color:${color}"></i>
${escHtml(g.lugar_nombre)}
<span class="esp-grupo-count">${g.pacientes.length}</span>
<a href="${url}" class="esp-grupo-ir">Ir <i class="fas fa-arrow-right fa-xs"></i></a>
</div>
${g.pacientes.map(p => `
<a href="${url}" class="esp-pac-row">
<span class="esp-pac-orden">#${p.numero_orden}</span>
<span>${escHtml(p.paciente_nombre)}</span>
<span class="esp-pac-hora">${formatHora(p.inicio_lugar_at)}</span>
</a>`).join('')}
</div>`;
}
panel.innerHTML = html;
panel.style.display = html ? '' : 'none';
}
/* ── Ocupación exclusiva del puesto ──────────────────────────── */
(function(_ID, _NOMBRE, _VIEW, _PARAM) {
if (!_ID) return;
function _e(s) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(s ?? '')));
return d.innerHTML;
}
function _post(body) {
return fetch(API + 'ocupar_lugar.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.assign({ lugar_id: _ID }, body)),
}).then(r => r.json());
}
function _redirigirSiguiente(disponibles) {
if (disponibles && disponibles.length) {
window.location.href = BASE_WA + 'erp.php?m=turnero&v=' + _VIEW + '&' + _PARAM + '=' + disponibles[0].id;
} else {
window.location.href = BASE_WA + 'erp.php?m=turnero';
}
}
function _mostrarBloqueado(ocupadoPor) {
document.body.innerHTML =
'<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;' +
'min-height:100vh;background:#f8faff;font-family:sans-serif;gap:14px;text-align:center;padding:28px">' +
'<div style="font-size:4rem">🔒</div>' +
'<h2 style="color:#1565c0;margin:0">' + _e(_NOMBRE) + ' está abierta en otro equipo</h2>' +
'<p style="color:#555;font-size:1.05rem;max-width:440px;line-height:1.6">' +
'Actualmente usada por <strong>' + _e(ocupadoPor) + '</strong>.<br>' +
'Puedes solicitar que cierre su sesión o ir al siguiente puesto disponible.</p>' +
'<div style="display:flex;gap:12px;flex-wrap:wrap;justify-content:center;margin-top:4px">' +
'<button id="_btn-solicitar" onclick="window._ocToma()" ' +
'style="padding:12px 26px;background:#1565c0;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer;font-weight:600">' +
'📢 Solicitar cierre en ese equipo</button>' +
'<button id="_btn-siguiente" onclick="window._ocSiguiente()" ' +
'style="padding:12px 26px;background:#f0f4ff;color:#1565c0;border:2px solid #1565c0;border-radius:8px;font-size:1rem;cursor:pointer;font-weight:600">' +
'➡ Ir al siguiente disponible</button>' +
'</div>' +
'<div id="_oc-estado" style="min-height:24px;color:#777;font-size:.95rem;margin-top:4px"></div>' +
'</div>';
}
function _setEstado(msg, color) {
const el = document.getElementById('_oc-estado');
if (el) el.innerHTML = '<span style="color:' + (color||'#555') + '">' + msg + '</span>';
}
let _checkTransferTimer = null;
window._ocToma = async function() {
const btn = document.getElementById('_btn-solicitar');
if (btn) btn.disabled = true;
_setEstado('Enviando solicitud…', '#555');
try {
const j = await _post({ action: 'solicitar' });
if (!j.ok) { _setEstado('Error al enviar solicitud.', '#c62828'); if (btn) btn.disabled = false; return; }
if (j.transferido) { location.reload(); return; }
_setEstado('✉ Solicitud enviada. El otro equipo tiene 5 segundos para responder…', '#1565c0');
clearInterval(_checkTransferTimer);
_checkTransferTimer = setInterval(async () => {
try {
const r = await _post({ action: 'check_transfer' });
if (r.disponible) {
clearInterval(_checkTransferTimer);
_setEstado('✅ Puesto liberado. Tomando control…', '#2e7d32');
const rc = await _post({ action: 'claim' });
if (rc.disponible) location.reload();
else _setEstado('No se pudo tomar el control. Recargue la página.', '#c62828');
} else if (r.denegado) {
clearInterval(_checkTransferTimer);
_setEstado('❌ La solicitud fue denegada.', '#c62828');
if (btn) { btn.disabled = false; btn.textContent = '📢 Volver a solicitar'; }
}
} catch (_) {}
}, 1500);
} catch (e) {
_setEstado('Error de red: ' + e.message, '#c62828');
if (btn) btn.disabled = false;
}
};
window._ocSiguiente = async function() {
const btn = document.getElementById('_btn-siguiente');
if (btn) btn.disabled = true;
_setEstado('Buscando puesto disponible…', '#555');
try {
const j = await _post({ action: 'disponibles' });
if (!j.ok || !j.disponibles.length) {
_setEstado('No hay otros puestos disponibles.', '#c62828');
if (btn) btn.disabled = false;
return;
}
_redirigirSiguiente(j.disponibles);
} catch (e) {
_setEstado('Error de red.', '#c62828');
if (btn) btn.disabled = false;
}
};
let _modalActivo = false;
function _mostrarModalSolicitud(solicitante) {
if (_modalActivo) return;
_modalActivo = true;
const modal = document.createElement('div');
modal.style.cssText =
'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:99999;display:flex;align-items:center;justify-content:center';
modal.innerHTML =
'<div style="background:#fff;border-radius:14px;padding:32px 28px;max-width:420px;width:92%;text-align:center;box-shadow:0 8px 32px rgba(0,0,0,.25)">' +
'<div style="font-size:2.5rem">⚠️</div>' +
'<h3 style="color:#c62828;margin:10px 0 6px">Solicitud de transferencia</h3>' +
'<p style="color:#333;margin:0 0 6px"><strong>' + _e(solicitante) + '</strong> quiere usar este puesto.</p>' +
'<p style="color:#555;font-size:.95rem">Si no responde, se cerrará automáticamente en <strong id="_sol-cnt">5</strong> segundos.</p>' +
'<div style="display:flex;gap:10px;justify-content:center;margin-top:18px">' +
'<button id="_sol-aceptar" style="padding:10px 22px;background:#c62828;color:#fff;border:none;border-radius:8px;font-size:1rem;cursor:pointer;font-weight:600">Aceptar y ceder</button>' +
'<button id="_sol-denegar" style="padding:10px 22px;background:#f5f5f5;color:#333;border:1px solid #ccc;border-radius:8px;font-size:1rem;cursor:pointer">Denegar</button>' +
'</div></div>';
document.body.appendChild(modal);
let secs = 5;
const tick = setInterval(() => {
secs--;
const el = document.getElementById('_sol-cnt');
if (el) el.textContent = secs;
if (secs <= 0) { clearInterval(tick); _liberar(); }
}, 1000);
async function _liberar() {
modal.remove();
await _post({ action: 'aceptar' }).catch(() => {});
try {
const j = await _post({ action: 'disponibles' });
_redirigirSiguiente(j.disponibles || []);
} catch (_) {
window.location.href = BASE_WA + 'erp.php?m=turnero';
}
}
document.getElementById('_sol-aceptar').onclick = () => { clearInterval(tick); _liberar(); };
document.getElementById('_sol-denegar').onclick = async () => {
clearInterval(tick);
modal.remove();
_modalActivo = false;
await _post({ action: 'denegar' }).catch(() => {});
};
}
async function _iniciar() {
try {
const j = await _post({ action: 'claim' });
if (!j.ok) return;
if (!j.disponible) { _mostrarBloqueado(j.ocupado_por); return; }
setInterval(() => _post({ action: 'ping' }).catch(() => {}), 60000);
setInterval(async () => {
if (_modalActivo) return;
try {
const r = await _post({ action: 'check_solicitud' });
if (r.hay_solicitud) _mostrarModalSolicitud(r.solicitante);
} catch (_) {}
}, 2000);
} catch (_) {}
}
window.addEventListener('beforeunload', () => {
navigator.sendBeacon(API + 'ocupar_lugar.php',
new Blob([JSON.stringify({ lugar_id: _ID, action: 'release' })], { type: 'application/json' }));
});
_iniciar();
})(lugarId, LUGAR_NOMBRE, 'lugar', 'lugar_id');
</script>
<div class="rec-toast" id="lug-toast">
<span class="ico" id="lug-toast-ico"><i class="fas fa-info-circle"></i></span>
<span id="lug-toast-msg"></span>
</div>
<!-- ── Modal Mi Firma ─────────────────────────────────────── -->
<div id="modal-mi-firma" style="display:none;position:fixed;inset:0;z-index:9500;
background:rgba(0,0,0,.55);align-items:center;justify-content:center;padding:16px">
<div style="background:#fff;border-radius:14px;box-shadow:0 8px 40px rgba(0,0,0,.25);
width:min(96vw,480px);display:flex;flex-direction:column;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;
padding:12px 18px;border-bottom:1px solid #e2e8f0">
<span style="font-weight:700;font-size:.95rem;color:#1e293b">
<i class="fas fa-signature me-2 text-success"></i>Mi firma pre-guardada
</span>
<button onclick="cerrarModalMiFirma()"
style="background:none;border:none;font-size:1.2rem;color:#94a3b8;cursor:pointer;padding:2px 6px">
<i class="fas fa-times"></i>
</button>
</div>
<div style="padding:16px">
<?php if ($adminFirmaSvg): ?>
<div id="mf-preview" style="text-align:center;margin-bottom:12px">
<p class="small text-success mb-1"><i class="fas fa-check-circle me-1"></i>Firma guardada</p>
<img src="<?= htmlspecialchars($adminFirmaSvg) ?>" alt="Mi firma"
style="max-height:80px;max-width:100%;border:1px solid #c9d8ff;border-radius:8px;padding:6px">
<br><a href="#" class="small text-muted mt-1 d-inline-block" onclick="document.getElementById('mf-preview').style.display='none';document.getElementById('mf-canvas-wrap').style.display='';return false">✎ Dibujar nueva</a>
</div>
<div id="mf-canvas-wrap" style="display:none">
<?php else: ?>
<div id="mf-canvas-wrap">
<?php endif; ?>
<p class="small text-muted mb-2">Dibuje su firma en el recuadro:</p>
<canvas id="mf-canvas" width="420" height="140"
style="border:2px solid #198754;border-radius:8px;background:#f8fff9;
cursor:crosshair;display:block;width:100%;touch-action:none"></canvas>
<div class="mt-2 d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" onclick="mfLimpiar()">
<i class="fas fa-eraser me-1"></i>Limpiar
</button>
<button class="btn btn-success btn-sm ms-auto" id="mf-btn-save" onclick="mfGuardar()">
<i class="fas fa-save me-1"></i>Guardar firma
</button>
</div>
</div>
<div id="mf-msg" class="mt-2 small"></div>
</div>
</div>
</div>
<script>
// ── Modal Mi Firma ────────────────────────────────────────────
(function() {
var canvas, ctx, drawing = false;
function init() {
canvas = document.getElementById('mf-canvas');
if (!canvas) return;
const ratio = window.devicePixelRatio || 1;
const w = canvas.offsetWidth || 420, h = canvas.offsetHeight || 140;
canvas.width = w * ratio; canvas.height = h * ratio;
canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
ctx = canvas.getContext('2d');
ctx.scale(ratio, ratio);
ctx.strokeStyle = '#000'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
function pos(e) { const r = canvas.getBoundingClientRect(), s = e.touches?e.touches[0]:e; return {x:s.clientX-r.left, y:s.clientY-r.top}; }
canvas.addEventListener('mousedown', e => { drawing=true; ctx.beginPath(); const p=pos(e); ctx.moveTo(p.x,p.y); });
canvas.addEventListener('mousemove', e => { if(!drawing) return; const p=pos(e); ctx.lineTo(p.x,p.y); ctx.stroke(); });
canvas.addEventListener('mouseup', () => drawing=false);
canvas.addEventListener('mouseleave', () => drawing=false);
canvas.addEventListener('touchstart', e => { e.preventDefault(); drawing=true; ctx.beginPath(); const p=pos(e); ctx.moveTo(p.x,p.y); }, {passive:false});
canvas.addEventListener('touchmove', e => { e.preventDefault(); if(!drawing) return; const p=pos(e); ctx.lineTo(p.x,p.y); ctx.stroke(); }, {passive:false});
canvas.addEventListener('touchend', () => drawing=false);
}
window.abrirModalMiFirma = function() {
document.getElementById('modal-mi-firma').style.display = 'flex';
if (!ctx) init();
};
window.cerrarModalMiFirma = function() {
document.getElementById('modal-mi-firma').style.display = 'none';
};
window.mfLimpiar = function() {
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
};
window.mfGuardar = function() {
const png = canvas.toDataURL('image/png');
if (png.length < 1000) { document.getElementById('mf-msg').innerHTML = '<span class="text-danger">Dibuje su firma primero.</span>'; return; }
const btn = document.getElementById('mf-btn-save');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando...';
fetch('<?= BASE_URL ?>modules/turnero/api/save_firma_profesional.php', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ firma_svg: png })
})
.then(r => r.json())
.then(d => {
if (d.ok) {
window._fpwPreFirma = png;
document.getElementById('mf-msg').innerHTML = '<span class="text-success"><i class="fas fa-check me-1"></i>Firma guardada. Se usará en el próximo consentimiento.</span>';
btn.innerHTML = '<i class="fas fa-check me-1"></i>Guardado';
// Actualizar preview si existe
var prev = document.getElementById('mf-preview');
if (prev) { prev.querySelector('img').src = png; prev.style.display=''; document.getElementById('mf-canvas-wrap').style.display='none'; }
} else {
document.getElementById('mf-msg').innerHTML = '<span class="text-danger">' + (d.error||'Error') + '</span>';
btn.disabled = false; btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar firma';
}
})
.catch(() => {
document.getElementById('mf-msg').innerHTML = '<span class="text-danger">Error de conexión.</span>';
btn.disabled = false; btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar firma';
});
};
})();
</script>
<!-- ── Modal: Anexar formulario ──────────────────────────────── -->
<div id="modal-anexar-form" style="display:none;position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,.5);align-items:center;justify-content:center;">
<div style="background:var(--bs-body-bg,#fff);border-radius:10px;padding:1.25rem;width:min(380px,92vw);box-shadow:0 8px 32px rgba(0,0,0,.25)">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;">
<strong><i class="fas fa-file-plus me-2"></i>Agregar formulario adicional</strong>
<button type="button" onclick="_cerrarAnexarFormulario()" style="background:none;border:none;font-size:1.2rem;cursor:pointer;opacity:.6">&times;</button>
</div>
<select id="sel-anexar-form" class="form-select mb-3">
<option value="">— Seleccionar formulario —</option>
</select>
<div id="anexar-form-msg" class="small text-danger mb-2" style="display:none"></div>
<div style="display:flex;gap:.5rem;justify-content:flex-end">
<button type="button" class="btn btn-secondary btn-sm" onclick="_cerrarAnexarFormulario()">Cancelar</button>
<button type="button" class="btn btn-primary btn-sm" id="btn-guardar-anexar" onclick="_guardarAnexarFormulario()">
<i class="fas fa-plus me-1"></i>Agregar
</button>
</div>
</div>
</div>
<script src="<?= defined('APP_URL') ? rtrim(APP_URL,'/') : '' ?>/assets/js/lab-sidebar.js"></script>
</body>
</html>