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:
co-authored by
Claude Opus 5
parent
2a87f66bb7
commit
6589503c57
@@ -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 */
|
||||
|
||||
@@ -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']),
|
||||
]);
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user