764 lines
43 KiB
JavaScript
764 lines
43 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 | 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: '⚠ <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' });
|
||
|
||
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' ? '⛔ <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="../unidad.html?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">');
|
||
}
|
||
|
||
// 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 `<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 === '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: '📄' }),
|
||
$('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);
|
||
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: '📄' }),
|
||
$('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 || ''; }
|
||
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
|
||
? '<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 ? '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 };
|
||
})();
|