5 Commits
Author SHA1 Message Date
LizandroandClaude Sonnet 4.6 9749d726fa feat(turnero): cambio de estado y reapertura de turnos desde historial
Agrega botón de cambio de estado en cada fila del historial de turnos.
Admins (sin role_id) pueden reabrir turnos finalizados/ausentes/cancelados.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-28 01:43:29 +00:00
Lizandro GuarnizoandClaude Sonnet 4.6 339ca8a40d debug: habilitar display_errors temporalmente para diagnosticar 500
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 08:47:07 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 3dbf40e4fa feat(formulario): firma profesional directa en modal embebido (formularios solo-pro)
- ver_formulario_enviado.php: pre-scan $_soloFirmaPro; muestra canvas pro cuando modoTurnero+embebido+sesión activa+sin firma paciente
- fpw-save JS: recopila campos del form y envía solo_profesional=true; al guardar muestra éxito y postMessage turneroFirmado
- firmar_profesional_consentimiento.php: si solo_profesional=true guarda datos_respuestas y marca estado=firmado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 12:49:39 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1e2cfbaa0b refactor(lugar): embebido como botón en fila de consentimiento, no sección separada
- Eliminar sec-form-embebido como sección independiente
- En modo embebido: fila de consentimiento muestra botón "Firmar" que abre modal directamente por token
- En modo link: botones "Firmar presencial" + "WA" como antes
- abrirModalConsentimientoPorToken(token) recibe token directo sin cache

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 12:37:50 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b399c3710e fix(formulario): no mostrar firma paciente si schema tiene firma_profesional
Usar _esquemaTieneFirmaAlguna (cualquier tipo firma) en lugar de
_esquemaTieneFirmaPaciente para suprimir el widget fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 12:32:01 -05:00
5 changed files with 275 additions and 92 deletions
+53 -3
View File
@@ -64,10 +64,60 @@ try {
$estadoActual = $turno['estado'];
// Estados finales no modificables
// Estados finales — solo admin (sin role_id) puede reabrir
if (in_array($estadoActual, ['finalizado', 'ausente', 'cancelado'], true)) {
$pdo->rollBack();
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422);
$roleId = $_SESSION['admin_user']['role_id'] ?? null;
if (!empty($roleId)) {
$pdo->rollBack();
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422);
}
$permitidosAdmin = ['en_servicio', 'en_espera_lugar', 'en_recepcion', 'espera'];
if (!in_array($nuevoEstado, $permitidosAdmin, true)) {
$pdo->rollBack();
jsonError("Estado destino inválido para reapertura. Opciones: " . implode(', ', $permitidosAdmin), 422);
}
$sets = ['estado = ?'];
$binds = [$nuevoEstado];
if ($nuevoEstado === 'en_servicio') {
$sets[] = 'fin_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = COALESCE(inicio_lugar_at, NOW())';
$sets[] = 'atendido_lugar_por = COALESCE(atendido_lugar_por, ?)';
$binds[] = adminId();
} elseif ($nuevoEstado === 'en_espera_lugar') {
$sets[] = 'fin_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = NULL';
$sets[] = 'llamado_lugar_at = NULL';
} elseif ($nuevoEstado === 'espera') {
$sets[] = 'llamado_recepcion_at = NULL';
$sets[] = 'inicio_recepcion_at = NULL';
$sets[] = 'fin_recepcion_at = NULL';
$sets[] = 'llamado_lugar_at = NULL';
$sets[] = 'inicio_lugar_at = NULL';
$sets[] = 'fin_lugar_at = NULL';
$sets[] = 'atendido_recepcion_por = NULL';
$sets[] = 'atendido_lugar_por = NULL';
$sets[] = 'recepcion_desk_id = NULL';
}
$sql = 'UPDATE turnero_turnos SET ' . implode(', ', $sets) . ' WHERE id = ?';
$binds[] = $turnoId;
$pdo->prepare($sql)->execute($binds);
$pdo->commit();
$stmt = $pdo->prepare(
'SELECT t.*, p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, p.color AS prioridad_color
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.id = ?'
);
$stmt->execute([$turnoId]);
$turnoActualizado = $stmt->fetch(PDO::FETCH_ASSOC);
notificarSSE((int) $turnoActualizado['sesion_id']);
jsonOk(['turno' => $turnoActualizado], "Turno reabierto: estado actualizado a '{$nuevoEstado}'");
}
// Verificar que la transición sea válida
@@ -16,6 +16,9 @@ $body = inputJson();
$turnoId = (int)($body['turno_id'] ?? 0);
$formularioId = (int)($body['formulario_id'] ?? 0);
$svg = $body['svg'] ?? '';
$soloPro = !empty($body['solo_profesional']);
$datosResp = (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']))
? json_encode($body['datos_respuestas'], JSON_UNESCAPED_UNICODE) : null;
if (!$turnoId) jsonError('turno_id requerido.');
if (!$formularioId) jsonError('formulario_id requerido.');
@@ -37,12 +40,22 @@ $tc = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$tc) jsonError('Consentimiento no encontrado.', 404);
$stmt = $pdo->prepare(
"UPDATE turnero_consentimientos
SET firma_profesional_svg = ?, firmado_profesional_at = NOW()
WHERE turno_id = ? AND formulario_id = ?"
);
$stmt->execute([$svg, $turnoId, $formularioId]);
if ($soloPro) {
// El profesional es el firmante final: guardar datos + marcar como firmado
$pdo->prepare(
"UPDATE turnero_consentimientos
SET firma_profesional_svg = ?, firmado_profesional_at = NOW(),
estado = 'firmado', firmado_at = NOW(),
datos_respuestas = COALESCE(?, datos_respuestas)
WHERE turno_id = ? AND formulario_id = ?"
)->execute([$svg, $datosResp, $turnoId, $formularioId]);
} else {
$pdo->prepare(
"UPDATE turnero_consentimientos
SET firma_profesional_svg = ?, firmado_profesional_at = NOW()
WHERE turno_id = ? AND formulario_id = ?"
)->execute([$svg, $turnoId, $formularioId]);
}
notificarSSE((int)$tc['sesion_id']);
+98 -2
View File
@@ -111,6 +111,21 @@ Layout::open('Historial de Turnos', 'fas fa-history');
.doc-pill.enviado { background:#fef9c3; color:#78350f; border:1px solid #fde68a; }
.doc-pill.pendiente{ background:#f1f5f9; color:#64748b; border:1px solid #e2e8f0; }
.docs-empty { font-size:.78rem; color:#94a3b8; font-style:italic; }
/* ── Modal cambio de estado ── */
.estado-modal-backdrop {
position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:1050;
display:flex; align-items:center; justify-content:center;
}
.estado-modal {
background:#fff; border-radius:14px; padding:24px 28px; width:340px;
box-shadow:0 20px 60px rgba(0,0,0,.2);
}
.estado-modal h5 { font-size:.95rem; font-weight:700; margin-bottom:4px; }
.estado-modal .sub { font-size:.78rem; color:#64748b; margin-bottom:16px; }
.estado-modal select { width:100%; padding:8px 10px; border:1px solid #e2e8f0;
border-radius:8px; font-size:.85rem; margin-bottom:16px; }
.estado-modal .actions { display:flex; gap:8px; justify-content:flex-end; }
</style>
<!-- ── Encabezado ────────────────────────────────────────────── -->
@@ -445,12 +460,17 @@ function renderTabla(turnos) {
<td><span class="eb ${cls}">${lbl}</span></td>
<td class="text-nowrap">${espMin}</td>
<td class="text-nowrap">${srvMin}</td>
<td>
<button class="btn btn-sm btn-outline-secondary py-0 px-2"
<td class="text-nowrap">
<button class="btn btn-sm btn-outline-secondary py-0 px-2 me-1"
onclick="toggleDetalle('${rowId}', this, ${t.id})"
title="Ver detalles">
<i class="fas fa-chevron-down" style="font-size:.65rem"></i>
</button>
<button class="btn btn-sm btn-outline-warning py-0 px-2"
onclick="abrirModalEstado(${t.id}, '${t.estado}', '${nombre.replace(/'/g,\'\\\'')}')"
title="Cambiar estado">
<i class="fas fa-exchange-alt" style="font-size:.65rem"></i>
</button>
</td>
</tr>
<tr id="${rowId}" class="detail-row" style="display:none">
@@ -641,6 +661,82 @@ function exportarCSV() {
window.open(`${API}export_historial.php?${params}`, '_blank');
}
// ── Cambio de estado ─────────────────────────────────────────
const ESTADOS_OPCIONES = {
espera : [{v:'en_recepcion', l:'En recepción'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
en_recepcion : [{v:'en_espera_lugar', l:'Esp. lugar'}, {v:'espera', l:'Espera'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
en_espera_lugar : [{v:'en_servicio', l:'En servicio'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
en_servicio : [{v:'finalizado', l:'Finalizado'}, {v:'en_espera_lugar', l:'Esp. lugar'}, {v:'ausente', l:'Ausente'}, {v:'cancelado', l:'Cancelado'}],
// estados finales: reapertura (admin)
finalizado : [{v:'en_servicio', l:'Reabrir → En servicio'}, {v:'en_espera_lugar', l:'Reabrir → Esp. lugar'}, {v:'espera', l:'Reabrir → Espera'}],
ausente : [{v:'espera', l:'Reabrir → Espera'}, {v:'en_servicio', l:'Reabrir → En servicio'}],
cancelado : [{v:'espera', l:'Reabrir → Espera'}, {v:'en_servicio', l:'Reabrir → En servicio'}],
};
let _modalTurnoId = null;
function abrirModalEstado(turnoId, estadoActual, nombre) {
_modalTurnoId = turnoId;
const opciones = ESTADOS_OPCIONES[estadoActual] || [];
if (!opciones.length) { alert('No hay transiciones disponibles para este estado.'); return; }
const sel = opciones.map(o => `<option value="${o.v}">${o.l}</option>`).join('');
const esReopetura = ['finalizado','ausente','cancelado'].includes(estadoActual);
const backdrop = document.createElement('div');
backdrop.className = 'estado-modal-backdrop';
backdrop.id = 'estadoModalBackdrop';
backdrop.innerHTML = `
<div class="estado-modal" onclick="event.stopPropagation()">
<h5><i class="fas fa-exchange-alt me-2 text-warning"></i>Cambiar estado del turno</h5>
<div class="sub">${nombre}${esReopetura ? ' <span class="badge bg-warning text-dark ms-1">Reapertura admin</span>' : ''}</div>
<select id="selectNuevoEstado">${sel}</select>
<div class="actions">
<button class="btn btn-sm btn-outline-secondary" onclick="cerrarModalEstado()">Cancelar</button>
<button class="btn btn-sm btn-warning" onclick="confirmarCambioEstado()">
<i class="fas fa-check me-1"></i>Confirmar
</button>
</div>
</div>`;
backdrop.addEventListener('click', cerrarModalEstado);
document.body.appendChild(backdrop);
}
function cerrarModalEstado() {
document.getElementById('estadoModalBackdrop')?.remove();
_modalTurnoId = null;
}
async function confirmarCambioEstado() {
const nuevoEstado = document.getElementById('selectNuevoEstado').value;
if (!_modalTurnoId || !nuevoEstado) return;
const btn = document.querySelector('#estadoModalBackdrop .btn-warning');
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Guardando…';
try {
const res = await fetch(`${API}cambiar_estado.php`, {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({ turno_id: _modalTurnoId, nuevo_estado: nuevoEstado }),
});
const json = await res.json();
if (json.ok) {
cerrarModalEstado();
buscar(_state.page);
} else {
alert('Error: ' + (json.error || 'No se pudo cambiar el estado.'));
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-check me-1"></i>Confirmar';
}
} catch(e) {
alert('Error de conexión');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-check me-1"></i>Confirmar';
}
}
// ── Helpers visuales ──────────────────────────────────────────
function mostrarSpinner() {
document.getElementById('tbody').innerHTML =
+42 -57
View File
@@ -321,14 +321,7 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
</div>
</div>
<!-- ── Sección: Consentimientos ── -->
<!-- ── Botón firma embebida (modo=embebido) ── -->
<div class="ficha-sec d-none" id="sec-form-embebido">
<h6><i class="fas fa-file-signature me-1"></i>Consentimiento informado</h6>
<button class="btn btn-primary w-100" onclick="abrirModalConsentimiento()">
<i class="fas fa-pen me-2"></i>Abrir formulario de firma
</button>
</div>
<div class="ficha-sec" id="sec-consent">
<h6><i class="fas fa-file-signature me-1"></i>Consentimientos informados</h6>
@@ -753,29 +746,40 @@ function renderConsentimientos(lista) {
const idJs = parseInt(c.id) || 0;
const tId = parseInt(c.turno_id) || (turnoActivo?.id) || 0;
// Firmar aquí: solo si no completado y hay token
const btnFirmar = (!ya && c.token)
? `<button class="btn btn-outline-primary" title="Firmar aquí"
onclick="abrirFirmaPresencial('${token}', ${idJs}, ${nomJs.replace(/"/g,'&quot;')})">
<i class="fas fa-signature"></i> Firmar
</button>` : '';
// Botón ver (firmado)
const btnVer = (ya && c.token)
? `<a href="${BASE_WA}ver_formulario_enviado.php?token=${token}" target="_blank"
class="btn btn-outline-secondary" title="Ver firmado">
<i class="fas fa-eye"></i> Ver
</a>` : '';
// WA / Ver: según estado
const btnWa = ya
? (c.token ? `<a href="${BASE_WA}ver_formulario_enviado.php?token=${token}" target="_blank"
class="btn btn-outline-secondary" title="Ver firmado">
<i class="fas fa-eye"></i> Ver
</a>` : '')
: `<button class="btn btn-outline-success" title="Reenviar por WhatsApp"
onclick="reenviarConsentimiento(${tId})">
<i class="fab fa-whatsapp"></i> WA
</button>`;
// Acción principal según modo y estado
let btnAccion = '';
if (!ya && c.token) {
if (LUGAR_FORM_MODO === 'embebido') {
// Modo embebido: abrir modal directamente
btnAccion = `<button class="btn btn-primary" title="Abrir formulario"
onclick="abrirModalConsentimientoPorToken('${token}')">
<i class="fas fa-pen me-1"></i>Firmar
</button>`;
} else {
// Modo link: firmar presencial o reenviar WA
btnAccion = `<button class="btn btn-outline-primary" title="Firmar aquí"
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"
onclick="reenviarConsentimiento(${tId})">
<i class="fab fa-whatsapp"></i> WA
</button>`;
}
}
return `<div class="consent-row ${c.estado}" data-consent-id="${c.id}">
<i class="fas ${ico}"></i>
<span class="nom-form">${escHtml(c.formulario_nombre || 'Consentimiento')}</span>
<span class="c-badge">${label}</span>
<div class="acciones-consent">${btnFirmar}${btnWa}</div>
<div class="acciones-consent">${btnAccion}${btnVer}</div>
</div>`;
}).join('');
}
@@ -889,27 +893,10 @@ async function cambiarEstadoTurno(nuevoEstado) {
// ── Embebido como modal ───────────────────────────────────────
let _consentTokenCache = null; // { turnoId, token }
async function cargarFormEmbebido(turnoId) {
const sec = document.getElementById('sec-form-embebido');
if (sec) sec.classList.remove('d-none');
// Pre-cargar token para que el modal abra rápido
try {
const res = await fetch(`${API}get_consent_token.php?turno_id=${turnoId}&lugar_id=${lugarId}`);
const json = await res.json();
if (json.ok && json.token) {
_consentTokenCache = { turnoId, token: json.token };
} else {
_consentTokenCache = null;
if (sec) sec.innerHTML = `<h6><i class="fas fa-file-signature me-1"></i>Consentimiento informado</h6>
<div class="text-danger small"><i class="fas fa-exclamation-triangle me-1"></i>${json.error || 'Sin formulario configurado'}</div>`;
}
} catch (e) {
_consentTokenCache = null;
}
}
// cargarFormEmbebido ya no necesita hacer nada (el token viene de la fila de consentimiento)
async function cargarFormEmbebido(turnoId) { /* token llega vía renderConsentimientos */ }
function abrirModalConsentimiento() {
if (!_consentTokenCache) { mostrarError('Token de consentimiento no disponible.'); return; }
function _abrirModalConToken(token) {
const modal = document.getElementById('modal-consentimiento');
const iframe = document.getElementById('modal-consent-iframe');
const load = document.getElementById('modal-consent-loading');
@@ -917,12 +904,20 @@ function abrirModalConsentimiento() {
iframe.src = '';
load.style.display = '';
modal.style.display = 'flex';
// Pequeño delay para que el modal sea visible antes de cargar el iframe
setTimeout(() => {
iframe.src = `${BASE_WA}ver_formulario_enviado.php?token=${encodeURIComponent(_consentTokenCache.token)}&embed=1`;
iframe.src = `${BASE_WA}ver_formulario_enviado.php?token=${encodeURIComponent(token)}&embed=1`;
}, 80);
}
function abrirModalConsentimiento() {
if (!_consentTokenCache?.token) { mostrarError('Token de consentimiento no disponible.'); return; }
_abrirModalConToken(_consentTokenCache.token);
}
function abrirModalConsentimientoPorToken(token) {
_abrirModalConToken(token);
}
function cerrarModalConsentimiento() {
const modal = document.getElementById('modal-consentimiento');
const iframe = document.getElementById('modal-consent-iframe');
@@ -952,17 +947,7 @@ function resetFicha() {
tieneConsent = false;
hayPendientes = false;
_consentTokenCache = null;
// Limpiar modal embebido
cerrarModalConsentimiento();
const sec = document.getElementById('sec-form-embebido');
if (sec) {
sec.classList.add('d-none');
sec.innerHTML = `<h6><i class="fas fa-file-signature me-1"></i>Consentimiento informado</h6>
<button class="btn btn-primary w-100" onclick="abrirModalConsentimiento()">
<i class="fas fa-pen me-2"></i>Abrir formulario de firma
</button>`;
}
document.getElementById('ficha-turno').classList.add('d-none');
document.getElementById('ficha-placeholder').classList.remove('d-none');
+63 -24
View File
@@ -1,4 +1,5 @@
<?php
ini_set('display_errors', 1); error_reporting(E_ALL); // ponytail: quitar después de depurar
/**
* ver_formulario_enviado.php — Ver respuesta de un formulario (imprimible / PDF)
* Acceso admin/enfermero: ver_formulario_enviado.php?id=ENVIO_ID (requiere sesión)
@@ -189,6 +190,9 @@ $datosPrefilled = json_decode($envio['datos_prefilled'] ?? '{}', true) ?? [];
$todos = array_merge($datosPrefilled, $datosCliente);
$modoEditar = $modoTurnero && $envio['estado'] !== 'firmado';
$embebido = isset($_GET['embed']) && $_GET['embed'] === '1';
// 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'));
// Mapa id → label
$labelMap = [];
@@ -482,6 +486,7 @@ function esc2(mixed $v): string {
$_renderedFirmaProfesional = false;
$_renderedFirmaPaciente = false;
$_esquemaTieneFirmaPaciente = false;
$_esquemaTieneFirmaAlguna = false; // true si hay cualquier campo firma/firma_profesional
$_firmaGlobalPacienteUsada = false; // la firma global solo va al primer campo firma
foreach ($esquema as $campo):
@@ -496,7 +501,8 @@ function esc2(mixed $v): string {
if (!$cid) continue;
$isPro = ($tipo === 'firma_profesional');
// Marcar siempre que el esquema tiene campo firma paciente
// Marcar presencia de campos firma en el esquema
$_esquemaTieneFirmaAlguna = true;
if (!$isPro) {
$_esquemaTieneFirmaPaciente = true;
}
@@ -555,14 +561,19 @@ function esc2(mixed $v): string {
<div class="turnero-msg mt-2 small"></div>
</div>
<?php elseif ($isPro): ?>
<?php if (!$modoTurnero && !$modoPublico && !$yaHayCanvasPro): ?>
<!-- Canvas del profesional: solo aparece una vez -->
<?php
// Mostrar canvas pro si: admin normal, O turnero+embebido+sesión activa+solo firma pro
$_mostrarCanvasPro = !$yaHayCanvasPro && (
(!$modoTurnero && !$modoPublico) ||
($modoTurnero && $embebido && isUserLoggedIn() && $_soloFirmaPro && $modoEditar)
);
if ($_mostrarCanvasPro): ?>
<!-- Canvas del profesional -->
<div class="firma-pro-widget no-print" id="fpw-<?= htmlspecialchars($cid) ?>"
data-envio="<?= (int)$envio['id'] ?>" data-campo="<?= htmlspecialchars($cid) ?>"
<?php if ($modoTurnero): ?>
data-turno="<?= (int)$tcRow['turno_id'] ?>"
data-formulario="<?= (int)$tcRow['formulario_id'] ?>"
<?php endif; ?>>
data-solo-pro="<?= ($modoTurnero && $_soloFirmaPro) ? '1' : '0' ?>"
data-turno="<?= isset($tcRow) ? (int)$tcRow['turno_id'] : '' ?>"
data-formulario="<?= isset($tcRow) ? (int)$tcRow['formulario_id'] : '' ?>">
<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">
@@ -789,8 +800,8 @@ function esc2(mixed $v): string {
</div>
<?php endif; ?>
<!-- ── Widget firma turnero (fallback si el esquema no tiene campo firma) ── -->
<?php if ($modoTurnero && $envio['estado'] !== 'firmado' && !$_esquemaTieneFirmaPaciente): ?>
<!-- ── Widget firma turnero (fallback solo si el esquema no tiene NINGÚN campo firma) ── -->
<?php if ($modoTurnero && $envio['estado'] !== 'firmado' && !$_esquemaTieneFirmaAlguna): ?>
<div class="mt-4 no-print turnero-firma-item" data-cid="__firma_global">
<div class="section-title" style="color:#1565c0">
<i class="fas fa-pen me-1"></i>Firma del paciente / responsable
@@ -889,7 +900,6 @@ function esc2(mixed $v): string {
btnSave.addEventListener('click', function() {
const svg = canvas.toDataURL('image/png');
// Verificar que no esté vacío (al menos 1000 bytes de data)
if (svg.length < 1000) {
msg.innerHTML = '<span class="text-danger"><i class="fas fa-exclamation-triangle me-1"></i>Por favor dibuje su firma antes de guardar.</span>';
return;
@@ -900,12 +910,32 @@ function esc2(mixed $v): string {
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';
const savePayload = isTurnero
? { turno_id: parseInt(turnoId), formulario_id: parseInt(formularioId), svg: svg }
// 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, {
@@ -916,18 +946,27 @@ function esc2(mixed $v): string {
.then(function(r){ return r.json(); })
.then(function(data) {
if (data.ok) {
// Reemplazar el canvas con la imagen firmada
const img = document.createElement('img');
img.src = svg;
img.alt = 'Firma profesional';
img.style.maxHeight = '140px';
img.style.maxWidth = '340px';
img.style.display = 'block';
const box = document.createElement('div');
box.className = 'firma-box';
box.style.borderColor = '#198754';
box.appendChild(img);
widget.replaceWith(box);
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;