cotizador-helgen/web/cotizador/index.html
Efrain (Helgen) d08f4025f5 Cobranza: esquema de Energy editable solo por el administrador; columnas USD en el PDF
- Esquema de Helgen Energy: solo ADMIN_DESBLOQUEO lo edita (antes de firmar),
  con motivo obligatorio que queda en la Bitacora junto con el esquema anterior.
  Al cambiar entre cotizaciones de Energy se conserva el esquema; al pasar de
  Helgen a Energy se pone el 60/30/10 (con confirmación si reemplaza otro).
- PDF en USD: columnas "P. Unitario (USD)" e "Importe (USD)"; en MXN sin cambios.
- Prueba de que la cotización USD imprime el mismo bloque de términos que MXN.
- Pruebas de cobranza e interfaz ampliadas; CLAUDE.md actualizado.

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

1074 lines
64 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; }
/* Cobranza */
.chip { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:bold; border:1px solid var(--borde); }
.chip.firmado { background:#E6FBF7; border-color:var(--teal); color:var(--azul); }
.chip.aceptada { background:#FFF7E6; border-color:#F5B544; color:#7A4B00; }
.chip.cancelado { background:#FEE2E2; border-color:#DC2626; color:#991B1B; }
.tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:10px; margin-bottom:14px; }
.tile { border:1px solid var(--borde); border-radius:8px; padding:10px 12px; }
.tile span { display:block; font-size:11px; color:var(--gris); text-transform:uppercase; }
.tile b { font-size:16px; color:var(--azul); }
.barra { height:8px; background:var(--cielo); border-radius:4px; overflow:hidden; min-width:80px; }
.barra i { display:block; height:100%; background:var(--teal); }
.fila-cancelada td { color:var(--gris); text-decoration:line-through; }
.fila-cancelada td:last-child { text-decoration:none; }
.aviso-moneda { color:#B45309; 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>
<button id="tabCob" class="oculto" onclick="verTab('cob')">Cobranza</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>Moneda *</label>
<select id="moneda" onchange="cambiarMoneda()">
<option value="MXN">MXN · pesos mexicanos</option>
<option value="USD">USD · dólares estadounidenses</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 id="notaMoneda" class="nota" style="margin-top:8px"></div>
</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 id="lblTot">TOTAL MXN</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>
<!-- ===== COBRANZA (solo rol de cobranza; el servidor lo vuelve a revisar en cada llamada) ===== -->
<section id="vistaCob" class="oculto">
<div class="modulo">
<h2>1 · Proyecto y contrato</h2>
<div class="proy-fila">
<div><label>Proyecto</label>
<input id="cobProyecto" list="cobProyectosList" autocomplete="off" placeholder="Busca por folio, cliente o nombre">
<datalist id="cobProyectosList"></datalist></div>
<button type="button" class="btn btn-sec" onclick="cargarProyectosCob()">Actualizar lista</button>
</div>
<div id="cobInfo" class="nota" style="margin-top:6px">Elige un proyecto para ver su contrato, esquema y pagos.</div>
<div id="cobDetalle" class="oculto">
<table style="margin-top:12px">
<thead><tr><th>Cotización</th><th>Fecha</th><th>Línea</th><th>Moneda</th><th class="num">Subtotal</th><th class="num">Total con IVA</th><th>PDF</th><th></th></tr></thead>
<tbody id="cobCots"></tbody>
</table>
<div id="cobContrato" class="sat"></div>
</div>
</div>
<div id="cobModEsquema" class="modulo oculto">
<h2>2 · Esquema de pagos</h2>
<div id="cobEsqNota" class="nota" style="margin-bottom:8px"></div>
<table>
<thead><tr><th style="width:30px">#</th><th>Etapa</th><th class="num" style="width:110px">%</th><th class="num">Monto</th>
<th class="num">Pagado</th><th class="num">Saldo</th><th style="width:120px">Avance</th><th style="width:30px"></th></tr></thead>
<tbody id="cobEsquema"></tbody>
</table>
<div id="cobEsqAcciones" class="acciones"></div>
</div>
<div id="cobModPagos" class="modulo oculto">
<h2>3 · Pagos</h2>
<div id="cobResumen" class="tiles"></div>
<div id="cobFormPago" class="sat oculto">
<div class="sat-titulo">Registrar pago · el comprobante es obligatorio (PDF o imagen, máx. 10 MB)</div>
<div class="grid">
<div><label>Fecha del pago *</label><input id="pgFecha" type="date"></div>
<div><label>Monto *</label><input id="pgMonto" type="number" min="0" step="0.01" style="text-align:right"></div>
<div><label>Moneda del pago *</label><select id="pgMoneda" onchange="pintarTipoCambio()"><option>MXN</option><option>USD</option></select></div>
<div id="pgTcCaja" class="oculto"><label>Tipo de cambio del día (pesos por dólar) *</label><input id="pgTc" type="number" min="0" step="0.0001" style="text-align:right"></div>
<div><label>Método *</label><select id="pgMetodo"></select></div>
<div><label>Referencia</label><input id="pgRef" maxlength="100" placeholder="Clave de rastreo, folio o núm. de cheque"></div>
<div><label>Etapa *</label><select id="pgEtapa"></select></div>
<div><label>Comprobante *</label><input id="pgArchivo" type="file" accept="application/pdf,image/jpeg,image/png,image/heic,.pdf,.jpg,.jpeg,.png,.heic"></div>
</div>
<div class="acciones">
<button type="button" id="btnPago" class="btn" onclick="registrarPago()">Registrar pago</button>
<span class="nota">Si el pago excede su etapa, el excedente pasa a la siguiente.</span>
</div>
</div>
<div id="cobSinPagos" class="nota"></div>
<table style="margin-top:12px">
<thead><tr><th>Folio</th><th>Fecha</th><th class="num">Monto</th><th class="num">Aplicado</th><th>Método / referencia</th><th>Etapa</th><th>Registró</th><th>Comprobante</th><th></th></tr></thead>
<tbody id="cobPagos"></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) {
// El error lleva el codigo del servidor (p. ej. 'PROYECTO') para que la interfaz reaccione
const fallar = (msg, codigo) => {
const e = new Error(msg); if (codigo) e.codigo = codigo;
if (alFallar) alFallar(e); 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.', res && res.codigo);
});
}
// 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 = [], PERMISOS = {};
// 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' });
// Importe con su moneda ("$1,160.00 MXN" / "$1,000.00 USD"); nunca se suman monedas distintas
const dinero = (n, moneda) => r2(n).toLocaleString('es-MX', { minimumFractionDigits:2, maximumFractionDigits:2 })
.replace(/^(-?)/, '$1$') + ' ' + (moneda || 'MXN');
const monedaActual = () => $('moneda').value === 'USD' ? 'USD' : '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);
// La pestaña Cobranza solo se muestra con permiso; el servidor lo revisa en cada llamada
PERMISOS = d.permisos || {};
$('tabCob').classList.toggle('oculto', !PERMISOS.cobranza);
$('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;
// El catálogo está en pesos: en una cotización en USD el precio se captura a mano
if (monedaActual() === 'USD') { tr.querySelector('.pu').value = ''; tr.querySelector('.pu').placeholder = 'USD'; tr.querySelector('.pu').focus(); }
else 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 = dinero(imp, monedaActual());
sub += imp;
});
sub = r2(sub);
const iva = r2(sub * IVA), m = monedaActual();
$('tSub').textContent = dinero(sub, m); $('tIva').textContent = dinero(iva, m); $('tTot').textContent = dinero(sub + iva, m);
$('lblTot').textContent = 'TOTAL ' + m;
}
function cambiarMoneda() {
const usd = monedaActual() === 'USD';
const conPrecio = leerItems().some(it => it.precio > 0);
$('notaMoneda').innerHTML = usd
? '<span class="aviso-moneda">Cotización en USD:</span> el catálogo llena clave y descripción; captura los precios en dólares.' +
(conPrecio ? ' <span class="aviso-moneda">Revisa los precios que ya capturaste: los del catálogo están en pesos.</span>' : '')
: '';
recalcular();
}
// ---- 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,
moneda: monedaActual(),
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 ' + dinero(r.total, r.moneda) +
(r.proyectoFolio ? ' · Proyecto <b>' + esc(r.proyectoFolio) + '</b>' : '') +
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
limpiar();
})
.withFailureHandler(errorAlGuardar)
.guardarCotizacion(payload);
}
// Si el servidor rechaza el proyecto (cancelado, cerrado o inexistente): se quita del campo y se refresca la lista
function errorAlGuardar(err) {
if (err && err.codigo === 'PROYECTO') {
$('proyecto').value = ''; pintarProyInfo();
google.script.run.withSuccessHandler(recibirProyectos).listarProyectos('', false);
mostrarError((err.message || '') + ' Se quitó el proyecto: elige otro o guarda sin proyecto.');
return;
}
mostrarError(err);
}
function limpiar() {
['cNombre','cEmpresa','cRfc','cEmail','cTel','cDir','notas'].forEach(id => $(id).value = '');
$('filas').innerHTML = ''; agregarFila(); recalcular();
$('cInfo').textContent = '';
$('moneda').value = 'MXN'; cambiarMoneda();
$('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) + ' ' + esc(c.moneda || 'MXN') + '</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(); });
// ---- Cobranza (solo rol de cobranza) ----
let COB_PROYECTOS = [], COB = null, ESQ = [], ID_PAGO = nuevoId();
const MARCA_TXT = { helgen: 'Helgen', energy: 'Helgen Energy' };
const etiquetaCob = p => p.folio + ' — ' + p.cliente + ' — ' + p.nombre;
function cargarProyectosCob() {
google.script.run.withSuccessHandler(lista => {
COB_PROYECTOS = lista || [];
$('cobProyectosList').innerHTML = COB_PROYECTOS.map(p =>
'<option value="' + esc(etiquetaCob(p)) + '" label="' + esc((p.contrato ? 'Contrato: ' + p.contrato : 'Sin aceptada') +
(p.estado !== 'Activo' ? ' · ' + p.estado : '')) + '">').join('');
}).withFailureHandler(mostrarError).listarProyectosCobranza('');
}
function proyectoCobElegido() {
const folio = ($('cobProyecto').value.trim().match(/^PRY-\d{4}-\d{4}/i) || [''])[0].toUpperCase();
return (folio && COB_PROYECTOS.find(p => p.folio === folio)) || null;
}
$('cobProyecto').addEventListener('change', () => { const p = proyectoCobElegido(); if (p) abrirProyectoCob(p.folio); });
function abrirProyectoCob(folio) {
$('cobInfo').textContent = 'Cargando…';
google.script.run.withSuccessHandler(pintarCobranza).withFailureHandler(mostrarError).getCobranzaProyecto(folio);
}
/** Pinta todo a partir de la respuesta del servidor (vistaCobranza_). */
function pintarCobranza(v) {
COB = v;
const c = v.contrato, r = v.resumen, firmado = c && c.estado === 'Firmado';
const mon = c ? c.moneda : 'MXN';
$('cobProyecto').value = etiquetaCob(v.proyecto);
$('cobInfo').innerHTML = '<b>' + esc(v.proyecto.folio) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' + esc(v.proyecto.nombre) +
(v.proyecto.estado !== 'Activo' ? ' · <span class="chip cancelado">' + esc(v.proyecto.estado) + '</span>' : '') +
' · Contrato: ' + (c ? '<span class="chip ' + (firmado ? 'firmado' : 'aceptada') + '">' + esc(c.estado) + '</span>' : 'sin cotización aceptada');
$('cobDetalle').classList.remove('oculto');
// Cotizaciones del proyecto: una sola aceptada
$('cobCots').innerHTML = v.cotizaciones.length ? v.cotizaciones.map(q => {
const esAceptada = c && c.cotizacion === q.folio;
return '<tr><td><b>' + esc(q.folio) + '</b></td><td>' + esc(q.fecha) + '</td><td>' + esc(MARCA_TXT[q.marca]) + '</td><td>' + esc(q.moneda) +
'</td><td class="num">' + dinero(q.subtotal, q.moneda) + '</td><td class="num">' + dinero(q.total, q.moneda) + '</td><td>' +
(q.pdf ? '<a href="' + esc(q.pdf) + '" target="_blank">Ver</a>' : '') + '</td><td>' +
(esAceptada ? '<span class="chip ' + (firmado ? 'firmado' : 'aceptada') + '">Aceptada</span>'
: firmado ? '' : '<button type="button" class="btn btn-sec" onclick="aceptarCot(\'' + esc(q.folio) + '\')">Marcar aceptada</button>') +
'</td></tr>';
}).join('') : '<tr><td colspan="8" class="nota">Este proyecto no tiene cotizaciones.</td></tr>';
// Contrato
let h = '';
if (!c) h = '<span class="nota">Marca la cotización aceptada para definir el monto contratado.</span>';
else {
h = '<div class="sat-titulo">Monto contratado: ' + dinero(c.total, mon) + ' (con IVA) · subtotal ' + dinero(c.subtotal, mon) +
' · ' + esc(MARCA_TXT[c.marca]) + '</div>';
if (firmado) {
h += '<div>Contrato firmado el <b>' + esc(c.fechaFirma) + '</b> · registró ' + esc(c.firmadoPor) +
'. La cotización aceptada, el monto y el esquema ya no se pueden cambiar.</div>';
if (v.puedeDesbloquear) {
h += '<div style="margin-top:10px"><label>Motivo del desbloqueo (queda registrado) *</label>' +
'<input id="cobMotivo" maxlength="500" placeholder="Ej. Se firmó con la cotización equivocada"></div>' +
'<div class="acciones"><button type="button" class="btn btn-sec" id="btnDesbloquear" onclick="desbloquearCob()">Desbloquear contrato</button></div>';
}
} else {
h += '<div class="proy-fila" style="margin-top:8px"><div style="max-width:220px"><label>Fecha de firma *</label>' +
'<input id="cobFechaFirma" type="date" max="' + esc(v.hoy) + '" value="' + esc(v.hoy) + '"></div>' +
'<button type="button" class="btn" id="btnFirmar" onclick="firmarCob()">Contrato firmado</button></div>' +
'<div class="nota" style="margin-top:6px">Al firmar se bloquean la cotización aceptada, el monto contratado y el esquema de pagos. ' +
'Después ya no se podrán agregar cotizaciones al proyecto.</div>';
}
}
$('cobContrato').innerHTML = h;
// Esquema
$('cobModEsquema').classList.toggle('oculto', !c);
ESQ = (v.esquema || []).map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje }));
$('cobEsqNota').textContent = !c ? '' : firmado ? 'Esquema bloqueado por el contrato firmado.'
: v.esquemaPideMotivo ? 'Helgen Energy: solo el administrador cambia este esquema; el motivo queda registrado. Deben sumar 100%.'
: v.esquemaEditable ? 'Helgen: puedes cambiar nombres, porcentajes y número de etapas hasta firmar el contrato. Deben sumar 100%.'
: 'Helgen Energy: esquema 60% / 30% / 10%. Solo el administrador lo puede cambiar.';
pintarEsquema();
// Pagos
$('cobModPagos').classList.toggle('oculto', !c);
$('cobResumen').innerHTML = r ? [['Contratado (con IVA)', dinero(r.contratado, mon)], ['Subtotal (referencia)', dinero(r.subtotal, mon)],
['Pagado', dinero(r.pagado, mon)], ['Saldo', dinero(r.saldo, mon)], ['Avance', r.avance + '%']]
.map(([k, x]) => '<div class="tile"><span>' + k + '</span><b>' + x + '</b></div>').join('') : '';
$('cobFormPago').classList.toggle('oculto', !v.puedePagar);
$('cobSinPagos').textContent = c && !v.puedePagar ? 'Los pagos se registran cuando el contrato está firmado.' : '';
if (v.puedePagar) {
$('pgFecha').max = v.hoy; if (!$('pgFecha').value) $('pgFecha').value = v.hoy;
$('pgMoneda').value = mon;
$('pgMetodo').innerHTML = (v.metodos || []).map(m => '<option>' + esc(m) + '</option>').join('');
$('pgEtapa').innerHTML = (r ? r.etapas : []).map((e, i) => '<option value="' + (i + 1) + '">' + (i + 1) + ' · ' + esc(e.etapa) +
' (saldo ' + dinero(e.saldo, mon) + ')</option>').join('');
const pendiente = r && r.etapas.findIndex(e => e.saldo > 0);
if (pendiente >= 0) $('pgEtapa').value = String(pendiente + 1);
pintarTipoCambio();
}
$('cobPagos').innerHTML = (v.pagos || []).map(p => '<tr' + (p.estado === 'Cancelado' ? ' class="fila-cancelada" title="' +
esc('Cancelado por ' + p.canceladoPor + ': ' + p.motivoCancelacion) + '"' : '') + '><td><b>' + esc(p.folio) + '</b></td><td>' + esc(p.fecha) +
'</td><td class="num">' + dinero(p.monto, p.monedaPago) + (p.tipoCambio ? '<div class="nota">TC ' + esc(p.tipoCambio) + '</div>' : '') +
'</td><td class="num">' + dinero(p.montoAplicado, p.monedaProyecto) + '</td><td>' + esc(p.metodo) + (p.referencia ? ' · ' + esc(p.referencia) : '') +
'</td><td>' + esc(p.etapa) + '</td><td>' + esc(p.registro) + '</td><td>' +
(p.comprobanteUrl ? '<a href="' + esc(p.comprobanteUrl) + '" target="_blank">Ver</a>' : '') + '</td><td>' +
(p.estado === 'Vigente' ? '<button type="button" class="quitar" title="Cancelar pago" onclick="cancelarPagoCob(\'' + esc(p.folio) + '\')">×</button>'
: '<span class="chip cancelado">Cancelado</span>') + '</td></tr>').join('');
}
function pintarEsquema() {
const v = COB, edit = v && v.esquemaEditable, mon = v && v.contrato ? v.contrato.moneda : 'MXN';
const total = v && v.contrato ? v.contrato.total : 0;
const res = v && v.resumen ? v.resumen.etapas : [];
const suma = r2(ESQ.reduce((s, e) => s + (Number(e.porcentaje) || 0), 0));
// Vista previa de montos (el servidor los recalcula): la última etapa se queda con los centavos
let acum = 0;
const montos = ESQ.map((e, i) => { const m = i === ESQ.length - 1 ? r2(total - acum) : r2(total * (Number(e.porcentaje) || 0) / 100); acum = r2(acum + m); return m; });
$('cobEsquema').innerHTML = ESQ.map((e, i) => {
const x = res[i] || {};
return '<tr><td>' + (i + 1) + '</td><td>' + (edit ? '<input value="' + esc(e.etapa) + '" maxlength="40" oninput="editarEtapa(' + i + ',\'etapa\',this.value)">' : esc(e.etapa)) +
'</td><td class="num">' + (edit ? '<input type="number" min="0" max="100" step="0.01" style="text-align:right" value="' + esc(e.porcentaje) +
'" oninput="editarEtapa(' + i + ',\'porcentaje\',this.value)">' : esc(e.porcentaje) + '%') +
'</td><td class="num">' + dinero(montos[i], mon) + '</td><td class="num">' + (x.pagado != null ? dinero(x.pagado, mon) : '') +
'</td><td class="num">' + (x.saldo != null ? dinero(x.saldo, mon) : '') + '</td><td>' +
(x.avance != null ? '<div class="barra" title="' + x.avance + '%"><i style="width:' + Math.max(0, Math.min(100, x.avance)) + '%"></i></div>' : '') +
'</td><td>' + (edit && ESQ.length > 1 ? '<button type="button" class="quitar" title="Quitar etapa" onclick="quitarEtapa(' + i + ')">×</button>' : '') + '</td></tr>';
}).join('') + (ESQ.length ? '<tr><td></td><td><b>Suma</b></td><td class="num"><b' + (Math.abs(suma - 100) > 0.001 ? ' class="aviso-moneda"' : '') + '>' +
suma + '%</b></td><td class="num"><b>' + dinero(total, mon) + '</b></td><td colspan="4"></td></tr>' : '');
$('cobEsqAcciones').innerHTML = edit ? (v.esquemaPideMotivo ? '<div style="flex:1;min-width:260px"><label>Motivo del cambio (queda registrado) *</label>' +
'<input id="cobMotivoEsq" maxlength="500" placeholder="Ej. El cliente negoció 50% de anticipo"></div>' : '') +
'<button type="button" class="btn btn-sec" onclick="agregarEtapa()">+ Agregar etapa</button>' +
'<button type="button" class="btn" id="btnEsquema" onclick="guardarEsquemaCob()">Guardar esquema</button>' : '';
}
// Al editar un nombre no se vuelve a pintar (no se pierde el cursor); el % sí actualiza montos y suma
function editarEtapa(i, campo, valor) { ESQ[i][campo] = valor; if (campo === 'porcentaje') pintarEsquemaSinFoco(); }
function pintarEsquemaSinFoco() { const a = document.activeElement, idx = a && a.closest && a.closest('tr') ? [...$('cobEsquema').children].indexOf(a.closest('tr')) : -1;
pintarEsquema(); if (idx >= 0) { const inp = $('cobEsquema').children[idx].querySelector('input[type=number]'); if (inp) { inp.focus(); const l = inp.value.length; try { inp.setSelectionRange(l, l); } catch (e) {} } } }
function agregarEtapa() { if (ESQ.length >= 10) { mostrarError('Máximo 10 etapas.'); return; } ESQ.push({ etapa: '', porcentaje: 0 }); pintarEsquema(); }
function quitarEtapa(i) { ESQ.splice(i, 1); pintarEsquema(); }
// Botones que llaman al servidor: se desactivan mientras responde
function accionCob(boton, funcion, args, alExito) {
const b = boton && $(boton);
if (b) { if (b.disabled) return; b.disabled = true; }
const listo = () => { if (b) b.disabled = false; };
google.script.run.withSuccessHandler(x => { listo(); (alExito || pintarCobranza)(x); })
.withFailureHandler(err => { listo(); mostrarError(err); })[funcion](...args);
}
function aceptarCot(folio, confirmado) {
accionCob(null, 'marcarAceptada', [COB.proyecto.folio, folio, !!confirmado], v => {
if (v && v.confirmar) { if (window.confirm(v.mensaje)) aceptarCot(folio, true); return; }
pintarCobranza(v); aviso('ok', 'Cotización <b>' + esc(folio) + '</b> marcada como aceptada.');
});
}
function guardarEsquemaCob() {
const motivo = COB.esquemaPideMotivo ? $('cobMotivoEsq').value.trim() : '';
if (COB.esquemaPideMotivo && !motivo) { mostrarError('Escribe el motivo del cambio al esquema de Helgen Energy.'); return; }
accionCob('btnEsquema', 'guardarEsquema', [COB.proyecto.folio, ESQ.map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje })), motivo],
v => { pintarCobranza(v); aviso('ok', 'Esquema de pagos guardado.'); });
}
function firmarCob() {
const fecha = $('cobFechaFirma').value;
if (!fecha) { mostrarError('Captura la fecha de firma.'); return; }
if (!window.confirm('¿Registrar el contrato de ' + COB.proyecto.folio + ' como firmado el ' + fecha + '? ' +
'Después ya no se podrán cambiar la cotización aceptada, el monto ni el esquema de pagos.')) return;
accionCob('btnFirmar', 'firmarContrato', [COB.proyecto.folio, fecha], v => { pintarCobranza(v); cargarProyectosCob(); aviso('ok', 'Contrato firmado.'); });
}
function desbloquearCob() {
const motivo = $('cobMotivo').value.trim();
if (!motivo) { mostrarError('Escribe el motivo del desbloqueo.'); return; }
accionCob('btnDesbloquear', 'desbloquearContrato', [COB.proyecto.folio, motivo], v => { pintarCobranza(v); cargarProyectosCob(); aviso('ok', 'Contrato desbloqueado. Quedó registrado el motivo.'); });
}
function cancelarPagoCob(folio) {
const motivo = window.prompt('Motivo para cancelar el pago ' + folio + ' (queda registrado; el pago no se borra):');
if (motivo == null) return;
if (!motivo.trim()) { mostrarError('Escribe el motivo de la cancelación.'); return; }
accionCob(null, 'cancelarPago', [folio, motivo], v => { pintarCobranza(v); aviso('ok', 'Pago <b>' + esc(folio) + '</b> cancelado.'); });
}
function pintarTipoCambio() {
const distinta = COB && COB.contrato && $('pgMoneda').value !== COB.contrato.moneda;
$('pgTcCaja').classList.toggle('oculto', !distinta);
if (!distinta) $('pgTc').value = '';
}
/** Lee el archivo como base64 (sin el prefijo data:). */
function leerArchivo(archivo) {
return new Promise((ok, mal) => {
const lector = new FileReader();
lector.onload = () => ok(String(lector.result).split(',')[1] || '');
lector.onerror = () => mal(new Error('No se pudo leer el comprobante.'));
lector.readAsDataURL(archivo);
});
}
function registrarPago() {
const archivo = $('pgArchivo').files && $('pgArchivo').files[0];
if (!archivo) { mostrarError('Adjunta el comprobante de pago (PDF o imagen).'); return; }
if (archivo.size > 10 * 1024 * 1024) { mostrarError('El comprobante pesa más de 10 MB. Reduce la imagen o el PDF.'); return; }
if ($('btnPago').disabled) return;
$('btnPago').disabled = true; $('btnPago').textContent = 'Subiendo comprobante…';
const listo = () => { $('btnPago').disabled = false; $('btnPago').textContent = 'Registrar pago'; };
leerArchivo(archivo).then(datos => {
google.script.run.withSuccessHandler(r => {
listo(); ID_PAGO = nuevoId();
['pgMonto', 'pgTc', 'pgRef', 'pgArchivo'].forEach(id => $(id).value = '');
pintarCobranza(r.vista);
aviso('ok', 'Pago <b>' + esc(r.folio) + '</b> registrado · <a href="' + esc(r.url) + '" target="_blank">Ver comprobante</a>');
}).withFailureHandler(err => { listo(); mostrarError(err); }).registrarPago({
proyectoFolio: COB.proyecto.folio, fecha: $('pgFecha').value, monto: $('pgMonto').value, monedaPago: $('pgMoneda').value,
tipoCambio: $('pgTc').value, metodo: $('pgMetodo').value, referencia: $('pgRef').value, etapaOrden: Number($('pgEtapa').value),
comprobante: { nombre: archivo.name, datos: datos }, idSolicitud: ID_PAGO,
});
}, err => { listo(); mostrarError(err); });
}
// ---- UI ----
function verTab(t) {
$('vistaNueva').classList.toggle('oculto', t !== 'nueva');
$('vistaHist').classList.toggle('oculto', t !== 'hist');
$('vistaCat').classList.toggle('oculto', t !== 'cat');
$('vistaCob').classList.toggle('oculto', t !== 'cob');
$('tabCob').classList.toggle('activo', t === 'cob');
$('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();
if (t === 'cob') cargarProyectosCob();
$('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>