firma: tabs canvas/foto, drag&drop, fallback seguro, aviso builder

This commit is contained in:
Lizandro Guarnizo
2026-03-17 13:34:18 -05:00
parent 775c654790
commit 23bda70cb8
2 changed files with 96 additions and 33 deletions
+75 -20
View File
@@ -301,6 +301,8 @@ const API = 'api/lab/submit_formulario.php';
let _data = null; // respuesta de verificar token
let _cfg = {}; // config de diseño del laboratorio
let _firmaDibujada = false;
let _firmaModosGlobal = null; // modos del topbar (columna firma_modos en DB)
let _hasInlineFirma = false; // true si el esquema tiene campos tipo firma/firma_profesional
// ══════════════════════════════════════════════════════════════════════
// HELPERS
@@ -357,6 +359,20 @@ const firma = (() => {
canvas.addEventListener('touchstart', e => { e.preventDefault(); drawing=true; const p=getPos(e); lastX=p.x; lastY=p.y; }, {passive:false});
canvas.addEventListener('touchmove', e => { e.preventDefault(); if (!drawing) return; trazo(getPos(e)); }, {passive:false});
canvas.addEventListener('touchend', ()=> { drawing=false; ctx.beginPath(); });
// Drag & drop en área de foto global
const fotoDrop = $('firma-foto-drop');
if (fotoDrop) {
fotoDrop.addEventListener('dragover', e => { e.preventDefault(); fotoDrop.classList.add('dragover'); });
fotoDrop.addEventListener('dragleave', () => fotoDrop.classList.remove('dragover'));
fotoDrop.addEventListener('drop', e => {
e.preventDefault();
fotoDrop.classList.remove('dragover');
const file = e.dataTransfer.files?.[0];
if (!file || !file.type.startsWith('image/')) return;
_onFotoChange({ files: [file] });
});
}
}
function trazo(p) {
@@ -438,7 +454,11 @@ function renderCampos(esquema, prefilled) {
return;
}
if (c.tipo === 'firma' || c.tipo === 'firma_profesional') {
html += renderFirmaInline(c.id, c.modos || ['canvas', 'foto'], c.label, c.required, c.tipo === 'firma_profesional');
// firma del paciente usa firma_modos global (topbar); profesional usa sus propios modos
const modos = (c.tipo === 'firma' && _firmaModosGlobal)
? _firmaModosGlobal
: (c.modos || ['canvas']);
html += renderFirmaInline(c.id, modos, c.label, c.required, c.tipo === 'firma_profesional');
return;
}
if (c.tipo === 'parrafo') {
@@ -553,6 +573,7 @@ const _fw = {}; // { fieldId: { canvas, ctx, hasFirma, foto, drawing, lastX, la
function renderFirmaInline(fid, modos, label, required, isPro) {
const hasCanvas = (modos||[]).includes('canvas');
const hasFoto = (modos||[]).includes('foto');
const hasBoth = hasCanvas && hasFoto;
const borderC = isPro ? '#198754' : '#1565c0';
const bgC = isPro ? '#f0fff4' : '#fff';
const iconC = isPro ? 'fa-user-md' : 'fa-signature';
@@ -565,6 +586,16 @@ function renderFirmaInline(fid, modos, label, required, isPro) {
</label>`;
let html = `<div class="mb-4" id="fw-${fid}"><hr class="mb-3">${lbl}`;
// Tabs cuando ambos modos están activos
if (hasBoth) {
html += `<div class="firma-modo-btns mb-2">
<button type="button" class="firma-modo-btn activo" id="fw-${fid}-tab-canvas"
onclick="firmaWidgetTab('${fid}','canvas')">✍️ Dibujar</button>
<button type="button" class="firma-modo-btn" id="fw-${fid}-tab-foto"
onclick="firmaWidgetTab('${fid}','foto')">📷 Foto</button>
</div>`;
}
if (hasCanvas) {
html += `<div id="fw-${fid}-canvas-area">
<p class="text-muted small mb-2">✍️ Dibuja la firma con el dedo o el mouse.</p>
@@ -581,11 +612,11 @@ function renderFirmaInline(fid, modos, label, required, isPro) {
}
if (hasFoto) {
html += `<div id="fw-${fid}-foto-area" class="${hasCanvas ? 'mt-3' : ''}">
<p class="text-muted small mb-2">📷 Adjunta una foto de la firma.</p>
html += `<div id="fw-${fid}-foto-area"${hasBoth ? ' style="display:none"' : (hasCanvas ? ' class="mt-3"' : '')}>
<p class="text-muted small mb-2">📷 Adjunta una foto de la firma o arrastra la imagen aquí.</p>
<label class="firma-foto-drop" id="fw-${fid}-foto-drop" for="fw-${fid}-foto-input">
<i class="fas fa-camera fa-2x d-block mb-2"></i>
Toca para abrir cámara o galería
Toca para abrir cámara / galería, o arrastra aquí
</label>
<input type="file" id="fw-${fid}-foto-input" accept="image/*" capture="environment"
class="d-none" onchange="firmaWidgetFoto('${fid}', this)">
@@ -651,6 +682,31 @@ function firmaWidgetInit(fid) {
canvas.addEventListener('touchstart', start, { passive: false });
canvas.addEventListener('touchmove', move, { passive: false });
canvas.addEventListener('touchend', end);
// Drag & drop en área de foto del widget
const fotoDrop = document.getElementById('fw-' + fid + '-foto-drop');
if (fotoDrop) {
fotoDrop.addEventListener('dragover', e => { e.preventDefault(); fotoDrop.classList.add('dragover'); });
fotoDrop.addEventListener('dragleave', () => fotoDrop.classList.remove('dragover'));
fotoDrop.addEventListener('drop', e => {
e.preventDefault();
fotoDrop.classList.remove('dragover');
const file = e.dataTransfer.files?.[0];
if (!file || !file.type.startsWith('image/')) return;
firmaWidgetFoto(fid, { files: [file] });
});
}
}
function firmaWidgetTab(fid, tab) {
const ca = document.getElementById('fw-' + fid + '-canvas-area');
const fa = document.getElementById('fw-' + fid + '-foto-area');
const bt = document.getElementById('fw-' + fid + '-tab-canvas');
const bf = document.getElementById('fw-' + fid + '-tab-foto');
if (ca) ca.style.display = tab === 'canvas' ? '' : 'none';
if (fa) fa.style.display = tab === 'foto' ? '' : 'none';
if (bt) bt.classList.toggle('activo', tab === 'canvas');
if (bf) bf.classList.toggle('activo', tab === 'foto');
}
function firmaWidgetLimpiar(fid) {
@@ -845,33 +901,32 @@ const formCliente = {
}
// Campos
// firma_modos global: autoridad para campos tipo 'firma' (topbar del builder)
const fModosStr = form.firma_modos || '';
_firmaModosGlobal = fModosStr
? fModosStr.split(',').map(m => m.trim()).filter(Boolean)
: null;
const hasInlineFirma = form.esquema_decoded.some(c => c.tipo === 'firma' || c.tipo === 'firma_profesional');
_hasInlineFirma = hasInlineFirma;
renderCampos(form.esquema_decoded, prefilled);
// Firma (se inicializa DESPUÉS de mostrar el form para que offsetWidth sea correcto)
if (form.permite_firma) {
// Firma global (solo si NO hay firmas inline y permite_firma está activo)
if (form.permite_firma && !hasInlineFirma) {
show('firma-container');
if (form.requiere_firma) show('firma-req-badge');
// Configurar modos de firma: primero columna firma_modos, luego campo en esquema
const firmaCampo = form.esquema_decoded.find(c => c.tipo === 'firma');
const fModosStr = form.firma_modos || '';
const firmaModosArr = fModosStr
? fModosStr.split(',').map(m => m.trim()).filter(Boolean)
: (firmaCampo?.modos ?? null);
firma.setModos(firmaModosArr);
firma.setModos(_firmaModosGlobal);
}
hide('loading-screen');
show('form-screen');
if (form.permite_firma) {
// Pequeño delay para asegurar que el layout ya renderizó
if (form.permite_firma && !hasInlineFirma) {
requestAnimationFrame(() => requestAnimationFrame(() => {
firma.init();
form.esquema_decoded.forEach(c => {
if (c.tipo === 'firma' || c.tipo === 'firma_profesional') firmaWidgetInit(c.id);
});
}));
} else {
}
if (hasInlineFirma) {
requestAnimationFrame(() => requestAnimationFrame(() => {
form.esquema_decoded.forEach(c => {
if (c.tipo === 'firma' || c.tipo === 'firma_profesional') firmaWidgetInit(c.id);
@@ -933,7 +988,7 @@ const formCliente = {
return `El campo "${c.label}" es obligatorio.`;
}
}
if (_data.formulario.requiere_firma && !firma.obtenerSVG() && !firma.obtenerFoto()) {
if (_data.formulario.requiere_firma && !_hasInlineFirma && !firma.obtenerSVG() && !firma.obtenerFoto()) {
$('firma-container').scrollIntoView({block:'center', behavior:'smooth'});
return 'La firma es obligatoria en este formulario.';
}