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
+19
View File
@@ -41,6 +41,25 @@ if ($_POST && !$loginBlocked) {
$_SESSION['login_ip'] = $clientIp;
$_SESSION['login_time'] = time();
// Tablet fija: redirigir al lugar asignado por IP y bloquear el resto
try {
$_dispStmt = Database::getInstance()->getConnection()->prepare(
"SELECT td.lugar_id, td.nombre, tl.tipo
FROM turnero_dispositivos td
JOIN turnero_lugares tl ON tl.id = td.lugar_id
WHERE td.ip = ? AND td.activo = 1 LIMIT 1"
);
$_dispStmt->execute([$clientIp]);
$_disp = $_dispStmt->fetch(PDO::FETCH_ASSOC);
if ($_disp) {
$_SESSION['turnero_dispositivo'] = $_disp;
$url = $_disp['tipo'] === 'recepcion'
? BASE_URL . 'erp.php?m=turnero&v=recepcion&desk_id=' . $_disp['lugar_id']
: BASE_URL . 'erp.php?m=turnero&v=lugar&lugar_id=' . $_disp['lugar_id'];
header('Location: ' . $url); exit;
}
} catch (\Throwable $_) {}
$roleSlug = $adminUser['role'] ?? 'admin';
$modules = $adminUser['modules'] ?? [];
@@ -0,0 +1,7 @@
-- ============================================================
-- Migration: 20260707_admin_users_firma
-- Firma pre-guardada del profesional para firma en 1 clic
-- ============================================================
ALTER TABLE admin_users
ADD COLUMN IF NOT EXISTS firma_svg MEDIUMTEXT DEFAULT NULL
COMMENT 'Firma pre-guardada del profesional (data URL PNG)';
@@ -0,0 +1,107 @@
-- ============================================================
-- Migration: 20260707_formulario_vih_turnero
-- Clon de F-LAB-05 VIH para uso en turnero.
--
-- Problema del original (id=1):
-- · Esquema sin campos tipo "firma" ni "firma_profesional"
-- · Paciente firmaba por fallback global (canvas genérico al pie)
-- · Profesional NUNCA podía firmar (requiere $_soloFirmaPro=true
-- o campo firma_profesional en esquema — ninguno se cumplía)
-- · doc_color vacío, requiere_firma=0
--
-- Esta versión corrige el esquema agregando:
-- · Separador + campo texto para responsable (menores/incapaces)
-- · campo tipo="firma" → firma inline del paciente
-- · campo tipo="firma_profesional" → 1-clic para el profesional
-- ============================================================
SET @esquema_vih = '[
{
"id": "_8h4v4jg",
"tipo": "parrafo",
"contenido": "Autorización voluntaria para realizar la prueba presuntiva para VIH (Decreto 1543/97 del Ministerio de Protección Social por el cual se reglamentan los mecanismos de prevención, diagnóstico, manejo y reporte epidemiológico de la infección por VIH)",
"flujoLibre": false
},
{
"id": "_z7pv612",
"tipo": "fecha_hoy",
"label": "Fecha",
"required": false
},
{
"id": "_afsycwa",
"tipo": "parrafo",
"contenido": "Qué es el síndrome de inmunodeficiencia adquirida (SIDA)? \nEs una enfermedad producida por un virus conocido como el virus de inmunodeficiencia Humana (VIH), el cual infecta y destruye las células del sistema inmune, originando una falla progresiva y grave en los sistemas de defensa del organismo el cual queda expuesto a la infección y ciertos tipos de tumores. \n\n¿Cómo se adquiere la enfermedad? \nLa enfermedad se adquiere principalmente por contacto sexual con personas infectadas con el VIH. Por exposición a la sangre y a ciertos productos derivados de la misma contaminados con el virus. Además, durante el embarazo, las madres infectadas con el virus de inmunodeficiencia humana pueden transmitir la infección al feto a través de la placenta. \n\n¿Cómo se hace el diagnóstico de la infección? El diagnóstico se hace mediante una prueba de sangre que busca anticuerpos producidos por el organismo contra el virus. Existen dos clases de pruebas de laboratorio. Presuntivas, que pueden indicar una posible infección, y las pruebas confirmatorias, las cuales se hacen únicamente en caso de que la prueba presuntiva de positiva. \n\n¿Cuál es el procedimiento que el laboratorio debe realizar en el análisis de la prueba? El laboratorio procesa la muestra y en caso de un resultado presuntamente positivo o inconcluyente, realiza un segundo examen con otra muestra. En caso de que la segunda muestra también arroje un resultado presuntamente positivo, el resultado de la prueba se reporta como reactivo. En caso de que la segunda muestra no confirme los resultados de la primera muestra, las dos muestras se envían a un tercer laboratorio, con el fin de que sea procesada en él. Los resultados de este tercer laboratorio se toman como definitivos para decidir el reporte como Reactivo o Negativo. En todo caso, los costos por estas pruebas son asumidos por cuenta del laboratorio. \n\n¿Cómo se debe interpretar el resultado de la prueba? La prueba inicial, como ya se anotó, es apenas una prueba presuntiva, y por lo tanto, el hecho de salir reactiva no implica que usted tenga SIDA, o esté infectado por el virus. Este resultado debe ser confirmado mediante una prueba llamada Western Blot, con el fin de eliminar posibles falsos positivos de la prueba presuntiva. Para este segundo examen es importante tomar una nueva muestra y procesarla asumiendo usted los costos. Aún si esta segunda prueba confirma la presencia de anticuerpos para el VIH, esto no significa que usted tiene SIDA, pues existe un período de la enfermedad, controlable, en el cual los pacientes tienen anticuerpos contra el VIH, pero no tienen síntomas de la enfermedad, y pueden, inclusive, no desarrollar jamás la enfermedad. Lo que es muy urgente, es consultar con un médico para que se determine el estado de su enfermedad, y se inicie el tratamiento apropiado. \n\nEl resultado será entregado personalmente, previa identificación. ",
"flujoLibre": false
},
{
"id": "_wsqqoiu",
"tipo": "parrafo_inline",
"contenido": "Yo, {nombre_completo}, con N.º de identificación {numero_documento}, declaro que fui informado (a) sobre el examen de anticuerpos contra el VIH que me será practicado el día de hoy, he recibido asesoría, me han explicado en que consiste; el procedimiento y sus implicaciones en mi vida, y la confidencialidad con la que se manejara las información que he dado y que se obtendrá. YO COMPRENDO Y AUTORIZO LA REALIZACIÓN DE LA PRUEBA DE FORMA LIBRE Y ESPONTÁNEA, en el Laboratorio Clínico Ximena Caicedo Empresa Unipersonal."
},
{
"id": "_vihsep1",
"tipo": "separador",
"label": "AUTORIZACIÓN Y FIRMA"
},
{
"id": "_vihresp",
"tipo": "texto",
"label": "En caso de menor o incapacitado: nombre y parentesco del responsable",
"required": false
},
{
"id": "_vihfpac",
"tipo": "firma",
"label": "Firma del paciente / responsable",
"required": true
},
{
"id": "_vihsep2",
"tipo": "separador",
"label": "USO EXCLUSIVO DEL PROFESIONAL"
},
{
"id": "_vihfpro",
"tipo": "firma_profesional",
"label": "Firma del profesional de salud"
}
]';
INSERT INTO lab_formularios (
nombre,
descripcion,
categoria,
esquema,
permite_firma,
requiere_firma,
doc_encabezado,
doc_subtitulo,
doc_color,
tipo,
is_principal,
is_active,
version,
creado_por
) VALUES (
'CONSENTIMIENTO INFORMADO VIH F-LAB-05 V.5',
'Autorización voluntaria para prueba presuntiva de VIH — uso turnero',
'consentimiento',
@esquema_vih,
1,
1,
'XIMENA CAICEDO G. E.U',
'Laboratorio Hematológico',
'#a0a59c',
'consentimiento',
0,
1,
5,
1
);
-- Asignar a todos los puestos de toma de muestras activos
INSERT INTO turnero_lugar_consentimientos (lugar_id, formulario_id)
SELECT id, LAST_INSERT_ID()
FROM turnero_lugares
WHERE tipo = 'muestras' AND activo = 1;
@@ -0,0 +1,28 @@
-- ============================================================
-- Migration: 20260707_turnero_dispositivos
-- Mapeo IP fija → lugar para tablets del turnero.
-- Cada tablet solo puede acceder a su lugar asignado.
-- ============================================================
CREATE TABLE IF NOT EXISTS turnero_dispositivos (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
ip VARCHAR(45) NOT NULL UNIQUE,
lugar_id INT NOT NULL,
nombre VARCHAR(100) NOT NULL,
activo TINYINT(1) NOT NULL DEFAULT 1,
INDEX idx_ip (ip),
INDEX idx_lugar (lugar_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO turnero_dispositivos (ip, lugar_id, nombre) VALUES
('192.168.21.61', 1, 'Toma de Muestras 1'),
('192.168.21.62', 2, 'Toma de Muestras 2'),
('192.168.21.63', 13, 'Toma de Muestras 3'),
('192.168.21.64', 14, 'Toma de Muestras 4'),
('192.168.21.65', 15, 'Toma de Muestras 5'),
('192.168.21.66', 16, 'Toma de Muestras 6'),
('192.168.21.67', 17, 'Toma de Muestras 7'),
('192.168.21.68', 18, 'Toma de Muestras 8'),
('192.168.0.221', 10, 'Recepción 1 - Vianny Ortega'),
('192.168.0.29', 11, 'Recepción 2 - Angel Wilches'),
('192.168.0.225', 19, 'Recepción 3 - Gina Gomez')
ON DUPLICATE KEY UPDATE lugar_id = VALUES(lugar_id), nombre = VALUES(nombre);
+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();
+355 -79
View File
@@ -55,6 +55,25 @@ if ($modoTurnero) {
echo json_encode(['ok' => false, 'error' => 'Este consentimiento ya fue firmado']); exit;
}
$input = json_decode(file_get_contents('php://input'), true) ?? [];
// Borrador: guardar campos sin firma (auto-save desde el formulario)
// Muestras Prolongadas: marcar completo sin SVG global
if (isset($input['mp_completar'])) {
$dr = is_array($input['datos_respuestas'] ?? null) ? $input['datos_respuestas'] : [];
$existing = json_decode($tcRow['datos_respuestas'] ?? '{}', true) ?: [];
$db->getConnection()
->prepare("UPDATE turnero_consentimientos SET estado='firmado', firmado_at=NOW(), datos_respuestas=? WHERE token=?")
->execute([json_encode(array_merge($existing, $dr), JSON_UNESCAPED_UNICODE), $tokenTurnero]);
require_once __DIR__ . '/modules/turnero/api/_helpers.php';
notificarSSE((int)$tcRow['sesion_id']);
echo json_encode(['ok' => true, 'mp_completado' => true]); exit;
}
if (!array_key_exists('firma_svg', $input) && isset($input['datos_respuestas']) && is_array($input['datos_respuestas'])) {
$existing = json_decode($tcRow['datos_respuestas'] ?? '{}', true) ?: [];
$db->getConnection()
->prepare("UPDATE turnero_consentimientos SET datos_respuestas=? WHERE token=? AND estado='pendiente'")
->execute([json_encode(array_merge($existing, $input['datos_respuestas']), JSON_UNESCAPED_UNICODE), $tokenTurnero]);
echo json_encode(['ok' => true, 'draft' => true]); exit;
}
$firmaSvg = trim($input['firma_svg'] ?? '');
if (strlen($firmaSvg) < 100) {
echo json_encode(['ok' => false, 'error' => 'Firma requerida']); exit;
@@ -199,10 +218,59 @@ $datosPrefilled = json_decode($envio['datos_prefilled'] ?? '{}', true) ?? [];
$todos = array_merge($datosPrefilled, $datosCliente);
$modoEditar = $modoTurnero && $envio['estado'] !== 'firmado';
$embebido = isset($_GET['embed']) && $_GET['embed'] === '1';
$compact = $embebido && isset($_GET['compact']);
// Firma pre-guardada del profesional logueado (para botón de 1 clic)
$firmaProfPreguardada = null;
if ($modoTurnero && $embebido && isUserLoggedIn()) {
$uid = (int)($_SESSION['admin_user']['id'] ?? 0);
if ($uid) {
try {
$stmt = $db->getConnection()->prepare("SELECT firma_svg FROM admin_users WHERE id = ? LIMIT 1");
$stmt->execute([$uid]);
$firmaProfPreguardada = $stmt->fetchColumn() ?: null;
} catch (\Throwable $_) {}
}
}
// Pre-scan: formulario que solo requiere firma del profesional (sin firma paciente)
$_soloFirmaPro = !empty(array_filter($esquema, fn($c) => ($c['tipo'] ?? '') === 'firma_profesional'))
&& empty(array_filter($esquema, fn($c) => ($c['tipo'] ?? '') === 'firma'));
// Muestras Prolongadas: detectar secciones hora+firma_profesional con intervalos de minutos
// Agrupa por prefijo de sección (ej. "Taukit", "Heliprobe") para calcular esperas correctas
$_mpMap = [];
if ($modoTurnero && $embebido && $_soloFirmaPro) {
$_mpSecs = []; $_mpLabel = null; $_mpMin = null; $_mpHora = null;
foreach ($esquema as $_c) {
$_t = $_c['tipo'] ?? '';
if ($_t === 'separador') {
$_mpLabel = $_c['label'] ?? ''; $_mpMin = null; $_mpHora = null;
if (preg_match('/[Mm]inuto\s+(\d+)/u', $_mpLabel, $_mx)) $_mpMin = (int)$_mx[1];
} elseif ($_t === 'hora' && $_mpMin !== null) {
$_mpHora = $_c['id'] ?? null;
} elseif ($_t === 'firma_profesional' && $_mpHora !== null && $_mpMin !== null) {
$_prefix = trim(explode('·', $_mpLabel)[0] ?? '');
$_mpSecs[] = ['label' => $_mpLabel, 'min' => $_mpMin, 'hora' => $_mpHora, 'firma' => $_c['id'], 'tipo' => $_prefix];
$_mpHora = null;
}
}
$_mpGroups = [];
foreach ($_mpSecs as $_s) { $_mpGroups[$_s['tipo']][] = $_s; }
foreach ($_mpGroups as $_tipo => $_secs) {
for ($i = 0, $n = count($_secs); $i < $n; $i++) {
$_s = $_secs[$i]; $_nx = $_secs[$i + 1] ?? null;
$_mpMap[$_s['firma']] = [
'hora_campo' => $_s['hora'],
'esperar_min' => $_nx ? ($_nx['min'] - $_s['min']) : 0,
'next_label' => $_nx ? $_nx['label'] : null,
'next_firma_id' => $_nx ? $_nx['firma'] : null,
'is_last' => !$_nx,
'exam_type' => $_tipo,
];
}
}
}
// Mapa id → label
$labelMap = [];
foreach ($esquema as $c) {
@@ -337,6 +405,14 @@ function esc2(mixed $v): string {
.alert { font-size: 12px; padding: 6px 10px; }
<?php endif; ?>
<?php if ($compact): ?>
/* ── Modo compact (tablet, 2 columnas) ── */
@media (min-width: 480px) {
.campos-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 14px; align-items: start; }
.campos-grid .campo-full { grid-column: span 2; }
}
<?php endif; ?>
/* ── Canvas firma profesional ───────────────────── */
.firma-pro-widget { max-width: 520px; margin-top: 8px; }
.fpw-canvas { border: 2px solid #198754; border-radius: 8px; background: #f8fff9;
@@ -391,6 +467,7 @@ function esc2(mixed $v): string {
background:#fff; color:#0288d1; cursor:pointer; font-weight:600;
display:inline-flex; align-items:center; gap:5px; transition:all .15s; }
.btn-topaz:hover { background:#e0f2fe; }
@keyframes mp-pulse { 0%,100%{opacity:1} 50%{opacity:.65} }
</style>
</head>
<body>
@@ -534,10 +611,11 @@ function esc2(mixed $v): string {
$_esquemaTieneFirmaAlguna = false; // true si hay cualquier campo firma/firma_profesional
$_firmaGlobalPacienteUsada = false; // la firma global solo va al primer campo firma
if ($compact): ?><div class="campos-grid"><?php endif;
foreach ($esquema as $campo):
$tipo = $campo['tipo'] ?? '';
if ($tipo === 'separador'): ?>
<div class="esquema-sep"><?= esc2($campo['label'] ?? '') ?></div>
<div class="esquema-sep<?= $compact ? ' campo-full' : '' ?>"><?= esc2($campo['label'] ?? '') ?></div>
<?php continue; endif;
// ── Firmas inline: cada campo muestra su propia firma ──
@@ -555,7 +633,9 @@ function esc2(mixed $v): string {
// La firma profesional global solo se muestra en el PRIMER campo firma_profesional.
// Campos posteriores (ej. desistimiento) solo muestran su propia firma por campo.
$fSvg = $isPro
? (!$_renderedFirmaProfesional ? ($firmaSharedProfesional ?? $datosCliente[$cid . '_svg'] ?? null) : ($datosCliente[$cid . '_svg'] ?? null))
? (empty($_mpMap) && !$_renderedFirmaProfesional
? ($firmaSharedProfesional ?? $datosCliente[$cid . '_svg'] ?? null)
: ($datosCliente[$cid . '_svg'] ?? null))
: ($datosCliente[$cid . '_svg'] ?? (!$_firmaGlobalPacienteUsada ? $firmaSharedPaciente : null) ?? null);
$fFoto = $datosCliente[$cid . '_foto'] ?? null;
$fIcon = $isPro ? 'fa-user-md' : 'fa-signature';
@@ -571,13 +651,14 @@ function esc2(mixed $v): string {
}
}
$yaHayCanvasPro = $isPro && $_renderedFirmaProfesional;
$yaHayCanvasPro = $isPro && $_renderedFirmaProfesional && empty($_mpMap);
if ($isPro) $_renderedFirmaProfesional = true;
if (!$isPro && $fSvg) {
$_renderedFirmaPaciente = true;
$_firmaGlobalPacienteUsada = true;
}
?>
<?php if ($compact): ?><div class="campo-full"><?php endif; ?>
<div class="section-title mt-3" style="color:<?= $fColor ?>">
<i class="fas <?= $fIcon ?> me-1"></i><?= $fLabel ?>
</div>
@@ -616,12 +697,26 @@ function esc2(mixed $v): string {
($modoTurnero && $embebido && isUserLoggedIn() && $_soloFirmaPro && $modoEditar)
);
if ($_mostrarCanvasPro): ?>
<?php if ($firmaProfPreguardada): ?>
<script>window._fpwPreFirma = <?= json_encode($firmaProfPreguardada) ?>;</script>
<?php endif; ?>
<!-- Canvas del profesional -->
<div class="firma-pro-widget no-print" id="fpw-<?= htmlspecialchars($cid) ?>"
data-envio="<?= (int)$envio['id'] ?>" data-campo="<?= htmlspecialchars($cid) ?>"
data-solo-pro="<?= ($modoTurnero && $_soloFirmaPro) ? '1' : '0' ?>"
data-turno="<?= isset($tcRow) ? (int)$tcRow['turno_id'] : '' ?>"
data-formulario="<?= isset($tcRow) ? (int)$tcRow['formulario_id'] : '' ?>">
<?php if ($firmaProfPreguardada): ?>
<div class="fpw-oneclic">
<button class="btn btn-success fpw-oneclic-btn w-100" style="font-size:1rem;padding:.55rem 1rem">
<i class="fas fa-check-circle me-2"></i>Firmar consentimiento
</button>
<div class="text-center mt-1">
<a href="#" class="small text-muted fpw-usar-otra">✎ Usar otra firma</a>
</div>
</div>
<div class="fpw-canvas-wrap" style="display:none">
<?php endif; ?>
<p class="text-muted small mb-2"><i class="fas fa-pen me-1"></i>Dibuje su firma en el recuadro:</p>
<canvas class="fpw-canvas" width="500" height="150"></canvas>
<div class="mt-2 d-flex gap-2 flex-wrap">
@@ -631,6 +726,7 @@ function esc2(mixed $v): string {
</button>
<button class="btn btn-success btn-sm fpw-save"><i class="fas fa-check me-1"></i>Guardar firma</button>
</div>
<?php if ($firmaProfPreguardada): ?></div><?php endif; ?>
<div class="fpw-msg mt-2 small"></div>
</div>
<?php elseif ($modoPublico): ?>
@@ -640,13 +736,14 @@ function esc2(mixed $v): string {
</div>
<?php endif; ?>
<?php endif; ?>
<?php if ($compact): ?></div><?php endif; ?>
<?php continue; endif;
// ── Parrafo estático ──────────────────────────────────
if ($tipo === 'parrafo'):
$ws = !empty($campo['flujoLibre']) ? 'normal' : 'pre-wrap';
?>
<div class="mb-3" style="font-size:.88rem;line-height:1.75;color:#444;text-align:justify;white-space:<?= $ws ?>"><?= htmlspecialchars($campo['contenido'] ?? '', ENT_QUOTES) ?></div>
<div class="mb-3<?= $compact ? ' campo-full' : '' ?>" style="font-size:.88rem;line-height:1.75;color:#444;text-align:justify;white-space:<?= $ws ?>"><?= htmlspecialchars($campo['contenido'] ?? '', ENT_QUOTES) ?></div>
<?php continue; endif;
// ── Parrafo inline (texto con marcadores {key}) ───────
@@ -659,7 +756,7 @@ function esc2(mixed $v): string {
// Convertir saltos de línea a <br>
$rendered = nl2br(htmlspecialchars($rendered, ENT_QUOTES));
?>
<div class="mb-3" style="font-size:.88rem;line-height:2;color:#444;text-align:justify"><?= $rendered ?></div>
<div class="mb-3<?= $compact ? ' campo-full' : '' ?>" style="font-size:.88rem;line-height:2;color:#444;text-align:justify"><?= $rendered ?></div>
<?php continue; endif;
$cid = $campo['id'] ?? null;
@@ -668,6 +765,14 @@ function esc2(mixed $v): string {
// ── Modo editar (turnero o envío normal): campos interactivos ──
if ($modoEditar):
$prefill = $todos[$cid] ?? '';
// Auto-llenar campos del profesional desde la sesión
if ($prefill === '' && !empty($campo['auto_profesional']) && isUserLoggedIn()) {
$prefill = match($campo['auto_profesional']) {
'nombre' => $_SESSION['admin_user']['full_name'] ?? '',
'cargo' => $_SESSION['admin_user']['cargo'] ?? '',
default => ''
};
}
$label = esc2($campo['label'] ?? $cid);
$req = !empty($campo['required']) ? ' required' : '';
if ($tipo === 'linked'):
@@ -682,7 +787,7 @@ function esc2(mixed $v): string {
<?php endif; continue; endif; // linked
if ($tipo === 'textarea'):
?>
<div class="campo-edit">
<div class="campo-edit<?= $compact ? ' campo-full' : '' ?>">
<label><?= $label ?></label>
<textarea class="form-control form-control-sm" name="<?= esc2($cid) ?>"
rows="3"<?= $req ?>><?= esc2($prefill) ?></textarea>
@@ -691,7 +796,7 @@ function esc2(mixed $v): string {
if ($tipo === 'radio'):
$opts = $campo['opciones'] ?? $campo['options'] ?? [];
?>
<div class="campo-edit">
<div class="campo-edit<?= ($compact && count($opts) > 3) ? ' campo-full' : '' ?>">
<label><?= $label ?></label>
<?php foreach ($opts as $opt): ?>
<div class="form-check">
@@ -708,7 +813,7 @@ function esc2(mixed $v): string {
$checkedArr = is_array($prefill) ? $prefill
: (is_string($prefill) && $prefill !== '' ? (json_decode($prefill, true) ?: [$prefill]) : []);
?>
<div class="campo-edit">
<div class="campo-edit<?= $compact ? ' campo-full' : '' ?>">
<label><?= $label ?></label>
<?php foreach ($opts as $opt): ?>
<div class="form-check">
@@ -788,6 +893,7 @@ function esc2(mixed $v): string {
<div class="campo-valor"><?= esc2($display) ?></div>
</div>
<?php endforeach; ?>
<?php if ($compact): ?></div><?php endif; ?>
<!-- Firma digital global (solo si el esquema no tiene campo firma propio) -->
<?php if ($envio['firma_svg'] && !$_renderedFirmaPaciente): ?>
@@ -913,6 +1019,196 @@ function esc2(mixed $v): string {
</div><!-- /doc-wrap -->
<script>
<?php if (!empty($_mpMap)): ?>
window._muestrasMap = <?= json_encode($_mpMap, JSON_UNESCAPED_UNICODE) ?>;
window._mpSavedFirmas = {};
<?php endif; ?>
/* ── Firma del profesional: función compartida canvas/1-clic ────── */
function _guardarFirmaPro(widget, svg, msgEl) {
const turnoId = widget.dataset.turno;
const formularioId = widget.dataset.formulario;
const envioId = parseInt(widget.dataset.envio, 10);
const campoId = widget.dataset.campo;
const _mpEntry = window._muestrasMap && window._muestrasMap[campoId];
if (_mpEntry) { _guardarFirmaMP(widget, svg, msgEl, _mpEntry, campoId); return; }
const soloPro = widget.dataset.soloPro === '1';
const isTurnero = !!(turnoId && formularioId);
const saveUrl = isTurnero
? 'modules/turnero/api/firmar_profesional_consentimiento.php'
: 'api/lab/firmar_profesional.php';
var datosRespuestas = {};
if (soloPro) {
document.querySelectorAll('[name]').forEach(function(el) {
var raw = el.name, isArr = raw.slice(-2) === '[]', name = isArr ? raw.slice(0,-2) : raw;
if (el.type==='checkbox') { if(el.checked){ if(!Array.isArray(datosRespuestas[name])) datosRespuestas[name]=[]; datosRespuestas[name].push(el.value); } }
else if (el.type==='radio') { if(el.checked) datosRespuestas[name]=el.value; }
else if (el.value!=='') datosRespuestas[name]=el.value;
});
}
const payload = isTurnero
? { turno_id: parseInt(turnoId), formulario_id: parseInt(formularioId), svg,
...(soloPro ? { solo_profesional: true, datos_respuestas: datosRespuestas } : {}) }
: { envio_id: envioId, campo_id: campoId, svg };
fetch(saveUrl, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload) })
.then(r => r.json())
.then(function(data) {
if (data.ok) {
if (soloPro) {
document.querySelectorAll('.firma-pro-widget, .campo-edit, .section-title').forEach(el => el.style.display='none');
var ok = document.createElement('div');
ok.className = 'alert alert-success mt-4 d-flex align-items-center gap-2 no-print';
ok.innerHTML = '<i class="fas fa-check-circle fs-4"></i><div><strong>Firmado correctamente.</strong><br>Puede cerrar esta ventana.</div>';
widget.parentNode.insertBefore(ok, widget.nextSibling);
widget.style.display = 'none';
try { window.parent.postMessage({ type: 'turneroFirmado' }, '*'); } catch(e) {}
} else {
var img = document.createElement('img');
img.src = svg; img.alt = 'Firma profesional';
img.style.cssText = 'max-height:140px;max-width:340px;display:block';
var box = document.createElement('div');
box.className = 'firma-box'; box.style.borderColor = '#198754';
box.appendChild(img);
widget.replaceWith(box);
}
} else {
if (msgEl) msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>' + (data.error||'Error al guardar') + '</span>';
widget.querySelectorAll('button').forEach(b => { b.disabled=false; });
var btnOC = widget.querySelector('.fpw-oneclic-btn');
if (btnOC) btnOC.innerHTML = '<i class="fas fa-check-circle me-2"></i>Firmar consentimiento';
}
})
.catch(function() {
if (msgEl) msgEl.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>Error de conexión.</span>';
widget.querySelectorAll('button').forEach(b => { b.disabled=false; });
});
}
/* ── Muestras Prolongadas: guardar firma por campo + countdown ───── */
function _guardarFirmaMP(widget, svg, msgEl, entry, campoId) {
var dr = {};
document.querySelectorAll('[name]').forEach(function(el) {
var n = el.name.endsWith('[]') ? el.name.slice(0,-2) : el.name;
if (el.type==='checkbox'){ if(el.checked){ if(!Array.isArray(dr[n]))dr[n]=[]; dr[n].push(el.value); } }
else if (el.type==='radio'){ if(el.checked) dr[n]=el.value; }
else if (el.value!=='') dr[n]=el.value;
});
Object.assign(dr, window._mpSavedFirmas || {});
dr[campoId + '_svg'] = svg;
(window._mpSavedFirmas = window._mpSavedFirmas||{})[campoId + '_svg'] = svg;
fetch(window.location.href, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ datos_respuestas: dr })
})
.then(function(r){ return r.json(); })
.then(function(data) {
if (!data.ok) {
if (msgEl) msgEl.innerHTML = '<span class="text-danger">Error al guardar</span>';
widget.querySelectorAll('button').forEach(function(b){ b.disabled=false; });
var bc = widget.querySelector('.fpw-oneclic-btn');
if (bc) bc.innerHTML = '<i class="fas fa-check-circle me-2"></i>Firmar consentimiento';
return;
}
// Mostrar firma guardada en lugar del widget
var img = document.createElement('img');
img.src = svg; img.style.cssText = 'max-height:80px;max-width:240px;display:block';
var box = document.createElement('div');
box.className = 'firma-box'; box.style.borderColor = '#198754';
box.appendChild(img);
widget.replaceWith(box);
if (!entry.is_last && entry.esperar_min > 0) {
_mpIniciarCountdown(entry.esperar_min, entry.next_label, entry.next_firma_id);
} else if (!entry.is_last) {
var nw = entry.next_firma_id ? document.getElementById('fpw-' + entry.next_firma_id) : document.querySelector('.firma-pro-widget');
if (nw) nw.scrollIntoView({ behavior:'smooth', block:'center' });
} else {
_mpMostrarFinalizar(entry.exam_type);
}
})
.catch(function() {
if (msgEl) msgEl.innerHTML = '<span class="text-danger">Error de conexión.</span>';
});
}
function _mpIniciarCountdown(minutos, nextLabel, nextFirmaId) {
var targetMs = Date.now() + minutos * 60000;
var box = document.getElementById('mp-countdown');
if (!box) {
box = document.createElement('div');
box.id = 'mp-countdown';
box.style.cssText = 'position:sticky;bottom:12px;background:#1565c0;color:#fff;padding:10px 16px;border-radius:8px;margin:12px 0;text-align:center;font-size:.9rem;z-index:50';
document.querySelector('.doc-body').appendChild(box);
}
if (box._mpTick) clearInterval(box._mpTick);
box._mpTick = setInterval(function() {
var left = targetMs - Date.now();
if (left <= 0) {
clearInterval(box._mpTick);
box.style.background = '#dc3545';
box.style.animation = 'mp-pulse 1s ease-in-out infinite';
box.innerHTML = '<strong><i class="fas fa-bell me-2"></i>¡Hora de la siguiente muestra!</strong>'
+ (nextLabel ? '<br><small>' + nextLabel + '</small>' : '');
if (navigator.vibrate) navigator.vibrate([400,200,400,200,400]);
var nw = nextFirmaId ? document.getElementById('fpw-' + nextFirmaId) : document.querySelector('.firma-pro-widget');
if (nw) {
nw.scrollIntoView({ behavior:'smooth', block:'center' });
nw.style.outline = '3px solid #dc3545';
nw.style.borderRadius = '6px';
}
return;
}
var m = Math.floor(left / 60000);
var s = Math.floor((left % 60000) / 1000);
box.innerHTML = '⏱ Próxima muestra'
+ (nextLabel ? ' <small>(' + nextLabel + ')</small>' : '')
+ ' en <strong>' + String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0') + '</strong>';
}, 1000);
}
function _mpMostrarFinalizar(examType) {
if (document.getElementById('mp-finalizar')) return;
var cd = document.getElementById('mp-countdown');
if (cd) { clearInterval(cd._mpTick); cd.remove(); }
var wrap = document.createElement('div');
wrap.id = 'mp-finalizar';
wrap.style.cssText = 'position:sticky;bottom:12px;margin:12px 0;text-align:center;z-index:50';
var btn = document.createElement('button');
btn.className = 'btn btn-success fw-bold px-4 py-2';
btn.style.fontSize = '1rem';
btn.innerHTML = '<i class="fas fa-check-double me-2"></i>Finalizar toma · ' + (examType || 'Muestras');
btn.addEventListener('click', function() {
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Guardando...';
var dr = {};
document.querySelectorAll('[name]').forEach(function(el) {
var n = el.name.endsWith('[]') ? el.name.slice(0,-2) : el.name;
if (el.type==='checkbox'){ if(el.checked){ if(!Array.isArray(dr[n]))dr[n]=[]; dr[n].push(el.value); } }
else if (el.type==='radio'){ if(el.checked) dr[n]=el.value; }
else if (el.value!=='') dr[n]=el.value;
});
Object.assign(dr, window._mpSavedFirmas || {});
fetch(window.location.href, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ mp_completar: true, datos_respuestas: dr })
})
.then(function(r){ return r.json(); })
.then(function(data) {
if (data.ok) {
wrap.innerHTML = '<div class="alert alert-success py-2 mb-0"><i class="fas fa-check-circle me-2"></i>Toma completada correctamente.</div>';
try { window.parent.postMessage({ type:'turneroFirmado' }, '*'); } catch(e) {}
}
})
.catch(function(){});
});
wrap.appendChild(btn);
document.querySelector('.doc-body').appendChild(wrap);
}
/* ── Canvas firma del profesional ───────────────────────────────── */
(function () {
document.querySelectorAll('.firma-pro-widget').forEach(function (widget) {
@@ -965,6 +1261,26 @@ function esc2(mixed $v): string {
topaz.activar({ canvas: canvas, onAccept: function() {} });
});
// ── Botón 1 clic: firma pre-guardada ──────────────────────
const btnOneClic = widget.querySelector('.fpw-oneclic-btn');
const btnUsarOtra = widget.querySelector('.fpw-usar-otra');
if (btnOneClic) {
btnOneClic.addEventListener('click', function() {
const preFirma = window._fpwPreFirma;
if (!preFirma) return;
btnOneClic.disabled = true;
btnOneClic.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Firmando...';
_guardarFirmaPro(widget, preFirma, msg);
});
}
if (btnUsarOtra) {
btnUsarOtra.addEventListener('click', function(e) {
e.preventDefault();
widget.querySelector('.fpw-oneclic').style.display = 'none';
widget.querySelector('.fpw-canvas-wrap').style.display = '';
});
}
btnSave.addEventListener('click', function() {
const svg = canvas.toDataURL('image/png');
if (svg.length < 1000) {
@@ -974,77 +1290,7 @@ function esc2(mixed $v): string {
btnSave.disabled = true;
btnSave.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando...';
msg.textContent = '';
const turnoId = widget.dataset.turno;
const formularioId = widget.dataset.formulario;
const soloPro = widget.dataset.soloPro === '1';
const isTurnero = !!(turnoId && formularioId);
const saveUrl = isTurnero
? 'modules/turnero/api/firmar_profesional_consentimiento.php'
: 'api/lab/firmar_profesional.php';
// Recopilar campos del formulario cuando el pro es el firmante final
var datosRespuestas = {};
if (soloPro) {
document.querySelectorAll('[name]').forEach(function(el) {
var raw = el.name;
var isArr = raw.slice(-2) === '[]';
var name = isArr ? raw.slice(0, -2) : raw;
if (el.type === 'checkbox') {
if (el.checked) { if (!Array.isArray(datosRespuestas[name])) datosRespuestas[name] = []; datosRespuestas[name].push(el.value); }
} else if (el.type === 'radio') {
if (el.checked) datosRespuestas[name] = el.value;
} else if (el.value !== '') {
datosRespuestas[name] = el.value;
}
});
}
const savePayload = isTurnero
? { turno_id: parseInt(turnoId), formulario_id: parseInt(formularioId), svg: svg,
...(soloPro ? { solo_profesional: true, datos_respuestas: datosRespuestas } : {}) }
: { envio_id: envioId, campo_id: campoId, svg: svg };
fetch(saveUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(savePayload)
})
.then(function(r){ return r.json(); })
.then(function(data) {
if (data.ok) {
if (soloPro) {
// Modo solo-profesional: mostrar éxito y notificar al padre
document.querySelectorAll('.firma-pro-widget, .campo-edit, .section-title').forEach(function(el) {
el.style.display = 'none';
});
var ok = document.createElement('div');
ok.className = 'alert alert-success mt-4 d-flex align-items-center gap-2 no-print';
ok.innerHTML = '<i class="fas fa-check-circle fs-4"></i><div><strong>Firmado correctamente.</strong><br>Puede cerrar esta ventana.</div>';
widget.parentNode.insertBefore(ok, widget.nextSibling);
widget.style.display = 'none';
try { window.parent.postMessage({ type: 'turneroFirmado' }, '*'); } catch(e) {}
} else {
// Reemplazar canvas con imagen firmada
var img = document.createElement('img');
img.src = svg; img.alt = 'Firma profesional';
img.style.maxHeight = '140px'; img.style.maxWidth = '340px'; img.style.display = 'block';
var box = document.createElement('div');
box.className = 'firma-box'; box.style.borderColor = '#198754';
box.appendChild(img);
widget.replaceWith(box);
}
} else {
msg.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>' + (data.error||'Error al guardar') + '</span>';
btnSave.disabled = false;
btnSave.innerHTML = '<i class="fas fa-check me-1"></i>Guardar firma';
}
})
.catch(function() {
msg.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>Error de conexión.</span>';
btnSave.disabled = false;
btnSave.innerHTML = '<i class="fas fa-check me-1"></i>Guardar firma';
});
_guardarFirmaPro(widget, svg, msg);
});
});
})();
@@ -1302,6 +1548,36 @@ const topaz = (() => {
return { activar, cancelar, limpiarPad, aceptar };
})();
<?php if ($modoTurnero && $modoEditar): ?>
// ── Auto-save borrador (campo a campo, sin firma) ─────────────
(function() {
var _t;
function leerCampos() {
var c = {};
document.querySelectorAll('[name]').forEach(function(el) {
var n = el.name.endsWith('[]') ? el.name.slice(0, -2) : el.name;
if (el.type === 'checkbox') {
if (el.checked) { if (!Array.isArray(c[n])) c[n] = []; c[n].push(el.value); }
} else if (el.type === 'radio') {
if (el.checked) c[n] = el.value;
} else if (el.value !== '') {
c[n] = el.value;
}
});
return c;
}
function guardar() {
fetch(window.location.href, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ datos_respuestas: leerCampos() })
}).catch(function() {});
}
document.addEventListener('input', function() { clearTimeout(_t); _t = setTimeout(guardar, 1200); });
document.addEventListener('change', function() { clearTimeout(_t); _t = setTimeout(guardar, 400); });
})();
<?php endif; ?>
</script>
</body>
</html>