hbox-docs/docs/formularios/acta.js

841 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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
item.tipo = 'sitio' → lista de sitios (puertos/terminales) desde /api/sitios; con
guardaEn:'sitio' el slug queda en la raíz del acta y la página
del sitio la encuentra (sitios/sitio.html?id=)
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;
}
/* Sitios (puertos / terminales) para el ítem tipo 'sitio': una sola consulta por página. */
let sitiosProm = null;
function cargarSitios() {
if (!sitiosProm) sitiosProm = fetch('/api/sitios', { cache: 'no-store' }).then(r => r.json()).then(j => j.sitios || []).catch(() => []);
return sitiosProm;
}
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' });
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' });
const armar = $('div', { class: 'armar-hbox' }); const armarSel = {};
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);
});
if (!cfg.sinEnsambladas) cargarEnsambladas();
if (!cfg.sinEnsambladas) serieSel.insertBefore($('option', { value: '__HBOX__', text: 'Unidad HBOX completa: elegir sus 3 piezas' }), serieSel.children[1] || null);
serieSel.append($('option', { value: '__OTRA__', text: 'Otra unidad (no está en la lista)' }));
serieSel.append($('option', { value: 'CAMPO-PRUEBA', text: 'PRUEBA (no cuenta)' }));
// Unidad HBOX completa armada a mano: tarjeta + ArduSimple + módem → misma serie que el acta de ensamble.
[['einar', 'Tarjeta EinarBoard', 'V5', 2], ['ard', 'ArduSimple', 'ARD', 3], ['modem', 'Módem 4G', 'RTK', 4]].forEach(([k, t, pre, pad]) => {
const sel = $('select'); opciones(sel, pre, 1, 30, pad); armarSel[k] = sel;
const d = $('div'); d.append($('label', { text: t }), sel); armar.append(d);
sel.addEventListener('change', () => loadSerie());
});
armar.style.display = 'none';
const dS = $('div'); dS.append($('label', { text: cfg.etiquetaSerie || 'Unidad' }), serieSel, serieOtra, armar, 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.
// Una unidad HBOX completa también se revisa pieza por pieza (tarjeta, ArduSimple, módem).
const u = compuesta ? null : unidadSel();
const series = compuesta ? Object.values(seleccion()).filter(v => v)
: [serieActual()].concat(u && u.componentes ? Object.values(u.componentes) : []);
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' ? '<b>' + s + ' está dada de BAJA</b>' : '<b>' + s + ' está marcada como DAÑADA</b>')
+ (a.fecha ? ' desde el ' + a.fecha : '') + (a.clasificacion ? ' — ' + a.clasificacion : '')
+ (a.resumen ? '<br>' + a.resumen : '')
+ '<br><a href="../tableros/unidad/?serie=' + encodeURIComponent(s) + '">Ver su expediente</a>'
+ (j.marca === 'BAJA' ? '. No debe usarse ni instalarse.' : '. Revisa el acta de daño antes de continuar.');
}).join('<hr style="border:0;border-top:1px solid currentColor;opacity:.3;margin:8px 0">');
}
// Unidades HBOX completas: salen de las actas de ensamble aprobadas y van primero en la lista,
// para que envío, recepción, daño, banco… puedan elegir la unidad entera y no solo sus piezas.
async function cargarEnsambladas() {
let j = null;
try { j = await (await fetch('/api/actas', { cache: 'no-store' })).json(); } catch (e) { return; }
const vistas = new Set(cfg.unidades.map(u => u.serie));
const us = (j && j.actas || []).filter(a => a.tipo === 'ensamble' && a.resultado === 'OK' && a.serie
&& !esPrueba(a.serie) && !vistas.has(a.serie) && vistas.add(a.serie));
if (!us.length) return;
const og = $('optgroup', { label: 'Unidades HBOX completas' });
us.sort((x, y) => x.serie < y.serie ? -1 : 1).forEach(a => {
const c = a.componentes || {};
const piezas = Object.values(c).filter(Boolean).join(' · ');
const u = { serie: a.serie, terminal: 'Unidades HBOX completas', componentes: c,
etiqueta: a.serie + (piezas ? ' (' + piezas + ')' : ''),
nota: 'Unidad completa' + (piezas ? ': ' + piezas : '') + '.' };
cfg.unidades.push(u);
og.append($('option', { value: u.serie, text: u.etiqueta }));
});
serieSel.insertBefore(og, serieSel.children[1] || null);
}
// 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;
if (serieSel.value === '__HBOX__') {
const c = {}; for (const k in armarSel) c[k] = armarSel[k].value;
const piezas = Object.values(c).filter(Boolean).join(' · ');
return { terminal: 'Unidades HBOX completas', componentes: c, etiqueta: 'HBOX completa (' + piezas + ')',
nota: 'Unidad completa' + (piezas ? ': ' + piezas : '') + '.' };
}
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);
if (serieSel.value === '__HBOX__') {
const c = {}; for (const k in armarSel) c[k] = armarSel[k].value;
if (Object.values(c).some(v => !v)) return '';
if (Object.values(c).some(esPrueba)) return 'HBOX5-PRUEBA';
return 'HBOX5-E' + num(c.einar) + '-A' + num(c.ard) + '-R' + num(c.modem);
}
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 `<id>__<fila>__<col>`), 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 === 'sitio') {
box.append($('label', { html: it.txt || 'Sitio' }));
if (it.ayuda) box.append($('div', { class: 'nota-item', html: it.ayuda }));
const sel = $('select', { 'data-id': id, 'data-lbl': String(it.txt || 'Sitio').replace(/<[^>]+>/g, ''), style: 'max-width:420px' });
sel.append($('option', { value: '', text: it.req ? '— elegir sitio —' : '— sin sitio / no aplica —' }));
sel.append($('option', { value: '…', text: 'cargando sitios…', disabled: 'disabled' }));
cargarSitios().then(ss => {
sel.querySelector('option[value="…"]').remove();
ss.forEach(s => sel.append($('option', { value: s.id, text: s.nombre + (s.ubicacion && s.ubicacion !== 'por confirmar' ? ' — ' + s.ubicacion : '') })));
sel.append($('option', { value: 'otro', text: 'Otro / no está en la lista (anótalo en observaciones)' }));
if (sel.dataset.pend !== undefined) { sel.value = sel.dataset.pend; delete sel.dataset.pend; }
if (!ss.length) sel.append($('option', { value: '', text: '(no hay sitios dados de alta; pide a Leo que los cree)' }));
});
box.append(sel);
} 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 ? '<b>Archivo obligatorio</b> — ' : '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 ? '<b>Foto obligatoria</b> — ' : '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 <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 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: 'Log' }),
$('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 || '';
// un <select> cuyas opciones aún no llegan (sitios): guarda el valor y aplícalo al cargar
if (el.tagName === 'SELECT' && v && el.value !== v) el.dataset.pend = v;
}
});
}
}
function showEditable() {
setEnabled(true);
btn.disabled = false; btn.textContent = 'Enviar acta firmada'; // tras enviar una serie y elegir otra
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';
}
// ---------------- 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: 'Log' }),
$('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 = '<b>Modo prueba</b> — 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 = '🆕 <b>Primera visita</b> registrada para esta unidad.'; return; }
hist.innerHTML = '';
hist.append($('div', { class: 'hist-t', html: '<b>' + j.total + ' visita(s) anterior(es)</b> 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:
'<b>' + f + '</b> · ' + (v.tecnico || '—') +
(v.clasificacion ? ' · <b>' + v.clasificacion + '</b>' : '') +
(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 || '';
if (u.componentes) d.componentes = u.componentes; }
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';
armar.style.display = serieSel.value === '__HBOX__' ? '' : 'none';
const u = unidadSel();
fichaUnidad.innerHTML = u && u.componentes ? u.nota : u
? '<b>' + (u.maquina || '—') + '</b> · ' + (u.terminal || '—') + (u.nota ? '<br>' + 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 ? (serieSel.value === '__HBOX__' ? 'Elige las 3 piezas' : '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 = '<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', () => { 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 <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 (cfg.repetible) {
msg.innerHTML = '<b>Registro enviado</b> para <b>' + d.serie + '</b>'
+ (d.fotos && d.fotos.length ? ' con ' + d.fotos.length + ' foto(s)' : '')
+ '.<br>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 };
})();