cotizador-helgen/web/cotizador/index.html
Efrain (Helgen) aae01d6918 Proyectos: folio PRY-AAAA-NNNN, carpeta en Drive y cotizaciones ligadas por proyecto_folio
- Pestaña "Proyectos" (se crea sola) con cliente_id (RFC o SIN-RFC:nombre|empresa), estado con lista
  desplegable y vendedor del token. Folio con contador propio CONSECUTIVO_PROY_AAAA bajo el mismo candado.
- Carpeta Cotizador Helgen/Proyectos/AAAA/<folio> — <cliente> — <nombre>/ con Cotizaciones,
  Órdenes de compra y Pagos; las subcarpetas borradas o renombradas se vuelven a crear.
- Cotizaciones: columna T proyecto_folio (las viejas quedan vacías = "Sin proyecto"); con proyecto, el PDF
  va a su carpeta Cotizaciones. Proyecto inexistente o no Activo se rechaza sin gastar folio.
- Interfaz: campo Proyecto opcional con buscador y "Nuevo proyecto" en el módulo 1; columna en Historial.
- Pruebas: tests/proyectos.test.js (Drive simulado con árbol de carpetas) y sesion.test.js ampliada.
- CLAUDE.md: §6.9 Proyectos y §6.10 modelo futuro (OC, Pagos, Gastos, cotización "Aceptada").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:59:37 -06:00

737 lines
42 KiB
HTML
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.

