This commit is contained in:
Lizandro Guarnizo
2026-03-17 12:58:42 -05:00
parent 262009f4e5
commit 775c654790
4 changed files with 263 additions and 12 deletions
+192 -5
View File
@@ -50,7 +50,11 @@ $token = trim($_GET['t'] ?? '');
cursor:crosshair; touch-action:none;
background:#fff; display:block; width:100%; height:150px; }
#firma-canvas.empty { border-style:dashed; border-color:#adb5bd; }
/* ── Firma inline por campo ──────────────────────────────── */
.fw-canvas { cursor:crosshair; touch-action:none;
background:#fff; display:block; width:100%; height:140px;
border-radius:8px; }
.fw-canvas.empty { border-style:dashed !important; }
/* ── Firma modos ─────────────────────────────── */
.firma-modo-btns { display:flex; gap:8px; margin-bottom:12px; }
.firma-modo-btn { flex:1; padding:7px 0; border:2px solid #dee2e6; border-radius:8px;
@@ -433,7 +437,10 @@ function renderCampos(esquema, prefilled) {
</div>`;
return;
}
if (c.tipo === 'firma') return; // se maneja aparte
if (c.tipo === 'firma' || c.tipo === 'firma_profesional') {
html += renderFirmaInline(c.id, c.modos || ['canvas', 'foto'], c.label, c.required, c.tipo === 'firma_profesional');
return;
}
if (c.tipo === 'parrafo') {
const ws = c.flujoLibre ? 'normal' : 'pre-wrap';
html += `<div class="mb-3" style="font-size:.88rem;line-height:1.75;color:#222;text-align:justify;white-space:${ws}">${esc(c.contenido||'')}</div>`;
@@ -538,6 +545,158 @@ function renderCampos(esquema, prefilled) {
cont.innerHTML = html;
}
// ══════════════════════════════════════════════════════════════════════
// FIRMAS INLINE (por campo)
// ══════════════════════════════════════════════════════════════════════
const _fw = {}; // { fieldId: { canvas, ctx, hasFirma, foto, drawing, lastX, lastY } }
function renderFirmaInline(fid, modos, label, required, isPro) {
const hasCanvas = (modos||[]).includes('canvas');
const hasFoto = (modos||[]).includes('foto');
const borderC = isPro ? '#198754' : '#1565c0';
const bgC = isPro ? '#f0fff4' : '#fff';
const iconC = isPro ? 'fa-user-md' : 'fa-signature';
const badge = isPro
? `<span class="badge ms-1" style="background:#198754;font-size:.65rem">Profesional</span>`
: `<span class="badge ms-1" style="background:#1565c0;font-size:.65rem">Paciente</span>`;
const star = required ? '<span class="text-danger ms-1">*</span>' : '';
const lbl = `<label class="form-label fw-semibold">
<i class="fas ${iconC} me-1" style="color:${borderC}"></i>${esc(label||'')}${star}${badge}
</label>`;
let html = `<div class="mb-4" id="fw-${fid}"><hr class="mb-3">${lbl}`;
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>
<canvas id="fw-${fid}-canvas" class="fw-canvas empty"
style="border:2px dashed ${borderC};background:${bgC};"></canvas>
<div class="d-flex gap-2 mt-2">
<button type="button" class="btn btn-outline-secondary btn-sm"
onclick="firmaWidgetLimpiar('${fid}')">
<i class="fas fa-eraser me-1"></i>Limpiar
</button>
<span id="fw-${fid}-status" class="small text-muted align-self-center">Sin firma</span>
</div>
</div>`;
}
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>
<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
</label>
<input type="file" id="fw-${fid}-foto-input" accept="image/*" capture="environment"
class="d-none" onchange="firmaWidgetFoto('${fid}', this)">
<img id="fw-${fid}-foto-preview" src="" alt="Firma"
style="max-width:100%;max-height:160px;border-radius:8px;border:2px solid ${borderC};display:none;margin-top:8px;object-fit:contain;">
<div class="d-flex gap-2 mt-2">
<button type="button" class="btn btn-outline-secondary btn-sm"
onclick="firmaWidgetLimpiarFoto('${fid}')">
<i class="fas fa-eraser me-1"></i>Quitar foto
</button>
<span id="fw-${fid}-foto-status" class="small text-muted align-self-center">Sin foto</span>
</div>
</div>`;
}
html += `</div>`;
return html;
}
function firmaWidgetInit(fid) {
const canvas = document.getElementById('fw-' + fid + '-canvas');
if (!canvas) return;
const ratio = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * ratio;
canvas.height = canvas.offsetHeight * ratio;
const ctx = canvas.getContext('2d');
ctx.scale(ratio, ratio);
ctx.strokeStyle = '#1a1a2e';
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
_fw[fid] = { canvas, ctx, drawing: false, hasFirma: false, foto: null, lastX: 0, lastY: 0 };
function pos(e) {
const r = canvas.getBoundingClientRect();
const src = e.touches?.[0] ?? e;
return { x: src.clientX - r.left, y: src.clientY - r.top };
}
function start(e) {
e.preventDefault();
const s = _fw[fid]; s.drawing = true;
const p = pos(e); s.lastX = p.x; s.lastY = p.y;
}
function move(e) {
e.preventDefault();
const s = _fw[fid];
if (!s.drawing) return;
const p = pos(e);
s.ctx.beginPath(); s.ctx.moveTo(s.lastX, s.lastY);
s.ctx.lineTo(p.x, p.y); s.ctx.stroke();
s.lastX = p.x; s.lastY = p.y;
if (!s.hasFirma) {
s.hasFirma = true;
canvas.classList.remove('empty');
canvas.style.borderStyle = 'solid';
const st = document.getElementById('fw-' + fid + '-status');
if (st) { st.textContent = '✅ Firmado'; st.className = 'small text-success align-self-center fw-semibold'; }
}
}
function end() { if (_fw[fid]) _fw[fid].drawing = false; }
canvas.addEventListener('mousedown', start);
canvas.addEventListener('mousemove', move);
canvas.addEventListener('mouseup', end);
canvas.addEventListener('touchstart', start, { passive: false });
canvas.addEventListener('touchmove', move, { passive: false });
canvas.addEventListener('touchend', end);
}
function firmaWidgetLimpiar(fid) {
const s = _fw[fid];
if (!s?.canvas) return;
const r = window.devicePixelRatio || 1;
s.ctx.clearRect(0, 0, s.canvas.width / r, s.canvas.height / r);
s.hasFirma = false;
s.canvas.classList.add('empty');
s.canvas.style.borderStyle = 'dashed';
const st = document.getElementById('fw-' + fid + '-status');
if (st) { st.textContent = 'Sin firma'; st.className = 'small text-muted align-self-center'; }
}
function firmaWidgetFoto(fid, input) {
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
if (!_fw[fid]) _fw[fid] = {};
_fw[fid].foto = e.target.result;
const p = document.getElementById('fw-' + fid + '-foto-preview');
const d = document.getElementById('fw-' + fid + '-foto-drop');
const s = document.getElementById('fw-' + fid + '-foto-status');
if (p) { p.src = e.target.result; p.style.display = 'block'; }
if (d) d.style.display = 'none';
if (s) { s.textContent = '✅ Foto lista'; s.className = 'small text-success align-self-center fw-semibold'; }
};
reader.readAsDataURL(file);
}
function firmaWidgetLimpiarFoto(fid) {
if (_fw[fid]) _fw[fid].foto = null;
const p = document.getElementById('fw-' + fid + '-foto-preview');
const d = document.getElementById('fw-' + fid + '-foto-drop');
const s = document.getElementById('fw-' + fid + '-foto-status');
const i = document.getElementById('fw-' + fid + '-foto-input');
if (p) { p.src = ''; p.style.display = 'none'; }
if (d) d.style.display = '';
if (s) { s.textContent = 'Sin foto'; s.className = 'small text-muted align-self-center'; }
if (i) i.value = '';
}
function firmaWidgetObtenerSVG(fid) { return _fw[fid]?.hasFirma ? _fw[fid].canvas.toDataURL('image/png') : null; }
function firmaWidgetObtenerFoto(fid) { return _fw[fid]?.foto || null; }
// ══════════════════════════════════════════════════════════════════════
// formCliente
// ══════════════════════════════════════════════════════════════════════
@@ -706,7 +865,18 @@ const formCliente = {
if (form.permite_firma) {
// Pequeño delay para asegurar que el layout ya renderizó
requestAnimationFrame(() => requestAnimationFrame(() => firma.init()));
requestAnimationFrame(() => requestAnimationFrame(() => {
firma.init();
form.esquema_decoded.forEach(c => {
if (c.tipo === 'firma' || c.tipo === 'firma_profesional') firmaWidgetInit(c.id);
});
}));
} else {
requestAnimationFrame(() => requestAnimationFrame(() => {
form.esquema_decoded.forEach(c => {
if (c.tipo === 'firma' || c.tipo === 'firma_profesional') firmaWidgetInit(c.id);
});
}));
}
},
@@ -716,7 +886,7 @@ const formCliente = {
const campos = _data.formulario.esquema_decoded;
campos.forEach(c => {
if (c.tipo === 'separador' || c.tipo === 'firma' || c.tipo === 'parrafo') return;
if (c.tipo === 'separador' || c.tipo === 'firma' || c.tipo === 'firma_profesional' || c.tipo === 'parrafo') return;
if (c.tipo === 'linked') { return; } // linked viene del prefilled
// parrafo_inline: recolectar solo los inputs editables (los que el usuario llenó)
@@ -739,6 +909,14 @@ const formCliente = {
datos[c.id] = el ? el.value : '';
}
});
// Recopilar firmas inline (firma y firma_profesional por campo)
campos.forEach(c => {
if (c.tipo !== 'firma' && c.tipo !== 'firma_profesional') return;
const svg = firmaWidgetObtenerSVG(c.id);
const foto = firmaWidgetObtenerFoto(c.id);
if (svg) datos[c.id + '_svg'] = svg;
if (foto) datos[c.id + '_foto'] = foto;
});
return datos;
},
@@ -746,7 +924,7 @@ const formCliente = {
const campos = _data.formulario.esquema_decoded;
for (const c of campos) {
if (!c.required) continue;
if (c.tipo === 'separador' || c.tipo === 'linked' || c.tipo === 'firma' || c.tipo === 'parrafo' || c.tipo === 'parrafo_inline') continue;
if (c.tipo === 'separador' || c.tipo === 'linked' || c.tipo === 'firma' || c.tipo === 'firma_profesional' || c.tipo === 'parrafo' || c.tipo === 'parrafo_inline') continue;
const v = datos[c.id];
const vacio = Array.isArray(v) ? v.length === 0 : !v || !String(v).trim();
if (vacio) {
@@ -759,6 +937,15 @@ const formCliente = {
$('firma-container').scrollIntoView({block:'center', behavior:'smooth'});
return 'La firma es obligatoria en este formulario.';
}
// Validar firmas inline requeridas
for (const c of campos) {
if (!c.required || (c.tipo !== 'firma' && c.tipo !== 'firma_profesional')) continue;
if (!firmaWidgetObtenerSVG(c.id) && !firmaWidgetObtenerFoto(c.id)) {
const el = document.getElementById('fw-' + c.id);
if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
return `El campo "${c.label}" requiere firma.`;
}
}
return null;
},