Tablet de firma para el paciente en Recepción 4

Hasta ahora, para que el paciente firmara el consentimiento de bienvenida
había que girarle el monitor a la recepcionista o pasarle el mouse: el botón
Firmar abre el formulario en la pantalla de ella.

Se agrega una pantalla aparte (/erp.php?m=turnero&v=firma) para poner frente
al paciente. Sola, sin que la recepcionista haga nada: cuando el turno entra
a ese puesto y le falta el F-LAB-01, aparece su nombre y un botón grande de
firmar; al terminar dice gracias y vuelve a reposo.

Reutiliza lo que ya existía: el formulario de ver_formulario_enviado.php, el
aviso 'turneroFirmado' que ya emite al firmar, y el sondeo de recepción, que
seguirá poniendo el renglón en verde sin cambios.

Sobre el acceso: va en PUBLIC_ROUTES porque nadie va a iniciar sesión cada
mañana en una tablet que manipula el público. No queda abierta: se identifica
por la cookie del dispositivo, y sin ella no muestra ningún dato. El endpoint
devuelve solo el turno que está en ese puesto en ese instante —no permite
buscar, ni ver otros, ni consultar historial— y descarta turnos de días
anteriores, igual que el televisor. La tablet debe quedar en modo kiosco.

Falta registrar el dispositivo de Recepción 4 desde Configuración; hoy no
tiene ninguno.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-12 16:35:20 -05:00
co-authored by Claude Opus 5
parent 2a87f66bb7
commit 6589503c57
3 changed files with 338 additions and 0 deletions
+4
View File
@@ -23,6 +23,10 @@ class Router
private const PUBLIC_ROUTES = [
'turnero/display',
'turnero/kiosko',
// Tablet de firma del paciente: la manipula el público y nadie va a
// iniciar sesión en ella cada mañana. No queda abierta: se identifica
// por la cookie del dispositivo y sin ella no muestra dato alguno.
'turnero/firma',
];
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
+134
View File
@@ -0,0 +1,134 @@
<?php
/**
* GET /modules/turnero/api/get_firma_pendiente.php
*
* Le dice a la tablet del paciente qué mostrar: si hay alguien siendo atendido
* en su puesto y le falta firmar el consentimiento de bienvenida (F-LAB-01).
*
* SIN SESIÓN DE OPERADOR, a propósito: esta tablet la manipula el público y
* nadie va a iniciar sesión en ella cada mañana. Se identifica por la cookie
* del dispositivo, que es un token de 64 caracteres registrado en Configuración.
*
* Por eso devuelve lo mínimo: el turno que está en ese puesto en este instante
* y nada más. No permite consultar otros turnos, ni buscar, ni ver historial.
* Sin dispositivo reconocido no responde nada.
*/
require_once __DIR__ . '/../../../config/config.php';
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
/** El único formulario que se firma en esta tablet: el de bienvenida. */
const FORMULARIO_BIENVENIDA = 17;
function responder(array $datos): void {
echo json_encode($datos, JSON_UNESCAPED_UNICODE);
exit;
}
$pdo = Database::getInstance()->getConnection();
// ── 1. ¿Qué puesto es esta tablet? ────────────────────────────────────────
// Solo por token de navegador. La IP no sirve aquí: varias tablets salen por
// la misma y acabaríamos mostrándole a un paciente los datos de otro puesto.
$token = trim($_COOKIE['turnero_token'] ?? '');
if ($token === '') {
responder(['ok' => false, 'motivo' => 'sin_dispositivo']);
}
$stmt = $pdo->prepare(
"SELECT td.lugar_id, td.nombre AS dispositivo, tl.nombre AS lugar, 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"
);
$stmt->execute([$token]);
$disp = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$disp || $disp['tipo'] !== 'recepcion') {
responder(['ok' => false, 'motivo' => 'sin_dispositivo']);
}
// ── 2. ¿Hay alguien siendo atendido ahí ahora? ────────────────────────────
// Mismo criterio que la pantalla del televisor: el turno vale hasta la
// medianoche de su día, para no mostrar a un paciente que ya se fue.
// La sesión está abierta mientras fin_at siga en nulo; no hay columna de estado.
$stmt = $pdo->prepare(
"SELECT t.id, t.codigo, t.paciente_id, t.paciente_nombre
FROM turnero_turnos t
JOIN turnero_sesiones s ON s.id = t.sesion_id
WHERE t.estado = 'en_recepcion'
AND t.recepcion_desk_id = ?
AND DATE(t.llamado_recepcion_at) = CURDATE()
AND s.fin_at IS NULL
ORDER BY t.llamado_recepcion_at DESC
LIMIT 1"
);
$stmt->execute([(int)$disp['lugar_id']]);
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$turno) {
responder(['ok' => true, 'estado' => 'reposo', 'lugar' => $disp['lugar']]);
}
// Sin paciente vinculado no hay a quién atribuirle la firma
if (empty($turno['paciente_id'])) {
responder(['ok' => true, 'estado' => 'reposo', 'lugar' => $disp['lugar']]);
}
// ── 3. ¿Le falta firmar el consentimiento de bienvenida? ──────────────────
$stmt = $pdo->prepare(
"SELECT token, estado FROM turnero_consentimientos
WHERE turno_id = ? AND formulario_id = ?
LIMIT 1"
);
$stmt->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA]);
$consent = $stmt->fetch(PDO::FETCH_ASSOC);
if ($consent && in_array($consent['estado'], ['firmado', 'rechazado'], true)) {
responder([
'ok' => true,
'estado' => 'firmado',
'turno_id' => (int)$turno['id'],
'codigo' => $turno['codigo'],
]);
}
// Si el consentimiento todavía no existe se crea aquí. Es lo que permite que
// la tablet aparezca sola, sin que la recepcionista tenga que mandarlo.
if (!$consent) {
$tokenFirma = sprintf(
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
$ins = $pdo->prepare(
"INSERT IGNORE INTO turnero_consentimientos
(turno_id, formulario_id, token, estado, creado_por)
VALUES (?, ?, ?, 'pendiente', NULL)"
);
$ins->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA, $tokenFirma]);
// INSERT IGNORE puede no haber insertado si otra petición se adelantó:
// se relee para quedarse con el token que realmente quedó guardado.
$stmt->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA]);
$consent = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$consent) {
responder(['ok' => false, 'motivo' => 'no_se_pudo_crear']);
}
}
responder([
'ok' => true,
'estado' => 'por_firmar',
'turno_id' => (int)$turno['id'],
'codigo' => $turno['codigo'],
'paciente' => $turno['paciente_nombre'],
'url' => BASE_URL . 'ver_formulario_enviado.php?token=' . urlencode($consent['token']),
]);
+200
View File
@@ -0,0 +1,200 @@
<?php
/**
* modules/turnero/views/firma.php
* Tablet de firma del paciente — /erp.php?m=turnero&v=firma
*
* Pantalla de cara al público: se pone frente al paciente en el mostrador y
* muestra, sola, el consentimiento de bienvenida cuando le toca firmarlo.
* Antes había que girarle el monitor a la recepcionista o pasarle el mouse.
*
* La tablet se identifica por la cookie del dispositivo, registrada desde
* Configuración. Sin ella no muestra nada: es lo que impide que cualquiera
* abra esta dirección y vea el nombre del paciente de turno.
*
* Conviene dejarla en modo kiosco, sin barra de direcciones, para que desde
* ella no se pueda navegar al resto del ERP.
*/
$_fCfg = [];
try {
$__pdo = Database::getInstance()->getConnection();
$_fCfg = $__pdo->query(
"SELECT clave, valor FROM lab_config WHERE clave IN ('empresa_nombre','doc_logo_base64','doc_color')"
)->fetchAll(PDO::FETCH_KEY_PAIR) ?: [];
} catch (\Throwable $_) {}
$_fNombre = htmlspecialchars($_fCfg['empresa_nombre'] ?? 'Laboratorio');
$_fLogo = $_fCfg['doc_logo_base64'] ?? '';
$_fColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_fCfg['doc_color'] ?? '') ? $_fCfg['doc_color'] : '#1565c0';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Firma · <?= $_fNombre ?></title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --brand: <?= $_fColor ?>; }
html, body {
height: 100%; width: 100%; overflow: hidden;
font-family: 'Segoe UI', system-ui, sans-serif;
background: #f8fafc; color: #1e293b;
-webkit-user-select: none; user-select: none;
}
.pantalla {
height: 100vh; display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; padding: 4vh 5vw; gap: 2.5vh;
}
.pantalla.oculta { display: none; }
.logo { max-height: 14vh; max-width: 50vw; object-fit: contain; }
.marca { font-size: clamp(1.2rem, 3vw, 2rem); font-weight: 700; color: #64748b; }
.saludo { font-size: clamp(1.4rem, 4vw, 2.6rem); font-weight: 600; color: #94a3b8; }
.etiqueta { font-size: clamp(.9rem, 2vw, 1.2rem); font-weight: 700;
letter-spacing: 3px; text-transform: uppercase; color: #94a3b8; }
.paciente { font-size: clamp(1.8rem, 5.5vw, 3.4rem); font-weight: 800; line-height: 1.15; }
.codigo { font-size: clamp(1.1rem, 2.6vw, 1.6rem); font-weight: 700; color: var(--brand); }
/* Botón deliberadamente enorme: lo va a tocar gente mayor, de pie y de afán */
.btn-firmar {
margin-top: 2vh;
background: var(--brand); color: #fff; border: 0;
border-radius: 18px; cursor: pointer;
font-family: inherit; font-weight: 800;
font-size: clamp(1.5rem, 4.5vw, 2.6rem);
padding: clamp(1rem, 3.5vh, 2.2rem) clamp(2.5rem, 12vw, 6rem);
box-shadow: 0 10px 30px rgba(0,0,0,.18);
transition: transform .12s ease, filter .12s ease;
}
.btn-firmar:active { transform: scale(.96); filter: brightness(.92); }
.ok-icono { font-size: clamp(3.5rem, 12vw, 7rem); color: #16a34a; line-height: 1; }
.ok-txt { font-size: clamp(1.4rem, 4vw, 2.4rem); font-weight: 700; color: #166534; }
.aviso { font-size: clamp(1rem, 2.4vw, 1.4rem); color: #94a3b8; max-width: 34ch; line-height: 1.5; }
/* El formulario ocupa toda la pantalla al abrirse */
#marco { position: fixed; inset: 0; z-index: 50; background: #fff; display: none; }
#marco.abierto { display: block; }
#marco iframe { width: 100%; height: 100%; border: 0; }
</style>
</head>
<body>
<!-- En reposo: nadie a quien pedirle firma -->
<div class="pantalla" id="p-reposo">
<?php if ($_fLogo): ?><img class="logo" src="<?= htmlspecialchars($_fLogo) ?>" alt=""><?php endif; ?>
<div class="marca"><?= $_fNombre ?></div>
<div class="saludo">Bienvenido</div>
</div>
<!-- Hay alguien y le falta firmar -->
<div class="pantalla oculta" id="p-firmar">
<div class="etiqueta">Turno <span id="f-codigo"></span></div>
<div class="paciente" id="f-paciente"></div>
<div class="aviso">Por favor lea y firme el consentimiento para continuar con su atención.</div>
<button class="btn-firmar" id="btn-firmar">Firmar</button>
</div>
<!-- Ya firmó -->
<div class="pantalla oculta" id="p-gracias">
<div class="ok-icono">✓</div>
<div class="ok-txt">¡Gracias!</div>
<div class="aviso">Su consentimiento quedó registrado. Puede continuar en el mostrador.</div>
</div>
<!-- La tablet no está registrada -->
<div class="pantalla oculta" id="p-sin-registro">
<div class="marca"><?= $_fNombre ?></div>
<div class="aviso">Esta tablet todavía no está asignada a un puesto.<br>
Regístrela desde Configuración del turnero.</div>
</div>
<div id="marco"><iframe id="marco-iframe" src="about:blank"></iframe></div>
<script>
const API = '<?= defined('BASE_URL') ? BASE_URL : '/' ?>modules/turnero/api/';
let turnoEnPantalla = null; // turno que se está mostrando
let firmando = false; // con el formulario abierto no se cambia de pantalla
function mostrar(id) {
['p-reposo','p-firmar','p-gracias','p-sin-registro']
.forEach(p => document.getElementById(p).classList.toggle('oculta', p !== id));
}
function abrirFormulario(url) {
firmando = true;
document.getElementById('marco-iframe').src = url;
document.getElementById('marco').classList.add('abierto');
}
function cerrarFormulario() {
firmando = false;
document.getElementById('marco').classList.remove('abierto');
document.getElementById('marco-iframe').src = 'about:blank';
}
document.getElementById('btn-firmar').addEventListener('click', () => {
const url = document.getElementById('btn-firmar').dataset.url;
if (url) abrirFormulario(url);
});
async function revisar() {
// Con el formulario abierto no se toca la pantalla: el paciente está firmando
if (firmando) return;
let d;
try {
const r = await fetch(API + 'get_firma_pendiente.php', { cache: 'no-store' });
d = await r.json();
} catch (_) {
return; // Sin red se deja lo que haya puesto; ya volverá
}
if (!d.ok) {
if (d.motivo === 'sin_dispositivo') mostrar('p-sin-registro');
return;
}
if (d.estado === 'por_firmar') {
document.getElementById('f-codigo').textContent = d.codigo || '';
document.getElementById('f-paciente').textContent = d.paciente || '';
document.getElementById('btn-firmar').dataset.url = d.url || '';
turnoEnPantalla = d.turno_id;
mostrar('p-firmar');
return;
}
if (d.estado === 'firmado') {
// El "gracias" solo tiene sentido para quien acaba de firmar aquí.
// Si la tablet se abre con un turno ya firmado de antes, va a reposo.
mostrar(turnoEnPantalla === d.turno_id ? 'p-gracias' : 'p-reposo');
return;
}
// reposo: se olvida el turno anterior para no dejar datos de un paciente
// en pantalla mientras llega el siguiente
turnoEnPantalla = null;
mostrar('p-reposo');
}
// ver_formulario_enviado.php avisa al terminar; si no llega el aviso, el sondeo
// se encarga igual cuando el consentimiento aparezca como firmado.
window.addEventListener('message', (e) => {
const t = e.data && e.data.type;
if (t === 'turneroFirmado') {
cerrarFormulario();
mostrar('p-gracias');
setTimeout(revisar, 2500);
}
});
revisar();
setInterval(revisar, 2000);
</script>
</body>
</html>