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:
co-authored by
Claude Sonnet 4.6
parent
4de9c2f9b4
commit
8760b685ad
@@ -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.']);
|
||||
@@ -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'");
|
||||
|
||||
@@ -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,'"')})">
|
||||
<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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user