<!DOCTYPE html>
<html lang="es-MX">
<head>
<base target="_top">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Cotizador Helgen</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
<link rel="icon" type="image/png" sizes="512x512" href="/isologo-512.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="theme-color" content="#2C3C5B">
<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; }
/* Proyecto de la cotización (opcional) */
.proy { margin-top:16px; padding-top:14px; border-top:1px dashed var(--borde); }
.proy-fila { display:flex; gap:8px; align-items:flex-end; flex-wrap:wrap; }
.proy-fila > div { flex:1; min-width:240px; }
a { color:var(--azul); font-weight:bold; }
/* Sesión con Google (solo cuentas @helgen.tech) */
.usuario { margin-left:auto; display:flex; align-items:center; gap:12px; font-size:12px; color:#D5DCE8; }
.usuario.oculto { display:none; }
.usuario button { background:none; border:1px solid #A9B4C8; color:#fff; border-radius:6px; padding:5px 10px; cursor:pointer;
font-family:'League Spartan',Arial,sans-serif; font-size:13px; }
.usuario button:hover { border-color:var(--aqua); color:var(--aqua); }
#login { position:fixed; inset:0; z-index:10; background:var(--azul); display:flex; align-items:center; justify-content:center;
padding:16px; overflow-y:auto; }
#login.oculto { display:none; }
.login-envoltura { width:100%; max-width:380px; text-align:center; }
.login-envoltura > img { height:34px; margin-bottom:18px; }
.login-caja { background:#fff; border-radius:12px; border-top:4px solid var(--aqua); padding:28px 24px; }
.login-caja h2 { font-family:'League Spartan',Arial,sans-serif; color:var(--azul); font-size:18px; margin:0 0 6px; }
.login-caja p { color:var(--gris); margin:0 0 18px; }
#gisBoton { display:flex; justify-content:center; min-height:44px; }
#loginMsg { margin:14px 0 0; color:#B91C1C; font-size:12px; }
#loginMsg:empty { display:none; }
</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>
<div id="usuario" class="usuario oculto">
<span id="usuarioNombre"></span>
<button type="button" onclick="cerrarSesion()">Cerrar sesión</button>
</div>
</header>
<!-- Inicio de sesión: tapa la app hasta tener un token de Google de @helgen.tech -->
<div id="login">
<div class="login-envoltura">
<img id="loginLogo" alt="Helgen">
<div class="login-caja">
<h2>Cotizador Helgen</h2>
<p>Inicia sesión con tu cuenta @helgen.tech</p>
<div id="gisBoton"></div>
<p id="loginMsg"></p>
</div>
</div>
</div>
<main>
<div id="aviso" class="aviso"></div>
<!-- ===== NUEVA COTIZACIÓN ===== -->
<section id="vistaNueva">
<div class="modulo">
<h2>1 · Cliente y proyecto</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>
<!-- Proyecto (opcional): vacío = "Sin proyecto" -->
<div class="proy">
<div class="proy-fila">
<div><label>Proyecto (opcional)</label>
<input id="proyecto" list="proyectosList" autocomplete="off" placeholder="Sin proyecto · busca por folio, cliente o nombre">
<datalist id="proyectosList"></datalist></div>
<button type="button" class="btn btn-sec" onclick="abrirNuevoProyecto()">+ Nuevo proyecto</button>
</div>
<div id="proyInfo" class="nota" style="margin-top:4px"></div>
<div id="nuevoProy" class="sat oculto">
<div class="sat-titulo">Nuevo proyecto · cliente: <span id="npCliente"></span></div>
<div class="grid">
<div><label>Nombre del proyecto *</label><input id="npNombre" maxlength="80" placeholder="Ej. Techo solar planta 2"></div>
<div><label>Ubicación</label><input id="npUbicacion" maxlength="200" placeholder="Ciudad, dirección u obra"></div>
</div>
<div class="acciones">
<button type="button" id="btnProy" class="btn" onclick="crearProyecto()">Crear proyecto</button>
<button type="button" class="btn btn-sec" onclick="cerrarNuevoProyecto()">Cancelar</button>
<span class="nota">El cliente es el de "Nombre / Contacto": elígelo de la lista o escribe uno nuevo.</span>
</div>
</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, empresa o proyecto…" 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>Vendedor</th><th>Proyecto</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) no existe google.script.run: esta capa lo imita con fetch al doPost.
// Cada llamada lleva el ID token de Google; el servidor lo verifica (aud, dominio) antes de ejecutar nada.
// 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 = 'https://script.google.com/macros/s/AKfycbwBjF7ra_4O8INsxBq0eh-4r-BlSsJ4LLxcJgVCPRpx1U1a-Pz1CUSdYtvj_Mh0S6y2/exec';
// Client ID de OAuth (no es secreto). Debe ser el mismo que CLIENT_ID_GOOGLE en Code.js (proyecto de Apps Script 1GsH8Dja…).
const CLIENT_ID_GOOGLE = '884526916417-cn5fh45io0nnpvlad2439thkpius4063.apps.googleusercontent.com';
const DOMINIO = 'helgen.tech';
const CLAVE_SESION = 'cotizadorHelgenSesion';
const $ = id => document.getElementById(id);
// ---- Sesión ----
let token = null, expira = 0, gisListo = false;
const pendientes = []; // llamadas que esperan a que se inicie sesión
// Solo para mostrar el nombre y saber cuándo expira; quien decide es el servidor al verificar el token
function leerToken(t) {
const b = t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const bytes = Uint8Array.from(atob(b + '==='.slice((b.length + 3) % 4)), c => c.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
}
const vigente = () => !!token && expira * 1000 > Date.now() + 60000; // margen de 1 min
function pintarUsuario(datos) {
const nombre = [datos.given_name, datos.family_name].filter(Boolean).join(' ') || datos.name || datos.email;
$('usuarioNombre').textContent = nombre;
$('usuarioNombre').title = datos.email || '';
$('usuario').classList.remove('oculto');
}
function guardarSesion(t) {
const datos = leerToken(t);
token = t; expira = Number(datos.exp) || 0;
try { sessionStorage.setItem(CLAVE_SESION, t); } catch (e) {}
pintarUsuario(datos);
}
function olvidarSesion() {
token = null; expira = 0;
try { sessionStorage.removeItem(CLAVE_SESION); } catch (e) {}
$('usuario').classList.add('oculto');
}
function mostrarLogin(mensaje) {
const yaVisible = !$('login').classList.contains('oculto');
$('loginMsg').textContent = mensaje || '';
$('login').classList.remove('oculto');
if (gisListo && !yaVisible) google.accounts.id.prompt(); // One Tap; el botón queda como respaldo
}
function pedirSesion(mensaje) {
olvidarSesion();
mostrarLogin(mensaje);
}
// Respuesta de Google Identity Services al elegir cuenta
function alIniciarSesion(resp) {
let datos;
try { datos = leerToken(resp.credential); } catch (e) { mostrarLogin('No se pudo leer la sesión. Intenta de nuevo.'); return; }
if (datos.hd !== DOMINIO) { mostrarLogin('Solo se permiten cuentas @' + DOMINIO + '.'); return; }
guardarSesion(resp.credential);
$('login').classList.add('oculto');
pendientes.splice(0).forEach(f => f());
}
window.cerrarSesion = function () {
if (gisListo) google.accounts.id.disableAutoSelect();
pedirSesion('Cerraste sesión.');
};
// ---- Llamadas al servidor ----
function llamar(funcion, args, alExito, alFallar, esReintento) {
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; }
if (!vigente()) {
pendientes.push(() => llamar(funcion, args, alExito, alFallar, esReintento));
if (token) pedirSesion('Tu sesión expiró. Vuelve a iniciar sesión.'); else mostrarLogin();
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, token: token }) })
.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); return; }
if (res && res.codigo === 'SESION') {
// Una vez: se pide sesión de nuevo y la llamada se repite al entrar (guardar es idempotente por idSolicitud)
if (!esReintento) {
pendientes.push(() => llamar(funcion, args, alExito, alFallar, true));
pedirSesion(res.error);
return;
}
pedirSesion(res.error);
}
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);
},
});
}
const script = { run: corredor(null, null) };
window.google = window.google || {};
google.script = script;
// ---- Google Identity Services (se carga con el <script> de accounts.google.com, abajo) ----
window.iniciarGis = function () {
if (!google.script) google.script = script; // por si la biblioteca de Google reemplazó window.google
if (CLIENT_ID_GOOGLE.indexOf('PEGAR_AQUI') === 0) { mostrarLogin('Falta configurar el Client ID de Google (CLIENT_ID_GOOGLE).'); return; }
google.accounts.id.initialize({
client_id: CLIENT_ID_GOOGLE,
callback: alIniciarSesion,
hd: DOMINIO, // Google solo ofrece cuentas del dominio (el servidor lo vuelve a revisar)
auto_select: true, // si ya entró antes, vuelve a entrar sin clics
cancel_on_tap_outside: false,
use_fedcm_for_prompt: true,
context: 'signin',
});
google.accounts.id.renderButton($('gisBoton'), {
theme: 'outline', size: 'large', shape: 'pill', text: 'signin_with', locale: 'es-419', width: 280,
});
gisListo = true;
if (!$('login').classList.contains('oculto')) google.accounts.id.prompt();
};
window.errorGis = function () {
mostrarLogin('No se pudo cargar el inicio de sesión de Google. Revisa tu conexión y recarga la página.');
};
// Al cargar: el logo de la pantalla de entrada es el mismo del encabezado; se reutiliza una sesión vigente
$('loginLogo').src = document.querySelector('header img').src;
try {
const guardado = sessionStorage.getItem(CLAVE_SESION);
if (guardado) guardarSesion(guardado);
} catch (e) { olvidarSesion(); }
if (vigente()) $('login').classList.add('oculto'); else olvidarSesion();
})();
</script>
<script src="https://accounts.google.com/gsi/client" async onload="iniciarGis()" onerror="errorGis()"></script>
<script>
let IVA = 0.16, CATALOGO = [], CLIENTES = [], PROYECTOS = [];
// 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();
let ID_PROYECTO = nuevoId(); // igual que ID_SOLICITUD, para "Crear proyecto"
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);
recibirProyectos(d.proyectos);
$('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,
proyectoFolio: '',
idSolicitud: ID_SOLICITUD,
};
if ($('btnGuardar').disabled) return;
if ($('proyecto').value.trim()) {
const p = proyectoElegido();
if (!p) { mostrarError('Elige un proyecto de la lista o deja el campo vacío para cotizar sin proyecto.'); return; }
payload.proyectoFolio = p.folio;
}
$('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) +
(r.proyectoFolio ? ' · Proyecto <b>' + esc(r.proyectoFolio) + '</b>' : '') +
' · <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 = '';
$('proyecto').value = ''; pintarProyInfo(); cerrarNuevoProyecto();
google.script.run.withSuccessHandler(recibirClientes).getClientes(); // incluye al cliente recién guardado
google.script.run.withSuccessHandler(recibirProyectos).listarProyectos('', false); // y proyectos de otros vendedores
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="8">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.vendedor || c.elaboro) + '</td><td>' +
(c.proyecto === 'Sin proyecto' ? '<span class="nota">Sin proyecto</span>' : esc(c.proyecto)) + '</td><td>' +
(c.pdf ? '<a href="' + esc(c.pdf) + '" target="_blank">Ver</a>' : '') + '</td></tr>').join('')
: '<tr><td colspan="8">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(); });
// ---- Proyectos (opcional al cotizar) ----
// La opción de la lista es "PRY-2026-0001 — Cliente — Nombre" (así se puede buscar por cualquiera de los tres)
const etiquetaProyecto = p => p.folio + ' — ' + p.cliente + ' — ' + p.nombre;
function recibirProyectos(lista) {
PROYECTOS = lista || [];
$('proyectosList').innerHTML = PROYECTOS.map(p =>
'<option value="' + esc(etiquetaProyecto(p)) + '" label="' + esc(p.ubicacion || 'Proyecto activo') + '">').join('');
pintarProyInfo();
}
/** Proyecto que corresponde a lo escrito (etiqueta completa o solo el folio); null si no hay. */
function proyectoElegido() {
const v = $('proyecto').value.trim();
const folio = (v.match(/^PRY-\d{4}-\d{4}/i) || [''])[0].toUpperCase();
return (folio && PROYECTOS.find(p => p.folio === folio)) || null;
}
function pintarProyInfo() {
const v = $('proyecto').value.trim(), p = proyectoElegido();
$('proyInfo').textContent = p ? 'Proyecto activo' + (p.ubicacion ? ' · ' + p.ubicacion : '') + ' · desde ' + p.fecha
: v ? 'Elige un proyecto de la lista (solo aparecen los activos) o deja el campo vacío.'
: 'Sin proyecto: el PDF se guarda en la carpeta del año, como siempre.';
}
/** Al elegir un proyecto con "Nombre / Contacto" vacío, se llena con su cliente. */
function alElegirProyecto() {
pintarProyInfo();
const p = proyectoElegido();
if (!p) return;
$('proyecto').value = etiquetaProyecto(p);
if ($('cNombre').value.trim()) return;
const c = CLIENTES.find(x => x.id === p.clienteId);
if (c) { $('cNombre').value = c.etiqueta; autorrellenarCliente(); return; }
const [nombre, empresa] = p.cliente.split(' — ');
$('cNombre').value = nombre || ''; $('cEmpresa').value = empresa || '';
}
$('proyecto').addEventListener('change', alElegirProyecto);
$('proyecto').addEventListener('input', () => { if (proyectoElegido() && $('proyecto').value.includes(' — ')) alElegirProyecto(); else pintarProyInfo(); });
function abrirNuevoProyecto() {
$('nuevoProy').classList.remove('oculto');
pintarClienteProyecto();
$('npNombre').focus();
}
function cerrarNuevoProyecto() {
$('nuevoProy').classList.add('oculto');
$('npNombre').value = ''; $('npUbicacion').value = '';
}
function pintarClienteProyecto() {
const n = $('cNombre').value.trim(), e = $('cEmpresa').value.trim();
$('npCliente').textContent = n ? n + (e ? ' — ' + e : '') : '(primero escribe o elige el cliente arriba)';
}
['cNombre', 'cEmpresa'].forEach(id => $(id).addEventListener('input', pintarClienteProyecto));
function crearProyecto() {
if (!$('cNombre').value.trim()) { mostrarError('Primero elige o escribe el cliente en "Nombre / Contacto".'); return; }
if (!$('npNombre').value.trim()) { mostrarError('Escribe el nombre del proyecto.'); return; }
if ($('btnProy').disabled) return;
$('btnProy').disabled = true; $('btnProy').textContent = 'Creando carpeta…';
const listo = () => { $('btnProy').disabled = false; $('btnProy').textContent = 'Crear proyecto'; };
google.script.run
.withSuccessHandler(p => {
listo(); ID_PROYECTO = nuevoId();
recibirProyectos([p].concat(PROYECTOS.filter(x => x.folio !== p.folio)));
$('proyecto').value = etiquetaProyecto(p); pintarProyInfo(); cerrarNuevoProyecto();
aviso('ok', 'Proyecto <b>' + esc(p.folio) + '</b> creado y elegido para esta cotización · ' +
'<a href="' + esc(p.url) + '" target="_blank">Abrir carpeta</a>');
})
.withFailureHandler(err => { listo(); mostrarError(err); })
.crearProyecto({
nombre: $('npNombre').value, ubicacion: $('npUbicacion').value,
cliente: { nombre: $('cNombre').value, empresa: $('cEmpresa').value, rfc: $('cRfc').value },
idSolicitud: ID_PROYECTO,
});
}
// ---- 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>