This commit is contained in:
Lizandro Guarnizo
2026-04-21 21:36:24 -05:00
parent 3a19f08bf8
commit 4b8b668774
7 changed files with 541 additions and 223 deletions
+12
View File
@@ -142,6 +142,18 @@ function obtenerOCrearSesionHoy(): int
return (int) $pdo->lastInsertId(); return (int) $pdo->lastInsertId();
} }
/**
* Devuelve ['id', 'fin_at'] de la sesión de hoy si existe, o null.
* Para uso en pantallas: NO crea sesión nueva.
*/
function sesionHoy(): ?array
{
$pdo = db();
$stmt = $pdo->prepare('SELECT id, fin_at FROM turnero_sesiones WHERE fecha = CURDATE() LIMIT 1');
$stmt->execute();
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
/** /**
* Genera el siguiente número correlativo de turno para una sesión. * Genera el siguiente número correlativo de turno para una sesión.
* Usa bloqueo a nivel de fila para evitar duplicados en concurrencia. * Usa bloqueo a nivel de fila para evitar duplicados en concurrencia.
+21 -1
View File
@@ -25,7 +25,27 @@ if ($area === 'lugar' && !$lugarId) {
$pdo = db(); $pdo = db();
// ── Sesión de hoy ───────────────────────────────────────────── // ── Sesión de hoy ─────────────────────────────────────────────
$sesionId = obtenerOCrearSesionHoy(); // Si no existe sesión hoy o está cerrada (fin_at set), pantalla limpia.
$_ses = sesionHoy();
$sesionCerrada = (!$_ses || $_ses['fin_at'] !== null);
// Usar sesion_id real solo si está abierta; 0 hace que las queries devuelvan vacío.
$sesionId = ($sesionCerrada) ? 0 : (int)$_ses['id'];
if ($sesionCerrada) {
jsonOk([
'sesion_cerrada' => true,
'activo' => null,
'cola' => [],
'stats' => [
'total' => 0, 'en_espera' => 0, 'en_recepcion' => 0,
'en_espera_lugar' => 0, 'en_servicio' => 0,
'finalizados' => 0, 'no_atendidos' => 0,
'tiempo_promedio_atencion' => null,
],
'timestamp' => date('c'),
]);
exit;
}
// ── Cola según área ─────────────────────────────────────────── // ── Cola según área ───────────────────────────────────────────
if ($area === 'recepcion') { if ($area === 'recepcion') {
+7 -2
View File
@@ -9,8 +9,12 @@
require_once __DIR__ . '/_helpers.php'; require_once __DIR__ . '/_helpers.php';
requireMethod('GET'); requireMethod('GET');
$pdo = db(); $pdo = db();
$sesionId = obtenerOCrearSesionHoy();
// Si no hay sesión hoy o está cerrada → $sesionId = 0 (queries devuelven vacío).
$_ses = sesionHoy();
$sesionCerrada = (!$_ses || $_ses['fin_at'] !== null);
$sesionId = ($sesionCerrada) ? 0 : (int)$_ses['id'];
// ── 1. Escritorios de Recepción activos + su turno en_recepcion ─ // ── 1. Escritorios de Recepción activos + su turno en_recepcion ─
// Cada escritorio puede tener un turno distinto (varios recepcionistas). // Cada escritorio puede tener un turno distinto (varios recepcionistas).
@@ -175,6 +179,7 @@ $stats = $stmtStats->fetch(PDO::FETCH_ASSOC);
jsonOk([ jsonOk([
'sesion_id' => $sesionId, 'sesion_id' => $sesionId,
'sesion_cerrada' => $sesionCerrada,
'activos_recepcion'=> $activosRecepcion, // array de desks con su turno 'activos_recepcion'=> $activosRecepcion, // array de desks con su turno
'lugares' => $lugaresActivos, // array de estaciones de muestras 'lugares' => $lugaresActivos, // array de estaciones de muestras
'cola' => $cola, 'cola' => $cola,
+14 -1
View File
@@ -22,7 +22,20 @@ $desk = $stmtDesk->fetch(PDO::FETCH_ASSOC);
if (!$desk) jsonError('Escritorio no encontrado.', 404); if (!$desk) jsonError('Escritorio no encontrado.', 404);
$sesionId = obtenerOCrearSesionHoy(); $_ses = sesionHoy();
$sesionCerrada = (!$_ses || $_ses['fin_at'] !== null);
if ($sesionCerrada) {
jsonOk([
'sesion_cerrada' => true,
'desk_id' => (int)$desk['id'],
'desk_nombre'=> $desk['nombre'],
'turno' => null,
'stats' => ['en_espera' => 0, 'en_atencion' => 0, 'finalizados' => 0],
'timestamp' => date('c'),
]);
exit;
}
$sesionId = (int)$_ses['id'];
// Turno activo en este escritorio (el más reciente llamado_recepcion_at) // Turno activo en este escritorio (el más reciente llamado_recepcion_at)
$stmtT = $pdo->prepare( $stmtT = $pdo->prepare(
+280 -157
View File
@@ -6,11 +6,27 @@
*/ */
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
// Cargar prioridades activas desde BD // ── Configuración visual del laboratorio ──────────────────────
$_kioskoCfg = [];
try {
$_kpdo = Database::getInstance()->getConnection();
$_krows = $_kpdo->query(
"SELECT clave, valor FROM lab_config
WHERE clave IN ('empresa_nombre','doc_logo_base64','doc_color')"
)->fetchAll(PDO::FETCH_KEY_PAIR);
$_kioskoCfg = $_krows ?: [];
} catch (\Throwable $_) {}
$_kNombre = htmlspecialchars($_kioskoCfg['empresa_nombre'] ?? 'Laboratorio Clínico');
$_kLogo = $_kioskoCfg['doc_logo_base64'] ?? '';
$_kColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_kioskoCfg['doc_color'] ?? '')
? $_kioskoCfg['doc_color'] : '#1565c0';
// ── Cargar prioridades activas desde BD ───────────────────────
$prioridades = []; $prioridades = [];
try { try {
$pdo = Database::getInstance()->getConnection(); $_pdo = Database::getInstance()->getConnection();
$stmt = $pdo->query( $stmt = $_pdo->query(
"SELECT id, codigo, nombre, color, icono, descripcion, orden_peso "SELECT id, codigo, nombre, color, icono, descripcion, orden_peso
FROM turnero_prioridades FROM turnero_prioridades
WHERE activo = 1 WHERE activo = 1
@@ -21,11 +37,11 @@ try {
// Fallback con prioridades por defecto si la BD no está disponible // Fallback con prioridades por defecto si la BD no está disponible
$prioridades = [ $prioridades = [
['codigo'=>'A','nombre'=>'Niños', 'color'=>'#ef4444','icono'=>'fas fa-child', 'descripcion'=>'Pacientes menores de edad'], ['codigo'=>'A','nombre'=>'Niños', 'color'=>'#ef4444','icono'=>'fas fa-child', 'descripcion'=>'Pacientes menores de edad'],
['codigo'=>'B','nombre'=>'Embarazadas', 'color'=>'#f97316','icono'=>'fas fa-heart', 'descripcion'=>'Mujeres en estado de embarazo'], ['codigo'=>'B','nombre'=>'Embarazadas', 'color'=>'#f97316','icono'=>'fas fa-heart', 'descripcion'=>'Mujeres en estado de embarazo'],
['codigo'=>'C','nombre'=>'Adulto mayor', 'color'=>'#eab308','icono'=>'fas fa-person-cane', 'descripcion'=>'Mayores de 60 años'], ['codigo'=>'C','nombre'=>'Adulto mayor', 'color'=>'#eab308','icono'=>'fas fa-person-cane', 'descripcion'=>'Mayores de 60 años'],
['codigo'=>'D','nombre'=>'Discapacidad', 'color'=>'#8b5cf6','icono'=>'fas fa-wheelchair', 'descripcion'=>'Personas con discapacidad'], ['codigo'=>'D','nombre'=>'Discapacidad', 'color'=>'#8b5cf6','icono'=>'fas fa-wheelchair', 'descripcion'=>'Personas con discapacidad'],
['codigo'=>'E','nombre'=>'Paciente general', 'color'=>'#3b82f6','icono'=>'fas fa-user', 'descripcion'=>'Atención general'], ['codigo'=>'E','nombre'=>'Paciente general', 'color'=>'#3b82f6','icono'=>'fas fa-user', 'descripcion'=>'Atención general'],
['codigo'=>'F','nombre'=>'Muestra pendiente', 'color'=>'#6b7280','icono'=>'fas fa-vial', 'descripcion'=>'Entrega de muestra tomada'], ['codigo'=>'F','nombre'=>'Muestra pendiente','color'=>'#6b7280','icono'=>'fas fa-vial', 'descripcion'=>'Entrega de muestra tomada'],
]; ];
} }
@@ -50,176 +66,312 @@ unset($p);
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Kiosko de Turnos</title> <title>Kiosko <?= $_kNombre ?></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <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="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style> <style>
:root {
--brand: <?= $_kColor ?>;
--brand-dark: color-mix(in srgb, <?= $_kColor ?> 75%, #000);
--brand-light: color-mix(in srgb, <?= $_kColor ?> 10%, #fff);
--brand-mid: color-mix(in srgb, <?= $_kColor ?> 22%, #fff);
}
/* ── Reset fullscreen ── */ /* ── Reset fullscreen ── */
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
html, body { html, body {
margin: 0; padding: 0; margin: 0; padding: 0;
height: 100%; width: 100%; height: 100%; width: 100%;
overflow: hidden; overflow: hidden;
background: #0f172a; background: var(--brand-light);
color: #f8fafc; color: #1e293b;
font-family: 'Segoe UI', system-ui, sans-serif; font-family: 'Segoe UI', system-ui, sans-serif;
touch-action: manipulation; touch-action: manipulation;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
/* ── Layout general ── */
.kiosko-wrap {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* ── Header con logo ── */
.kiosko-topbar {
background: var(--brand);
padding: 14px 28px;
display: flex;
align-items: center;
gap: 14px;
flex-shrink: 0;
box-shadow: 0 2px 12px rgba(0,0,0,.18);
}
.kiosko-topbar .logo-img {
height: 48px;
max-width: 140px;
object-fit: contain;
filter: brightness(0) invert(1);
}
.kiosko-topbar .logo-fallback {
width: 46px; height: 46px;
background: rgba(255,255,255,.2);
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 1.3rem;
}
.kiosko-topbar .lab-nombre {
color: #fff;
font-size: clamp(1rem, 2.5vw, 1.4rem);
font-weight: 700;
letter-spacing: -.3px;
flex: 1;
}
.kiosko-topbar .turno-badge {
background: rgba(255,255,255,.15);
border: 1.5px solid rgba(255,255,255,.3);
color: #fff;
border-radius: 8px;
padding: 4px 12px;
font-size: .8rem;
font-weight: 600;
}
/* ── Pantalla / paso ── */ /* ── Pantalla / paso ── */
.screen { .screen {
position: fixed; inset: 0; flex: 1;
display: flex; flex-direction: column; display: flex;
align-items: center; justify-content: center; flex-direction: column;
padding: 2rem; align-items: center;
transition: opacity .35s ease, transform .35s ease; justify-content: center;
padding: 2rem 1.5rem;
transition: opacity .3s ease, transform .3s ease;
overflow: hidden;
} }
.screen.hidden { opacity: 0; pointer-events: none; transform: scale(.97); } .screen.hidden { opacity: 0; pointer-events: none; transform: scale(.98); position: absolute; }
.screen.visible { opacity: 1; pointer-events: all; transform: scale(1); } .screen.visible { opacity: 1; pointer-events: all; transform: scale(1); position: relative; }
/* ── Logo / cabecera ── */ /* ── Instrucción central ── */
.kiosko-header { text-align: center; margin-bottom: 2.5rem; } .kiosko-titulo {
.kiosko-header h1 { font-size: clamp(1.6rem, 4vw, 3rem); font-weight: 700; letter-spacing: -.5px; } font-size: clamp(1.4rem, 3.5vw, 2.2rem);
.kiosko-header p { font-size: clamp(.9rem, 2vw, 1.2rem); color: #94a3b8; margin: 0; } font-weight: 700;
color: var(--brand-dark);
text-align: center;
margin-bottom: .4rem;
}
.kiosko-subtitulo {
font-size: clamp(.85rem, 1.8vw, 1.05rem);
color: #64748b;
text-align: center;
margin-bottom: 2rem;
}
/* ── Botones de prioridad ── */ /* ── Grid de prioridades ── */
.prioridad-grid { .prioridad-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
gap: 1rem; gap: 1rem;
width: 100%; width: 100%;
max-width: 900px; max-width: 860px;
} }
.btn-prioridad { .btn-prioridad {
border: none; border-radius: 16px; border: 2px solid transparent;
border-radius: 18px;
padding: 1.6rem 1.2rem; padding: 1.6rem 1.2rem;
cursor: pointer; cursor: pointer;
display: flex; flex-direction: column; display: flex; flex-direction: column;
align-items: center; justify-content: center; align-items: center; justify-content: center;
gap: .6rem; gap: .5rem;
transition: transform .12s, box-shadow .12s, filter .12s; transition: transform .12s, box-shadow .12s, filter .12s;
color: #fff; font-weight: 600; font-weight: 600;
box-shadow: 0 4px 20px rgba(0,0,0,.35); box-shadow: 0 2px 12px rgba(0,0,0,.10);
-webkit-user-select: none; user-select: none; -webkit-user-select: none; user-select: none;
background: #fff;
} }
.btn-prioridad:active { transform: scale(.95); filter: brightness(.88); } .btn-prioridad:active { transform: scale(.95); filter: brightness(.93); }
.btn-prioridad .letra { font-size: clamp(2rem, 6vw, 4rem); line-height: 1; font-weight: 800; } .btn-prioridad:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,.13); }
.btn-prioridad .nombre { font-size: clamp(.85rem, 2vw, 1.1rem); text-align: center; line-height: 1.2; } .btn-prioridad .icon-wrap {
.btn-prioridad .desc { font-size: clamp(.7rem, 1.5vw, .85rem); opacity: .8; text-align: center; } width: 52px; height: 52px;
.btn-prioridad i { font-size: clamp(1.4rem, 3.5vw, 2.2rem); } border-radius: 14px;
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem;
color: #fff;
margin-bottom: .2rem;
}
.btn-prioridad .letra { font-size: clamp(1rem, 2.5vw, 1.4rem); font-weight: 800; color: #1e293b; }
.btn-prioridad .nombre { font-size: clamp(.82rem, 1.8vw, 1rem); color: #334155; text-align: center; }
.btn-prioridad .desc { font-size: clamp(.68rem, 1.3vw, .8rem); color: #94a3b8; text-align: center; }
/* ── Formulario opcional ── */ /* ── Formulario de datos ── */
.form-kiosko { width: 100%; max-width: 520px; } .form-kiosko { width: 100%; max-width: 480px; }
.form-kiosko label { color: #cbd5e1; font-size: 1rem; margin-bottom: .35rem; } .badge-prio-mini {
display: inline-flex; align-items: center; gap: 8px;
padding: .45rem 1.2rem; border-radius: 99px;
font-size: 1rem; font-weight: 700; color: #fff;
margin-bottom: 1.5rem;
}
.form-kiosko label { color: #475569; font-size: .95rem; margin-bottom: .35rem; font-weight: 500; }
.form-kiosko .form-control { .form-kiosko .form-control {
background: #1e293b; border: 1.5px solid #334155; background: #fff;
color: #f8fafc; border-radius: 12px; border: 1.5px solid #e2e8f0;
padding: .8rem 1rem; font-size: 1.1rem; color: #1e293b;
border-radius: 14px;
padding: .85rem 1.1rem;
font-size: 1.05rem;
box-shadow: 0 1px 4px rgba(0,0,0,.05);
transition: border-color .2s, box-shadow .2s;
} }
.form-kiosko .form-control:focus { .form-kiosko .form-control:focus {
background: #1e293b; border-color: #60a5fa; border-color: var(--brand);
box-shadow: 0 0 0 3px rgba(96,165,250,.2); color: #f8fafc; box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand) 20%, transparent);
outline: none;
} }
.form-kiosko .hint { color: #64748b; font-size: .85rem; margin-top: .4rem; } .form-kiosko .hint { color: #94a3b8; font-size: .82rem; margin-top: .35rem; }
.btn-kiosko-main { .btn-kiosko-main {
border: none; border-radius: 14px; border: none; border-radius: 14px;
padding: 1rem 2.5rem; font-size: 1.15rem; font-weight: 700; padding: 1rem 2rem; font-size: 1.05rem; font-weight: 700;
cursor: pointer; color: #fff; cursor: pointer; color: #fff;
background: linear-gradient(135deg, #3b82f6, #6366f1); background: var(--brand);
box-shadow: 0 4px 16px rgba(99,102,241,.4); box-shadow: 0 4px 16px color-mix(in srgb, var(--brand) 40%, transparent);
transition: transform .12s, box-shadow .12s; transition: transform .12s, box-shadow .12s, filter .12s;
width: 100%; margin-top: 1rem; width: 100%; margin-top: 1rem;
} }
.btn-kiosko-main:active { transform: scale(.97); } .btn-kiosko-main:active { transform: scale(.97); filter: brightness(.9); }
.btn-kiosko-back { .btn-kiosko-back {
background: transparent; border: 1.5px solid #475569; background: transparent;
border: 1.5px solid #e2e8f0;
color: #94a3b8; border-radius: 12px; padding: .7rem 1.5rem; color: #94a3b8; border-radius: 12px; padding: .7rem 1.5rem;
font-size: .95rem; cursor: pointer; margin-top: .6rem; width: 100%; font-size: .92rem; cursor: pointer; margin-top: .6rem; width: 100%;
transition: background .12s; transition: background .12s, border-color .12s;
} }
.btn-kiosko-back:active { background: #1e293b; } .btn-kiosko-back:hover { background: var(--brand-mid); border-color: var(--brand); color: var(--brand-dark); }
/* ── Pantalla de ticket ── */ /* ── Ticket ── */
.ticket-box { .ticket-box {
background: #1e293b; border-radius: 24px; background: #fff;
padding: 2.5rem 3rem; text-align: center; border-radius: 24px;
box-shadow: 0 8px 40px rgba(0,0,0,.5); padding: 2.5rem 3rem;
max-width: 480px; width: 100%; text-align: center;
box-shadow: 0 4px 30px rgba(0,0,0,.10);
max-width: 460px; width: 100%;
border-top: 6px solid var(--brand);
} }
.ticket-label { color: #94a3b8; font-size: .85rem; text-transform: uppercase;
letter-spacing: .08em; margin-bottom: .3rem; }
.ticket-codigo { .ticket-codigo {
font-size: clamp(4rem, 18vw, 9rem); font-size: clamp(4rem, 18vw, 9rem);
font-weight: 900; line-height: 1; font-weight: 900; line-height: 1;
letter-spacing: -2px; letter-spacing: -2px;
color: var(--brand);
} }
.ticket-subtitle { color: #94a3b8; font-size: 1rem; margin-top: .5rem; } .ticket-prio-badge {
.ticket-prioridad { font-size: 1.05rem; margin-top: .8rem; } display: inline-block; padding: .3rem 1.1rem;
.ticket-numero { font-size: 2rem; font-weight: 700; color: #e2e8f0; } border-radius: 99px; font-weight: 700; color: #fff;
.ticket-instruc { font-size: .95rem; margin-top: .8rem;
margin-top: 1.5rem; color: #64748b; font-size: .9rem; line-height: 1.5;
} }
.ticket-pos {
margin-top: 1.2rem;
background: var(--brand-light);
border-radius: 12px;
padding: .8rem;
}
.ticket-pos .lbl { font-size: .78rem; color: #64748b; text-transform: uppercase; letter-spacing: .06em; }
.ticket-pos .num { font-size: 1.8rem; font-weight: 800; color: var(--brand-dark); }
.ticket-instruc { margin-top: 1.4rem; color: #94a3b8; font-size: .88rem; line-height: 1.5; }
.btn-nuevo { .btn-nuevo {
margin-top: 2rem; border: none; border-radius: 14px; margin-top: 1.8rem; border: 2px solid var(--brand-mid);
padding: .85rem 2rem; font-size: 1rem; font-weight: 600; border-radius: 12px; padding: .75rem 2rem;
cursor: pointer; color: #fff; font-size: .95rem; font-weight: 600; cursor: pointer;
background: #475569; color: var(--brand-dark); background: #fff;
transition: background .15s; transition: background .15s, border-color .15s;
} }
.btn-nuevo:hover { background: #64748b; } .btn-nuevo:hover { background: var(--brand-light); border-color: var(--brand); }
/* ── Spinner ── */ /* ── Spinner ── */
.spinner-overlay { .spinner-overlay {
position: fixed; inset: 0; position: fixed; inset: 0;
background: rgba(15,23,42,.75); background: rgba(248,250,252,.8);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
z-index: 999; opacity: 0; pointer-events: none; z-index: 999; opacity: 0; pointer-events: none;
transition: opacity .2s; transition: opacity .2s;
} }
.spinner-overlay.active { opacity: 1; pointer-events: all; } .spinner-overlay.active { opacity: 1; pointer-events: all; }
.spinner { width: 56px; height: 56px; border: 5px solid #334155; .spinner {
border-top-color: #60a5fa; border-radius: 50%; animation: spin .8s linear infinite; } width: 52px; height: 52px;
border: 4px solid var(--brand-mid);
border-top-color: var(--brand);
border-radius: 50%;
animation: spin .75s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
/* ── Error toast ── */ /* ── Toast de error ── */
.toast-error { .toast-error {
position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%); position: fixed; bottom: 2rem; left: 50%; transform: translateX(-50%);
background: #ef4444; color: #fff; padding: .8rem 1.6rem; background: #ef4444; color: #fff; padding: .75rem 1.5rem;
border-radius: 12px; font-size: .95rem; font-weight: 600; border-radius: 12px; font-size: .92rem; font-weight: 600;
box-shadow: 0 4px 20px rgba(0,0,0,.4); box-shadow: 0 4px 20px rgba(0,0,0,.2);
opacity: 0; pointer-events: none; transition: opacity .25s; opacity: 0; pointer-events: none; transition: opacity .25s;
z-index: 1000; white-space: nowrap; z-index: 1000; white-space: nowrap;
} }
.toast-error.show { opacity: 1; } .toast-error.show { opacity: 1; }
/* ── Responsive: pantalla grande (TV táctil) ── */ /* ── Footer discreto ── */
.kiosko-footer {
text-align: center;
padding: 8px;
font-size: .7rem;
color: color-mix(in srgb, var(--brand) 55%, #fff);
background: var(--brand-mid);
flex-shrink: 0;
}
/* ── Responsive ── */
@media (min-height: 800px) { @media (min-height: 800px) {
.prioridad-grid { gap: 1.4rem; } .prioridad-grid { gap: 1.3rem; }
.btn-prioridad { padding: 2rem 1.5rem; } .btn-prioridad { padding: 1.9rem 1.4rem; }
}
@media (max-width: 480px) {
.kiosko-topbar { padding: 10px 16px; }
.ticket-box { padding: 1.8rem 1.5rem; }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="kiosko-wrap">
<!-- ══ Top bar con logo ════════════════════════════════════════ -->
<header class="kiosko-topbar">
<?php if ($_kLogo): ?>
<img src="<?= htmlspecialchars($_kLogo) ?>" alt="Logo" class="logo-img">
<?php else: ?>
<div class="logo-fallback"><i class="fas fa-flask"></i></div>
<?php endif; ?>
<div class="lab-nombre"><?= $_kNombre ?></div>
<div class="turno-badge"><i class="fas fa-ticket-alt me-1"></i>Turnero</div>
</header>
<!-- ══ PASO 1: Selección de prioridad ══════════════════════════ --> <!-- ══ PASO 1: Selección de prioridad ══════════════════════════ -->
<div id="screen-prio" class="screen visible"> <div id="screen-prio" class="screen visible">
<div class="kiosko-header"> <div class="kiosko-titulo">Seleccione su tipo de atención</div>
<i class="fas fa-ticket-alt fa-2x mb-3" style="color:#60a5fa"></i> <div class="kiosko-subtitulo">Toque la categoría que le corresponde para obtener su turno</div>
<h1>Solicitar Turno</h1>
<p>Seleccione su tipo de atención</p>
</div>
<div class="prioridad-grid"> <div class="prioridad-grid">
<?php foreach ($prioridades as $prio): ?> <?php foreach ($prioridades as $prio): ?>
<button <button
class="btn-prioridad" class="btn-prioridad"
style="background: <?= htmlspecialchars($prio['color']) ?>;" style="border-color:<?= htmlspecialchars($prio['color']) ?>22"
data-codigo="<?= htmlspecialchars($prio['codigo']) ?>" data-codigo="<?= htmlspecialchars($prio['codigo']) ?>"
data-nombre="<?= htmlspecialchars($prio['nombre']) ?>" data-nombre="<?= htmlspecialchars($prio['nombre']) ?>"
data-color="<?= htmlspecialchars($prio['color']) ?>" data-color="<?= htmlspecialchars($prio['color']) ?>"
onclick="seleccionarPrioridad(this)" onclick="seleccionarPrioridad(this)"
> >
<i class="<?= htmlspecialchars($prio['icono']) ?>"></i> <div class="icon-wrap" style="background:<?= htmlspecialchars($prio['color']) ?>">
<span class="letra"><?= htmlspecialchars($prio['codigo']) ?></span> <i class="<?= htmlspecialchars($prio['icono']) ?>"></i>
</div>
<span class="letra" style="color:<?= htmlspecialchars($prio['color']) ?>"><?= htmlspecialchars($prio['codigo']) ?></span>
<span class="nombre"><?= htmlspecialchars($prio['nombre']) ?></span> <span class="nombre"><?= htmlspecialchars($prio['nombre']) ?></span>
<?php if (!empty($prio['descripcion'])): ?> <?php if (!empty($prio['descripcion'])): ?>
<span class="desc"><?= htmlspecialchars($prio['descripcion']) ?></span> <span class="desc"><?= htmlspecialchars($prio['descripcion']) ?></span>
@@ -231,33 +383,31 @@ unset($p);
<!-- ══ PASO 2: Datos del paciente ═══════════════════════════════ --> <!-- ══ PASO 2: Datos del paciente ═══════════════════════════════ -->
<div id="screen-datos" class="screen hidden"> <div id="screen-datos" class="screen hidden">
<div class="kiosko-header"> <div id="badge-prio" class="badge-prio-mini"></div>
<div id="badge-prio" style="display:inline-block; padding:.5rem 1.4rem; border-radius:40px; font-size:1.2rem; font-weight:700; margin-bottom:1rem;"></div> <div class="kiosko-titulo">Ingrese su número de cédula</div>
<h1>Ingresa tu cédula</h1> <div class="kiosko-subtitulo">Le ayudará a identificar su turno correctamente</div>
<p>Escribe tu número de documento de identidad</p>
</div>
<div class="form-kiosko"> <div class="form-kiosko">
<div class="mb-4"> <div class="mb-3">
<label for="inp-cedula">Número de cédula <span style="color:#ef4444">*</span></label> <label for="inp-cedula">Número de cédula <span style="color:#ef4444">*</span></label>
<input type="text" id="inp-cedula" class="form-control" <input type="text" id="inp-cedula" class="form-control"
placeholder="Ej: 1234567890" maxlength="20" placeholder="Ej: 1234567890" maxlength="20"
autocomplete="off" inputmode="numeric"> autocomplete="off" inputmode="numeric">
</div> </div>
<div class="mb-4"> <div class="mb-3">
<label for="inp-cel">WhatsApp <label for="inp-cel">WhatsApp
<span style="color:#64748b;font-size:.85rem;font-weight:400"> (opcional)</span> <span style="color:#94a3b8;font-size:.82rem;font-weight:400"> (opcional)</span>
</label> </label>
<input type="tel" id="inp-cel" class="form-control" <input type="tel" id="inp-cel" class="form-control"
placeholder="Ej: 3001234567" maxlength="20" placeholder="Ej: 3001234567" maxlength="20"
autocomplete="off" inputmode="numeric"> autocomplete="off" inputmode="numeric">
<div class="hint">Para recibir notificaciones de su turno</div> <div class="hint"><i class="fas fa-bell me-1"></i>Para recibir notificaciones de su turno</div>
</div> </div>
<button class="btn-kiosko-main" onclick="confirmarTurno()"> <button class="btn-kiosko-main" onclick="confirmarTurno()">
<i class="fas fa-ticket-alt me-2"></i>Obtener mi turno <i class="fas fa-ticket-alt me-2"></i>Obtener mi turno
</button> </button>
<button class="btn-kiosko-back" onclick="volverPrioridades()"> <button class="btn-kiosko-back" onclick="volverPrioridades()">
<i class="fas fa-arrow-left me-1"></i>Cambiar tipo <i class="fas fa-arrow-left me-1"></i>Cambiar tipo de atención
</button> </button>
</div> </div>
</div> </div>
@@ -265,13 +415,15 @@ unset($p);
<!-- ══ PASO 3: Ticket asignado ══════════════════════════════════ --> <!-- ══ PASO 3: Ticket asignado ══════════════════════════════════ -->
<div id="screen-ticket" class="screen hidden"> <div id="screen-ticket" class="screen hidden">
<div class="ticket-box"> <div class="ticket-box">
<div class="ticket-subtitle">Su número de turno es</div> <div class="ticket-label">Su número de turno es</div>
<div id="tick-codigo" class="ticket-codigo">—</div> <div id="tick-codigo" class="ticket-codigo">—</div>
<div id="tick-prioridad" class="ticket-prioridad"></div> <div id="tick-prio-badge"></div>
<div class="ticket-subtitle mt-2">Posición en cola</div> <div class="ticket-pos">
<div id="tick-posicion" class="ticket-numero">—</div> <div class="lbl">Posición en cola</div>
<div class="num" id="tick-posicion">—</div>
</div>
<div class="ticket-instruc"> <div class="ticket-instruc">
Por favor espere ser llamado.<br> Por favor espere a ser llamado.<br>
Recuerde traer su documento de identidad y la orden médica. Recuerde traer su documento de identidad y la orden médica.
</div> </div>
</div> </div>
@@ -280,23 +432,24 @@ unset($p);
</button> </button>
</div> </div>
<!-- ══ Spinner de carga ════════════════════════════════════════ --> <!-- ══ Footer ═════════════════════════════════════════════════ -->
<div class="spinner-overlay" id="spinner"> <footer class="kiosko-footer"><?= $_kNombre ?> · Sistema de Turnos</footer>
<div class="spinner"></div>
</div> </div><!-- /kiosko-wrap -->
<!-- ══ Spinner ════════════════════════════════════════════════ -->
<div class="spinner-overlay" id="spinner"><div class="spinner"></div></div>
<!-- ══ Toast de error ════════════════════════════════════════ --> <!-- ══ Toast de error ════════════════════════════════════════ -->
<div class="toast-error" id="toast-error"></div> <div class="toast-error" id="toast-error"></div>
<script> <script>
// ── Estado local ──────────────────────────────────────────
let prioCodigo = ''; let prioCodigo = '';
let prioNombre = ''; let prioNombre = '';
let prioColor = ''; let prioColor = '';
const API_URL = '<?= BASE_URL ?>modules/turnero/api/create_turno.php'; const API_URL = '<?= BASE_URL ?>modules/turnero/api/create_turno.php';
// ── Navegación entre pasos ────────────────────────────────
function mostrar(id) { function mostrar(id) {
document.querySelectorAll('.screen').forEach(s => { document.querySelectorAll('.screen').forEach(s => {
s.classList.toggle('visible', s.id === id); s.classList.toggle('visible', s.id === id);
@@ -309,57 +462,34 @@ unset($p);
prioNombre = btn.dataset.nombre; prioNombre = btn.dataset.nombre;
prioColor = btn.dataset.color; prioColor = btn.dataset.color;
document.getElementById('badge-prio').textContent = prioCodigo + ' — ' + prioNombre; const badge = document.getElementById('badge-prio');
document.getElementById('badge-prio').style.background = prioColor; badge.textContent = prioCodigo + ' — ' + prioNombre;
badge.style.background = prioColor;
mostrar('screen-datos'); mostrar('screen-datos');
document.getElementById('inp-cedula').focus(); document.getElementById('inp-cedula').focus();
} }
function volverPrioridades() { function volverPrioridades() { mostrar('screen-prio'); }
mostrar('screen-prio');
}
// ── Crear turno ───────────────────────────────────────────
async function confirmarTurno() { async function confirmarTurno() {
const cedula = document.getElementById('inp-cedula').value.trim(); const cedula = document.getElementById('inp-cedula').value.trim();
const cel = document.getElementById('inp-cel').value.trim(); const cel = document.getElementById('inp-cel').value.trim();
if (!cedula) { if (!cedula) { mostrarError('Por favor ingrese su número de cédula.'); return; }
mostrarError('Por favor ingresa tu número de cédula.'); if (!/^\d{5,15}$/.test(cedula)) { mostrarError('La cédula debe tener solo dígitos (mínimo 5).'); return; }
return; if (cel && !/^\+?\d{7,15}$/.test(cel.replace(/\s/g,''))) { mostrarError('Ingrese un número de WhatsApp válido.'); return; }
}
if (!/^\d{5,15}$/.test(cedula)) {
mostrarError('La cédula debe contener solo dígitos (mínimo 5).');
return;
}
if (cel && !/^\+?\d{7,15}$/.test(cel.replace(/\s/g, ''))) {
mostrarError('Ingresa un número de WhatsApp válido.');
return;
}
setSpinner(true); setSpinner(true);
try { try {
const res = await fetch(API_URL, { const res = await fetch(API_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ prioridad_codigo: prioCodigo, paciente_nombre: cedula, paciente_cel: cel || null }),
prioridad_codigo: prioCodigo,
paciente_nombre: cedula, // se guarda como referencia de identificación
paciente_cel: cel || null,
}),
}); });
const json = await res.json(); const json = await res.json();
if (!json.ok) throw new Error(json.error ?? 'Error al crear turno');
if (!json.ok) { mostrarTicket(json.turno ?? json.data?.turno);
throw new Error(json.error ?? 'Error al crear turno');
}
const t = json.turno ?? json.data?.turno;
mostrarTicket(t);
} catch (err) { } catch (err) {
mostrarError(err.message); mostrarError(err.message);
} finally { } finally {
@@ -367,34 +497,26 @@ unset($p);
} }
} }
// ── Mostrar ticket ────────────────────────────────────────
function mostrarTicket(t) { function mostrarTicket(t) {
document.getElementById('tick-codigo').textContent = t.codigo; document.getElementById('tick-codigo').textContent = t.codigo;
document.getElementById('tick-codigo').style.color = prioColor; document.getElementById('tick-codigo').style.color = prioColor;
document.getElementById('tick-prioridad').innerHTML =
'<span style="background:' + prioColor + ';padding:.3rem .9rem;border-radius:20px;font-weight:700;">'
+ prioCodigo + ' — ' + prioNombre + '</span>';
document.getElementById('tick-posicion').textContent = '#' + t.posicion_cola; document.getElementById('tick-posicion').textContent = '#' + t.posicion_cola;
document.getElementById('tick-prio-badge').innerHTML =
`<span class="ticket-prio-badge" style="background:${prioColor}">${prioCodigo} — ${prioNombre}</span>`;
mostrar('screen-ticket'); mostrar('screen-ticket');
clearTimeout(window._timer);
// Auto-reinicio tras 30 s para liberar el kiosko window._timer = setTimeout(reiniciar, 30000);
clearTimeout(window._reinicioTimer);
window._reinicioTimer = setTimeout(reiniciar, 30000);
} }
// ── Reinicio ──────────────────────────────────────────────
function reiniciar() { function reiniciar() {
clearTimeout(window._reinicioTimer); clearTimeout(window._timer);
document.getElementById('inp-cedula').value = ''; document.getElementById('inp-cedula').value = '';
document.getElementById('inp-cel').value = ''; document.getElementById('inp-cel').value = '';
prioCodigo = prioNombre = prioColor = ''; prioCodigo = prioNombre = prioColor = '';
mostrar('screen-prio'); mostrar('screen-prio');
} }
// ── Utilidades ──────────────────────────────────────────── function setSpinner(on) { document.getElementById('spinner').classList.toggle('active', on); }
function setSpinner(on) {
document.getElementById('spinner').classList.toggle('active', on);
}
function mostrarError(msg) { function mostrarError(msg) {
const el = document.getElementById('toast-error'); const el = document.getElementById('toast-error');
@@ -405,3 +527,4 @@ unset($p);
</script> </script>
</body> </body>
</html> </html>
+47 -34
View File
@@ -120,16 +120,19 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
/* ── Consentimientos ── */ /* ── Consentimientos ── */
.consent-row { .consent-row {
display: flex; align-items: center; gap: .6rem; display: flex; align-items: center; gap: .6rem;
padding: .5rem .65rem; border-radius: 8px; margin-bottom: .35rem; padding: .55rem .7rem; border-radius: 10px; margin-bottom: .4rem;
font-size: .85rem; font-size: .84rem; border: 1px solid transparent;
} }
.consent-row.firmado { background: #f0fdf4; color: #166534; } .consent-row.firmado { background: #f0fdf4; color: #166534; border-color: #bbf7d0; }
.consent-row.rechazado { background: #f1f5f9; color: #475569; } .consent-row.rechazado { background: #f8fafc; color: #64748b; border-color: #e2e8f0; }
.consent-row.enviado { background: #eff6ff; color: #1e40af; } .consent-row.enviado { background: #eff6ff; color: #1e40af; border-color: #bfdbfe; }
.consent-row.pendiente { background: #fffbeb; color: #92400e; } .consent-row.pendiente { background: #fffbeb; color: #92400e; border-color: #fde68a; }
.consent-row.visto { background: #faf5ff; color: #6b21a8; } .consent-row.visto { background: #faf5ff; color: #6b21a8; border-color: #ddd6fe; }
.consent-row .nom-form { flex: 1; } .consent-row .nom-form { flex: 1; font-weight: 500; }
.consent-row .acciones-consent { display: flex; gap: .4rem; } .consent-row .c-badge { font-size: .68rem; padding: 1px 8px; border-radius: 99px;
border: 1px solid currentColor; font-weight: 700; opacity: .85; }
.consent-row .acciones-consent { display: flex; gap: .3rem; flex-shrink: 0; }
.consent-row .acciones-consent .btn { font-size: .72rem; padding: 2px 9px; border-radius: 7px; }
/* ── Barra de acciones ── */ /* ── Barra de acciones ── */
.ficha-acciones { .ficha-acciones {
@@ -518,6 +521,14 @@ async function cargarFichaSolicitud(turnoId) {
} }
// ── Consentimientos ─────────────────────────────────────────── // ── Consentimientos ───────────────────────────────────────────
const CONSENT_IC = {
firmado:'fa-check-circle', rechazado:'fa-ban',
enviado:'fa-envelope', visto:'fa-eye', pendiente:'fa-clock',
};
const CONSENT_LBL = {
firmado:'Firmado', rechazado:'Rechazado', enviado:'Enviado', visto:'Visto', pendiente:'Pendiente',
};
function renderConsentimientos(lista) { function renderConsentimientos(lista) {
tieneConsent = lista.length > 0; tieneConsent = lista.length > 0;
hayPendientes = lista.some(c => !['firmado', 'rechazado'].includes(c.estado)); hayPendientes = lista.some(c => !['firmado', 'rechazado'].includes(c.estado));
@@ -541,35 +552,37 @@ function renderConsentimientos(lista) {
btnIni.disabled = hayPendientes; btnIni.disabled = hayPendientes;
listEl.innerHTML = lista.map(c => { listEl.innerHTML = lista.map(c => {
const cls = c.estado; const ya = ['firmado','rechazado'].includes(c.estado);
const ico = c.estado === 'firmado' ? 'fa-check-circle' const ico = CONSENT_IC[c.estado] || 'fa-clock';
: c.estado === 'rechazado' ? 'fa-ban' const label = CONSENT_LBL[c.estado] || c.estado;
: c.estado === 'enviado' ? 'fa-envelope' const token = escHtml(c.token || '');
: c.estado === 'visto' ? 'fa-eye' const nomJs = JSON.stringify(c.formulario_nombre || 'Consentimiento');
: 'fa-clock'; const idJs = parseInt(c.id) || 0;
const label = c.estado === 'firmado' ? 'Firmado' const tId = parseInt(c.turno_id) || (turnoActivo?.id) || 0;
: c.estado === 'rechazado' ? 'Rechazado'
: c.estado === 'enviado' ? 'Enviado'
: c.estado === 'visto' ? 'Visto'
: 'Pendiente';
const botonesAccion = !['firmado','rechazado'].includes(c.estado) ? ` // Firmar aquí: solo si no completado y hay token
<div class="acciones-consent"> const btnFirmar = (!ya && c.token)
<button class="btn btn-outline-secondary btn-sm py-0" title="Reenviar WhatsApp" ? `<button class="btn btn-outline-primary" title="Firmar aquí"
onclick="reenviarConsentimiento(${c.turno_id})"> onclick="abrirFirmaPresencial('${token}', ${idJs}, ${nomJs.replace(/"/g,'&quot;')})">
<i class="fas fa-paper-plane" style="font-size:.75rem"></i> <i class="fas fa-signature"></i> Firmar
</button> </button>` : '';
<button class="btn btn-outline-primary btn-sm py-0" title="Firmar presencialmente"
onclick="abrirFirmaPresencial('${c.token}', ${c.id}, ${JSON.stringify(c.formulario_nombre).replace(/"/g,'&quot;')})">
<i class="fas fa-signature" style="font-size:.75rem"></i> Firmar aquí
</button>
</div>` : '';
return `<div class="consent-row ${cls}" data-consent-id="${c.id}"> // WA / Ver: según estado
const btnWa = ya
? (c.token ? `<button class="btn btn-outline-secondary" title="Ver firmado"
onclick="window.open(BASE_WA+'ver_formulario_enviado.php?token='+encodeURIComponent('${token}'),'_blank')">
<i class="fas fa-eye"></i> Ver
</button>` : '')
: `<button class="btn btn-outline-success" title="Reenviar por WhatsApp"
onclick="reenviarConsentimiento(${tId})">
<i class="fab fa-whatsapp"></i> WA
</button>`;
return `<div class="consent-row ${c.estado}" data-consent-id="${c.id}">
<i class="fas ${ico}"></i> <i class="fas ${ico}"></i>
<span class="nom-form">${escHtml(c.formulario_nombre || 'Consentimiento')}</span> <span class="nom-form">${escHtml(c.formulario_nombre || 'Consentimiento')}</span>
<span class="badge">${escHtml(label)}</span> <span class="c-badge">${label}</span>
${botonesAccion} <div class="acciones-consent">${btnFirmar}${btnWa}</div>
</div>`; </div>`;
}).join(''); }).join('');
} }
+160 -28
View File
@@ -147,13 +147,41 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
/* ── Consentimientos ── */ /* ── Consentimientos ── */
.consent-item { .consent-item {
display: flex; align-items: center; gap: .6rem; display: flex; align-items: center; gap: .5rem;
padding: .4rem .6rem; border-radius: 8px; padding: .5rem .7rem; border-radius: 10px;
font-size: .85rem; margin-bottom: .3rem; font-size: .84rem; margin-bottom: .4rem;
border: 1px solid transparent;
} }
.consent-item.firmado { background: #f0fdf4; color: #166534; } .consent-item.firmado { background: #f0fdf4; color: #166534; border-color: #bbf7d0; }
.consent-item.pendiente{ background: #fffbeb; color: #92400e; } .consent-item.pendiente { background: #fffbeb; color: #92400e; border-color: #fde68a; }
.consent-item.enviado { background: #eff6ff; color: #1e40af; } .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: .72rem; padding: 2px 8px; border-radius: 7px; }
/* ── Modal firma presencial ── */
.modal-firma-backdrop {
display: none; position: fixed; inset: 0;
background: rgba(15,23,42,.65); z-index: 1050;
align-items: center; justify-content: center;
}
.modal-firma-backdrop.open { display: flex; }
.modal-firma-box {
background: #fff; border-radius: 16px;
width: min(94vw, 860px); height: min(88vh, 720px);
display: flex; flex-direction: column; overflow: hidden;
box-shadow: 0 8px 40px rgba(0,0,0,.35);
}
.modal-firma-hdr {
padding: 12px 18px; border-bottom: 1px solid #e2e8f0;
display: flex; align-items: center; justify-content: space-between;
font-size: .95rem; font-weight: 600; flex-shrink: 0;
}
.modal-firma-box iframe { flex: 1; border: 0; }
/* ── Barra de acciones ── */ /* ── Barra de acciones ── */
.ficha-acciones { .ficha-acciones {
@@ -344,12 +372,15 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
<!-- ── Sección 5: Consentimientos ── --> <!-- ── Sección 5: Consentimientos ── -->
<div class="ficha-section" id="sec-consentimientos" style="display:none!important"> <div class="ficha-section" id="sec-consentimientos" style="display:none!important">
<h6><i class="fas fa-file-signature me-1"></i>Consentimientos</h6> <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 id="lista-consentimientos"></div>
<button class="btn btn-outline-primary btn-sm mt-2"
id="btn-reenviar-consent" onclick="enviarConsentimientos()">
<i class="fas fa-paper-plane me-1"></i>Enviar / Reenviar por WhatsApp
</button>
</div> </div>
<!-- ── Acciones ── --> <!-- ── Acciones ── -->
@@ -373,6 +404,19 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
</div><!-- /rec-layout --> </div><!-- /rec-layout -->
</main> </main>
<!-- ══ Modal firma presencial ═════════════════════════════════ -->
<div class="modal-firma-backdrop" id="modal-firma-rec">
<div class="modal-firma-box">
<div class="modal-firma-hdr">
<span><i class="fas fa-signature me-2 text-primary"></i><span id="modal-firma-titulo-rec">Firmar consentimiento</span></span>
<button class="btn btn-sm btn-outline-secondary" onclick="cerrarModalFirmaRec()">
<i class="fas fa-times"></i>
</button>
</div>
<iframe id="firma-iframe-rec" src="" title="Formulario de consentimiento"></iframe>
</div>
</div>
<script> <script>
// ── Estado ──────────────────────────────────────────────────── // ── Estado ────────────────────────────────────────────────────
let turnoActivo = null; let turnoActivo = null;
@@ -650,26 +694,93 @@ async function guardarSolicitud() {
} }
} }
// ── 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) { function renderConsentimientos(lista) {
document.getElementById('lista-consentimientos').innerHTML = lista.map(c => { const el = document.getElementById('lista-consentimientos');
const cls = c.estado === 'firmado' ? 'firmado' if (!lista.length) {
: c.estado === 'enviado' ? 'enviado' el.innerHTML = '<div class="text-muted small"><i class="fas fa-check-circle text-success me-1"></i>Sin consentimientos requeridos</div>';
: 'pendiente'; return;
const ico = c.estado === 'firmado' ? 'fa-check-circle' }
: c.estado === 'enviado' ? 'fa-envelope' el.innerHTML = lista.map(c => {
: 'fa-clock'; const m = CONSENT_META[c.estado] || CONSENT_META.pendiente;
return `<div class="consent-item ${cls}"> const ya = ['firmado','rechazado'].includes(c.estado);
<i class="fas ${ico}"></i> const token = escHtml(c.token || '');
<span class="flex-1">${escHtml(c.formulario_nombre || 'Consentimiento')}</span> const nom = escHtml(c.formulario_nombre || 'Consentimiento');
<span class="badge">${escHtml(c.estado)}</span> const nomJs = JSON.stringify(c.formulario_nombre || 'Consentimiento');
// Botón de firma (solo si no está firmado/rechazado)
const btnFirmar = (!ya && c.token)
? `<button class="btn btn-outline-primary" onclick="abrirFirmaPresencialRec('${token}', ${nomJs})"
title="Firmar aquí en pantalla">
<i class="fas fa-signature"></i> Firmar
</button>` : '';
// Botón enviar/reenviar WhatsApp
const btnWa = ya
? (c.token ? `<button class="btn btn-outline-secondary" onclick="verFirmado('${token}')" title="Ver formulario firmado">
<i class="fas fa-eye"></i> Ver
</button>` : '')
: `<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>`;
return `<div class="consent-item ${m.cls}">
<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>`; </div>`;
}).join(''); }).join('');
} }
// ── Enviar consentimientos ──────────────────────────────────── // ── Firmar presencialmente (modal iframe) ─────────────────────
async function enviarConsentimientos() { function abrirFirmaPresencialRec(token, nombreForm) {
const url = BASE_WA + 'ver_formulario_enviado.php?token=' + encodeURIComponent(token);
document.getElementById('modal-firma-titulo-rec').textContent = 'Firmar: ' + nombreForm;
document.getElementById('firma-iframe-rec').src = url;
document.getElementById('modal-firma-rec').classList.add('open');
}
function cerrarModalFirmaRec() {
document.getElementById('modal-firma-rec').classList.remove('open');
document.getElementById('firma-iframe-rec').src = '';
// Refrescar consentimientos al cerrar
if (turnoActivo) refrescarConsentimientos(turnoActivo.id);
}
function verFirmado(token) {
window.open(BASE_WA + 'ver_formulario_enviado.php?token=' + encodeURIComponent(token), '_blank');
}
// Cerrar modal al clic fuera
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('modal-firma-rec').addEventListener('click', function(e) {
if (e.target === this) cerrarModalFirmaRec();
});
});
// ── 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 || !solicitudActiva) return; if (!turnoActivo || !solicitudActiva) return;
const btn = document.getElementById('btn-enviar-consent'); const btn = document.getElementById('btn-reenviar-consent');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Enviando…'; btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Enviando…';
try { try {
@@ -680,18 +791,39 @@ async function enviarConsentimientos() {
}); });
const json = await res.json(); const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; } if (!json.ok) { mostrarError(json.error); return; }
consentimientos = json.consentimientos ?? consentimientos;
consentimientos = json.consentimientos ?? json.data?.consentimientos ?? consentimientos;
renderConsentimientos(consentimientos); renderConsentimientos(consentimientos);
btn.innerHTML = '<i class="fas fa-check me-1"></i>Enviado'; 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) { } catch (err) {
mostrarError(err.message); mostrarError(err.message);
btn.innerHTML = '<i class="fas fa-paper-plane me-1"></i>Enviar consentimientos'; btn.innerHTML = '<i class="fas fa-paper-plane me-1"></i>Enviar todos';
} finally { } finally {
btn.disabled = false; 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 ───────────────────────────────────────────── // ── Pasar a lugar ─────────────────────────────────────────────
async function pasarALugar() { async function pasarALugar() {
if (!turnoActivo || !solicitudActiva) return; if (!turnoActivo || !solicitudActiva) return;