Files
whatsapp/modules/turnero/views/recepcion.php
T
Lizandro GuarnizoandClaude Sonnet 4.6 dfff945066 feat: clear TomSelect input after exam selection
After pressing Enter to add an exam, the typed text is cleared
and the dropdown closes so the next code can be typed immediately.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 13:22:39 -05:00

2639 lines
129 KiB
PHP

<?php
/**
* Puesto de Recepción — Módulo Turnero
* Requiere login + módulo turnero.
*/
require_once __DIR__ . '/../../../config/config.php';
if (!isUserLoggedIn()) {
header('Location: ' . BASE_URL . 'login.php');
exit;
}
// Tablet asignada → forzar su escritorio por token de navegador (o IP como fallback)
$_recepForzado = 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=lugar&lugar_id=' . $_forzado); exit;
}
if ((int)($_GET['desk_id'] ?? 0) !== $_forzado) {
header('Location: ' . BASE_URL . 'erp.php?m=turnero&v=recepcion&desk_id=' . $_forzado); exit;
}
$_recepForzado = $_forzado;
}
} catch (\Throwable $_) {}
// Restricción a nivel de usuario (turnero_lugar_id en admin_users)
if (!$_recepForzado) {
$_userDesk = (int)($_SESSION['admin_user']['turnero_lugar_id'] ?? 0);
if ($_userDesk) {
if ((int)($_GET['desk_id'] ?? 0) !== $_userDesk) {
header('Location: ' . BASE_URL . 'erp.php?m=turnero&v=recepcion&desk_id=' . $_userDesk); exit;
}
$_recepForzado = $_userDesk;
}
}
// Cargar catálogos para la ficha
try {
$pdo = Database::getInstance()->getConnection();
$lugarRecepcion = $pdo->query(
"SELECT id, nombre FROM turnero_lugares WHERE tipo='recepcion' AND activo=1 ORDER BY sort_order LIMIT 1"
)->fetch(PDO::FETCH_ASSOC);
// Destinos agrupados: un representante por grupo_id, individuales si grupo_id=NULL
$lugaresDestino = $pdo->query(
"SELECT MIN(id) AS id,
CASE WHEN grupo_id IS NOT NULL THEN 'Toma de Muestras' ELSE nombre END AS nombre,
grupo_id
FROM turnero_lugares
WHERE tipo='muestras' AND activo=1
GROUP BY COALESCE(grupo_id, id)
ORDER BY MIN(sort_order)"
)->fetchAll(PDO::FETCH_ASSOC);
$lugarMuestras = $lugaresDestino[0] ?? null;
$examenes = $pdo->query(
"SELECT et.id, et.codigo, et.nombre, et.categoria,
IF(COUNT(etc.formulario_id) > 0, 1, 0) AS tiene_consentimiento
FROM exam_tipos et
LEFT JOIN exam_tipo_consentimientos etc ON etc.exam_tipo_id = et.id
WHERE et.activo = 1
GROUP BY et.id
ORDER BY et.categoria, et.nombre"
)->fetchAll(PDO::FETCH_ASSOC);
// Agrupar exámenes por categoría
$examenesAgrupados = [];
foreach ($examenes as $ex) {
$cat = $ex['categoria'] ?: 'General';
$examenesAgrupados[$cat][] = $ex;
}
// Escritorio de recepción (vía ?desk_id=X)
$deskId = (int)($_GET['desk_id'] ?? 0);
$desk = null;
$desksRecepcion = $pdo->query(
"SELECT id, nombre FROM turnero_lugares WHERE tipo='recepcion' AND activo=1 ORDER BY sort_order"
)->fetchAll(PDO::FETCH_ASSOC);
if ($deskId) {
foreach ($desksRecepcion as $d) {
if ($d['id'] === $deskId) { $desk = $d; break; }
}
if (!$desk) { $deskId = 0; }
}
// Si solo hay un escritorio, asignarlo automáticamente
if (!$deskId && count($desksRecepcion) === 1) {
$desk = $desksRecepcion[0];
$deskId = (int)$desk['id'];
}
} catch (\Throwable $e) {
$lugares = [];
$examenesAgrupados = [];
$deskId = 0;
$desk = null;
$lugarRecepcion = null;
$lugarMuestras = null;
}
$deskNombre = $desk['nombre'] ?? null; // null = vista genérica
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Operador';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($deskNombre ?? 'Recepción') ?> — Turnero</title>
<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=12" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/css/tom-select.bootstrap5.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/js/tom-select.complete.min.js"></script>
<style>
/* ── Layout de dos columnas ── */
.rec-layout {
display: grid;
grid-template-columns: 370px 1fr;
height: calc(100vh - 56px);
overflow: hidden;
}
@media (max-width: 680px) {
.rec-layout { grid-template-columns: 1fr; position: relative; overflow: hidden; }
.rec-cola { transition: transform .2s ease; }
.rec-cola.mobile-oculta { transform: translateX(-100%); position: absolute; inset: 0; pointer-events: none; }
.rec-ficha { position: absolute; inset: 0; background: #fff; z-index: 10; transform: translateX(100%); transition: transform .2s ease; overflow-y: auto; }
.rec-ficha.mobile-visible { transform: translateX(0); }
.btn-volver-cola { display: inline-flex !important; align-items: center; gap: 6px; }
}
/* En pantalla 2 columnas el botón de topbar queda oculto (hay uno en la cola) */
@media (min-width: 681px) { #btn-llamar { display: none !important; } }
/* ── Columna cola ── */
.rec-cola {
background: #f8fafc;
border-right: 1px solid #e2e8f0;
display: flex; flex-direction: column;
overflow: hidden;
}
.rec-cola-header {
padding: 1rem 1.2rem .6rem;
background: #fff; border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
}
.cola-items { flex: 1; overflow-y: auto; padding: .5rem; }
.cola-card {
background: #fff; border: 1px solid #e2e8f0;
border-radius: 11px; padding: .65rem .9rem;
margin-bottom: .4rem; cursor: default;
display: flex; align-items: center; gap: .6rem;
transition: box-shadow .15s;
}
.cola-card.activo { border-color: #3b82f6; box-shadow: 0 0 0 2px #bfdbfe; }
.cola-card.en-atencion { background: #eff6ff; border-color: #93c5fd; border-left: 4px solid #2563eb; }
.atencion-chip { font-size: .6rem; font-weight: 700; border-radius: 99px;
padding: 0 6px; line-height: 1.7;
background: #dbeafe; color: #1d4ed8; }
.cola-card .prio-dot {
width: 36px; height: 36px; border-radius: 9px;
display: flex; align-items: center; justify-content: center;
font-size: 1.1rem; font-weight: 800; color: #fff; flex-shrink: 0;
}
.cola-card .turno-info { flex: 1; min-width: 0; }
.cola-card .turno-info .cod { font-size: 1rem; font-weight: 700; }
.cola-card .turno-info .pac { font-size: .78rem; color: #64748b;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cola-vacia { text-align: center; padding: 3rem 1rem; color: #94a3b8; }
.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; }
/* ── Columna ficha ── */
.rec-ficha {
overflow-y: auto; padding: 1.5rem;
background: #fff;
}
.ficha-placeholder {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
height: 100%; color: #94a3b8; text-align: center;
}
/* ── Secciones de la ficha ── */
.ficha-section {
background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 12px; padding: 1.2rem; margin-bottom: 1rem;
}
.ficha-section h6 { color: #475569; font-size: .78rem;
text-transform: uppercase; letter-spacing: .08em; margin-bottom: .8rem; }
/* ── Búsqueda de paciente ── */
.pac-resultado {
border: 1px solid #e2e8f0; border-radius: 8px; padding: .5rem .75rem;
cursor: pointer; margin-top: .25rem; transition: background .12s;
}
.pac-resultado:hover { background: #f1f5f9; }
.pac-resultado .pac-nombre { font-weight: 600; font-size: .9rem; }
.pac-resultado .pac-doc { font-size: .78rem; color: #64748b; }
.pac-seleccionado {
background: #eff6ff; border: 1px solid #93c5fd;
border-radius: 8px; padding: .65rem .9rem;
display: flex; align-items: center; justify-content: space-between;
}
/* ── Tom Select — exámenes ── */
#wrap-examenes .ts-wrapper { font-size: .88rem; }
#wrap-examenes .ts-control { min-height: 44px; border-radius: 8px; }
#wrap-examenes .ts-dropdown { font-size: .88rem; }
#wrap-examenes .ts-dropdown .optgroup-header {
font-size: .7rem; font-weight: 700; color: #94a3b8;
text-transform: uppercase; letter-spacing: .08em;
}
.ts-consent-badge {
font-size: .65rem; font-weight: 700; border-radius: 4px;
padding: 1px 5px; margin-left: 5px;
background: #fef3c7; color: #92400e; border: 1px solid #fde68a;
white-space: nowrap;
}
.ts-consent-dot { margin-left: 3px; color: #d97706; font-size: .75rem; }
#wrap-examenes.disabled { opacity: .4; pointer-events: none; }
/* ── Consentimientos ── */
.consent-item {
display: flex; align-items: center; gap: .5rem;
padding: .5rem .7rem; border-radius: 10px;
font-size: .84rem; margin-bottom: .4rem;
border: 1px solid transparent;
}
.consent-item.firmado { background: #f0fdf4; color: #166534; border-color: #bbf7d0; }
.consent-item.pendiente { background: #fffbeb; color: #92400e; border-color: #fde68a; }
.consent-item.enviado { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; }
.consent-item.visto { background: #faf5ff; color: #6b21a8; border-color: #ddd6fe; }
.consent-item.rechazado { background: #f8fafc; color: #64748b; border-color: #e2e8f0; }
.consent-item .c-nom { flex: 1; font-weight: 500; }
.consent-item .c-badge { font-size: .7rem; font-weight: 700; padding: 1px 8px;
border-radius: 99px; border: 1px solid currentColor; opacity: .85; }
.consent-acciones { display: flex; gap: .3rem; flex-shrink: 0; }
.consent-acciones .btn { font-size: .82rem; padding: 5px 12px; border-radius: 7px; }
.consent-prof-row { display:flex; align-items:center; gap:.4rem; font-size:.75rem;
margin-top:.25rem; padding-top:.25rem; border-top:1px solid rgba(0,0,0,.07); }
.consent-prof-row .badge { font-size:.65rem; }
/* ── 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: 1rem;
}
/* ── Badge de turno activo en topbar ── */
.badge-turno-activo {
background: #eff6ff; color: #1d4ed8;
border: 1px solid #bfdbfe; border-radius: 8px;
padding: .25rem .75rem; font-size: .82rem; font-weight: 700;
}
/* ── 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; }
/* ── Historial del paciente ── */
.hist-toggle-btn {
display: flex; align-items: center; gap: 6px;
width: 100%; padding: 6px 10px; margin-top: 8px;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;
font-size: .78rem; font-weight: 600; color: #475569;
cursor: pointer; text-align: left; transition: background .15s;
}
.hist-toggle-btn:hover { background: #f1f5f9; }
.hist-toggle-btn .hist-chev { margin-left: auto; transition: transform .2s; }
.hist-toggle-btn.open .hist-chev { transform: rotate(180deg); }
.hist-pac-body {
border: 1px solid #e2e8f0; border-top: none;
border-radius: 0 0 8px 8px; background: #fff;
max-height: 260px; overflow-y: auto;
}
.pac-timeline { padding: 10px 12px; }
.timeline-item {
display: flex; gap: 10px; align-items: flex-start;
padding-bottom: 10px; position: relative;
}
.timeline-item:not(:last-child)::before {
content: ''; position: absolute; left: 6px; top: 14px;
width: 2px; bottom: 0; background: #e2e8f0;
}
.timeline-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;
}
.timeline-content { flex: 1; min-width: 0; }
.timeline-fecha { font-size: .68rem; color: #94a3b8; }
.timeline-codigo { font-size: .82rem; font-weight: 700; }
.timeline-lugar { font-size: .73rem; color: #64748b; }
.timeline-eb { font-size: .62rem; padding: 1px 7px; border-radius: 99px; font-weight: 700; display: inline-block; margin-top: 2px; }
/* ── Botón "Llamar siguiente" en columna de cola ── */
.btn-llamar-cola {
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; margin-top: .5rem;
}
.btn-llamar-cola:hover { background: #1d4ed8; }
.btn-llamar-cola:active { transform: scale(.98); }
.btn-llamar-cola:disabled { background: #94a3b8; cursor: not-allowed; }
/* ── Barra de acciones: botones primarios/secundarios touch-friendly ── */
.accion-primary-row { margin-bottom: .5rem; }
.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.verde { background: #16a34a; color: #fff; }
.btn-accion-primary.verde:hover { background: #15803d; }
.btn-accion-primary.azul { background: #2563eb; color: #fff; }
.btn-accion-primary.azul:hover { background: #1d4ed8; }
.accion-secondary-row { display: flex; gap: .4rem; 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; }
/* ── Mejoras de touch target (tablet) ── */
.consent-item { min-height: 46px; }
.pac-resultado { min-height: 50px; }
.cola-card { min-height: 62px; }
</style>
</head>
<body>
<?php
$SIDEBAR_TITLE = 'Turnero';
$SIDEBAR_ICON = 'fas fa-ticket-alt';
require_once __DIR__ . '/../../../shared/components/sidebar.php';
?>
<?php if (!$deskId && count($desksRecepcion) > 1): ?>
<!-- Modal selector de escritorio (se muestra si no hay desk_id en URL) -->
<div class="modal fade" id="modalDeskSelect" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header" style="background:var(--doc-color,#1565c0);color:#fff">
<h5 class="modal-title"><i class="fas fa-desktop me-2"></i>Seleccione su escritorio</h5>
</div>
<div class="modal-body pb-2">
<p class="text-muted small mb-3">¿Desde qué escritorio de recepción está trabajando hoy?</p>
<div class="d-grid gap-2">
<?php foreach ($desksRecepcion as $d): ?>
<a href="?m=turnero&v=recepcion&desk_id=<?= $d['id'] ?>"
class="btn btn-outline-primary btn-lg">
<i class="fas fa-chair me-2"></i><?= htmlspecialchars($d['nombre']) ?>
</a>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
new bootstrap.Modal(document.getElementById('modalDeskSelect')).show();
});
</script>
<?php endif; ?>
<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">
<div class="d-flex align-items-center gap-2">
<i class="fas fa-concierge-bell text-primary"></i>
<strong><?= htmlspecialchars($deskNombre ?? 'Recepción') ?></strong>
<span id="badge-turno-activo" class="badge-turno-activo d-none">
Turno: <span id="badge-codigo">—</span>
</span>
</div>
<div class="d-flex align-items-center gap-2">
<span class="text-muted small"><?= htmlspecialchars($adminNombre) ?></span>
<?php if ($deskId): ?>
<a href="<?= BASE_URL ?>erp.php?m=turnero&v=display_recepcion&desk_id=<?= $deskId ?>" class="btn btn-outline-info btn-sm" target="_blank">
<i class="fas fa-tv me-1"></i>Ver Pantalla
</a>
<?php endif; ?>
<button class="btn btn-primary btn-sm" id="btn-llamar" onclick="llamarSiguiente()">
<i class="fas fa-bell me-1"></i>Llamar siguiente
</button>
</div>
</div>
<div class="rec-layout">
<!-- ══ Columna izquierda: cola ════════════════════════ -->
<div class="rec-cola">
<div class="rec-cola-header">
<div class="d-flex align-items-center justify-content-between">
<span class="fw-semibold text-secondary small">
<i class="fas fa-list-ol me-1"></i>EN ESPERA
</span>
<span class="badge bg-primary rounded-pill" id="badge-count">0</span>
</div>
<button class="btn-llamar-cola" id="btn-llamar-cola" onclick="llamarSiguiente()">
<i class="fas fa-bell"></i>Llamar siguiente
</button>
</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>
<!-- ══ Columna derecha: ficha del turno ═══════════════ -->
<div class="rec-ficha" id="rec-ficha">
<button class="btn btn-sm btn-outline-secondary btn-volver-cola mb-3"
style="display:none" onclick="volverACola()">
<i class="fas fa-arrow-left"></i> Cola
</button>
<div class="ficha-placeholder" id="ficha-placeholder">
<i class="fas fa-ticket-alt fa-3x mb-3" style="color:#cbd5e1"></i>
<p class="mb-0 fw-semibold">Sin turno activo</p>
<small class="text-muted">Haga clic en <i class="fas fa-bell"></i> de un turno para llamarlo, o use "Llamar siguiente"</small>
</div>
<!-- Ficha de turno (oculta hasta llamar) -->
<div id="ficha-turno" class="d-none">
<!-- Encabezado del turno -->
<div class="d-flex align-items-center gap-3 mb-3">
<div id="ficha-prio-dot" class="prio-dot"
style="width:52px;height:52px;border-radius:12px;display:flex;align-items:center;
justify-content:center;font-size:1.6rem;font-weight:900;color:#fff;background:#3b82f6">
</div>
<div class="flex-grow-1">
<div class="h4 mb-0 fw-bold" id="ficha-codigo">—</div>
<div class="text-muted small" id="ficha-prio-nombre">—</div>
</div>
<button class="btn btn-outline-primary btn-sm" onclick="rellamarActivo()"
title="Volver a llamar este turno (suena en pantalla)">
<i class="fas fa-bell"></i> Re-llamar
</button>
</div>
<!-- ── Sección 1: Paciente ── -->
<!-- ── Número de orden ── -->
<div class="ficha-section" style="padding-bottom:10px;border-bottom:1px solid #f1f5f9;margin-bottom:4px">
<div class="d-flex align-items-center gap-2">
<label style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:#94a3b8;white-space:nowrap;margin:0">
<i class="fas fa-hashtag me-1"></i>N.° Orden
</label>
<input type="text" id="inp-consecutivo"
class="form-control form-control-sm"
style="max-width:140px;font-weight:700;font-size:.95rem;letter-spacing:.04em"
placeholder="Cargando…" autocomplete="off">
<button class="btn btn-outline-secondary btn-sm" title="Regenerar siguiente" onclick="recargarConsecutivo()" type="button">
<i class="fas fa-rotate-right" style="font-size:.75rem"></i>
</button>
</div>
</div>
<div class="ficha-section">
<h6><i class="fas fa-user me-1"></i>Paciente</h6>
<!-- Si el kiosko capturó un nombre -->
<div id="bloque-nombre-kiosko" class="d-none mb-2">
<small class="text-muted">Kiosko:</small>
<div class="fw-semibold" id="lbl-nombre-kiosko"></div>
<div id="bloque-pac-kiosko" class="d-none mt-1">
<small class="text-muted">Paciente:</small>
<div class="fw-semibold text-primary" id="lbl-pac-kiosko-nombre"></div>
</div>
</div>
<div id="bloque-pac-no-vinculado">
<div class="input-group mb-2">
<input type="text" id="inp-buscar-pac"
class="form-control form-control-sm"
placeholder="Buscar por nombre o documento…"
autocomplete="off">
<button class="btn btn-outline-secondary btn-sm" onclick="buscarPaciente()" type="button">
<i class="fas fa-search"></i>
</button>
</div>
<div id="lista-pacientes-res"></div>
</div>
<div id="bloque-pac-seleccionado" class="d-none">
<div class="pac-seleccionado">
<div>
<div class="fw-semibold" id="lbl-pac-nombre">—</div>
<div class="text-muted small" id="lbl-pac-doc">—</div>
<div class="text-muted small" id="lbl-pac-cel"></div>
</div>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm" onclick="abrirModalPaciente(pacienteActivo?.id)" title="Ver / Editar paciente">
<i class="fas fa-pen fa-xs"></i>
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="cambiarPaciente()" title="Buscar otro paciente">
<i class="fas fa-exchange-alt fa-xs"></i>
</button>
</div>
</div>
<!-- Historial colapsable del paciente -->
<div id="sec-historial-pac" class="d-none">
<button class="hist-toggle-btn" id="btn-hist-toggle" onclick="toggleHistorialPaciente()">
<i class="fas fa-history" style="color:#6366f1"></i>
Historial del paciente
<i class="fas fa-chevron-down hist-chev"></i>
</button>
<div id="hist-pac-body" class="hist-pac-body d-none">
<div id="hist-pac-content" class="pac-timeline">
<div class="text-center text-muted py-2 small">
<i class="fas fa-spinner fa-spin me-1"></i>Cargando…
</div>
</div>
</div>
</div>
<!-- Embarazo -->
<div class="mt-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="chk-embarazada">
<label class="form-check-label small fw-semibold text-danger" for="chk-embarazada">
<i class="fas fa-baby me-1"></i>Paciente embarazada
</label>
</div>
</div>
<!-- Médico tratante -->
<div class="mt-2" id="bloque-medico">
<div class="small fw-semibold text-secondary mb-1">
<i class="fas fa-user-md me-1"></i>Médico tratante <span class="text-muted fw-normal">(opcional)</span>
</div>
<div id="medico-seleccionado" class="d-none mb-1">
<span class="badge text-bg-light border" style="font-size:.8rem;padding:5px 9px" id="medico-badge-txt"></span>
<button type="button" class="btn btn-link btn-sm p-0 ms-1 text-danger" onclick="quitarMedico()" title="Quitar médico"><i class="fas fa-times"></i></button>
</div>
<div id="medico-buscador">
<input type="text" id="inp-buscar-medico" class="form-control form-control-sm"
placeholder="Buscar por nombre, código o doc…"
autocomplete="off" oninput="buscarMedico()">
<div id="medico-dropdown" class="position-relative">
<ul id="medico-resultados" class="list-unstyled mb-0 border rounded bg-white shadow-sm position-absolute w-100 d-none"
style="z-index:200;max-height:180px;overflow-y:auto;top:2px"></ul>
</div>
</div>
<input type="hidden" id="inp-medico-id">
</div>
</div>
</div>
<!-- ── Sección 2: Lugar destino ── -->
<div class="ficha-section">
<h6><i class="fas fa-map-marker-alt me-1"></i>Lugar destino</h6>
<select id="sel-lugar" class="form-select form-select-sm">
<option value="">— Seleccione destino —</option>
<?php foreach ($lugaresDestino as $ld): ?>
<option value="<?= (int)$ld['id'] ?>"><?= htmlspecialchars($ld['nombre']) ?></option>
<?php endforeach; ?>
</select>
</div>
<!-- ── Sección 3: Exámenes ── -->
<div class="ficha-section">
<h6><i class="fas fa-vial me-1"></i>Exámenes solicitados</h6>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="chk-solo-muestras" onchange="toggleSoloMuestras()">
<label class="form-check-label small fw-semibold text-warning" for="chk-solo-muestras">
<i class="fas fa-vial me-1"></i>Solo entrega de muestras
</label>
</div>
<div id="wrap-examenes">
<select id="sel-examenes" multiple placeholder="Buscar y seleccionar exámenes…">
<?php foreach ($examenesAgrupados as $cat => $items): ?>
<optgroup label="<?= htmlspecialchars($cat) ?>">
<?php foreach ($items as $ex): ?>
<option value="<?= (int)$ex['id'] ?>"
data-consent="<?= (int)$ex['tiene_consentimiento'] ?>">
<?= htmlspecialchars($ex['codigo']) ?> — <?= htmlspecialchars($ex['nombre']) ?>
</option>
<?php endforeach; ?>
</optgroup>
<?php endforeach; ?>
</select>
</div>
<div class="mt-2">
<button class="btn btn-outline-secondary btn-sm" onclick="examTS && examTS.clear()">
<i class="fas fa-times me-1"></i>Limpiar selección
</button>
</div>
</div>
<!-- ── Sección 3.5: Empresa / Convenio ── -->
<div class="ficha-section" id="sec-empresa">
<h6><i class="fas fa-building me-1"></i>Empresa / Convenio <span class="text-muted fw-normal">(opcional)</span></h6>
<!-- Buscador empresa -->
<div id="empresa-seleccionada" class="d-none mb-2">
<div class="d-flex align-items-center gap-2 p-2 rounded border" style="background:#f0f9ff">
<div class="flex-grow-1">
<div class="fw-semibold small" id="empresa-badge-nombre"></div>
<div class="text-muted" style="font-size:.75rem" id="empresa-badge-meta"></div>
</div>
<button type="button" class="btn btn-link btn-sm p-0 text-danger" onclick="quitarEmpresa()" title="Quitar empresa">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div id="empresa-buscador">
<input type="text" id="inp-buscar-empresa" class="form-control form-control-sm"
placeholder="Buscar empresa por nombre o NIT…"
autocomplete="off" oninput="buscarEmpresa()">
<div class="position-relative">
<ul id="empresa-resultados" class="list-unstyled mb-0 border rounded bg-white shadow-sm position-absolute w-100 d-none"
style="z-index:200;max-height:200px;overflow-y:auto;top:2px"></ul>
</div>
</div>
<input type="hidden" id="inp-empresa-nit">
<!-- Subgrupo (se muestra cuando hay subgrupos) -->
<div id="wrap-subgrupo" class="d-none mt-2">
<label class="form-label mb-1 small fw-semibold">Subgrupo</label>
<select id="sel-subgrupo" class="form-select form-select-sm" onchange="recalcularPrecios()">
<option value="">— Sin subgrupo —</option>
</select>
</div>
<!-- Autorización -->
<div id="wrap-autorizacion" class="d-none mt-2">
<label class="form-label mb-1 small fw-semibold text-warning">
<i class="fas fa-key me-1"></i>N.° Autorización <span class="text-danger">*</span>
</label>
<input type="text" id="inp-autorizacion" class="form-control form-control-sm"
placeholder="Número de autorización EPS" maxlength="100">
</div>
<!-- Diagnóstico -->
<div id="wrap-diag" class="d-none mt-2">
<label class="form-label mb-1 small fw-semibold">Diagnóstico (CIE-10)</label>
<input type="text" id="inp-diag" class="form-control form-control-sm"
placeholder="Código CIE-10 diagnóstico principal" maxlength="20">
</div>
<!-- Tabla de precios calculados -->
<div id="panel-precios" class="d-none mt-3">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="small fw-semibold text-muted text-uppercase" style="font-size:.7rem;letter-spacing:.06em">
Precios
</span>
<span class="badge bg-light text-dark border small" id="precio-tarifa-label"></span>
</div>
<div id="tabla-precios" class="mb-1" style="font-size:.82rem"></div>
<div class="d-flex justify-content-between align-items-center pt-1 border-top">
<span class="fw-bold small">Total calculado</span>
<span class="fw-bold text-success" id="precio-total-calc">$0</span>
</div>
<div id="precio-dcto-row" class="d-none d-flex justify-content-between text-muted" style="font-size:.78rem">
<span>Descuento empresa (<span id="precio-dcto-pct"></span>%)</span>
<span id="precio-dcto-val" class="text-danger"></span>
</div>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-2"
onclick="aplicarPrecioCalculado()" id="btn-aplicar-precio">
<i class="fas fa-check me-1"></i>Aplicar al cobro
</button>
</div>
</div>
<!-- ── Sección 4: Pago ── -->
<div class="ficha-section">
<h6><i class="fas fa-dollar-sign me-1"></i>Cobro (opcional)</h6>
<div class="row g-2">
<div class="col-6">
<input type="number" id="inp-total" class="form-control form-control-sm"
placeholder="Total $" step="100" min="0">
</div>
<div class="col-6">
<select id="sel-pago" class="form-select form-select-sm" onchange="togglePagoCombinado()">
<option value="">— Forma de pago —</option>
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
<option value="tarjeta">Tarjeta</option>
<option value="eps">EPS / Convenio</option>
<option value="cortesia">Cortesía</option>
<option value="bono">Bono</option>
<option value="combinado">Pago combinado</option>
</select>
</div>
</div>
<!-- Panel pago combinado -->
<div id="panel-combinado" class="d-none mt-2 p-2 border rounded" style="background:#f8fafc">
<div class="row g-2 align-items-center mb-1">
<div class="col-5"><label class="form-label mb-0 small fw-semibold"><i class="fas fa-money-bill-wave me-1 text-success"></i>Efectivo</label></div>
<div class="col-7"><input type="number" id="comb-efectivo" class="form-control form-control-sm" placeholder="$0" min="0" step="100" oninput="sumarCombinado()"></div>
</div>
<div class="row g-2 align-items-center mb-1">
<div class="col-5"><label class="form-label mb-0 small fw-semibold"><i class="fas fa-university me-1 text-primary"></i>Transferencia</label></div>
<div class="col-7"><input type="number" id="comb-transferencia" class="form-control form-control-sm" placeholder="$0" min="0" step="100" oninput="sumarCombinado()"></div>
</div>
<div class="row g-2 align-items-center">
<div class="col-5"><label class="form-label mb-0 small fw-semibold"><i class="fas fa-credit-card me-1 text-warning"></i>Tarjeta</label></div>
<div class="col-7"><input type="number" id="comb-tarjeta" class="form-control form-control-sm" placeholder="$0" min="0" step="100" oninput="sumarCombinado()"></div>
</div>
</div>
<textarea id="inp-obs" class="form-control form-control-sm mt-2"
rows="2" placeholder="Observaciones…" maxlength="500"></textarea>
</div>
<!-- ── Sección 5: Consentimientos ── -->
<div class="ficha-section" id="sec-consentimientos" style="display:none">
<div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="mb-0"><i class="fas fa-file-signature me-1"></i>Consentimientos informados</h6>
<button class="btn btn-sm btn-outline-primary py-0 px-2"
id="btn-reenviar-consent" onclick="enviarConsentimientosTodos()"
title="Enviar todos por WhatsApp">
<i class="fas fa-paper-plane me-1"></i>Enviar todos
</button>
</div>
<div id="lista-consentimientos"></div>
</div>
<!-- ── Acciones ── -->
<div class="ficha-acciones">
<div class="accion-primary-row">
<button class="btn-accion-primary verde" id="btn-guardar" onclick="guardarSolicitud()">
<i class="fas fa-save"></i>Guardar solicitud
</button>
<button class="btn-accion-primary azul d-none" id="btn-pasar-lugar" onclick="pasarALugar()">
<i class="fas fa-arrow-right"></i>Pasar a lugar
</button>
</div>
<div class="accion-secondary-row">
<button class="btn-accion-sec warning d-none" id="btn-enviar-consent" onclick="enviarConsentimientos()">
<i class="fas fa-paper-plane"></i>Consentimientos
</button>
<button class="btn-accion-sec info" id="btn-soltar" onclick="soltarTurno()" title="Liberar turno para que otro escritorio lo llame">
<i class="fas fa-undo-alt"></i>Soltar
</button>
<button class="btn-accion-sec danger" onclick="marcarAusente()">
<i class="fas fa-user-slash"></i>Ausente
</button>
</div>
</div>
</div><!-- /ficha-turno -->
</div><!-- /rec-ficha -->
</div><!-- /rec-layout -->
</main>
<!-- ═══ Modal firma enfermero ═══════════════════════════════════════ -->
<div class="modal fade" id="modalFirmaEnfermero" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title mb-0"><i class="fas fa-pen-nib me-1"></i>Firma del enfermero</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<p class="text-muted small mb-2" id="fe-nombre-consentimiento"></p>
<canvas id="fe-canvas" width="440" height="180"
style="border:1px solid #cbd5e1;border-radius:8px;touch-action:none;cursor:crosshair;max-width:100%"></canvas>
<div class="d-flex justify-content-between mt-2">
<button class="btn btn-sm btn-outline-secondary" onclick="feCanvas.limpiar()">
<i class="fas fa-eraser me-1"></i>Limpiar
</button>
<span class="text-muted small align-self-center">Firme en el recuadro</span>
</div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancelar</button>
<button type="button" class="btn btn-primary btn-sm" onclick="feGuardarFirma()">
<i class="fas fa-check me-1"></i>Guardar firma
</button>
</div>
</div>
</div>
</div>
<!-- ═══ Modal Paciente (crear / ver / editar) ════════════════════════════ -->
<div class="modal fade" id="modalPacienteRec" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content border-0 shadow-lg overflow-hidden" style="border-radius:1rem">
<!-- Header con avatar -->
<div id="mpac-header"
style="background:linear-gradient(135deg,#1043a0 0%,#1565c0 100%);
color:#fff;padding:1.3rem 1.5rem 1rem;
display:flex;align-items:center;gap:.9rem">
<div id="mpac-avatar"
style="width:52px;height:52px;border-radius:50%;
background:rgba(255,255,255,.18);display:flex;
align-items:center;justify-content:center;
font-size:1.4rem;font-weight:800;color:#fff;
border:2px solid rgba(255,255,255,.35);flex-shrink:0">?</div>
<div class="flex-grow-1">
<div style="font-size:1rem;font-weight:700" id="mpac-titulo">Nuevo Paciente</div>
<div style="font-size:.75rem;opacity:.75" id="mpac-subtitulo">Completa los datos del paciente</div>
</div>
<button type="button" class="btn-close btn-close-white ms-auto" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body py-3 px-4">
<form id="form-paciente-rec" autocomplete="off">
<input type="hidden" id="mpac-id">
<!-- Datos personales -->
<div class="ficha-section mb-3">
<h6><i class="fas fa-id-card me-1"></i>Datos personales</h6>
<div class="row g-2">
<div class="col-12">
<div class="form-floating">
<input type="text" class="form-control form-control-sm" id="mpac-nombre"
required placeholder="Nombre completo"
oninput="mpacAvatar(this.value)">
<label>Nombre completo <span class="text-danger">*</span></label>
</div>
</div>
<div class="col-sm-4">
<div class="form-floating">
<select class="form-select form-select-sm" id="mpac-tipo-doc">
<option value="CC">CC — Cédula</option>
<option value="CE">CE — Extranjería</option>
<option value="TI">TI — Tarjeta Identidad</option>
<option value="PA">PA — Pasaporte</option>
<option value="DE">DE — Doc. Extranjero</option>
<option value="NIT">NIT</option>
<option value="RC">RC — Reg. Civil</option>
</select>
<label>Tipo documento</label>
</div>
</div>
<div class="col-sm-4">
<div class="form-floating">
<input type="text" class="form-control form-control-sm" id="mpac-doc" placeholder="Número documento">
<label>Número documento</label>
</div>
</div>
<div class="col-sm-4">
<div class="form-floating">
<input type="date" class="form-control form-control-sm" id="mpac-fnac" placeholder="Fecha nacimiento">
<label>Fecha nacimiento</label>
</div>
</div>
</div>
</div>
<!-- Contacto -->
<div class="ficha-section mb-3">
<h6><i class="fas fa-phone me-1"></i>Contacto</h6>
<div class="row g-2">
<div class="col-sm-5">
<label class="form-label small fw-semibold mb-1" style="color:#64748b">Teléfono</label>
<div class="input-group input-group-sm">
<select id="mpac-tel-prefijo" class="form-select" style="max-width:82px">
<option value="57" selected>🇨🇴 57</option>
<option value="58">🇻🇪 58</option>
<option value="1">🇺🇸 1</option>
</select>
<input type="tel" class="form-control" id="mpac-tel"
placeholder="3001234567" onblur="mpacVerificarWA()">
</div>
<div id="mpac-wa-badge" style="font-size:.78rem;min-height:18px;margin-top:3px"></div>
<input type="hidden" id="mpac-user-id">
</div>
<div class="col-sm-4">
<div class="form-floating">
<input type="email" class="form-control form-control-sm" id="mpac-email" placeholder="Email">
<label>Email</label>
</div>
</div>
<div class="col-sm-3">
<div class="form-floating">
<select class="form-select form-select-sm" id="mpac-genero">
<option value="">—</option>
<option value="M">Masculino</option>
<option value="F">Femenino</option>
<option value="O">Otro</option>
</select>
<label>Género</label>
</div>
</div>
</div>
</div>
<!-- Ubicación y EPS -->
<div class="ficha-section mb-2">
<h6><i class="fas fa-map-marker-alt me-1"></i>Ubicación y EPS</h6>
<div class="row g-2">
<div class="col-sm-4">
<div class="form-floating">
<input type="text" class="form-control form-control-sm" id="mpac-eps" placeholder="EPS">
<label>EPS</label>
</div>
</div>
<div class="col-sm-4">
<div class="form-floating">
<input type="text" class="form-control form-control-sm" id="mpac-ciudad" placeholder="Ciudad" value="Cúcuta">
<label>Ciudad</label>
</div>
</div>
<div class="col-sm-4">
<div class="form-floating">
<input type="text" class="form-control form-control-sm" id="mpac-barrio" placeholder="Barrio">
<label>Barrio</label>
</div>
</div>
<div class="col-12">
<div class="form-floating">
<input type="text" class="form-control form-control-sm" id="mpac-dir" placeholder="Dirección">
<label>Dirección</label>
</div>
</div>
<div class="col-12">
<div class="form-floating">
<textarea class="form-control form-control-sm" id="mpac-notas"
placeholder="Notas internas" style="height:68px"></textarea>
<label>Notas internas</label>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer" style="background:#f8faff;border-top:1px solid #e8edf5;padding:.8rem 1.25rem">
<button type="button" class="btn btn-sm btn-light px-3" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>Cancelar
</button>
<button type="button" class="btn btn-sm btn-primary px-4" id="mpac-btn-guardar" onclick="mpacGuardar()">
<i class="fas fa-save me-1"></i>Guardar paciente
</button>
</div>
</div>
</div>
</div>
<div class="rec-toast" id="rec-toast">
<span class="ico" id="toast-ico"><i class="fas fa-check-circle"></i></span>
<span id="toast-msg"></span>
</div>
<script>
// ── Estado ────────────────────────────────────────────────────
let turnoActivo = null;
let pacienteActivo = null;
let solicitudActiva = null;
let consentimientos = [];
let pollingColaId = null;
const API = '<?= BASE_URL ?>modules/turnero/api/';
const API_PAC = '<?= BASE_URL ?>api/lab/get_pacientes.php';
const BASE_WA = '<?= BASE_URL ?>';
const DESK_ID = <?= $deskId ?: 'null' ?>;
const DESK_NOMBRE = '<?= addslashes($deskNombre ?? 'Recepción') ?>';
// ── Arranque ──────────────────────────────────────────────────
let pollingConsentimientosId = null;
let examTS;
document.addEventListener('DOMContentLoaded', () => {
examTS = new TomSelect('#sel-examenes', {
plugins: ['remove_button'],
maxOptions: null,
selectOnTab: true,
onItemAdd() { recalcularPrecios(); examTS.setTextboxValue(''); examTS.refreshOptions(false); },
onItemRemove: () => recalcularPrecios(),
onType(str) {
if (!str) return;
requestAnimationFrame(() => {
const first = examTS.dropdown_content.querySelector('.option:not(.disabled)');
if (first) examTS.setActiveOption(first);
});
},
render: {
option(data, escape) {
const consent = data['data-consent'] == '1' || data.consent == '1';
return `<div class="d-flex align-items-center justify-content-between">
<span>${escape(data.text)}</span>
${consent ? '<span class="ts-consent-badge"><i class="fas fa-file-signature me-1"></i>Consentimiento</span>' : ''}
</div>`;
},
item(data, escape) {
const consent = data['data-consent'] == '1' || data.consent == '1';
return `<div>${escape(data.text)}${consent ? '<span class="ts-consent-dot" title="Requiere consentimiento"> ⚠</span>' : ''}</div>`;
}
}
});
cargarCola();
pollingColaId = setInterval(cargarCola, 3000);
document.getElementById('inp-buscar-pac')
.addEventListener('keydown', e => { if (e.key === 'Enter') buscarPaciente(); });
document.getElementById('sel-lugar')
.addEventListener('change', onLugarChange);
document.addEventListener('visibilitychange', () => {
if (!document.hidden && turnoActivo) refrescarConsentimientosLugar();
});
});
// ── Polling automático de consentimientos cuando hay uno abierto ──
async function refrescarConsentimientosLugar() {
if (!turnoActivo) return;
try {
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoActivo.id}`);
const json = await res.json();
if (json.ok && json.consentimientos) {
consentimientos = json.consentimientos;
renderConsentimientos(consentimientos);
}
} catch (_) {}
}
// ── Formularios del lugar (aparecen al seleccionar lugar) ────
// El dropdown sel-lugar es solo para elegir destino del paciente.
// Los consentimientos del desk se cargan via cargarConsentimientosDesk() y no dependen de este cambio.
function onLugarChange() {}
async function abrirFormularioLugar(formularioId, nombre) {
if (!turnoActivo) { mostrarError('Llama un turno primero.'); return; }
if (!pacienteActivo) { mostrarError('Vincula el paciente antes de abrir el consentimiento.'); return; }
try {
const res = await fetch(API + 'create_consent_token.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, formulario_id: formularioId, paciente_id: pacienteActivo?.id || null }),
});
const json = await res.json();
if (json.ok && (json.data?.url || json.url)) {
window.open(json.data?.url || json.url, '_blank');
// Polling automático para detectar la firma
if (!pollingConsentimientosId) {
pollingConsentimientosId = setInterval(refrescarConsentimientosLugar, 3000);
}
} else {
mostrarError(json.error || 'No se pudo generar el enlace de firma.');
}
} catch (err) {
mostrarError('Error al generar enlace: ' + err.message);
}
}
// ── Cola ──────────────────────────────────────────────────────
async function cargarCola() {
try {
const res = await fetch(API + 'get_cola.php?area=recepcion');
const json = await res.json();
if (!json.ok) return;
renderCola(json);
} catch (_) {}
}
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) {
const lista = document.getElementById('cola-items');
const todos = snap.cola || [];
const badge = document.getElementById('badge-count');
// Filtrar: 'espera' todos; 'en_recepcion' solo los de este escritorio
const visibles = todos.filter(t =>
t.estado === 'espera' ||
(t.estado === 'en_recepcion' && (t.recepcion_desk_id === null || t.recepcion_desk_id == DESK_ID))
);
badge.textContent = visibles.length;
if (visibles.length === 0) {
lista.innerHTML = `<div class="cola-vacia">
<i class="fas fa-hourglass-start fa-2x mb-2 d-block"></i>Cola vacía</div>`;
return;
}
lista.innerHTML = visibles.map(t => {
const enAtencion = t.estado === 'en_recepcion';
const te = tiempoEspera(t.creado_at);
const badgeRow = enAtencion
? `<div class="t-badge-row"><span class="atencion-chip"><i class="fas fa-circle" style="font-size:.45rem;vertical-align:middle;margin-right:3px"></i>EN ATENCIÓN</span></div>`
: (te ? `<div class="t-badge-row"><span class="tiempo-chip ${te.cls}">${escHtml(te.txt)}</span></div>` : '');
const clases = ['cola-card', turnoActivo?.id === t.id ? 'activo' : '', enAtencion ? 'en-atencion' : ''].filter(Boolean).join(' ');
return `
<div class="${clases}" data-id="${t.id}">
<div class="prio-dot" style="background:${t.prioridad_color};cursor:pointer" onclick="seleccionarTurno(${t.id})">${t.prioridad_codigo}</div>
<div class="turno-info" style="cursor:pointer" onclick="seleccionarTurno(${t.id})">
<div class="cod">${escHtml(t.codigo)}</div>
<div class="pac">${escHtml(t.paciente_nombre || 'Sin nombre')}</div>
${badgeRow}
</div>
<button onclick="llamarTurnoEspecifico(${t.id}, '${t.estado}')"
title="${enAtencion ? 'Re-llamar este turno' : 'Llamar este turno ahora'}"
style="flex-shrink:0;background:${enAtencion ? '#1d4ed8' : '#2563eb'};border:none;color:#fff;
border-radius:7px;padding:5px 9px;font-size:.78rem;cursor:pointer">
<i class="fas ${enAtencion ? 'fa-volume-up' : 'fa-bell'}"></i>
</button>
</div>`;
}).join('');
}
// ── Llamar siguiente ──────────────────────────────────────────
async function llamarSiguiente() {
const btn = document.getElementById('btn-llamar');
const btn2 = document.getElementById('btn-llamar-cola');
if (btn) btn.disabled = true;
if (btn2) btn2.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: 'recepcion', desk_id: DESK_ID }),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
const turnoLlamado = json.turno ?? json.data?.turno;
if (!turnoLlamado) {
mostrarAviso('Cola vacía', 'No hay turnos en espera.');
resetPlaceholder();
return;
}
mostrarLlamando(turnoLlamado.codigo);
abrirFicha(turnoLlamado);
cargarCola();
} catch (err) {
mostrarError(err.message);
} finally {
if (btn) btn.disabled = false;
if (btn2) btn2.disabled = false;
}
}
// ── Seleccionar turno de la cola (ver ficha sin cambiar estado) ─────
async function seleccionarTurno(turnoId) {
try {
const res = await fetch(API + 'get_turno.php?id=' + turnoId);
const json = await res.json();
if (!json.ok) { mostrarError(json.error || 'No se pudo cargar el turno'); return; }
abrirFicha(json.turno ?? json.data?.turno);
} catch (err) {
mostrarError('Error al cargar turno: ' + err.message);
}
}
// ── Llamar turno específico (cambia estado → en_recepcion) ─────────
async function llamarTurnoEspecifico(turnoId, estadoActual) {
// Mostrar feedback inmediato
const card = document.querySelector(`.cola-card[data-id="${turnoId}"]`);
if (card) {
const cod = card.querySelector('.cod')?.textContent || '';
mostrarLlamando(cod);
}
// Si ya está en recepción: re-llamar (actualiza llamado_at para que la pantalla TV reactive)
if (estadoActual === 'en_recepcion') {
try {
const res = await fetch(API + 'rellamar.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoId, desk_id: DESK_ID }),
});
const json = await res.json();
if (json.ok && json.turno) { abrirFicha(json.turno); return; }
} catch (_) {}
// Fallback: solo abrir ficha
seleccionarTurno(turnoId);
return;
}
try {
const res = await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoId, nuevo_estado: 'en_recepcion', desk_id: DESK_ID }),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
abrirFicha(json.turno);
cargarCola();
} catch (err) {
mostrarError('Error al llamar turno: ' + err.message);
}
}
// ── Re-llamar desde la ficha ────────────────────────────────
async function rellamarActivo() {
if (!turnoActivo) return;
mostrarLlamando(turnoActivo.codigo);
try {
await fetch(API + 'rellamar.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, desk_id: DESK_ID }),
});
} catch (_) {}
}
// ── Abrir ficha ───────────────────────────────────────────────
async function cargarConsecutivo() {
const inp = document.getElementById('inp-consecutivo');
if (!inp) return;
inp.value = '…';
try {
const r = await fetch(API + 'get_consecutivo.php?tipo=F');
const d = await r.json();
if (d.ok) inp.value = d.consecutivo;
} catch(_) { inp.value = ''; }
}
function recargarConsecutivo() { cargarConsecutivo(); }
function abrirFicha(turno) {
turnoActivo = turno;
pacienteActivo = null;
solicitudActiva = null;
document.getElementById('ficha-placeholder').classList.add('d-none');
document.getElementById('ficha-turno').classList.remove('d-none');
cargarConsecutivo();
// Datos del turno
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 || '#3b82f6';
// Badge top
document.getElementById('badge-turno-activo').classList.remove('d-none');
document.getElementById('badge-codigo').textContent = turno.codigo;
// Nombre del kiosko (valor capturado en kiosko)
if (turno.paciente_nombre) {
document.getElementById('bloque-nombre-kiosko').classList.remove('d-none');
document.getElementById('lbl-nombre-kiosko').textContent = turno.paciente_nombre;
document.getElementById('bloque-pac-kiosko').classList.add('d-none');
document.getElementById('inp-buscar-pac').value = turno.paciente_nombre;
}
// Reset secciones
resetCheckboxes();
document.getElementById('sec-consentimientos').style.removeProperty('display');
document.getElementById('sec-consentimientos').style.display = 'none';
document.getElementById('btn-pasar-lugar').classList.add('d-none');
document.getElementById('btn-enviar-consent').classList.add('d-none');
document.getElementById('btn-guardar').classList.remove('d-none');
document.getElementById('btn-guardar').disabled = false;
desvincularPaciente();
// Auto-vincular paciente si ya viene con ID desde el kiosko
if (turno.paciente_id) {
fetch(`${API_PAC}?id=${turno.paciente_id}`)
.then(r => r.json())
.then(json => {
const data = (json.data || json.registros || [])[0];
if (data && !pacienteActivo) seleccionarPaciente(data);
})
.catch(() => {});
}
// Cargar consentimientos del desk actual para este turno
if (DESK_ID) cargarConsentimientosDesk(turno.id);
mostrarFichaMobile();
}
// ── Cargar consentimientos del desk para el turno ─────────────
async function cargarConsentimientosDesk(turnoId) {
const sec = document.getElementById('sec-consentimientos');
const lista = document.getElementById('lista-consentimientos');
try {
// 1. Si el turno ya tiene registros creados, mostrarlos con su estado real
const resExist = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}`);
const jsonExist = await resExist.json();
if (jsonExist.ok && jsonExist.consentimientos?.length) {
consentimientos = jsonExist.consentimientos.map(c => ({
...c,
formulario_nombre: c.formulario_nombre || c.nombre,
}));
renderConsentimientos(consentimientos);
sec.style.display = '';
return;
}
// 2. Aún no existen registros: mostrar los configurados para el desk como pendientes
const resDesk = await fetch(`${API}get_lugar_formularios.php?lugar_id=${DESK_ID}`);
const jsonDesk = await resDesk.json();
if (!jsonDesk.ok || !jsonDesk.formularios?.length) {
sec.style.display = 'none';
return;
}
lista.innerHTML = jsonDesk.formularios.map(f => {
const nom = escHtml(f.nombre);
return `<div class="consent-item pendiente">
<i class="fas fa-file-signature"></i>
<span class="c-nom">${nom}</span>
<span class="c-badge">Pendiente</span>
<div class="consent-acciones">
<button class="btn btn-outline-primary"
onclick="abrirFormularioLugar(${f.id}, '${nom.replace(/'/g, "\\'")}')"
title="Abrir formulario en nueva pestaña">
<i class="fas fa-external-link-alt"></i> Firmar
</button>
</div>
</div>`;
}).join('');
sec.style.display = '';
} catch (_) {
sec.style.display = 'none';
}
}
// ── Buscador de paciente ──────────────────────────────────────
async function buscarPaciente() {
const q = document.getElementById('inp-buscar-pac').value.trim();
if (q.length < 2) return;
try {
const res = await fetch(`${API_PAC}?busqueda=${encodeURIComponent(q)}&limit=8`);
const json = await res.json();
const lista = document.getElementById('lista-pacientes-res');
const datos = json.data || json.registros || [];
if (!datos.length) {
lista.innerHTML = `<div class="text-muted small mt-1">Sin resultados.
<a href="#" onclick="abrirModalPaciente(null,'${escHtml(q).replace(/'/g,"\\'")}');return false">Crear nuevo</a></div>`;
return;
}
lista.innerHTML = datos.map(p => `
<div class="pac-resultado" onclick="seleccionarPaciente(${JSON.stringify(p).replace(/"/g,'&quot;')})">
<div class="pac-nombre">${escHtml(p.nombre_completo||p.full_name||p.nombre||'')}</div>
<div class="pac-doc">${escHtml(p.tipo_documento||'')} ${escHtml(p.documento||p.numero_documento||'')}
${p.telefono||p.celular ? '· ' + escHtml(p.telefono||p.celular) : ''}</div>
</div>`).join('');
} catch (_) {}
}
function seleccionarPaciente(pac) {
pacienteActivo = pac;
// Persistir vínculo en BD para que create_consent_token pueda verificar paciente_id
if (turnoActivo?.id && pac?.id) {
fetch(API + 'vincular_paciente.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, paciente_id: pac.id }),
}).catch(() => {});
}
document.getElementById('bloque-pac-no-vinculado').classList.add('d-none');
document.getElementById('bloque-pac-seleccionado').classList.remove('d-none');
const pacNombre = (pac.full_name || pac.nombre_completo || (pac.nombre||'') + ' ' + (pac.apellido||'')).trim();
document.getElementById('lbl-pac-nombre').textContent = pacNombre;
document.getElementById('lbl-pac-doc').textContent =
(pac.tipo_documento||'') + ' ' + (pac.documento||pac.numero_documento||'');
document.getElementById('lbl-pac-cel').textContent =
pac.telefono || pac.celular || '';
document.getElementById('lista-pacientes-res').innerHTML = '';
// Mostrar nombre del paciente vinculado debajo del valor del kiosko
if (!document.getElementById('bloque-nombre-kiosko').classList.contains('d-none')) {
document.getElementById('lbl-pac-kiosko-nombre').textContent = pacNombre;
document.getElementById('bloque-pac-kiosko').classList.remove('d-none');
}
// Mostrar historial del paciente
const secHist = document.getElementById('sec-historial-pac');
secHist.classList.remove('d-none');
// Resetear estado colapsado
document.getElementById('hist-pac-body').classList.add('d-none');
document.getElementById('btn-hist-toggle').classList.remove('open');
// Precargar en background (usuario decide si abre)
_historialPacienteCargado = false;
_historialPacienteId = pac.id;
}
function togglePagoCombinado() {
const combinado = document.getElementById('sel-pago').value === 'combinado';
document.getElementById('panel-combinado').classList.toggle('d-none', !combinado);
const inp = document.getElementById('inp-total');
inp.readOnly = combinado;
inp.style.background = combinado ? '#e9ecef' : '';
if (combinado) sumarCombinado();
}
function sumarCombinado() {
const v = id => parseFloat(document.getElementById(id).value) || 0;
const total = v('comb-efectivo') + v('comb-transferencia') + v('comb-tarjeta');
document.getElementById('inp-total').value = total || '';
}
function resetPagoCombinado() {
['comb-efectivo','comb-transferencia','comb-tarjeta'].forEach(id => document.getElementById(id).value = '');
document.getElementById('panel-combinado').classList.add('d-none');
const inp = document.getElementById('inp-total');
inp.readOnly = false;
inp.style.background = '';
}
function desvincularPaciente() {
pacienteActivo = null;
_historialPacienteId = null;
_historialPacienteCargado = false;
document.getElementById('bloque-pac-no-vinculado').classList.remove('d-none');
document.getElementById('bloque-pac-seleccionado').classList.add('d-none');
document.getElementById('bloque-pac-kiosko').classList.add('d-none');
document.getElementById('sec-historial-pac').classList.add('d-none');
document.getElementById('hist-pac-body').classList.add('d-none');
document.getElementById('btn-hist-toggle').classList.remove('open');
document.getElementById('lista-pacientes-res').innerHTML = '';
document.getElementById('inp-buscar-pac').value = '';
document.getElementById('chk-embarazada').checked = false;
}
function cambiarPaciente() {
desvincularPaciente();
setTimeout(() => document.getElementById('inp-buscar-pac').focus(), 80);
}
// ── Historial del paciente ──────────────────────────────────────
let _historialPacienteId = null;
let _historialPacienteCargado = false;
async function toggleHistorialPaciente() {
const body = document.getElementById('hist-pac-body');
const btn = document.getElementById('btn-hist-toggle');
const open = !body.classList.contains('d-none');
body.classList.toggle('d-none', open);
btn.classList.toggle('open', !open);
if (!open && !_historialPacienteCargado && _historialPacienteId) {
_historialPacienteCargado = true;
await cargarHistorialPaciente(_historialPacienteId);
}
}
async function cargarHistorialPaciente(pacienteId) {
const content = document.getElementById('hist-pac-content');
content.innerHTML = '<div class="text-center text-muted py-2 small"><i class="fas fa-spinner fa-spin me-1"></i>Cargando historial…</div>';
try {
const res = await fetch(`${API}get_historial.php?paciente_id=${pacienteId}&per_page=5&page=1`);
const json = await res.json();
if (!json.ok || !json.turnos || !json.turnos.length) {
content.innerHTML = '<div class="text-center text-muted py-3 small"><i class="fas fa-inbox me-1"></i>Sin visitas anteriores registradas</div>';
return;
}
content.innerHTML = renderHistorialTimeline(json.turnos, json.total);
} catch(_) {
content.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 CONS_ICON = {
pendiente: '📋', enviado: '📤', visto: '👁', firmado: '✅', rechazado: '❌'
};
const totalLabel = total > turnos.length ? `<div class="text-end px-3 pb-1" style="font-size:.67rem;color:#94a3b8">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 = t.fecha_sesion || '—';
const est = t.estado || '';
const eStyle = ESTADO_STYLE[est] || 'background:#f1f5f9;color:#334155';
const eLbl = 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>` : '';
// Exámenes
let examStr = '';
if (t.examenes && t.examenes.length) {
examStr = `<div style="margin-top:6px;font-size:.75rem">
<strong style="color:#64748b">Exámenes:</strong>
<div style="color:#475569">${t.examenes.map(e => escHtml(e.nombre)).join(', ')}</div>
</div>`;
}
// Comentarios
let comStr = '';
if (t.comentarios && t.comentarios.length) {
comStr = `<div style="margin-top:6px;font-size:.72rem;border-top:1px solid #e2e8f0;padding-top:6px">
<strong style="color:#64748b">📝 Últimos comentarios:</strong>
<div style="color:#475569;margin-top:4px">${t.comentarios.map(c =>
`<div style="margin-bottom:3px"><strong>${escHtml(c.usuario_nombre)}</strong>: ${escHtml(c.comentario)}</div>`
).join('')}</div>
</div>`;
}
return `<div class="timeline-item">
<div class="timeline-dot" style="color:${color};background:${color}"></div>
<div class="timeline-content">
<div class="timeline-fecha">${escHtml(fecha)}</div>
<div class="timeline-codigo" style="color:${color}">${escHtml(t.codigo)}</div>
<div class="timeline-lugar">${lugar}</div>
<span class="timeline-eb" style="${eStyle}">${escHtml(eLbl)}</span>${mins}
${examStr}${comStr}
</div>
</div>`;
}).join('');
return `<div class="pac-timeline">${items}</div>${totalLabel}`;
}
// ── Guardar solicitud ─────────────────────────────────────────
async function guardarSolicitud() {
if (!turnoActivo) return;
if (!pacienteActivo) { mostrarError('Seleccione un paciente antes de guardar.'); return; }
const lugarId = parseInt(document.getElementById('sel-lugar').value);
if (!lugarId) { mostrarError('Seleccione el lugar destino.'); return; }
const soloMuestras = document.getElementById('chk-solo-muestras').checked;
// ✅ VALIDACIÓN: verificar que los consentimientos del lugar estén firmados (omitir en solo muestras)
if (!soloMuestras) {
const consentPendientes = Array.from(document.querySelectorAll('.consent-item.pendiente, .consent-item.enviado, .consent-item.visto')).length;
if (consentPendientes > 0) {
mostrarError('⚠️ El paciente debe firmar los consentimientos del lugar antes de guardar la solicitud.');
return;
}
}
const examIds = examTS ? examTS.getValue().map(v => parseInt(v)) : [];
if (!soloMuestras && !examIds.length) { mostrarError('Seleccione al menos un examen.'); return; }
const btn = document.getElementById('btn-guardar');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando…';
try {
const res = await fetch(API + 'create_solicitud.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
turno_id: turnoActivo.id,
paciente_id: pacienteActivo.id,
lugar_id: lugarId,
exam_tipo_ids: examIds,
numero_orden: document.getElementById('inp-consecutivo').value.trim() || null,
total_cobrado: parseFloat(document.getElementById('inp-total').value) || null,
metodo_pago: document.getElementById('sel-pago').value || null,
pagos_detalle: document.getElementById('sel-pago').value === 'combinado' ? {
efectivo: parseFloat(document.getElementById('comb-efectivo').value) || 0,
transferencia: parseFloat(document.getElementById('comb-transferencia').value) || 0,
tarjeta: parseFloat(document.getElementById('comb-tarjeta').value) || 0,
} : null,
observaciones: document.getElementById('inp-obs').value.trim() || null,
embarazada: document.getElementById('chk-embarazada').checked ? 1 : 0,
solo_muestras: soloMuestras ? 1 : 0,
medico_id: parseInt(document.getElementById('inp-medico-id').value) || null,
nit_empresa: document.getElementById('inp-empresa-nit').value.trim() || null,
subgrupo_id: parseInt(document.getElementById('sel-subgrupo').value) || null,
autorizacion: document.getElementById('inp-autorizacion').value.trim() || null,
diag_ppal: document.getElementById('inp-diag').value.trim() || null,
items_precio: _preciosActivos.length ? _preciosActivos : null,
}),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
solicitudActiva = json.solicitud ?? json.data?.solicitud;
consentimientos = json.consentimientos_requeridos ?? json.data?.consentimientos_requeridos ?? [];
// Asegurar que turnoActivo tiene el estado correcto (en_recepcion)
if (turnoActivo) turnoActivo.estado = 'en_recepcion';
btn.innerHTML = '<i class="fas fa-check me-1"></i>Guardado';
// Confirmación de recepción generada
if (json.recepcion_id) {
mostrarToast(`Recepción #${json.recepcion_id} generada`, 'ok', 3000);
}
// Detener polling de consentimientos (ya se guardó)
if (pollingConsentimientosId) {
clearInterval(pollingConsentimientosId);
pollingConsentimientosId = null;
}
if (consentimientos && consentimientos.length > 0) {
renderConsentimientos(consentimientos);
document.getElementById('sec-consentimientos').style.display = '';
document.getElementById('btn-enviar-consent').classList.remove('d-none');
document.getElementById('btn-pasar-lugar').classList.remove('d-none');
document.getElementById('btn-guardar').classList.add('d-none');
} else {
// Sin consentimientos → pasar directamente al lugar
await pasarALugar();
}
} catch (err) {
mostrarError(err.message);
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar solicitud';
}
}
// ── Labels / iconos para estados ─────────────────────────────
const CONSENT_META = {
firmado : { cls:'firmado', ico:'fa-check-circle', lbl:'Firmado' },
enviado : { cls:'enviado', ico:'fa-envelope', lbl:'Enviado' },
visto : { cls:'visto', ico:'fa-eye', lbl:'Visto' },
rechazado : { cls:'rechazado', ico:'fa-ban', lbl:'Rechazado' },
pendiente : { cls:'pendiente', ico:'fa-clock', lbl:'Pendiente' },
};
function renderConsentimientos(lista) {
const el = document.getElementById('lista-consentimientos');
// Solo mostrar consentimientos de examen (origen_lugar_id null); los de toma de muestras se gestionan en lugar.php
lista = lista.filter(c => !c.origen_lugar_id);
if (!lista.length) {
el.innerHTML = '<div class="text-muted small"><i class="fas fa-check-circle text-success me-1"></i>Sin consentimientos requeridos</div>';
return;
}
el.innerHTML = lista.map(c => {
const m = CONSENT_META[c.estado] || CONSENT_META.pendiente;
const ya = ['firmado','rechazado'].includes(c.estado);
const token = escHtml(c.token || '');
const nom = escHtml(c.formulario_nombre || 'Consentimiento');
const fId = c.formulario_id;
const nomJs = (c.formulario_nombre || 'Consentimiento').replace(/'/g, "\\'");
// Botón de firma paciente (solo si no está firmado/rechazado)
const btnFirmar = (!ya)
? `<button class="btn btn-outline-primary" onclick="firmarConsentimiento(${fId}, '${nomJs}')"
title="Abrir firma en nueva pestaña">
<i class="fas fa-external-link-alt"></i> Firmar
</button>` : '';
// Botón ver/enviar WhatsApp
const btnWa = ya
? (c.token ? `<a href="${BASE_WA}ver_formulario_enviado.php?token=${token}" target="_blank"
class="btn btn-outline-secondary" title="Ver formulario firmado">
<i class="fas fa-eye"></i> Ver
</a>` : '')
: `<button class="btn btn-outline-success" onclick="enviarConsentimientoUno(${c.turno_id || turnoActivo?.id})"
title="Enviar enlace de firma por WhatsApp">
<i class="fab fa-whatsapp"></i> WhatsApp
</button>`;
// Fila de firma del profesional (solo si el formulario la requiere y el paciente ya firmó)
let profRow = '';
if (c.estado === 'firmado' && c.requiere_firma_profesional) {
const tienePro = c.tiene_firma_profesional == 1 || c.tiene_firma_profesional === true;
if (tienePro) {
profRow = `<div class="consent-prof-row">
<i class="fas fa-user-nurse text-success"></i>
<span class="text-success">Firma enfermero</span>
<span class="badge bg-success ms-1">OK</span>
</div>`;
} else {
profRow = `<div class="consent-prof-row">
<i class="fas fa-user-nurse text-warning"></i>
<span class="text-warning fw-semibold">Falta firma enfermero</span>
<button class="btn btn-warning btn-sm ms-auto" style="font-size:.7rem;padding:1px 7px"
onclick="abrirFirmaEnfermero(${fId}, '${nomJs}')">
<i class="fas fa-pen-nib me-1"></i>Firmar
</button>
</div>`;
}
}
return `<div class="consent-item ${m.cls}" style="flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:.5rem;width:100%">
<i class="fas ${m.ico}"></i>
<span class="c-nom">${nom}</span>
<span class="c-badge">${m.lbl}</span>
<div class="consent-acciones">${btnFirmar}${btnWa}</div>
</div>
${profRow}
</div>`;
}).join('');
}
// ── Firma del enfermero: canvas modal ─────────────────────────
let _feFormularioId = null;
const feCanvas = {
_el: null, _ctx: null, _drawing: false,
init() {
this._el = document.getElementById('fe-canvas');
this._ctx = this._el.getContext('2d');
this._ctx.strokeStyle = '#1e293b';
this._ctx.lineWidth = 2;
this._ctx.lineCap = 'round';
this._el.addEventListener('pointerdown', e => { this._drawing = true; this._ctx.beginPath(); this._move(e); });
this._el.addEventListener('pointermove', e => { if (!this._drawing) return; this._ctx.lineTo(...this._pos(e)); this._ctx.stroke(); });
['pointerup','pointerleave'].forEach(ev => this._el.addEventListener(ev, () => { this._drawing = false; }));
},
_pos(e) { const r = this._el.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; },
_move(e) { const [x,y] = this._pos(e); this._ctx.moveTo(x, y); },
limpiar() { this._ctx.clearRect(0, 0, this._el.width, this._el.height); },
vacio() {
const d = this._ctx.getImageData(0, 0, this._el.width, this._el.height).data;
return !d.some(v => v !== 0);
},
png() { return this._el.toDataURL('image/png'); },
};
function abrirFirmaEnfermero(formularioId, nombre) {
if (!turnoActivo) { mostrarError('No hay turno activo.'); return; }
_feFormularioId = formularioId;
document.getElementById('fe-nombre-consentimiento').textContent = nombre;
if (!feCanvas._el) feCanvas.init();
feCanvas.limpiar();
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('modalFirmaEnfermero'));
modal.show();
}
async function feGuardarFirma() {
if (!turnoActivo || !_feFormularioId) return;
if (feCanvas.vacio()) { mostrarError('Dibuja la firma antes de guardar.'); return; }
const png = feCanvas.png();
const btn = document.querySelector('#modalFirmaEnfermero .btn-primary');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando…';
try {
const res = await fetch(API + 'firmar_profesional_consentimiento.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, formulario_id: _feFormularioId, svg: png }),
});
const json = await res.json();
if (json.ok) {
bootstrap.Modal.getOrCreateInstance(document.getElementById('modalFirmaEnfermero')).hide();
mostrarToast('Firma del enfermero guardada', 'success');
await refrescarConsentimientosLugar();
} else {
mostrarError(json.error || 'No se pudo guardar la firma.');
}
} catch(e) {
mostrarError('Error al guardar: ' + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-check me-1"></i>Guardar firma';
}
}
// ── Firmar en nueva pestaña ──────────────────────────────────
async function firmarConsentimiento(formularioId, nombre) {
if (!turnoActivo) { mostrarError('No hay turno activo.'); return; }
try {
const res = await fetch(API + 'create_consent_token.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, formulario_id: formularioId, paciente_id: pacienteActivo?.id || null }),
});
const json = await res.json();
if (json.ok && (json.url || json.data?.url)) {
window.open(json.url || json.data.url, '_blank');
// Iniciar polling automático para detectar firma
if (!pollingConsentimientosId) {
pollingConsentimientosId = setInterval(() => {
refrescarConsentimientosLugar();
}, 3000);
}
} else {
mostrarError(json.error || 'No se pudo crear el token de firma');
}
} catch (err) {
mostrarError('Error al abrir firma: ' + err.message);
}
}
function verFirmado(token) {
window.open(BASE_WA + 'ver_formulario_enviado.php?token=' + encodeURIComponent(token), '_blank');
}
// ── Refrescar estado de consentimientos ───────────────────────
async function refrescarConsentimientos(turnoId) {
try {
const res = await fetch(`${API}get_consentimientos.php?turno_id=${turnoId}`);
const json = await res.json();
if (json.ok) {
consentimientos = json.consentimientos || [];
renderConsentimientos(consentimientos);
}
} catch(_) {}
}
// ── Enviar consentimientos (todos) ───────────────────────────
async function enviarConsentimientosTodos() {
if (!turnoActivo) return;
const btn = document.getElementById('btn-reenviar-consent');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Enviando…';
try {
const res = await fetch(API + 'send_consentimiento.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id }),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
consentimientos = json.consentimientos ?? consentimientos;
renderConsentimientos(consentimientos);
btn.innerHTML = '<i class="fas fa-check me-1"></i>Enviado';
setTimeout(() => { btn.innerHTML = '<i class="fas fa-paper-plane me-1"></i>Enviar todos'; }, 3000);
} catch (err) {
mostrarError(err.message);
btn.innerHTML = '<i class="fas fa-paper-plane me-1"></i>Enviar todos';
} finally {
btn.disabled = false;
}
}
// ── Enviar consentimiento individual ─────────────────────────
async function enviarConsentimientoUno(turnoId) {
if (!turnoId) return;
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; }
consentimientos = json.consentimientos ?? consentimientos;
renderConsentimientos(consentimientos);
} catch (err) {
mostrarError(err.message);
}
}
// ── Enviar consentimientos (legacy — usado por btn-enviar-consent) ──
async function enviarConsentimientos() { return enviarConsentimientosTodos(); }
// ── Pasar a lugar ─────────────────────────────────────────────
async function pasarALugar() {
if (!turnoActivo || !solicitudActiva) return;
const btn = document.getElementById('btn-pasar-lugar');
if (btn) btn.disabled = true;
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: solicitudActiva.lugar_id,
}),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); if (btn) btn.disabled = false; return; }
// Reiniciar ficha
turnoActivo = null;
solicitudActiva = null;
document.getElementById('ficha-turno').classList.add('d-none');
document.getElementById('badge-turno-activo').classList.add('d-none');
mostrarToast('Turno pasado al lugar.', 'success', 2000);
setTimeout(() => {
cargarCola();
resetPlaceholder();
}, 500);
} catch (err) {
mostrarError(err.message);
if (btn) btn.disabled = false;
}
}
// ── Ausente ───────────────────────────────────────────────────
async function marcarAusente() {
if (!turnoActivo) return;
if (!confirm(`¿Marcar turno ${turnoActivo.codigo} como AUSENTE?`)) return;
await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: 'ausente' }),
});
turnoActivo = null;
solicitudActiva = null;
document.getElementById('ficha-turno').classList.add('d-none');
document.getElementById('ficha-placeholder').classList.remove('d-none');
document.getElementById('badge-turno-activo').classList.add('d-none');
cargarCola();
volverACola();
}
// ── Soltar turno ──────────────────────────────────────────────
async function soltarTurno() {
if (!turnoActivo) return;
if (!confirm(`¿Soltar turno ${turnoActivo.codigo}? Otro escritorio podrá llamarlo.`)) return;
await fetch(API + 'cambiar_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: 'espera' }),
});
turnoActivo = null;
solicitudActiva = null;
document.getElementById('ficha-turno').classList.add('d-none');
document.getElementById('ficha-placeholder').classList.remove('d-none');
document.getElementById('badge-turno-activo').classList.add('d-none');
mostrarToast('Turno liberado', 'success');
cargarCola();
volverACola();
}
// ── Helpers ───────────────────────────────────────────────────
// ── Médico tratante ─────────────────────────────────────────────
let _medicoTimer = null;
const API_MEDICOS = '<?= BASE_URL ?>modules/medicos/api/list.php';
function buscarMedico() {
clearTimeout(_medicoTimer);
const q = document.getElementById('inp-buscar-medico').value.trim();
const ul = document.getElementById('medico-resultados');
if (q.length < 2) { ul.classList.add('d-none'); ul.innerHTML = ''; return; }
_medicoTimer = setTimeout(async () => {
const res = await fetch(API_MEDICOS + '?q=' + encodeURIComponent(q));
const json = await res.json();
const rows = (json.data || []).slice(0, 8);
if (!rows.length) { ul.innerHTML = '<li class="px-3 py-2 text-muted small">Sin resultados</li>'; ul.classList.remove('d-none'); return; }
ul.innerHTML = rows.map(m =>
`<li class="px-3 py-2 small" style="cursor:pointer;border-bottom:1px solid #f1f5f9"
onmousedown="seleccionarMedico(${m.id},'${escJs(m.codigo)}','${escJs(m.nombres)} ${escJs(m.apellidos)}','${escJs(m.cod_especialidad||'')}')">
<span class="fw-semibold">${esc(m.nombres)} ${esc(m.apellidos)}</span>
<span class="text-muted ms-1">[${esc(m.codigo)}]</span>
${m.cod_especialidad ? `<span class="badge text-bg-light ms-1" style="font-size:.7rem">${esc(m.cod_especialidad)}</span>` : ''}
</li>`
).join('');
ul.classList.remove('d-none');
}, 250);
}
function seleccionarMedico(id, codigo, nombre, esp) {
document.getElementById('inp-medico-id').value = id;
document.getElementById('medico-badge-txt').textContent = nombre + (esp ? ' · ' + esp : '') + ' [' + codigo + ']';
document.getElementById('medico-seleccionado').classList.remove('d-none');
document.getElementById('medico-buscador').classList.add('d-none');
document.getElementById('medico-resultados').classList.add('d-none');
}
function quitarMedico() {
document.getElementById('inp-medico-id').value = '';
document.getElementById('medico-seleccionado').classList.add('d-none');
document.getElementById('medico-buscador').classList.remove('d-none');
document.getElementById('inp-buscar-medico').value = '';
}
function resetMedico() {
quitarMedico();
}
function escJs(s) { return String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'"); }
function toggleSoloMuestras() {
const solo = document.getElementById('chk-solo-muestras').checked;
const wrap = document.getElementById('wrap-examenes');
if (examTS) { examTS.clear(); solo ? examTS.disable() : examTS.enable(); }
wrap.classList.toggle('disabled', solo);
}
function resetCheckboxes() {
document.getElementById('chk-solo-muestras').checked = false;
if (examTS) { examTS.clear(); examTS.enable(); }
document.getElementById('wrap-examenes').classList.remove('disabled');
document.getElementById('sel-pago').value = '';
document.getElementById('inp-total').value = '';
document.getElementById('inp-obs').value = '';
resetPagoCombinado();
resetMedico();
quitarEmpresa();
}
// ── Empresa / Convenio ────────────────────────────────────────
let _empresaActiva = null;
let _empresaBusqTimer;
function buscarEmpresa() {
clearTimeout(_empresaBusqTimer);
_empresaBusqTimer = setTimeout(_doEmpresaSearch, 280);
}
async function _doEmpresaSearch() {
const q = document.getElementById('inp-buscar-empresa').value.trim();
const ul = document.getElementById('empresa-resultados');
if (q.length < 2) { ul.classList.add('d-none'); return; }
const res = await fetch(`${BASE_URL_API_LAB}empresas.php?action=list&search=${encodeURIComponent(q)}&activa=1&limit=10`);
const j = await res.json();
const list = j.empresas || [];
if (!list.length) { ul.innerHTML = '<li class="px-3 py-2 text-muted small">Sin resultados</li>'; ul.classList.remove('d-none'); return; }
ul.innerHTML = list.map(e => `
<li class="px-3 py-2 cursor-pointer hover-bg"
style="cursor:pointer;border-bottom:1px solid #f1f5f9"
onmousedown="seleccionarEmpresa(${JSON.stringify(e)})">
<div class="fw-semibold small">${escHtml(e.nombre)}</div>
<div class="text-muted" style="font-size:.75rem">NIT ${escHtml(e.nit)}${e.tarifa_nombre ? ' · ' + escHtml(e.tarifa_nombre) : ''}</div>
</li>
`).join('');
ul.classList.remove('d-none');
}
async function seleccionarEmpresa(e) {
document.getElementById('empresa-resultados').classList.add('d-none');
document.getElementById('empresa-buscador').classList.add('d-none');
document.getElementById('empresa-seleccionada').classList.remove('d-none');
document.getElementById('empresa-badge-nombre').textContent = e.nombre;
document.getElementById('empresa-badge-meta').textContent =
`NIT ${e.nit}${e.tarifa_nombre ? ' · ' + e.tarifa_nombre : ''}${parseFloat(e.descuento_pct) > 0 ? ' · Dcto: ' + e.descuento_pct + '%' : ''}`;
document.getElementById('inp-empresa-nit').value = e.nit;
_empresaActiva = e;
// Mostrar autorización y diagnóstico si la empresa los exige
document.getElementById('wrap-autorizacion').classList.toggle('d-none', !parseInt(e.req_autoriza));
document.getElementById('wrap-diag').classList.remove('d-none');
// Cargar subgrupos
const res = await fetch(`${BASE_URL_API_LAB}empresa_subgrupos.php?nit_empresa=${encodeURIComponent(e.nit)}`);
const j = await res.json();
const subs = j.subgrupos || [];
const wrapSub = document.getElementById('wrap-subgrupo');
const selSub = document.getElementById('sel-subgrupo');
if (subs.length) {
selSub.innerHTML = '<option value="">— Sin subgrupo —</option>' +
subs.map(s => `<option value="${s.id}">${escHtml(s.subgrupo)}${s.tarifa_nombre ? ' · ' + escHtml(s.tarifa_nombre) : ''}</option>`).join('');
wrapSub.classList.remove('d-none');
} else {
selSub.innerHTML = '<option value="">— Sin subgrupo —</option>';
wrapSub.classList.add('d-none');
}
// Si es EPS, preseleccionar forma de pago
if (document.getElementById('sel-pago').value === '') {
document.getElementById('sel-pago').value = 'eps';
togglePagoCombinado();
}
recalcularPrecios();
}
function quitarEmpresa() {
_empresaActiva = null;
document.getElementById('inp-empresa-nit').value = '';
document.getElementById('inp-buscar-empresa').value = '';
document.getElementById('empresa-seleccionada').classList.add('d-none');
document.getElementById('empresa-buscador').classList.remove('d-none');
document.getElementById('empresa-resultados').classList.add('d-none');
document.getElementById('wrap-subgrupo').classList.add('d-none');
document.getElementById('wrap-autorizacion').classList.add('d-none');
document.getElementById('wrap-diag').classList.add('d-none');
document.getElementById('panel-precios').classList.add('d-none');
document.getElementById('sel-subgrupo').innerHTML = '<option value="">— Sin subgrupo —</option>';
document.getElementById('inp-autorizacion').value = '';
document.getElementById('inp-diag').value = '';
}
// ── Motor de precios ──────────────────────────────────────────
let _recalcTimer;
let _preciosActivos = [];
function recalcularPrecios() {
clearTimeout(_recalcTimer);
_recalcTimer = setTimeout(_doRecalcular, 200);
}
async function _doRecalcular() {
const examIds = examTS ? examTS.getValue() : [];
const nit = document.getElementById('inp-empresa-nit').value.trim();
const subId = document.getElementById('sel-subgrupo').value;
if (!examIds.length || !nit) {
document.getElementById('panel-precios').classList.add('d-none');
return;
}
const params = new URLSearchParams({ nit_empresa: nit });
examIds.forEach(id => params.append('exam_ids[]', id));
if (subId) params.append('subgrupo_id', subId);
const res = await fetch(`${API}get_precios_examenes.php?${params}`);
const j = await res.json();
if (!j.ok) return;
_preciosActivos = j.items || [];
// Tabla
const tabla = document.getElementById('tabla-precios');
const tienePrecios = j.items.some(i => i.tiene_precio);
if (!tienePrecios) {
tabla.innerHTML = '<div class="text-muted small text-center py-1">Sin precios configurados para esta tarifa</div>';
} else {
tabla.innerHTML = j.items.map(i => `
<div class="d-flex justify-content-between py-1" style="border-bottom:1px solid #f1f5f9">
<span class="text-truncate me-2" style="max-width:200px" title="${escHtml(i.nombre)}">${escHtml(i.nombre)}</span>
<span class="${i.tiene_precio ? 'fw-semibold' : 'text-muted'}">
${i.tiene_precio ? '$' + fmt(i.valor_final) : 'Sin precio'}
</span>
</div>
`).join('');
}
// Totales
document.getElementById('precio-tarifa-label').textContent = j.tarifa_nombre || '';
document.getElementById('precio-total-calc').textContent = '$' + fmt(j.subtotal);
const dctoRow = document.getElementById('precio-dcto-row');
if (j.descuento_pct > 0) {
document.getElementById('precio-dcto-pct').textContent = j.descuento_pct;
document.getElementById('precio-dcto-val').textContent = '-$' + fmt(j.descuento_val);
document.getElementById('precio-total-calc').textContent = '$' + fmt(j.total);
dctoRow.classList.remove('d-none');
} else {
dctoRow.classList.add('d-none');
}
document.getElementById('panel-precios').classList.remove('d-none');
}
function aplicarPrecioCalculado() {
const total = _preciosActivos.reduce((sum, i) => sum + (i.valor_final || 0), 0);
const nit = document.getElementById('inp-empresa-nit').value.trim();
const emp = _empresaActiva;
const dcto = parseFloat(emp?.descuento_pct || 0);
const final = Math.round(total * (1 - dcto / 100));
document.getElementById('inp-total').value = final || '';
if (emp && !document.getElementById('sel-pago').value) {
document.getElementById('sel-pago').value = 'eps';
togglePagoCombinado();
}
mostrarToast('Total aplicado: $' + fmt(final), 'ok', 2500);
}
function fmt(n) {
return Math.round(n || 0).toLocaleString('es-CO');
}
const BASE_URL_API_LAB = '<?= BASE_URL ?>/api/lab/';
function escHtml(str) {
const d = document.createElement('div');
d.appendChild(document.createTextNode(String(str)));
return d.innerHTML;
}
const esc = escHtml;
function resetPlaceholder() {
const ph = document.getElementById('ficha-placeholder');
ph.innerHTML = `
<i class="fas fa-ticket-alt fa-3x mb-3" style="color:#cbd5e1"></i>
<p class="mb-0 fw-semibold">Sin turno activo</p>
<small class="text-muted">Haga clic en "Llamar siguiente" o en <i class="fas fa-bell"></i> de un turno de la cola</small>`;
volverACola();
}
function esMobile() { return window.innerWidth <= 680; }
function mostrarFichaMobile() {
if (!esMobile()) return;
document.getElementById('rec-ficha').classList.add('mobile-visible');
document.querySelector('.rec-cola').classList.add('mobile-oculta');
}
function volverACola() {
document.getElementById('rec-ficha').classList.remove('mobile-visible');
document.querySelector('.rec-cola').classList.remove('mobile-oculta');
}
function mostrarError(msg) {
mostrarToast(msg, 'error', 4000);
}
function mostrarAviso(titulo, msg) {
mostrarToast(titulo + ': ' + msg, 'warn', 3500);
}
function mostrarLlamando(codigo) {
mostrarToast('Llamando turno ' + codigo + '…', 'info', 2000);
}
/* ── Ocupación exclusiva del escritorio ──────────────────────── */
(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';
}
}
/* Pantalla de bloqueo */
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 escritorio 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>';
}
/* Solicitar toma de control */
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('✅ Escritorio 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;
}
};
/* Ir al siguiente disponible */
window._ocSiguiente = async function() {
const btn = document.getElementById('_btn-siguiente');
if (btn) btn.disabled = true;
_setEstado('Buscando escritorio disponible…', '#555');
try {
const j = await _post({ action: 'disponibles' });
if (!j.ok || !j.disponibles.length) {
_setEstado('No hay otros escritorios disponibles.', '#c62828');
if (btn) btn.disabled = false;
return;
}
_redirigirSiguiente(j.disponibles);
} catch(e) {
_setEstado('Error de red.', '#c62828');
if (btn) btn.disabled = false;
}
};
/* Modal de solicitud entrante (para el ocupante actual) */
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 escritorio.</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(() => {});
};
}
/* Arranque */
async function _iniciar() {
try {
const j = await _post({ action: 'claim' });
if (!j.ok) return;
if (!j.disponible) { _mostrarBloqueado(j.ocupado_por); return; }
// Heartbeat cada 60 s
setInterval(() => _post({ action: 'ping' }).catch(() => {}), 60000);
// Polling de solicitudes entrantes cada 2 s
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();
})(DESK_ID, DESK_NOMBRE, 'recepcion', 'desk_id');
// ── Modal paciente (crear / ver / editar) ────────────────────
const _mpacModal = () => bootstrap.Modal.getOrCreateInstance(document.getElementById('modalPacienteRec'));
function mpacAvatar(nombre) {
const parts = nombre.trim().split(/\s+/).filter(Boolean);
document.getElementById('mpac-avatar').textContent = parts.length >= 2
? (parts[0][0] + parts[1][0]).toUpperCase()
: (parts[0]?.[0] || '?').toUpperCase();
}
async function abrirModalPaciente(id, nombrePrefill) {
// Reset form
document.getElementById('form-paciente-rec').reset();
document.getElementById('mpac-id').value = '';
document.getElementById('mpac-avatar').textContent = '?';
document.getElementById('mpac-wa-badge').innerHTML = '';
document.getElementById('mpac-user-id').value = '';
document.getElementById('mpac-ciudad').value = 'Cúcuta';
if (id) {
document.getElementById('mpac-titulo').textContent = 'Cargando…';
document.getElementById('mpac-subtitulo').textContent = '';
_mpacModal().show();
try {
const r = await fetch(`${API_PAC}?id=${id}`);
const d = await r.json();
const p = (d.data || d.registros || [])[0];
if (!p) { mostrarError('No se pudo cargar el paciente.'); return; }
mpacRellenar(p);
} catch (_) { mostrarError('Error al cargar paciente.'); }
} else {
document.getElementById('mpac-titulo').textContent = 'Nuevo Paciente';
document.getElementById('mpac-subtitulo').textContent = 'Completa los datos del paciente';
if (nombrePrefill) {
document.getElementById('mpac-nombre').value = nombrePrefill;
mpacAvatar(nombrePrefill);
}
_mpacModal().show();
}
setTimeout(() => document.getElementById('mpac-nombre').focus(), 300);
}
function mpacRellenar(p) {
document.getElementById('mpac-id').value = p.id;
document.getElementById('mpac-nombre').value = p.nombre_completo || '';
document.getElementById('mpac-doc').value = p.numero_documento || '';
document.getElementById('mpac-tipo-doc').value = p.tipo_documento || 'CC';
document.getElementById('mpac-fnac').value = (p.fecha_nacimiento || '').slice(0, 10);
document.getElementById('mpac-email').value = p.email || '';
document.getElementById('mpac-genero').value = p.genero || '';
document.getElementById('mpac-eps').value = p.eps || '';
document.getElementById('mpac-ciudad').value = p.ciudad || 'Cúcuta';
document.getElementById('mpac-barrio').value = p.barrio || '';
document.getElementById('mpac-dir').value = p.direccion || '';
document.getElementById('mpac-notas').value = p.notas_admin || '';
// Teléfono con prefijo
let tel = p.telefono || '';
const prefijos = ['57','58','1'];
let prefijo = '57';
for (const pf of prefijos) {
if (tel.startsWith(pf) && tel.length > pf.length) {
prefijo = pf; tel = tel.slice(pf.length); break;
}
}
document.getElementById('mpac-tel-prefijo').value = prefijo;
document.getElementById('mpac-tel').value = tel;
mpacAvatar(p.nombre_completo || '');
document.getElementById('mpac-titulo').textContent = 'Editar Paciente';
document.getElementById('mpac-subtitulo').textContent = p.nombre_completo || '';
}
async function mpacVerificarWA() {
const prefijo = document.getElementById('mpac-tel-prefijo').value || '57';
let tel = (document.getElementById('mpac-tel').value || '').replace(/[^0-9]/g, '');
if (tel.length === 10) tel = prefijo + tel;
const badge = document.getElementById('mpac-wa-badge');
if (!tel || tel.length < 10) { badge.innerHTML = ''; return; }
badge.innerHTML = '<span class="text-muted"><i class="fas fa-spinner fa-spin me-1"></i>Verificando…</span>';
try {
const r = await fetch(`<?= BASE_URL ?>api/lab/check_whatsapp.php?phone=${encodeURIComponent(tel)}`);
const d = await r.json();
if (d.ok && d.found) {
badge.innerHTML = '<span class="text-success"><i class="fab fa-whatsapp me-1"></i>Registrado en WhatsApp</span>';
document.getElementById('mpac-user-id').value = d.user_id;
} else {
badge.innerHTML = '<span class="text-muted"><i class="fas fa-times-circle me-1"></i>Sin WhatsApp registrado</span>';
document.getElementById('mpac-user-id').value = '';
}
} catch(_) { badge.innerHTML = ''; }
}
async function mpacGuardar() {
const nombre = document.getElementById('mpac-nombre').value.trim();
if (!nombre) { mostrarError('El nombre es obligatorio.'); return; }
if (!/^[\p{L}\s'\-\.]+$/u.test(nombre)) {
mostrarError('El nombre solo debe contener letras, tildes y espacios.'); return;
}
if (nombre.split(/\s+/).filter(Boolean).length < 2) {
mostrarError('Ingresa nombre y apellido (mínimo 2 palabras).'); return;
}
const tipoDoc = document.getElementById('mpac-tipo-doc').value;
const docVal = document.getElementById('mpac-doc').value.trim();
if (docVal && ['CC','TI','RC','CE'].includes(tipoDoc)) {
const digits = docVal.replace(/[^0-9]/g, '');
if (digits.length < 4 || digits.length > 12) {
mostrarError('El documento debe tener entre 4 y 12 dígitos.'); return;
}
}
const emailVal = document.getElementById('mpac-email').value.trim();
if (emailVal && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(emailVal)) {
mostrarError('El correo electrónico no es válido.'); return;
}
const prefijo = document.getElementById('mpac-tel-prefijo').value || '57';
let tel = document.getElementById('mpac-tel').value.replace(/[^0-9]/g, '');
if (tel.length === 10) tel = prefijo + tel;
if (tel && prefijo === '57') {
const sinPref = tel.replace(/^57/, '');
if (!/^3\d{9}$/.test(sinPref)) {
mostrarError('El celular colombiano debe comenzar por 3 y tener 10 dígitos.'); return;
}
}
const id = document.getElementById('mpac-id').value;
const datos = {
nombre_completo: nombre,
tipo_documento: tipoDoc,
numero_documento: docVal || null,
fecha_nacimiento: document.getElementById('mpac-fnac').value || null,
telefono: tel || null,
email: emailVal || null,
genero: document.getElementById('mpac-genero').value || null,
eps: document.getElementById('mpac-eps').value.trim() || null,
ciudad: document.getElementById('mpac-ciudad').value.trim() || null,
barrio: document.getElementById('mpac-barrio').value.trim() || null,
direccion: document.getElementById('mpac-dir').value.trim() || null,
notas_admin: document.getElementById('mpac-notas').value.trim() || null,
user_id: document.getElementById('mpac-user-id').value || null,
};
if (id) datos.id = parseInt(id);
const btn = document.getElementById('mpac-btn-guardar');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando…';
try {
const r = await fetch('<?= BASE_URL ?>api/lab/save_paciente.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos),
});
const d = await r.json();
if (!d.success && !d.ok) {
mostrarError(d.error || 'Error al guardar el paciente.');
return;
}
_mpacModal().hide();
mostrarToast(d.message || 'Paciente guardado.', 'success');
if (id) {
// Edición: actualizar datos en pantalla si es el paciente activo
if (pacienteActivo && pacienteActivo.id == id) {
pacienteActivo.nombre_completo = nombre;
pacienteActivo.numero_documento = docVal;
pacienteActivo.tipo_documento = tipoDoc;
pacienteActivo.telefono = tel;
document.getElementById('lbl-pac-nombre').textContent =
(pacienteActivo.full_name || nombre).trim();
document.getElementById('lbl-pac-doc').textContent =
tipoDoc + ' ' + (docVal || '');
document.getElementById('lbl-pac-cel').textContent = tel || '';
}
} else {
// Creación: vincular al turno automáticamente
const nuevoPac = {
id: d.id || d.data?.id,
nombre_completo: nombre,
full_name: nombre,
tipo_documento: tipoDoc,
numero_documento: docVal,
telefono: tel,
};
seleccionarPaciente(nuevoPac);
}
} catch (err) {
mostrarError('Error de red: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar paciente';
}
}
// ── Toast ────────────────────────────────────────────────────
let toastTimer = null;
function mostrarToast(msg, type = 'info', duration = 2500) {
const el = document.getElementById('rec-toast');
const ico = document.getElementById('toast-ico');
const txt = document.getElementById('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);
}
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="<?= defined('APP_URL') ? rtrim(APP_URL,'/') : '' ?>/assets/js/lab-sidebar.js"></script>
</body>
</html>