/* 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=) Precarga (22-oct): formulario.html?serie=…&sitio=…&=… elige la unidad y llena los campos vacíos (lo usa el tablero de Instalaciones para abrir la siguiente acta). 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: '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 -> 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 = '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: '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 = '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 || ''; 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 cargarSerie() { 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 ? '' + (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 ? (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 = '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.'; } } } // ---------------- precarga desde la dirección (22-oct) ---------------- // El tablero de Instalaciones abre la siguiente acta ya con la unidad, el sitio y el vehículo: // ?serie=HBOX5-…&sitio=eit&maquina_id=TP-114 (cualquier campo por su clave). Solo llena lo vacío: // no pisa lo escrito ni el borrador guardado, y nunca marca casillas, fotos ni el resultado. const pre = new URLSearchParams(location.search); const preSerie = String(pre.get('serie') || '').trim().toUpperCase().replace(/[^A-Z0-9_-]/g, '-').slice(0, 24); async function precargarSerie() { if (!preSerie || !flota) return; const hay = () => Array.from(serieSel.options).some(o => o.value === preSerie); if (!hay() && ensambladas) { try { await ensambladas; } catch (e) {} } if (hay()) serieSel.value = preSerie; else { serieSel.value = '__OTRA__'; serieOtra.value = preSerie; } } function precargarCampos() { if (locked || (preSerie && serieActual() !== preSerie)) return; let n = preSerie && flota ? 1 : 0; pre.forEach((v, k) => { if (k === 'serie' || !/^[a-z][a-z0-9_]{0,39}$/.test(k)) return; v = String(v || '').trim().slice(0, 200); const els = Array.from(fields()).filter(e => e.dataset.id === k); if (!v || !els.length) return; if (els[0].type === 'radio') { if (els.some(e => e.checked)) return; const r = els.find(e => e.value === v); if (r) { r.checked = true; n++; } return; } const el = els[0]; if (el.disabled || ['checkbox', 'hidden', 'file'].includes(el.type)) return; if (el.tagName === 'SELECT') { if (el.value) return; if (Array.from(el.options).some(o => o.value === v)) el.value = v; else el.dataset.pend = v; } else { if (el.value && el.value !== el.dataset.def) return; el.value = v; } n++; }); if (!n) return; aplicarCondiciones(); if (banner.className === 'msg') { banner.className = 'msg ok'; banner.textContent = 'Datos precargados desde el tablero de Instalaciones. Revisa que la unidad, el sitio y el vehículo sean los correctos antes de firmar.'; } } async function loadSerie() { await cargarSerie(); precargarCampos(); } 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) {} await precargarSerie(); loadSerie(); })(); } window.Acta = { render, num }; })();