/* 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 | foto | archivo | fecha · Envío: POST /api/actas
Extensiones para actas de CAMPO (diagnóstico / instalación):
(c) cfg.repetible = true → la serie NO se bloquea: cada visita es un registro nuevo,
y se muestra el historial de visitas anteriores.
cfg.unidades = [{serie, etiqueta, maquina, terminal, nota}] → lista de la flota desplegada
(+ opción "otra / no listada" con captura libre).
cfg.tecnicoDesdeUsuario = true → precarga el nombre del usuario que inició sesión.
item.clave = 'led' → nombre estable de la respuesta (para condiciones y reportes)
item.si = v => v.led==='apagado' → muestra el ítem/sección solo si se cumple
item.req = true → obligatorio para poder enviar
item.guardaEn = 'clasificacion' → además copia el valor a la raíz del acta (índice/tablero)
item.tipo = 'foto' → cámara del teléfono; sube a /api/adjuntos y guarda la ruta
item.tipo = 'archivo' → captura de log (.txt .log .csv .nmea .ubx); sube a /api/archivos
item.tipo = 'fecha' → selector de fecha; arranca con la de hoy
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;
/* Reduce la foto antes de subirla: en el patio la señal es mala y una foto de 4 MB
no sube. 1600 px de lado mayor + JPEG 0.8 ≈ 200-400 kB y se sigue leyendo una
etiqueta o una soldadura quemada. */
async function reducir(file, max = 1600, calidad = 0.8) {
const bmp = await (window.createImageBitmap
? createImageBitmap(file, { imageOrientation: 'from-image' }).catch(() => createImageBitmap(file))
: new Promise((ok, no) => { const i = new Image(); i.onload = () => ok(i); i.onerror = no; i.src = URL.createObjectURL(file); }));
const w = bmp.width, h = bmp.height, e = Math.min(1, max / Math.max(w, h));
const cv = document.createElement('canvas');
cv.width = Math.round(w * e); cv.height = Math.round(h * e);
cv.getContext('2d').drawImage(bmp, 0, 0, cv.width, cv.height);
const blob = await new Promise(r => cv.toBlob(r, 'image/jpeg', calidad));
return blob || file;
}
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: '⚠ BORRADOR — 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 ->
const compBadge = {}; // clave -> estado
const preBadge = []; // prerrequisitos ->
const tecnico = $('input', { type: 'text', id: 'f_tecnico', placeholder: 'Nombre y apellido' });
const flota = Array.isArray(cfg.unidades) && cfg.unidades.length > 0;
const serieOtra = $('input', { type: 'text', id: 'f_serie_otra', placeholder: 'Escribe la etiqueta de la unidad tal como viene en la caja' });
const fichaUnidad = $('div', { class: 'ficha-unidad' });
if (flota) {
serieSel.append($('option', { value: '', text: '— elegir unidad —' }));
const grupos = {};
cfg.unidades.forEach(u => { (grupos[u.terminal || 'Otras'] = grupos[u.terminal || 'Otras'] || []).push(u); });
Object.keys(grupos).forEach(g => {
const og = $('optgroup', { label: g });
grupos[g].forEach(u => og.append($('option', { value: u.serie, text: u.etiqueta || (u.serie + (u.maquina ? ' · ' + u.maquina : '')) })));
serieSel.append(og);
});
serieSel.append($('option', { value: '__OTRA__', text: '➕ Otra unidad (no está en la lista)' }));
serieSel.append($('option', { value: 'CAMPO-PRUEBA', text: '⚙ PRUEBA (no cuenta)' }));
const dS = $('div'); dS.append($('label', { text: cfg.etiquetaSerie || 'Unidad' }), serieSel, serieOtra, fichaUnidad);
serieOtra.style.display = 'none'; serieOtra.style.marginTop = '8px';
const dT = $('div'); dT.append($('label', { text: 'Técnico' }), tecnico);
datos.append(dS, dT);
} else 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);
const hist = $('div', { class: 'hist' });
if (cfg.repetible) wrap.append(hist);
// Marca de daño: la consultan TODOS los formularios (banco, campo, produccion).
// Una tarjeta con corto no debe poder entrar a un ensamble sin que alguien lo vea.
const marcaBox = $('div', { class: 'marca' });
wrap.append(marcaBox);
async function consultarMarca() {
marcaBox.innerHTML = ''; marcaBox.className = 'marca';
if (cfg.tipo === 'dano') return;
// En un acta compuesta (ensamble, integración) se revisa CADA módulo elegido, no solo
// la unidad calculada: una tarjeta con corto no debe poder entrar a un ensamble.
const series = compuesta ? Object.values(seleccion()).filter(v => v) : [serieActual()];
const aRevisar = series.filter(v => v && !esPrueba(v));
if (!aRevisar.length) return;
const avisos = [];
await Promise.all(aRevisar.map(async s => {
try {
const j = await (await fetch(`/api/estado?serie=${encodeURIComponent(s)}`, { cache: 'no-store' })).json();
if (j.marca) avisos.push({ s, j });
} catch (e) {}
}));
if (!avisos.length) return;
const baja = avisos.some(x => x.j.marca === 'BAJA');
marcaBox.className = 'marca ' + (baja ? 'baja' : 'danada');
marcaBox.innerHTML = avisos.map(({ s, j }) => {
const a = j.acta || {};
return (j.marca === 'BAJA' ? '⛔ ' + s + ' está dada de BAJA ' : '⚠ ' + s + ' está marcada como DAÑADA ')
+ (a.fecha ? ' desde el ' + a.fecha : '') + (a.clasificacion ? ' — ' + a.clasificacion : '')
+ (a.resumen ? ' ' + a.resumen : '')
+ 'Ver su expediente '
+ (j.marca === 'BAJA' ? '. No debe usarse ni instalarse.' : '. Revisa el acta de daño antes de continuar.');
}).join(' ');
}
// selección actual de componentes -> {clave: valor}
function seleccion() { const s = {}; for (const k in compSel) s[k] = compSel[k].value; return s; }
function unidadSel() {
if (!flota) return null;
return (cfg.unidades || []).find(u => u.serie === serieSel.value) || null;
}
function serieActual() {
if (flota) {
if (serieSel.value === '__OTRA__')
return String(serieOtra.value || '').trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '-').slice(0, 24);
return serieSel.value;
}
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 ----------------
const condicionales = []; // [{el, fn}] — secciones o ítems que solo se muestran si se cumple algo
const obligatorios = []; // [{id, txt, esFoto}]
const guardaEn = {}; // data-id -> nombre del campo en la raíz del acta
let fotos = []; // [{ruta, etiqueta, campo}]
let archivos = []; // [{ruta, nombre, bytes, campo}] — capturas de log
const pendientes = []; // fotos que no lograron subir: {blob, etiqueta, li}
cfg.secciones.forEach((sec, si) => {
const s = $('section', { class: 'paso' });
s.append($('h2', { text: sec.titulo }));
if (sec.sub) s.append($('p', { class: 'sub', html: sec.sub }));
if (typeof sec.si === 'function') condicionales.push({ el: s, fn: sec.si });
(sec.items || []).forEach((it, ii) => {
const id = it.clave || `s${si}_${ii}`;
const box = $('div', { class: 'item' });
if (typeof it.si === 'function') condicionales.push({ el: box, fn: it.si });
if (it.req) obligatorios.push({ id, txt: String(it.txt || '').replace(/<[^>]+>/g, ''),
esFoto: it.tipo === 'foto', esArchivo: it.tipo === 'archivo' });
if (it.guardaEn) guardaEn[id] = it.guardaEn;
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 }));
} else if (it.tipo === 'tabla') {
// Tabla de registro: filas × columnas de entradas cortas. Cada celda es un campo propio
// (clave `____ `), así que se guarda, se relee y se bloquea igual que todo lo demás.
box.classList.add('tabla-item');
if (it.txt) box.append($('label', { html: it.txt }));
if (it.sub) box.append($('div', { class: 'nota-item', html: it.sub }));
const scroll = $('div', { class: 'tabla-scroll' });
const t = $('table', { class: 'tabla' });
const thead = $('thead'); const hr = $('tr'); hr.append($('th', { text: it.etiquetaFilas || '' }));
(it.columnas || []).forEach(c => { const th = $('th', { html: c.txt }); if (c.u) th.append($('small', { text: ' ' + c.u })); hr.append(th); });
thead.append(hr); t.append(thead);
const tbody = $('tbody');
(it.filas || []).forEach(f => {
const fk = String(f).replace(/[^A-Za-z0-9]+/g, '-');
const tr = $('tr'); tr.append($('th', { text: f }));
(it.columnas || []).forEach(c => {
const cid = `${id}__${fk}__${c.k}`;
const lbl = `${it.txt ? String(it.txt).replace(/<[^>]+>/g, '') + ' · ' : ''}${f} · ${String(c.txt).replace(/<[^>]+>/g, '')}`;
const td = $('td');
if (c.opciones) {
const sel = $('select', { 'data-id': cid, 'data-lbl': lbl });
sel.append($('option', { value: '', text: '—' }));
c.opciones.forEach(o => sel.append($('option', { value: o, text: o })));
td.append(sel);
} else {
td.append($('input', { type: 'text', inputmode: c.num ? 'decimal' : 'text', 'data-id': cid, 'data-lbl': lbl,
placeholder: c.ph || '', style: c.ancho ? `width:${c.ancho}` : '' }));
}
tr.append(td);
});
tbody.append(tr);
});
t.append(tbody); scroll.append(t); box.append(scroll);
} else if (it.tipo === 'fecha') {
box.append($('label', { html: it.txt }));
const hoy = new Date(); hoy.setMinutes(hoy.getMinutes() - hoy.getTimezoneOffset());
const hoyISO = hoy.toISOString().slice(0, 10);
box.append($('input', { type: 'date', 'data-id': id, 'data-lbl': String(it.txt || '').replace(/<[^>]+>/g, ''),
value: hoyISO, 'data-def': hoyISO, style: 'max-width:220px' }));
} else if (it.tipo === 'archivo') {
box.classList.add('foto');
box.append($('span', { class: 'txt', html: (it.req ? 'Archivo obligatorio — ' : 'Archivo — ') + it.txt }));
if (it.ayuda) box.append($('div', { class: 'nota-item', html: it.ayuda }));
const inp = $('input', { type: 'file', accept: '.txt,.log,.csv,.nmea,.ubx,.json,.cfg,text/plain', multiple: 'multiple', style: 'display:none' });
const btn = $('button', { type: 'button', class: 'btn-foto', text: '📄 Subir archivo de captura' });
const tiras = $('div', { class: 'miniaturas', 'data-fotos': id });
const marca = $('input', { type: 'hidden', 'data-id': id, 'data-lbl': 'Archivos: ' + String(it.txt || '').replace(/<[^>]+>/g, ''), value: '' });
btn.addEventListener('click', () => inp.click());
inp.addEventListener('change', () => { subirArchivos(inp.files, id, it.etiqueta || String(it.txt || '').replace(/<[^>]+>/g, '').slice(0, 60), tiras, marca); inp.value = ''; });
box.append(btn, inp, tiras, marca);
} else if (it.tipo === 'foto') {
box.classList.add('foto');
box.append($('span', { class: 'txt', html: (it.req ? 'Foto obligatoria — ' : 'Foto — ') + it.txt }));
if (it.ayuda) box.append($('div', { class: 'nota-item', html: it.ayuda }));
const inp = $('input', { type: 'file', accept: 'image/*', capture: 'environment', multiple: 'multiple', style: 'display:none' });
const btn = $('button', { type: 'button', class: 'btn-foto', text: '📷 Tomar o elegir foto' });
const tiras = $('div', { class: 'miniaturas', 'data-fotos': id });
const marca = $('input', { type: 'hidden', 'data-id': id, 'data-lbl': 'Fotos: ' + String(it.txt || '').replace(/<[^>]+>/g, ''), value: '' });
btn.addEventListener('click', () => inp.click());
inp.addEventListener('change', () => { subirFotos(inp.files, id, it.etiqueta || String(it.txt || '').replace(/<[^>]+>/g, '').slice(0, 60), tiras, marca); inp.value = ''; });
box.append(btn, inp, tiras, marca);
}
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.' }));
// cfg.resultadoFijo = 'REGISTRADO': actas que no aprueban ni reprueban nada (daños, envíos).
// Se oculta el selector y el valor se manda fijo, para que el servidor siga teniendo 'resultado'.
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);
if (!cfg.resultadoFijo) 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 seguí el procedimiento completo 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 pintarFotos() {
wrap.querySelectorAll('.miniaturas').forEach(t => t.innerHTML = '');
fotos.forEach(f => {
const t = wrap.querySelector(`.miniaturas[data-fotos="${f.campo}"]`);
const marca = wrap.querySelector(`[data-id="${f.campo}"]`);
if (!t) return;
const li = $('div', { class: 'mini ok' });
li.append($('img', { src: '/api/adjunto?ruta=' + encodeURIComponent(f.ruta) }),
$('span', { class: 'est', text: '✓ guardada' }));
if (marca) li.append(quitarFoto(f, li, marca));
t.append(li);
});
}
function pintarArchivos() {
archivos.forEach(a => {
const t = wrap.querySelector(`.miniaturas[data-fotos="${a.campo}"]`);
if (!t) return;
const li = $('div', { class: 'mini doc ok' });
li.append($('span', { class: 'ico', text: '📄' }),
$('span', { class: 'nom', text: String(a.nombre || '').slice(0, 28) }),
$('span', { class: 'est', text: '✓ guardado' }));
t.append(li);
});
}
function blankFields() {
fotos = []; archivos = [];
wrap.querySelectorAll('.miniaturas').forEach(t => t.innerHTML = '');
fields().forEach(el => {
if (el.type === 'checkbox' || el.type === 'radio') el.checked = false;
else el.value = (el.dataset.def !== undefined ? el.dataset.def : '');
});
const rr = wrap.querySelector('input[name=resultado]:checked'); if (rr) rr.checked = false;
conforme().checked = false; tecnico.value = nombreUsuario;
}
function fillFrom(a) {
if (Array.isArray(a.fotos)) { fotos = a.fotos.slice(); pintarFotos(); }
if (Array.isArray(a.archivos)) { archivos = a.archivos.slice(); pintarArchivos(); }
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 = '✓ Acta ya enviada por ' + (a.tecnico || '—') + ' '
+ (f ? ' el ' + f : '') + '. Registro final'
+ (a.resultado ? ' — resultado ' + a.resultado + ' ' : '') + '.'
+ (admin ? '' : ' Para rehacerla, pide a Leo que la borre.');
estado.textContent = 'Bloqueada';
}
// ---------------- condiciones, obligatorios y fotos (actas de campo) ----------------
function valores() {
const v = {};
fields().forEach(el => {
const k = el.dataset.id;
if (el.type === 'checkbox') v[k] = el.checked;
else if (el.type === 'radio') { if (el.checked) v[k] = el.value; else if (!(k in v)) v[k] = ''; }
else if (el.type !== 'hidden') v[k] = el.value;
});
v.__fotos = fotos;
v.__archivos = archivos;
// atajo para condiciones: ¿ya se subió un archivo de este campo?
v.subido = c => archivos.some(x => x.campo === c);
return v;
}
function aplicarCondiciones() {
if (!condicionales.length) return;
const v = valores();
condicionales.forEach(c => {
let ok = false;
try { ok = !!c.fn(v); } catch (e) { ok = false; }
c.el.classList.toggle('oculto', !ok);
});
}
function visible(el) {
for (let n = el; n && n !== document.body; n = n.parentElement)
if (n.classList && n.classList.contains('oculto')) return false;
return true;
}
function faltantes() {
const v = valores(); const falta = [];
obligatorios.forEach(o => {
const el = wrap.querySelector(`[data-id="${o.id}"]`);
if (!el || !visible(el)) return; // oculto por una condición: no aplica
if (o.esArchivo) { if (!archivos.some(a => a.campo === o.id)) falta.push(o.txt); return; }
if (o.esFoto) { if (!fotos.some(f => f.campo === o.id)) falta.push(o.txt); return; }
const val = v[o.id];
if (val === '' || val === false || val === undefined || val === null) falta.push(o.txt);
});
return falta;
}
function quitarFoto(f, li, marca) {
const b = $('button', { type: 'button', class: 'mini-btn', text: '✕ quitar' });
b.addEventListener('click', () => {
fotos = fotos.filter(x => x !== f); li.remove();
marca.value = fotos.filter(x => x.campo === f.campo).length || '';
guardarLocal(); aplicarCondiciones();
});
return b;
}
async function enviarFoto(blob, campo, etiqueta, li, est, marca) {
const fd = new FormData();
fd.append('tipo', cfg.tipo); fd.append('serie', serieActual());
fd.append('etiqueta', etiqueta); fd.append('foto', blob, 'foto.jpg');
try {
const r = await fetch('/api/adjuntos', { method: 'POST', body: fd });
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
const f = j.fotos[0]; f.campo = campo; fotos.push(f);
li.className = 'mini ok'; est.textContent = '✓ guardada';
li.append(quitarFoto(f, li, marca));
marca.value = fotos.filter(x => x.campo === campo).length;
guardarLocal(); aplicarCondiciones();
} catch (e) {
li.className = 'mini err'; est.textContent = 'no subió — ' + e.message;
const rt = $('button', { type: 'button', class: 'mini-btn', text: '⟳ reintentar' });
rt.addEventListener('click', () => {
li.className = 'mini subiendo'; est.textContent = 'subiendo…'; rt.remove();
enviarFoto(blob, campo, etiqueta, li, est, marca);
});
li.append(rt);
}
}
function kb(n) { return n > 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' kB'; }
async function subirArchivos(lista, campo, etiqueta, tiras, marca) {
if (!serieActual()) { fail('Primero elige la unidad: los archivos se guardan con su número de serie.'); return; }
for (const file of Array.from(lista).slice(0, 10)) {
const li = $('div', { class: 'mini doc subiendo' });
li.append($('span', { class: 'ico', text: '📄' }),
$('span', { class: 'nom', text: file.name.slice(0, 28) }));
const est = $('span', { class: 'est', text: 'subiendo…' }); li.append(est); tiras.append(li);
const fd = new FormData();
fd.append('tipo', cfg.tipo); fd.append('serie', serieActual());
fd.append('etiqueta', etiqueta); fd.append('archivo', file, file.name);
try {
const r = await fetch('/api/archivos', { method: 'POST', body: fd });
const j = await r.json();
if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
const a = j.archivos[0]; a.campo = campo; archivos.push(a);
li.className = 'mini doc ok'; est.textContent = '✓ ' + kb(a.bytes);
const q = $('button', { type: 'button', class: 'mini-btn', text: '✕ quitar' });
q.addEventListener('click', () => {
archivos = archivos.filter(x => x !== a); li.remove();
marca.value = archivos.filter(x => x.campo === campo).length || '';
guardarLocal(); aplicarCondiciones();
});
li.append(q);
marca.value = archivos.filter(x => x.campo === campo).length;
guardarLocal(); aplicarCondiciones();
} catch (e) {
li.className = 'mini doc err'; est.textContent = 'no subió — ' + e.message;
}
}
}
async function subirFotos(lista, campo, etiqueta, tiras, marca) {
if (!serieActual()) { fail('Primero elige la unidad: las fotos se guardan con su número de serie.'); return; }
for (const file of Array.from(lista).slice(0, 10)) {
const li = $('div', { class: 'mini subiendo' });
const img = $('img'); const est = $('span', { class: 'est', text: 'subiendo…' });
li.append(img, est); tiras.append(li);
let blob = file;
try { blob = await reducir(file); } catch (e) {}
try { img.src = URL.createObjectURL(blob); } catch (e) {}
await enviarFoto(blob, campo, etiqueta, li, est, marca);
}
}
// ---------------- historial de visitas (tipos repetibles) ----------------
async function cargarHistorial() {
if (!cfg.repetible) return;
const s = serieActual();
hist.innerHTML = ''; hist.className = 'hist';
if (!s) return;
if (esPrueba(s)) { hist.innerHTML = 'Modo prueba — este registro no cuenta.'; return; }
hist.innerHTML = 'Consultando visitas anteriores…';
try {
const r = await fetch(`/api/historial?tipo=${encodeURIComponent(cfg.tipo)}&serie=${encodeURIComponent(s)}`, { cache: 'no-store' });
const j = await r.json();
if (!j.total) { hist.innerHTML = '🆕 Primera visita registrada para esta unidad.'; return; }
hist.innerHTML = '';
hist.append($('div', { class: 'hist-t', html: '📋 ' + j.total + ' visita(s) anterior(es) a esta unidad — léelas antes de empezar:' }));
j.visitas.slice(0, 6).forEach(v => {
const f = (v.recibido || '').replace('T', ' ').slice(0, 16);
hist.append($('div', { class: 'hist-r', html:
'' + f + ' · ' + (v.tecnico || '—') +
(v.clasificacion ? ' · ' + v.clasificacion + ' ' : '') +
(v.accion ? ' → ' + v.accion : '') +
(v.fotos ? ' · ' + v.fotos + ' foto(s)' : '') }));
});
} catch (e) { hist.innerHTML = 'No se pudo leer el historial (sin señal). Continúa igual.'; }
}
// ---------------- verificación de componentes / prerrequisitos (modo compuesto) ----------------
let nombreUsuario = ''; // nombre precargado del usuario (no se borra al limpiar)
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: cfg.resultadoFijo || (rr ? rr.value : ''), respuestas: r };
if (compuesta) { d.componentes = seleccion(); d.verificacion = Object.assign({}, verif); }
if (fotos.length) d.fotos = fotos;
if (archivos.length) d.archivos = archivos;
const v = valores();
for (const k in guardaEn) if (v[k] !== undefined && v[k] !== '') d[guardaEn[k]] = v[k];
if (flota) {
const u = unidadSel();
if (u) { d.maquina = u.maquina || ''; d.terminal = u.terminal || ''; d.unidad = u.etiqueta || ''; }
else if (serieSel.value === '__OTRA__') d.unidad = 'no listada';
}
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;
if (flota) {
serieOtra.style.display = serieSel.value === '__OTRA__' ? '' : 'none';
const u = unidadSel();
fichaUnidad.innerHTML = u
? '' + (u.maquina || '—') + ' · ' + (u.terminal || '—') + (u.nota ? ' ' + u.nota : '')
: '';
}
const s = serieActual();
if (compuesta) serieCalc.value = s || '';
blankFields(); showEditable();
if (compuesta) { if (usados === null) await cargarUsados(); await verificar(); }
if (cfg.repetible) cargarHistorial();
consultarMarca();
if (!s) {
estado.textContent = compuesta ? 'Elige los tres módulos' : (flota ? 'Elige la unidad' : 'Elige el número de serie');
aplicarCondiciones(); return;
}
if (esPrueba(s)) { cargarLocal(); aplicarCondiciones(); 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); aplicarCondiciones(); }
else {
cargarLocal(); aplicarCondiciones();
if (compuesta && cfg.exigirAprobados && !todoAprobado()) {
banner.className = 'msg err';
banner.innerHTML = '⛔ No se puede ensamblar con esta selección. Todos los módulos deben estar aprobados en su acta y no estar ya asignados a otra unidad. Revisa los indicadores de arriba.';
}
}
}
wrap.addEventListener('input', () => { aplicarCondiciones(); guardarLocal(); });
wrap.addEventListener('change', aplicarCondiciones);
serieSel.addEventListener('change', loadSerie);
serieOtra.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) && marcaBox.classList.contains('baja'))
return fail('Uno de los módulos está dado de BAJA. No se puede ensamblar con él.');
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.');
const falta = faltantes();
if (falta.length) return fail('Faltan pasos obligatorios (' + falta.length + '): ' + falta.slice(0, 4).join(' · ') + (falta.length > 4 ? '…' : ''));
if (!d.resultado) return fail('Marca el resultado final (' + (cfg.textoOK || '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 ' + d.serie + ' .'
+ (compuesta && !esPrueba(d.serie) ? ' Etiqueta de la unidad: ' + d.serie + ' ' : '');
if (cfg.repetible) {
msg.innerHTML = '✓ Registro enviado para ' + d.serie + ' '
+ (d.fotos && d.fotos.length ? ' con ' + d.fotos.length + ' foto(s)' : '')
+ '. Ingeniería ya puede verlo. Puedes cerrar o elegir otra unidad.';
btn.disabled = false; btn.textContent = 'Enviar acta firmada';
estado.textContent = 'Enviado'; cargarHistorial();
}
else 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;
if (cfg.tecnicoDesdeUsuario && j.user) {
nombreUsuario = (cfg.nombres && cfg.nombres[j.user]) || j.user;
if (!tecnico.value) tecnico.value = nombreUsuario;
}
} catch (e) {}
loadSerie();
})();
}
window.Acta = { render, num };
})();