cotizador-helgen/Index.html
Efrain (Helgen) bbb34768b4 Respaldo inicial del Cotizador Helgen (19/10/2026)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:14:08 -06:00

467 lines
28 KiB
HTML
Raw Permalink 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.

<!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=League+Spartan:wght@700&family=Roboto+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
/* Brand Guidelines Helgen (Canva): primarios #2C3C5B / #51F3DA, secundario #0AC5A8 */
:root { --azul:#2C3C5B; --aqua:#51F3DA; --teal:#0AC5A8; --cielo:#F2F4F7; --gris:#6B7280; --borde:#E5E7EB; }
* { box-sizing:border-box; }
body { margin:0; font-family:'Roboto Mono',monospace; background:var(--cielo); color:#1F2937; font-size:13px; }
h1, h2, .btn, nav button { font-family:'League Spartan',Arial,sans-serif; }
header { background:var(--azul); color:#fff; padding:14px 24px; display:flex; align-items:center; gap:24px; flex-wrap:wrap;
border-bottom:3px solid var(--aqua); }
header img { height:30px; display:block; }
header h1 { font-size:16px; margin:0; letter-spacing:2px; color:var(--aqua); }
nav button { background:none; border:none; color:#A9B4C8; font-size:14px; cursor:pointer; padding:6px 10px; border-radius:6px; }
nav button.activo { background:rgba(255,255,255,.12); color:#fff; }
main { max-width:1100px; margin:24px auto; padding:0 16px; }
.modulo { background:#fff; border-radius:10px; padding:20px; margin-bottom:16px; border:1px solid var(--borde); }
.modulo h2 { margin:0 0 14px; font-size:15px; color:var(--azul); border-bottom:2px solid var(--teal); padding-bottom:6px; display:inline-block; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
label { display:block; font-size:12px; color:var(--gris); margin-bottom:4px; }
input, textarea, select { width:100%; padding:8px 10px; border:1px solid var(--borde); border-radius:6px; font:inherit; }
select { background:#fff; }
input:focus, textarea:focus, select:focus { outline:2px solid var(--teal); border-color:transparent; }
table { width:100%; border-collapse:collapse; }
th { text-align:left; font-size:11px; text-transform:uppercase; color:var(--gris); padding:6px; border-bottom:1px solid var(--borde); }
td { padding:4px; }
td.num, th.num { text-align:right; white-space:nowrap; }
.btn { background:var(--teal); color:var(--azul); border:none; padding:10px 18px; border-radius:6px; font-weight:bold; cursor:pointer; font-size:14px; }
.btn:hover { background:var(--aqua); }
.btn:disabled { opacity:.5; cursor:wait; }
.btn-sec { background:#fff; color:var(--azul); border:1px solid var(--azul); }
.btn-sec:hover { background:var(--cielo); }
.quitar { background:none; border:none; color:#DC2626; cursor:pointer; font-size:18px; }
.totales { margin-left:auto; width:320px; }
.totales div { display:flex; justify-content:space-between; padding:6px 0; }
.totales .total { background:var(--azul); color:var(--aqua); padding:10px 12px; border-radius:6px; font-size:18px; font-weight:bold; }
.aviso { padding:12px 14px; border-radius:6px; margin-bottom:16px; display:none; }
.aviso.ok { background:#E6FBF7; border:1px solid var(--teal); display:block; }
.aviso.error { background:#FEE2E2; border:1px solid #DC2626; display:block; }
.oculto { display:none; }
.fila-cat { cursor:pointer; }
.fila-cat:hover td { background:var(--cielo); }
.fila-cat.editando td { background:#E6FBF7; }
.acciones { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:12px; }
.nota { font-size:12px; color:var(--gris); }
.sat { margin-top:16px; padding:14px; border:1px dashed var(--borde); border-radius:8px; background:#FAFBFC; }
.sat-titulo { font-size:12px; font-weight:bold; color:var(--azul); margin-bottom:10px; }
.sat-op { display:flex; gap:12px; align-items:center; padding:7px 10px; border:1px solid var(--borde); border-radius:6px;
margin-top:6px; cursor:pointer; background:#fff; }
.sat-op:hover { border-color:var(--teal); background:#E6FBF7; }
.sat-op b { min-width:80px; }
.sat-razon { display:block; color:#5B6B85; font-size:12px; margin-top:2px; }
a { color:var(--azul); font-weight:bold; }
</style>
</head>
<body>
<header>
<img src="data:image/png;base64,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" alt="Helgen">
<h1>COTIZADOR</h1>
<nav>
<button id="tabNueva" class="activo" onclick="verTab('nueva')">Nueva cotización</button>
<button id="tabHist" onclick="verTab('hist')">Historial</button>
<button id="tabCat" onclick="verTab('cat')">Catálogo</button>
</nav>
</header>
<main>
<div id="aviso" class="aviso"></div>
<!-- ===== NUEVA COTIZACIÓN ===== -->
<section id="vistaNueva">
<div class="modulo">
<h2>1 · Datos del cliente</h2>
<div class="grid">
<div><label>Línea de negocio *</label>
<select id="marca">
<option value="helgen">Helgen</option>
<option value="energy">Helgen Energy (paneles solares)</option>
</select></div>
<div><label>Nombre / Contacto *</label>
<input id="cNombre" list="clientesList" autocomplete="off" placeholder="Escribe o elige un cliente">
<datalist id="clientesList"></datalist>
<div id="cInfo" class="nota" style="margin-top:4px"></div></div>
<div><label>Empresa</label><input id="cEmpresa"></div>
<div><label>RFC</label><input id="cRfc" maxlength="13"></div>
<div><label>Correo</label><input id="cEmail" type="email"></div>
<div><label>Teléfono</label><input id="cTel"></div>
<div><label>Dirección / Obra</label><input id="cDir"></div>
</div>
</div>
<div class="modulo">
<h2>2 · Conceptos</h2>
<datalist id="catalogo"></datalist>
<datalist id="claves"></datalist>
<table>
<thead><tr>
<th style="width:100px">Clave</th><th>Descripción</th><th style="width:80px">Unidad</th>
<th class="num" style="width:90px">Cant.</th><th class="num" style="width:130px">P. Unitario</th>
<th class="num" style="width:130px">Importe</th><th style="width:30px"></th>
</tr></thead>
<tbody id="filas"></tbody>
</table>
<button class="btn btn-sec" style="margin-top:10px" onclick="agregarFila()">+ Agregar concepto</button>
</div>
<div class="modulo">
<h2>3 · Resumen</h2>
<div style="display:flex; gap:24px; flex-wrap:wrap;">
<div style="flex:1; min-width:260px;">
<label>Notas para el cliente (opcional)</label>
<textarea id="notas" rows="4"></textarea>
</div>
<div class="totales">
<div><span>Subtotal</span><span id="tSub">$0.00</span></div>
<div><span id="lblIva">IVA (16%)</span><span id="tIva">$0.00</span></div>
<div class="total"><span>TOTAL</span><span id="tTot">$0.00</span></div>
<button id="btnGuardar" class="btn" style="width:100%; margin-top:12px" onclick="guardar()">Guardar y generar PDF</button>
</div>
</div>
</div>
</section>
<!-- ===== HISTORIAL ===== -->
<section id="vistaHist" class="oculto">
<div class="modulo">
<h2>Historial de cotizaciones</h2>
<input id="buscar" placeholder="Buscar por folio, cliente o empresa…" oninput="buscarDebounce()" style="margin-bottom:12px">
<table>
<thead><tr><th>Folio</th><th>Fecha</th><th>Cliente</th><th>Empresa</th><th class="num">Total</th><th>Elaboró</th><th>PDF</th></tr></thead>
<tbody id="hist"></tbody>
</table>
</div>
</section>
<!-- ===== CATÁLOGO ===== -->
<section id="vistaCat" class="oculto">
<div class="modulo">
<h2 id="tituloProd">Agregar producto</h2>
<div class="grid">
<div><label>Clave *</label><input id="pClave" maxlength="30" placeholder="Ej. CAR-7KW"></div>
<div style="grid-column:span 2"><label>Descripción *</label><input id="pDesc"></div>
<div><label>Unidad</label><input id="pUni" value="PZA"></div>
<div><label>Precio unitario sin IVA *</label><input id="pPrecio" type="number" min="0" step="0.01" style="text-align:right"></div>
</div>
<div class="sat">
<div class="sat-titulo">Datos para facturación (SAT) · uso interno de contabilidad, no aparecen en el PDF</div>
<div class="grid">
<div><label>Clave SAT (producto/servicio)</label><input id="pSat" maxlength="8" inputmode="numeric" placeholder="8 dígitos"></div>
<div><label>Unidad SAT</label><input id="pUniSat" list="unidadesSat" maxlength="3" placeholder="Ej. H87, E48"><datalist id="unidadesSat"></datalist></div>
<div style="grid-column:span 2"><label>Buscar en el catálogo del SAT</label>
<div style="display:flex; gap:8px"><input id="satBusca" placeholder="Se toma de la descripción; puedes cambiar las palabras">
<button class="btn btn-sec" onclick="sugerirSat()" style="white-space:nowrap">Sugerir clave SAT</button></div></div>
</div>
<div id="satRes"></div>
</div>
<div class="acciones">
<button id="btnProd" class="btn" onclick="guardarProd()">Guardar en catálogo</button>
<button class="btn btn-sec" onclick="limpiarProd()">Nuevo / Cancelar</button>
<span class="nota">Haz clic en un producto de la lista para editarlo.</span>
</div>
</div>
<div class="modulo">
<h2 id="tituloLista">Productos</h2>
<div class="acciones" style="margin:0 0 12px">
<input id="buscarCat" placeholder="Buscar por clave o descripción…" oninput="pintarCatalogo()" style="flex:1; min-width:220px">
<a id="linkCat" href="#" target="_blank" class="oculto">Abrir en Google Sheets</a>
</div>
<table>
<thead><tr><th style="width:130px">Clave</th><th>Descripción</th><th style="width:80px">Unidad</th><th class="num" style="width:150px">Precio sin IVA</th><th style="width:110px">Clave SAT</th></tr></thead>
<tbody id="listaCat"></tbody>
</table>
</div>
</section>
</main>
<script id="api-web">
// Fuera de Apps Script (Cloudflare Pages) no existe google.script.run: esta capa lo imita con fetch al doPost.
// Dentro de Apps Script no hace nada y se usa el original.
(function () {
if (window.google && google.script && google.script.run) return;
const API_URL = 'PEGAR_AQUI_LA_URL_/exec_DE_LA_IMPLEMENTACION_DE_PRUEBA';
function llamar(funcion, args, alExito, alFallar) {
const fallar = msg => { if (alFallar) alFallar(new Error(msg)); else console.error(msg); };
if (!/^https:\/\//.test(API_URL)) { fallar('Falta configurar la URL del servidor (API_URL).'); return; }
// text/plain: es una "solicitud simple" y el navegador no hace la consulta CORS previa (Apps Script no la responde)
fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'text/plain;charset=utf-8' },
body: JSON.stringify({ funcion: funcion, args: args }) })
.then(r => {
if (!r.ok) return { ok: false, error: 'El servidor respondió ' + r.status + '.' };
return r.json().catch(() => ({ ok: false, error: 'Respuesta inválida del servidor.' }));
}, err => ({ ok: false, error: 'No se pudo conectar con el servidor. Revisa tu conexión e intenta de nuevo. (' + err.message + ')' }))
.then(res => {
if (res && res.ok) { if (alExito) alExito(res.datos); }
else fallar((res && res.error) || 'Respuesta inválida del servidor.');
});
}
// Igual que google.script.run: withSuccessHandler / withFailureHandler encadenables y luego la función
function corredor(alExito, alFallar) {
return new Proxy({}, {
get(_, nombre) {
if (nombre === 'withSuccessHandler') return f => corredor(f, alFallar);
if (nombre === 'withFailureHandler') return f => corredor(alExito, f);
if (typeof nombre !== 'string' || nombre === 'then') return undefined;
return (...args) => llamar(nombre, args, alExito, alFallar);
},
});
}
window.google = window.google || {};
google.script = google.script || {};
google.script.run = corredor(null, null);
})();
</script>
<script>
let IVA = 0.16, CATALOGO = [], CLIENTES = [];
// Un id por cotización: si el envío se repite, el servidor devuelve el mismo folio en vez de crear otro.
const nuevoId = () => (crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(36).slice(2));
let ID_SOLICITUD = nuevoId();
const $ = id => document.getElementById(id);
const r2 = n => Math.round((Number(n) + Number.EPSILON) * 100) / 100;
const mxn = n => r2(n).toLocaleString('es-MX', { style:'currency', currency:'MXN' });
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// ---- Arranque ----
google.script.run.withSuccessHandler(d => {
IVA = d.ivaTasa; CATALOGO = d.catalogo;
recibirClientes(d.clientes);
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
pintarDatalists();
agregarFila();
}).withFailureHandler(mostrarError).getDatosIniciales();
// ---- Conceptos ----
function agregarFila() {
const tr = document.createElement('tr');
tr.innerHTML =
'<td><input class="clave" list="claves"></td>' +
'<td><input class="desc" list="catalogo" placeholder="Escribe o elige del catálogo"></td>' +
'<td><input class="uni" value="PZA"></td>' +
'<td><input class="cant" type="number" min="0" step="any" value="1" style="text-align:right"></td>' +
'<td><input class="pu" type="number" min="0" step="0.01" value="0" style="text-align:right"></td>' +
'<td class="num imp">$0.00</td>' +
'<td><button class="quitar" title="Quitar">×</button></td>';
// Al elegir por descripción o por clave se llenan clave, unidad y precio desde el catálogo
const aplicar = p => {
if (!p) return;
tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion;
tr.querySelector('.uni').value = p.unidad; tr.querySelector('.pu').value = p.precio;
};
tr.querySelector('.desc').addEventListener('change', e => { aplicar(CATALOGO.find(x => x.descripcion === e.target.value)); recalcular(); });
tr.querySelector('.clave').addEventListener('change', e => { aplicar(buscarClave(e.target.value)); recalcular(); });
tr.querySelectorAll('input').forEach(i => i.addEventListener('input', recalcular));
tr.querySelector('.quitar').onclick = () => { tr.remove(); recalcular(); };
$('filas').appendChild(tr);
tr.querySelector('.desc').focus();
}
function leerItems() {
return [...$('filas').querySelectorAll('tr')].map(tr => ({
clave: tr.querySelector('.clave').value,
descripcion: tr.querySelector('.desc').value,
unidad: tr.querySelector('.uni').value,
cantidad: Number(tr.querySelector('.cant').value) || 0,
precio: Number(tr.querySelector('.pu').value) || 0,
_tr: tr,
}));
}
function recalcular() {
let sub = 0;
leerItems().forEach(it => {
const imp = r2(it.cantidad * r2(it.precio));
it._tr.querySelector('.imp').textContent = mxn(imp);
sub += imp;
});
sub = r2(sub);
const iva = r2(sub * IVA);
$('tSub').textContent = mxn(sub); $('tIva').textContent = mxn(iva); $('tTot').textContent = mxn(sub + iva);
}
// ---- Guardar ----
function guardar() {
const payload = {
cliente: { nombre:$('cNombre').value, empresa:$('cEmpresa').value, rfc:$('cRfc').value,
email:$('cEmail').value, telefono:$('cTel').value, direccion:$('cDir').value },
items: leerItems().map(({_tr, ...it}) => it),
notas: $('notas').value,
marca: $('marca').value,
idSolicitud: ID_SOLICITUD,
};
if ($('btnGuardar').disabled) return;
$('btnGuardar').disabled = true; $('btnGuardar').textContent = 'Generando PDF…';
google.script.run
.withSuccessHandler(r => {
aviso('ok', 'Cotización <b>' + esc(r.folio) + '</b> guardada · Total ' + mxn(r.total) +
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
limpiar();
})
.withFailureHandler(mostrarError)
.guardarCotizacion(payload);
}
function limpiar() {
['cNombre','cEmpresa','cRfc','cEmail','cTel','cDir','notas'].forEach(id => $(id).value = '');
$('filas').innerHTML = ''; agregarFila(); recalcular();
$('cInfo').textContent = '';
google.script.run.withSuccessHandler(recibirClientes).getClientes(); // incluye al cliente recién guardado
ID_SOLICITUD = nuevoId();
$('btnGuardar').disabled = false; $('btnGuardar').textContent = 'Guardar y generar PDF';
}
// ---- Historial ----
let tBusq;
function buscarDebounce() { clearTimeout(tBusq); tBusq = setTimeout(cargarHistorial, 300); }
function cargarHistorial() {
$('hist').innerHTML = '<tr><td colspan="7">Cargando…</td></tr>';
google.script.run.withSuccessHandler(lista => {
$('hist').innerHTML = lista.length ? lista.map(c =>
'<tr><td><b>' + esc(c.folio) + '</b></td><td>' + esc(c.fecha) + '</td><td>' + esc(c.cliente) + '</td><td>' +
esc(c.empresa) + '</td><td class="num">' + esc(c.total) + '</td><td>' + esc(c.elaboro) + '</td><td>' +
(c.pdf ? '<a href="' + esc(c.pdf) + '" target="_blank">Ver</a>' : '') + '</td></tr>').join('')
: '<tr><td colspan="7">Sin resultados.</td></tr>';
}).withFailureHandler(mostrarError).listarCotizaciones($('buscar').value);
}
// ---- Clientes que ya cotizaron (salen de la hoja Cotizaciones) ----
const CAMPOS_CLIENTE = { cNombre:'nombre', cEmpresa:'empresa', cRfc:'rfc', cEmail:'email', cTel:'telefono', cDir:'direccion' };
function recibirClientes(lista) {
CLIENTES = lista || [];
$('clientesList').innerHTML = CLIENTES.map(c =>
'<option value="' + esc(c.etiqueta) + '" label="' + esc([c.empresa, c.rfc].filter(Boolean).join(' · ') || 'Cliente registrado') + '">').join('');
}
function autorrellenarCliente() {
const v = $('cNombre').value.trim().toLowerCase();
const c = v && CLIENTES.find(x => x.etiqueta.toLowerCase() === v);
if (!c) { $('cInfo').textContent = ''; return; }
Object.keys(CAMPOS_CLIENTE).forEach(id => $(id).value = c[CAMPOS_CLIENTE[id]] || '');
$('cInfo').textContent = 'Cliente registrado · ' + c.cotizaciones + (c.cotizaciones === 1 ? ' cotización' : ' cotizaciones') +
(c.ultimaFecha ? ' · última ' + c.ultimaFecha : '');
}
$('cNombre').addEventListener('change', autorrellenarCliente);
$('cNombre').addEventListener('input', () => { if (CLIENTES.some(x => x.etiqueta === $('cNombre').value)) autorrellenarCliente(); });
// ---- Catálogo ----
let CLAVE_EDITANDO = '';
const buscarClave = c => CATALOGO.find(x => x.clave.toUpperCase() === String(c || '').trim().toUpperCase());
function pintarDatalists() {
$('catalogo').innerHTML = CATALOGO.map(p =>
'<option value="' + esc(p.descripcion) + '" label="' + esc(p.clave + ' · ' + mxn(p.precio)) + '">').join('');
$('claves').innerHTML = CATALOGO.map(p =>
'<option value="' + esc(p.clave) + '" label="' + esc(p.descripcion + ' · ' + mxn(p.precio)) + '">').join('');
}
function cargarCatalogo() {
$('listaCat').innerHTML = '<tr><td colspan="5">Cargando…</td></tr>';
google.script.run.withSuccessHandler(recibirCatalogo).withFailureHandler(mostrarError).getCatalogo();
}
function recibirCatalogo(r) {
CATALOGO = r.productos;
$('linkCat').href = r.url; $('linkCat').classList.remove('oculto');
if (r.unidadesSat && r.unidadesSat.length) {
$('unidadesSat').innerHTML = r.unidadesSat.map(u => '<option value="' + esc(u[0]) + '" label="' + esc(u[1]) + '">').join('');
}
pintarDatalists(); pintarCatalogo();
}
function pintarCatalogo() {
const q = $('buscarCat').value.toLowerCase().trim();
const lista = CATALOGO.filter(p => !q || (p.clave + ' ' + p.descripcion).toLowerCase().includes(q));
$('tituloLista').textContent = 'Productos (' + CATALOGO.length + ')';
$('listaCat').innerHTML = lista.length ? lista.map(p =>
'<tr class="fila-cat' + (p.clave === CLAVE_EDITANDO ? ' editando' : '') + '" data-clave="' + esc(p.clave) + '">' +
'<td><b>' + esc(p.clave) + '</b></td><td>' + esc(p.descripcion) + '</td><td>' + esc(p.unidad) + '</td>' +
'<td class="num">' + mxn(p.precio) + '</td>' +
'<td>' + (p.claveSat ? esc(p.claveSat) + (p.unidadSat ? ' · ' + esc(p.unidadSat) : '') : '<span class="nota">Pendiente</span>') + '</td></tr>').join('')
: '<tr><td colspan="5">' + (CATALOGO.length ? 'Sin resultados.' : 'El catálogo está vacío. Agrega el primer producto arriba.') + '</td></tr>';
$('listaCat').querySelectorAll('.fila-cat').forEach(tr => tr.onclick = () => editarProd(tr.dataset.clave));
}
function editarProd(clave) {
const p = buscarClave(clave); if (!p) return;
CLAVE_EDITANDO = p.clave;
$('pClave').value = p.clave; $('pDesc').value = p.descripcion; $('pUni').value = p.unidad; $('pPrecio').value = p.precio;
$('pSat').value = p.claveSat || ''; $('pUniSat').value = p.unidadSat || ''; $('satBusca').value = ''; $('satRes').innerHTML = '';
$('tituloProd').textContent = 'Editar producto ' + p.clave;
$('btnProd').textContent = 'Guardar cambios';
pintarCatalogo(); window.scrollTo(0, 0); $('pDesc').focus();
}
function limpiarProd() {
CLAVE_EDITANDO = '';
['pClave','pDesc','pPrecio','pSat','pUniSat','satBusca'].forEach(id => $(id).value = ''); $('pUni').value = 'PZA';
$('satRes').innerHTML = '';
$('tituloProd').textContent = 'Agregar producto';
$('btnProd').textContent = 'Guardar en catálogo'; $('btnProd').disabled = false;
pintarCatalogo();
}
function guardarProd() {
if ($('btnProd').disabled) return;
const prod = { clave:$('pClave').value, descripcion:$('pDesc').value, unidad:$('pUni').value,
precio:$('pPrecio').value, claveSat:$('pSat').value, unidadSat:$('pUniSat').value,
claveOriginal:CLAVE_EDITANDO };
const editando = !!CLAVE_EDITANDO;
$('btnProd').disabled = true;
google.script.run.withSuccessHandler(r => {
recibirCatalogo(r);
aviso('ok', 'Producto <b>' + esc(r.clave) + '</b> ' + (editando ? 'actualizado' : 'agregado') + ' en el catálogo.');
limpiarProd();
}).withFailureHandler(err => { $('btnProd').disabled = false; mostrarError(err); }).guardarProducto(prod);
}
// ---- Sugerencias de clave SAT ----
function sugerirSat() {
const q = $('satBusca').value.trim() || $('pDesc').value.trim();
if (!q) { aviso('error', 'Escribe la descripción del producto o palabras para buscar en el catálogo del SAT.'); return; }
$('satBusca').value = q;
$('satRes').innerHTML = '<div class="nota" style="margin-top:8px">Buscando en el catálogo del SAT…</div>';
google.script.run.withSuccessHandler(lista => {
$('satRes').innerHTML = lista.length
? '<div class="nota" style="margin-top:10px">' + (lista[0].razon ? 'Sugeridas con IA. ' : '') +
'Elige la que mejor describa el producto (contabilidad debe validarla):</div>' +
lista.map(o => '<div class="sat-op" data-clave="' + esc(o.clave) + '"><b>' + esc(o.clave) + '</b><span>' + esc(o.descripcion) +
(o.razon ? '<small class="sat-razon">' + esc(o.razon) + '</small>' : '') + '</span></div>').join('')
: '<div class="nota" style="margin-top:8px">Sin coincidencias. Prueba con otras palabras (p. ej. el tipo de producto o "servicio de …").</div>';
$('satRes').querySelectorAll('.sat-op').forEach(d => d.onclick = () => {
$('pSat').value = d.dataset.clave; $('satRes').innerHTML = '';
});
}).withFailureHandler(err => { $('satRes').innerHTML = ''; mostrarError(err); }).sugerirClavesSat(q);
}
$('satBusca').addEventListener('keydown', e => { if (e.key === 'Enter') sugerirSat(); });
// ---- UI ----
function verTab(t) {
$('vistaNueva').classList.toggle('oculto', t !== 'nueva');
$('vistaHist').classList.toggle('oculto', t !== 'hist');
$('vistaCat').classList.toggle('oculto', t !== 'cat');
$('tabCat').classList.toggle('activo', t === 'cat');
$('tabNueva').classList.toggle('activo', t === 'nueva');
$('tabHist').classList.toggle('activo', t === 'hist');
if (t === 'hist') cargarHistorial();
if (t === 'cat') cargarCatalogo();
$('aviso').className = 'aviso';
}
function aviso(tipo, html) { const a = $('aviso'); a.className = 'aviso ' + tipo; a.innerHTML = html; window.scrollTo(0, 0); }
function mostrarError(err) {
aviso('error', esc(err && err.message ? err.message : err));
$('btnGuardar').disabled = false; $('btnGuardar').textContent = 'Guardar y generar PDF';
}
</script>
</body>
</html>