mirror of
https://github.com/efrainsosa27/cotizador-helgen.git
synced 2026-10-24 05:16:48 +00:00
- 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>
737 lines
42 KiB
HTML
737 lines
42 KiB
HTML
<!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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|