feat(turnero): dispositivos por IP, firmas, formularios y muestras prolongadas

- login.php: detectar dispositivo por IP al autenticar y redirigir al lugar asignado
- dashboard.php / lugar.php / recepcion.php: bloquear acceso por dispositivo (IP→lugar)
- lugar.php: modal firma profesional pre-guardada; compact 2-col en iframe; botón WA
  solo cuando el formulario requiere firma del paciente
- ver_formulario_enviado.php: modo compact tablet, auto-save borrador (merge), firma
  profesional en 1 clic, Muestras Prolongadas (countdown + Finalizar), auto_profesional
  pre-fill desde sesión, fecha→fecha_hoy en campos de toma/recepción
- get_consentimientos.php: añadir requiere_firma_paciente para controlar botón WA
- save_firma_profesional.php: API para guardar firma pre-configurada del profesional
- migrations: turnero_dispositivos, admin_users.firma_svg, clon VIH F-LAB-05 para turnero

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-07 21:58:52 -05:00
co-authored by Claude Sonnet 4.6
parent 4de9c2f9b4
commit 8760b685ad
10 changed files with 706 additions and 91 deletions
+8 -8
View File
@@ -55,10 +55,10 @@ foreach ($consentimientos as &$c) {
$decoded = json_decode($esquema, true);
$campos = is_array($decoded) ? $decoded : ($decoded['campos'] ?? []);
}
$c['requiere_firma_profesional'] = !empty(array_filter(
$campos,
fn($f) => ($f['tipo'] ?? '') === 'firma_profesional'
));
$tieneFirmaPro = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma_profesional'));
$tieneFirmaPac = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma'));
$c['requiere_firma_profesional'] = $tieneFirmaPro;
$c['requiere_firma_paciente'] = !($tieneFirmaPro && !$tieneFirmaPac); // false solo si es solo-profesional
unset($c['formulario_esquema']);
}
unset($c);
@@ -205,10 +205,10 @@ if ($incluirSolicitud) {
$decoded = json_decode($esquema, true);
$campos = is_array($decoded) ? $decoded : ($decoded['campos'] ?? []);
}
$c['requiere_firma_profesional'] = !empty(array_filter(
$campos,
fn($f) => ($f['tipo'] ?? '') === 'firma_profesional'
));
$tieneFirmaPro = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma_profesional'));
$tieneFirmaPac = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma'));
$c['requiere_firma_profesional'] = $tieneFirmaPro;
$c['requiere_firma_paciente'] = !($tieneFirmaPro && !$tieneFirmaPac);
unset($c['formulario_esquema']);
}
unset($c);
@@ -0,0 +1,22 @@
<?php
/**
* POST — Guarda la firma pre-configurada del profesional en su perfil (admin_users).
* Body JSON: { firma_svg: "data:image/png;base64,..." }
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$body = inputJson();
$svg = $body['firma_svg'] ?? '';
if (strlen($svg) < 100) jsonError('Firma requerida.');
if (!preg_match('/^data:image\/(svg\+xml|png|jpeg|webp);base64,/i', $svg)) {
jsonError('Formato de firma no válido.');
}
$uid = (int)($_SESSION['admin_user']['id'] ?? 0);
if (!$uid) jsonError('Sin sesión activa.');
db()->prepare("UPDATE admin_users SET firma_svg = ? WHERE id = ?")->execute([$svg, $uid]);
jsonOk(['mensaje' => 'Firma guardada correctamente.']);
+9
View File
@@ -7,6 +7,15 @@
require_once APP_ROOT . '/config/config.php';
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
// Tablet asignada → no tiene acceso al dashboard, ir a su lugar
if (!empty($_SESSION['turnero_dispositivo'])) {
$_d = $_SESSION['turnero_dispositivo'];
header('Location: ' . BASE_URL . ($_d['tipo'] === 'recepcion'
? 'erp.php?m=turnero&v=recepcion&desk_id=' . $_d['lugar_id']
: 'erp.php?m=turnero&v=lugar&lugar_id=' . $_d['lugar_id']));
exit;
}
$_iaActiva = false;
try {
$_cfgIA = Database::getInstance()->fetch("SELECT valor FROM lab_config WHERE clave = 'ia_activa'");
+139 -4
View File
@@ -9,6 +9,18 @@ if (!isUserLoggedIn()) {
exit;
}
// Tablet asignada → forzar su lugar, bloquear cualquier otro
if (!empty($_SESSION['turnero_dispositivo'])) {
$_d = $_SESSION['turnero_dispositivo'];
$_forzado = (int)$_d['lugar_id'];
if ($_d['tipo'] === 'recepcion') {
header('Location: ' . BASE_URL . 'erp.php?m=turnero&v=recepcion&desk_id=' . $_forzado); exit;
}
if ((int)($_GET['lugar_id'] ?? 0) !== $_forzado) {
header('Location: ' . BASE_URL . 'erp.php?m=turnero&v=lugar&lugar_id=' . $_forzado); exit;
}
}
try {
$pdo = Database::getInstance()->getConnection();
$lugares = $pdo->query(
@@ -33,6 +45,13 @@ foreach ($lugares as $l) {
}
$adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['username'] ?? 'Operador';
$adminFirmaSvg = null;
try {
$stmt = Database::getInstance()->getConnection()
->prepare("SELECT firma_svg FROM admin_users WHERE id = ? LIMIT 1");
$stmt->execute([(int)($_SESSION['admin_user']['id'] ?? 0)]);
$adminFirmaSvg = $stmt->fetchColumn() ?: null;
} catch (\Throwable $_) {}
?>
<!DOCTYPE html>
<html lang="es">
@@ -465,6 +484,10 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
</div>
<div class="d-flex align-items-center gap-2">
<span class="text-muted small d-none d-md-inline"><?= htmlspecialchars($adminNombre) ?></span>
<button class="btn btn-sm <?= $adminFirmaSvg ? 'btn-outline-success' : 'btn-warning' ?>"
onclick="abrirModalMiFirma()" title="<?= $adminFirmaSvg ? 'Ver / cambiar mi firma' : 'Configurar mi firma' ?>">
<i class="fas fa-signature me-1"></i><?= $adminFirmaSvg ? 'Mi firma' : 'Configurar firma' ?>
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="cambiarLugar()">
<i class="fas fa-exchange-alt me-1"></i>Cambiar
</button>
@@ -707,7 +730,7 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
background:rgba(0,0,0,.55);align-items:center;justify-content:center;padding:16px">
<div style="
background:#fff;border-radius:14px;box-shadow:0 8px 40px rgba(0,0,0,.25);
width:min(96vw,680px);max-height:92vh;
width:min(96vw,860px);max-height:92vh;
display:flex;flex-direction:column;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;
padding:12px 18px;border-bottom:1px solid #e2e8f0;flex-shrink:0">
@@ -1135,10 +1158,10 @@ function renderConsentimientos(lista) {
onclick="abrirFirmaPresencial('${token}', ${idJs}, ${nomJs.replace(/"/g,'&quot;')})">
<i class="fas fa-signature"></i> Firmar
</button>
<button class="btn btn-outline-success" title="Reenviar por WhatsApp"
${c.requiere_firma_paciente ? `<button class="btn btn-outline-success" title="Reenviar por WhatsApp"
onclick="reenviarConsentimiento(${tId})">
<i class="fab fa-whatsapp"></i> WA
</button>`;
</button>` : ''}`;
}
}
@@ -1201,7 +1224,7 @@ function _abrirModalConToken(token) {
load.style.display = '';
modal.style.display = 'flex';
setTimeout(() => {
iframe.src = `${BASE_WA}ver_formulario_enviado.php?token=${encodeURIComponent(token)}&embed=1`;
iframe.src = `${BASE_WA}ver_formulario_enviado.php?token=${encodeURIComponent(token)}&embed=1&compact=1`;
}, 80);
}
@@ -1878,6 +1901,118 @@ function mostrarLlamando(cod) { mostrarToast('Llamando turno ' + cod + '…', 'i
<span id="lug-toast-msg"></span>
</div>
<!-- ── Modal Mi Firma ─────────────────────────────────────── -->
<div id="modal-mi-firma" style="display:none;position:fixed;inset:0;z-index:9500;
background:rgba(0,0,0,.55);align-items:center;justify-content:center;padding:16px">
<div style="background:#fff;border-radius:14px;box-shadow:0 8px 40px rgba(0,0,0,.25);
width:min(96vw,480px);display:flex;flex-direction:column;overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;
padding:12px 18px;border-bottom:1px solid #e2e8f0">
<span style="font-weight:700;font-size:.95rem;color:#1e293b">
<i class="fas fa-signature me-2 text-success"></i>Mi firma pre-guardada
</span>
<button onclick="cerrarModalMiFirma()"
style="background:none;border:none;font-size:1.2rem;color:#94a3b8;cursor:pointer;padding:2px 6px">
<i class="fas fa-times"></i>
</button>
</div>
<div style="padding:16px">
<?php if ($adminFirmaSvg): ?>
<div id="mf-preview" style="text-align:center;margin-bottom:12px">
<p class="small text-success mb-1"><i class="fas fa-check-circle me-1"></i>Firma guardada</p>
<img src="<?= htmlspecialchars($adminFirmaSvg) ?>" alt="Mi firma"
style="max-height:80px;max-width:100%;border:1px solid #c9d8ff;border-radius:8px;padding:6px">
<br><a href="#" class="small text-muted mt-1 d-inline-block" onclick="document.getElementById('mf-preview').style.display='none';document.getElementById('mf-canvas-wrap').style.display='';return false">✎ Dibujar nueva</a>
</div>
<div id="mf-canvas-wrap" style="display:none">
<?php else: ?>
<div id="mf-canvas-wrap">
<?php endif; ?>
<p class="small text-muted mb-2">Dibuje su firma en el recuadro:</p>
<canvas id="mf-canvas" width="420" height="140"
style="border:2px solid #198754;border-radius:8px;background:#f8fff9;
cursor:crosshair;display:block;width:100%;touch-action:none"></canvas>
<div class="mt-2 d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" onclick="mfLimpiar()">
<i class="fas fa-eraser me-1"></i>Limpiar
</button>
<button class="btn btn-success btn-sm ms-auto" id="mf-btn-save" onclick="mfGuardar()">
<i class="fas fa-save me-1"></i>Guardar firma
</button>
</div>
</div>
<div id="mf-msg" class="mt-2 small"></div>
</div>
</div>
</div>
<script>
// ── Modal Mi Firma ────────────────────────────────────────────
(function() {
var canvas, ctx, drawing = false;
function init() {
canvas = document.getElementById('mf-canvas');
if (!canvas) return;
const ratio = window.devicePixelRatio || 1;
const w = canvas.offsetWidth || 420, h = canvas.offsetHeight || 140;
canvas.width = w * ratio; canvas.height = h * ratio;
canvas.style.width = w + 'px'; canvas.style.height = h + 'px';
ctx = canvas.getContext('2d');
ctx.scale(ratio, ratio);
ctx.strokeStyle = '#000'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round';
function pos(e) { const r = canvas.getBoundingClientRect(), s = e.touches?e.touches[0]:e; return {x:s.clientX-r.left, y:s.clientY-r.top}; }
canvas.addEventListener('mousedown', e => { drawing=true; ctx.beginPath(); const p=pos(e); ctx.moveTo(p.x,p.y); });
canvas.addEventListener('mousemove', e => { if(!drawing) return; const p=pos(e); ctx.lineTo(p.x,p.y); ctx.stroke(); });
canvas.addEventListener('mouseup', () => drawing=false);
canvas.addEventListener('mouseleave', () => drawing=false);
canvas.addEventListener('touchstart', e => { e.preventDefault(); drawing=true; ctx.beginPath(); const p=pos(e); ctx.moveTo(p.x,p.y); }, {passive:false});
canvas.addEventListener('touchmove', e => { e.preventDefault(); if(!drawing) return; const p=pos(e); ctx.lineTo(p.x,p.y); ctx.stroke(); }, {passive:false});
canvas.addEventListener('touchend', () => drawing=false);
}
window.abrirModalMiFirma = function() {
document.getElementById('modal-mi-firma').style.display = 'flex';
if (!ctx) init();
};
window.cerrarModalMiFirma = function() {
document.getElementById('modal-mi-firma').style.display = 'none';
};
window.mfLimpiar = function() {
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
};
window.mfGuardar = function() {
const png = canvas.toDataURL('image/png');
if (png.length < 1000) { document.getElementById('mf-msg').innerHTML = '<span class="text-danger">Dibuje su firma primero.</span>'; return; }
const btn = document.getElementById('mf-btn-save');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando...';
fetch('<?= BASE_URL ?>modules/turnero/api/save_firma_profesional.php', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ firma_svg: png })
})
.then(r => r.json())
.then(d => {
if (d.ok) {
window._fpwPreFirma = png;
document.getElementById('mf-msg').innerHTML = '<span class="text-success"><i class="fas fa-check me-1"></i>Firma guardada. Se usará en el próximo consentimiento.</span>';
btn.innerHTML = '<i class="fas fa-check me-1"></i>Guardado';
// Actualizar preview si existe
var prev = document.getElementById('mf-preview');
if (prev) { prev.querySelector('img').src = png; prev.style.display=''; document.getElementById('mf-canvas-wrap').style.display='none'; }
} else {
document.getElementById('mf-msg').innerHTML = '<span class="text-danger">' + (d.error||'Error') + '</span>';
btn.disabled = false; btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar firma';
}
})
.catch(() => {
document.getElementById('mf-msg').innerHTML = '<span class="text-danger">Error de conexión.</span>';
btn.disabled = false; btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar firma';
});
};
})();
</script>
<script src="<?= defined('APP_URL') ? rtrim(APP_URL,'/') : '' ?>/assets/js/lab-sidebar.js"></script>
</body>
</html>
+12
View File
@@ -9,6 +9,18 @@ if (!isUserLoggedIn()) {
exit;
}
// Tablet asignada → forzar su escritorio, bloquear cualquier otro
if (!empty($_SESSION['turnero_dispositivo'])) {
$_d = $_SESSION['turnero_dispositivo'];
$_forzado = (int)$_d['lugar_id'];
if ($_d['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;
}
}
// Cargar catálogos para la ficha
try {
$pdo = Database::getInstance()->getConnection();