hbox-docs/docs/formularios/acta.js.bak

382 lines
21 KiB
JavaScript

/* Motor de actas HBOX — renderiza el formulario, sincroniza con el servidor y firma.
- Un acta real se envía UNA vez: al elegir una serie ya enviada, se carga llena y bloqueada.
- Solo el administrador (leo) ve el botón "Borrar acta (reabrir)".
- La serie PRUEBA no se bloquea (para practicar).
Tipos de ítem: check | medida | texto | radio | nota · Envío: POST /api/actas
Modos de serie:
(a) simple: cfg.prefijo + cfg.series (cantidad) [+ cfg.desde, cfg.pad] → "RTK-005"
(b) compuesta: cfg.componentes = [{clave, etiqueta, prefijo, desde, hasta, pad, tipoActa}]
cfg.serie(sel) → número de serie calculado, p.ej. "HBOX5-E07-R010-M006"
cfg.prerequisitos = [{etiqueta, tipo, serie(sel)}] → otras actas que deben existir y estar OK
cfg.exigirAprobados = true → no se puede enviar si algún componente/prerrequisito no está aprobado
cfg.unicoPorComponente = true → un componente no puede estar en dos actas de este tipo */
(function () {
const $ = (t, a, ...k) => { const e = document.createElement(t); if (a) for (const p in a){ if(p==='html')e.innerHTML=a[p]; else if(p==='text')e.textContent=a[p]; else e.setAttribute(p,a[p]); } k.forEach(c=>e.append(c)); return e; };
const num = s => (String(s || '').match(/(\d+)\s*$/) || [,''])[1];
const esPrueba = s => String(s || '').toUpperCase().indexOf('PRUEBA') >= 0;
function opciones(sel, prefijo, desde, hasta, pad) {
sel.append($('option', { value: '', text: '— elegir —' }));
sel.append($('option', { value: prefijo + '-PRUEBA', text: '⚙ PRUEBA (no cuenta)' }));
for (let i = desde; i <= hasta; i++) {
const s = prefijo + '-' + String(i).padStart(pad, '0');
sel.append($('option', { value: s, text: s }));
}
}
function render(cfg) {
const root = document.getElementById('acta');
const compuesta = Array.isArray(cfg.componentes) && cfg.componentes.length > 0;
let admin = false;
const wrap = $('div', { class: 'wrap' });
const head = $('header', { class: 'top' });
head.append($('h1', { text: cfg.titulo }));
head.append($('p', { text: cfg.subtitulo || 'Helgen Technologies · llena mientras pruebas la tarjeta' }));
wrap.append(head);
const nav = $('div', { style: 'display:flex;justify-content:space-between;margin:2px 0 8px' });
nav.append($('a', { class: 'volver', href: '../actas/', text: '← Volver a Actas' }));
nav.append($('a', { class: 'volver', href: '../salir.html', text: 'Cerrar sesión' }));
wrap.append(nav);
if (cfg.borrador)
wrap.append($('div', { class: 'borrador', html: '⚠ <b>BORRADOR</b> — este formulario aún debe revisarse con ingeniería antes de usarlo como registro oficial.' }));
if (cfg.intro) wrap.append($('div', { class: 'nota-item', style: 'margin:0 0 10px', html: cfg.intro }));
// ---------------- cabecera: serie (simple o compuesta) + técnico ----------------
const datos = $('div', { class: 'datos' });
const serieSel = $('select', { id: 'f_serie' }); // modo simple
const serieCalc = $('input', { type: 'text', id: 'f_serie_calc', readonly: 'readonly', placeholder: 'se calcula al elegir los módulos' }); // modo compuesto
const compSel = {}; // clave -> <select>
const compBadge = {}; // clave -> <span> estado
const preBadge = []; // prerrequisitos -> <span>
const tecnico = $('input', { type: 'text', id: 'f_tecnico', placeholder: 'Nombre y apellido' });
if (!compuesta) {
opciones(serieSel, cfg.prefijo, cfg.desde || 1, (cfg.desde || 1) + (cfg.series || 15) - 1, cfg.pad || 2);
const dS = $('div'); dS.append($('label', { text: cfg.etiquetaSerie || 'Número de serie' }), serieSel);
const dT = $('div'); dT.append($('label', { text: 'Técnico' }), tecnico);
datos.append(dS, dT);
} else {
cfg.componentes.forEach(c => {
const sel = $('select', { id: 'f_comp_' + c.clave, 'data-comp': c.clave });
opciones(sel, c.prefijo, c.desde || 1, c.hasta || ((c.desde || 1) + (c.series || 15) - 1), c.pad || 2);
const badge = $('span', { class: 'badge pend', text: '—' });
const d = $('div'); d.append($('label', { text: c.etiqueta }), sel, badge);
datos.append(d); compSel[c.clave] = sel; compBadge[c.clave] = badge;
});
const dSer = $('div', { class: 'serie-calc' }); dSer.append($('label', { text: cfg.etiquetaSerie || 'Número de serie de la unidad (automático)' }), serieCalc);
(cfg.prerequisitos || []).forEach(p => {
const b = $('span', { class: 'badge pend', text: '—' });
const d = $('div'); d.append($('label', { text: p.etiqueta }), b); dSer.append(d); preBadge.push(b);
});
datos.append(dSer);
const dT = $('div'); dT.append($('label', { text: 'Técnico' }), tecnico);
datos.append(dT);
}
const cab = $('section', { class: 'paso' }); cab.append(datos);
wrap.append(cab);
const banner = $('div', { class: 'msg', id: 'f_banner' });
wrap.append(banner);
// selección actual de componentes -> {clave: valor}
function seleccion() { const s = {}; for (const k in compSel) s[k] = compSel[k].value; return s; }
function serieActual() {
if (!compuesta) return serieSel.value;
const s = seleccion();
if (Object.values(s).some(v => !v)) return '';
if (Object.values(s).some(esPrueba)) return (cfg.prefijoPrueba || 'PRUEBA') + '-PRUEBA';
return cfg.serie(s);
}
const key = () => 'acta:' + cfg.tipo + ':' + (serieActual() || '');
// ---------------- secciones ----------------
cfg.secciones.forEach((sec, si) => {
const s = $('section', { class: 'paso' });
s.append($('h2', { text: sec.titulo }));
if (sec.sub) s.append($('p', { class: 'sub', text: sec.sub }));
(sec.items || []).forEach((it, ii) => {
const id = `s${si}_${ii}`;
const box = $('div', { class: 'item' });
if (it.aviso) box.append($('div', { class: 'aviso', html: it.aviso }));
if (it.tipo === 'check') {
const lab = $('label', { class: 'chk' });
lab.append($('input', { type: 'checkbox', 'data-id': id, 'data-lbl': it.txt }));
lab.append($('span', { class: 'txt', html: it.txt }));
box.append(lab);
} else if (it.tipo === 'medida') {
box.classList.add('medida');
box.append($('span', { class: 'txt', text: it.txt }));
if (it.esperado) box.append($('span', { class: 'esp', html: 'Esperado: ' + it.esperado }));
box.append($('input', { type: 'text', inputmode: it.modo || 'decimal', 'data-id': id, 'data-lbl': it.txt, placeholder: it.ph || 'medido' }));
if (it.u) box.append($('span', { class: 'u', text: it.u }));
} else if (it.tipo === 'texto') {
box.append($('label', { text: it.txt }));
box.append($('textarea', { rows: it.rows || '2', 'data-id': id, 'data-lbl': it.txt, placeholder: it.ph || '' }));
} else if (it.tipo === 'radio') {
box.append($('label', { text: it.txt }));
const g = $('div', { class: 'radios' });
(it.opciones || []).forEach(o => {
const l = $('label', { class: 'rad' });
l.append($('input', { type: 'radio', name: 'r_' + id, value: o, 'data-id': id, 'data-lbl': it.txt }), document.createTextNode(' ' + o));
g.append(l);
});
box.append(g);
} else if (it.tipo === 'nota') {
box.append($('div', { class: 'nota-item', html: it.txt }));
}
s.append(box);
});
wrap.append(s);
});
// ---------------- firma ----------------
const firma = $('section', { class: 'paso firma' });
firma.append($('h2', { text: 'Cierre y firma' }));
firma.append($('p', { class: 'sub', text: 'Al enviar, quedas registrado como responsable de esta prueba.' }));
const res = $('div', { class: 'resultado' });
const rOK = $('label', { class: 'ok' }); rOK.append($('input', { type: 'radio', name: 'resultado', value: 'OK' }), document.createTextNode(cfg.textoOK || 'APROBADA'));
const rNO = $('label', { class: 'falla' }); rNO.append($('input', { type: 'radio', name: 'resultado', value: 'FALLA' }), document.createTextNode('FALLA'));
res.append(rOK, rNO);
firma.append($('label', { text: 'Resultado final' }), res);
const conf = $('label', { class: 'chk', style: 'margin-top:14px' });
conf.append($('input', { type: 'checkbox', id: 'f_conforme' }));
conf.append($('span', { class: 'txt', html: 'Confirmo que <b>seguí el procedimiento completo</b> y que los datos anotados son verídicos.' }));
firma.append(conf);
wrap.append(firma);
const msg = $('div', { class: 'msg', id: 'f_msg' });
wrap.append(msg);
root.append(wrap);
const barra = $('div', { class: 'barra' });
const estado = $('span', { class: 'estado', id: 'f_estado', text: 'Sin guardar' });
const limpiar = $('button', { class: 'limpiar', id: 'f_limpiar', text: 'Limpiar' });
const borrar = $('button', { class: 'limpiar', id: 'f_borrar', text: 'Borrar acta (reabrir)', style: 'display:none;border-color:var(--falla);color:var(--falla)' });
const btn = $('button', { class: 'enviar', id: 'f_enviar', text: 'Enviar acta firmada' });
const grp = $('div', { style: 'display:flex;gap:10px' }); grp.append(limpiar, borrar, btn);
barra.append(estado, grp);
document.body.append(barra);
// ---------------- helpers de estado ----------------
const conforme = () => document.getElementById('f_conforme');
function fields() { return wrap.querySelectorAll('[data-id]'); }
function setEnabled(on) {
fields().forEach(el => el.disabled = !on);
tecnico.disabled = !on; conforme().disabled = !on;
wrap.querySelectorAll('input[name=resultado]').forEach(el => el.disabled = !on);
}
function blankFields() {
fields().forEach(el => { if (el.type === 'checkbox' || el.type === 'radio') el.checked = false; else el.value = ''; });
const rr = wrap.querySelector('input[name=resultado]:checked'); if (rr) rr.checked = false;
conforme().checked = false; tecnico.value = '';
}
function fillFrom(a) {
tecnico.value = a.tecnico || '';
conforme().checked = !!a.conforme;
if (a.resultado) { const el = wrap.querySelector(`input[name=resultado][value="${a.resultado}"]`); if (el) el.checked = true; }
for (const k in (a.respuestas || {})) {
const v = a.respuestas[k].val;
const els = wrap.querySelectorAll(`[data-id="${k}"]`); if (!els.length) continue;
els.forEach(el => {
if (el.type === 'checkbox') el.checked = !!v;
else if (el.type === 'radio') el.checked = (el.value === v);
else el.value = v || '';
});
}
}
function showEditable() {
setEnabled(true);
limpiar.style.display = ''; btn.style.display = ''; borrar.style.display = 'none';
wrap.classList.remove('enviada'); banner.className = 'msg';
}
function showLocked(a) {
setEnabled(false);
limpiar.style.display = 'none'; btn.style.display = 'none';
borrar.style.display = admin ? '' : 'none';
banner.className = 'msg ok';
const f = (a._recibido || '').replace('T', ' ').slice(0, 16);
banner.innerHTML = '✓ <b>Acta ya enviada</b> por <b>' + (a.tecnico || '—') + '</b>'
+ (f ? ' el ' + f : '') + '. Registro final'
+ (a.resultado ? ' — resultado <b>' + a.resultado + '</b>' : '') + '.'
+ (admin ? '' : ' Para rehacerla, pide a Leo que la borre.');
estado.textContent = 'Bloqueada';
}
// ---------------- verificación de componentes / prerrequisitos (modo compuesto) ----------------
let verif = {}; // clave -> 'OK' | 'FALLA' | 'SIN_ACTA' | 'PRUEBA' | 'EN_USO'
let usados = null; // { clave: { valor: serieUnidad } } — componentes ya asignados en otras actas de este tipo
function setBadge(el, st, extra) {
const map = { OK: ['ok', '✓ Aprobado'], FALLA: ['falla', '✕ FALLA'], SIN_ACTA: ['falla', '✕ Sin acta'],
PRUEBA: ['pend', '⚙ prueba'], EN_USO: ['falla', '⚠ ya en ' + (extra || 'otra unidad')], CONSULTANDO: ['pend', '…'] };
const m = map[st] || ['pend', '—'];
el.className = 'badge ' + m[0]; el.textContent = m[1];
}
async function estadoActa(tipo, serie) {
if (!serie) return 'SIN_ACTA';
if (esPrueba(serie)) return 'PRUEBA';
try {
const r = await fetch(`/api/acta?tipo=${encodeURIComponent(tipo)}&serie=${encodeURIComponent(serie)}`);
const j = await r.json();
if (!j.exists) return 'SIN_ACTA';
return j.acta && j.acta.resultado === 'FALLA' ? 'FALLA' : 'OK';
} catch (e) { return 'SIN_ACTA'; }
}
async function cargarUsados() {
if (!cfg.unicoPorComponente) { usados = {}; return; }
usados = {};
try {
const r = await fetch('/api/actas', { cache: 'no-store' }); const j = await r.json();
(j.actas || []).filter(a => a.tipo === cfg.tipo && a.componentes && !esPrueba(a.serie)).forEach(a => {
for (const k in a.componentes) { usados[k] = usados[k] || {}; usados[k][a.componentes[k]] = a.serie; }
});
} catch (e) {}
}
async function verificar() {
verif = {};
const s = seleccion();
const tareas = cfg.componentes.map(async c => {
const v = s[c.clave]; const b = compBadge[c.clave];
if (!v) { setBadge(b, null); verif[c.clave] = 'SIN_SELECCION'; return; }
setBadge(b, 'CONSULTANDO');
let st = await estadoActa(c.tipoActa, v);
if (st === 'OK' && usados && usados[c.clave] && usados[c.clave][v] && usados[c.clave][v] !== serieActual()) {
st = 'EN_USO'; setBadge(b, st, usados[c.clave][v]); verif[c.clave] = st; return;
}
setBadge(b, st); verif[c.clave] = st;
});
const pre = (cfg.prerequisitos || []).map(async (p, i) => {
const b = preBadge[i];
if (Object.values(s).some(v => !v)) { setBadge(b, null); verif['pre_' + i] = 'SIN_SELECCION'; return; }
setBadge(b, 'CONSULTANDO');
const st = await estadoActa(p.tipo, Object.values(s).some(esPrueba) ? 'PRUEBA' : p.serie(s));
setBadge(b, st); verif['pre_' + i] = st;
});
await Promise.all(tareas.concat(pre));
}
function todoAprobado() {
return Object.values(verif).every(v => v === 'OK' || v === 'PRUEBA');
}
// ---------------- autoguardado local ----------------
let t, locked = false;
function snapshot() {
const r = {};
fields().forEach(el => {
if (el.type === 'radio') { if (el.checked) r[el.dataset.id] = { lbl: el.dataset.lbl, val: el.value }; else if (!r[el.dataset.id]) r[el.dataset.id] = { lbl: el.dataset.lbl, val: '' }; }
else r[el.dataset.id] = { lbl: el.dataset.lbl, val: el.type === 'checkbox' ? el.checked : el.value };
});
const rr = wrap.querySelector('input[name=resultado]:checked');
const d = { tipo: cfg.tipo, serie: serieActual(), tecnico: tecnico.value,
conforme: conforme().checked, resultado: rr ? rr.value : '', respuestas: r };
if (compuesta) { d.componentes = seleccion(); d.verificacion = Object.assign({}, verif); }
return d;
}
function guardarLocal() {
if (locked || !serieActual()) return;
clearTimeout(t);
t = setTimeout(() => {
try { localStorage.setItem(key(), JSON.stringify(snapshot())); } catch (e) {}
estado.textContent = 'Guardado local ' + new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
}, 400);
}
function cargarLocal() {
let d; try { d = JSON.parse(localStorage.getItem(key()) || 'null'); } catch (e) {}
if (d) { fillFrom(d); estado.textContent = 'Borrador recuperado'; }
else estado.textContent = 'Nueva acta';
}
// ---------------- cargar una serie ----------------
async function loadSerie() {
msg.className = 'msg'; locked = false;
const s = serieActual();
if (compuesta) serieCalc.value = s || '';
blankFields(); showEditable();
if (compuesta) { if (usados === null) await cargarUsados(); await verificar(); }
if (!s) { estado.textContent = compuesta ? 'Elige los tres módulos' : 'Elige el número de serie'; return; }
if (esPrueba(s)) { cargarLocal(); estado.textContent = 'Modo prueba (no cuenta)'; return; }
estado.textContent = 'Consultando…';
let j = null;
try { const r = await fetch(`/api/acta?tipo=${encodeURIComponent(cfg.tipo)}&serie=${encodeURIComponent(s)}`); j = await r.json(); } catch (e) {}
if (j && j.exists) { locked = true; fillFrom(j.acta); showLocked(j.acta); }
else {
cargarLocal();
if (compuesta && cfg.exigirAprobados && !todoAprobado()) {
banner.className = 'msg err';
banner.innerHTML = '⛔ <b>No se puede ensamblar con esta selección.</b> Todos los módulos deben estar <b>aprobados</b> en su acta y no estar ya asignados a otra unidad. Revisa los indicadores de arriba.';
}
}
}
wrap.addEventListener('input', guardarLocal);
serieSel.addEventListener('change', loadSerie);
Object.values(compSel).forEach(sel => sel.addEventListener('change', loadSerie));
limpiar.addEventListener('click', () => {
if (locked) return;
if (!confirm('¿Limpiar este formulario y empezar de nuevo?')) return;
try { localStorage.removeItem(key()); } catch (e) {}
blankFields(); msg.className = 'msg'; estado.textContent = 'Formulario limpio'; window.scrollTo(0, 0);
});
borrar.addEventListener('click', async () => {
if (!admin) return;
if (!confirm('¿Borrar el acta de ' + serieActual() + '? Quedará disponible para rehacerse.')) return;
borrar.disabled = true;
try {
const r = await fetch(`/api/acta?tipo=${encodeURIComponent(cfg.tipo)}&serie=${encodeURIComponent(serieActual())}`, { method: 'DELETE' });
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
locked = false; blankFields(); showEditable(); usados = null;
msg.className = 'msg ok'; msg.textContent = 'Acta borrada. Ya puede rehacerse.';
estado.textContent = 'Reabierta';
} catch (e) { msg.className = 'msg err'; msg.textContent = 'No se pudo borrar: ' + e.message; }
borrar.disabled = false;
});
// ---------------- envío ----------------
btn.addEventListener('click', async () => {
if (locked) return;
const d = snapshot();
if (!d.serie) return fail(compuesta ? 'Elige los tres módulos.' : 'Elige el número de serie.');
if (compuesta && cfg.exigirAprobados && !esPrueba(d.serie) && !todoAprobado())
return fail('Hay módulos sin aprobar, sin acta o ya asignados a otra unidad. No se puede enviar.');
if (!d.tecnico.trim()) return fail('Escribe tu nombre.');
if (!d.resultado) return fail('Marca el resultado final (Aprobada o Falla).');
if (!d.conforme) return fail('Debes confirmar que seguiste el procedimiento.');
btn.disabled = true; btn.textContent = 'Enviando…';
try {
const r = await fetch('/api/actas', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(d) });
const j = await r.json();
if (r.status === 409) {
try { localStorage.removeItem(key()); } catch (e) {}
usados = null; await loadSerie();
msg.className = 'msg err'; msg.textContent = j.error || 'Ya existe un acta para esta serie.';
btn.disabled = false; btn.textContent = 'Enviar acta firmada';
return;
}
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
try { localStorage.removeItem(key()); } catch (e) {}
msg.className = 'msg ok';
msg.innerHTML = '✓ Acta enviada y registrada como <b>' + d.serie + '</b>.'
+ (compuesta && !esPrueba(d.serie) ? '<br>Etiqueta de la unidad: <b style="font-size:18px;letter-spacing:.04em">' + d.serie + '</b>' : '');
if (!j.prueba) { locked = true; showLocked({ tecnico: d.tecnico, resultado: d.resultado, _recibido: new Date().toISOString() }); }
else { btn.disabled = false; btn.textContent = 'Enviar acta firmada'; estado.textContent = 'Prueba enviada'; }
window.scrollTo(0, document.body.scrollHeight);
} catch (e) {
fail('No se pudo enviar: ' + e.message + '. Revisa tu conexión (tu borrador está guardado).');
btn.disabled = false; btn.textContent = 'Enviar acta firmada';
}
});
function fail(m) { msg.className = 'msg err'; msg.textContent = m; window.scrollTo(0, document.body.scrollHeight); }
(async () => {
try { const r = await fetch('/api/whoami'); const j = await r.json(); admin = !!j.admin; } catch (e) {}
loadSerie();
})();
}
window.Acta = { render, num };
})();