cotizador-helgen/web/cotizador/index.html
Efrain (Helgen) 1a02e2df3f Compras: órdenes de compra por proyecto, proveedores y margen (fase 3)
- Rol nuevo ROL_COMPRAS (Script Properties): solo ese rol crea, ve, recibe y
  cancela órdenes; el servidor lo revisa en cada llamada y los demás reciben
  codigo PERMISO. getDatosIniciales agrega permisos.compras.
- Órdenes OC-AAAA-NNNN con el mismo candado e idempotencia, solo en proyectos
  Activos con contrato firmado. Estados Emitida / Recibida / Cancelada (con
  motivo; nunca se borran). La línea de negocio sale del contrato.
- Libro y carpeta "Cotizador Helgen — Compras" aparte y restringidos con
  privado_(): ni en la Base de datos ni en la carpeta del proyecto ni en
  cobranza. Pestañas OrdenesCompra, Proveedores (se llena sola) y Bitacora.
- PDF de la orden siempre con la razón social de Helgen y los términos
  TERMINOS_OC (se agregan solos a Config). Sin tipo de cambio ni línea.
- Costo siempre capturado, nunca el precio de venta. Orden en otra moneda que
  el proyecto: tipo de cambio obligatorio y costo convertido.
- Margen (contratado sin IVA − órdenes vigentes sin IVA) solo en la vista de
  cobranza; preparado para sumar nómina y horas. Con órdenes vigentes la
  aceptada no puede cambiar de moneda.
- Cobranza y compras comparten la mecánica de área privada (carpetaArea_,
  libroArea_, enRol_). Los proyectos nuevos ya no crean "Órdenes de compra".
- Interfaz: pestaña Compras y tarjeta de margen en Cobranza.
- Pruebas: tests/compras.test.js nuevo; interfaz, cobranza, sesión, proyectos
  y cotización actualizadas (oc.html de ejemplo). Las ocho pasan.

Sin publicar: falta respaldo, prueba en PRUEBAS, clasp y wrangler (CLAUDE.md §9).

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

1383 lines
83 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); }
.tile b.negativo { color:#B91C1C; }
.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>
<button id="tabCom" class="oculto" onclick="verTab('com')">Compras</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 (MXN) *</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">
<span id="tcCat" class="nota"></span>
<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 class="num" style="width:150px">Precio USD</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>
<!-- Margen: contratado sin IVA − órdenes de compra vigentes sin IVA (solo rol de cobranza) -->
<div id="cobMargen" class="tiles oculto" style="margin-top:14px"></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>
<!-- ===== COMPRAS (solo rol de compras; el servidor lo vuelve a revisar en cada llamada) ===== -->
<section id="vistaCom" class="oculto">
<div class="modulo">
<h2>1 · Proyecto y proveedor</h2>
<div class="proy-fila">
<div><label>Proyecto (activo y con contrato firmado)</label>
<input id="comProyecto" list="comProyectosList" autocomplete="off" placeholder="Busca por folio, cliente o nombre">
<datalist id="comProyectosList"></datalist></div>
<button type="button" class="btn btn-sec" onclick="cargarProyectosCom()">Actualizar lista</button>
</div>
<div id="comInfo" class="nota" style="margin-top:6px">Elige un proyecto para emitir órdenes de compra y ver las que tiene.</div>
<div id="comProveedor" class="grid oculto" style="margin-top:14px">
<div><label>Proveedor (razón social) *</label>
<input id="prvRazon" list="proveedoresList" autocomplete="off" maxlength="150" placeholder="Escribe o elige un proveedor">
<datalist id="proveedoresList"></datalist></div>
<div><label>RFC</label><input id="prvRfc" maxlength="13"></div>
<div><label>Contacto</label><input id="prvContacto" maxlength="100"></div>
<div><label>Correo</label><input id="prvCorreo" type="email" maxlength="100"></div>
<div><label>Teléfono</label><input id="prvTel" maxlength="40"></div>
</div>
</div>
<div id="comModRenglones" class="modulo oculto">
<h2>2 · Renglones</h2>
<datalist id="ocCatalogo"></datalist>
<datalist id="ocClaves"></datalist>
<div class="grid" style="margin-bottom:12px">
<div><label>Moneda de la orden *</label>
<select id="ocMoneda" onchange="pintarTcOc()">
<option value="MXN">MXN · pesos mexicanos</option>
<option value="USD">USD · dólares estadounidenses</option>
</select></div>
<div id="ocTcCaja" class="oculto"><label>Tipo de cambio del día (pesos por dólar) *</label>
<input id="ocTc" type="number" min="0" step="0.0001" style="text-align:right"></div>
<div><label>IVA *</label>
<select id="ocIva" onchange="recalcularOc()">
<option value="0.16">16%</option>
<option value="0.08">8% (región fronteriza)</option>
<option value="0">0% (extranjero o exento)</option>
</select></div>
</div>
<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">Costo unitario</th>
<th class="num" style="width:130px">Importe</th><th style="width:30px"></th>
</tr></thead>
<tbody id="ocFilas"></tbody>
</table>
<button class="btn btn-sec" style="margin-top:10px" onclick="agregarRenglonOc()">+ Agregar renglón</button>
<div class="nota" style="margin-top:8px">Del catálogo se llenan clave, descripción y unidad. El costo se captura a mano: nunca es el precio de venta.</div>
<div class="totales" style="margin-top:12px">
<div><span>Subtotal</span><span id="ocSub">$0.00</span></div>
<div><span id="ocLblIva">IVA (16%)</span><span id="ocIvaMonto">$0.00</span></div>
<div class="total"><span id="ocLblTot">TOTAL MXN</span><span id="ocTot">$0.00</span></div>
</div>
</div>
<div id="comModOrdenes" class="modulo oculto">
<h2>3 · Entrega y órdenes</h2>
<div id="comForm">
<div class="grid">
<div><label>Fecha de entrega *</label><input id="ocEntrega" type="date"></div>
<div style="grid-column:span 2"><label>Lugar de entrega *</label><input id="ocLugar" maxlength="200"></div>
<div><label>Condiciones de pago *</label><input id="ocCondiciones" maxlength="200" placeholder="Ej. Contado, crédito 30 días"></div>
</div>
<div style="margin-top:12px"><label>Notas para el proveedor (opcional)</label><textarea id="ocNotas" rows="3" maxlength="1000"></textarea></div>
<div class="acciones">
<button type="button" id="btnOc" class="btn" onclick="guardarOc()">Emitir orden y generar PDF</button>
<span class="nota">El PDF se guarda en la carpeta privada de compras; no se envía por correo.</span>
</div>
</div>
<div id="comNoEmite" class="nota"></div>
<table style="margin-top:14px">
<thead><tr><th>Folio</th><th>Fecha</th><th>Proveedor</th><th class="num">Total</th><th>Entrega</th><th>Estado</th><th>PDF</th><th></th></tr></thead>
<tbody id="ocLista"></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 = {};
let TC = { valor: null, fuente: '' }, MONEDA_ANT = 'MXN'; // tipo de cambio del catálogo (pestaña "Tipo de cambio")
// 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; TC = d.tipoCambio || TC;
recibirClientes(d.clientes);
recibirProyectos(d.proyectos);
// Las pestañas Cobranza y Compras solo se muestran con permiso; el servidor lo revisa en cada llamada
PERMISOS = d.permisos || {};
$('tabCob').classList.toggle('oculto', !PERMISOS.cobranza);
$('tabCom').classList.toggle('oculto', !PERMISOS.compras);
$('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;
// En USD: precio del catálogo convertido con el tipo de cambio del día; sin tipo de cambio, se captura a mano
const precio = precioCatalogo(p, monedaActual());
tr.querySelector('.pu').value = precio == null ? '' : precio;
if (precio == null) { tr.querySelector('.pu').placeholder = 'USD'; tr.querySelector('.pu').focus(); }
tr._cat = p.clave; // al cambiar de moneda, este renglón se convierte si su precio no se tocó
};
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;
}
/** Precio de un producto del catálogo en la moneda pedida; null si es USD y no hay tipo de cambio. */
function precioCatalogo(p, moneda) {
if (moneda !== 'USD') return p.precio;
return p.precioUsd != null ? p.precioUsd : null;
}
function cambiarMoneda() {
const m = monedaActual(), antes = MONEDA_ANT;
MONEDA_ANT = m;
// Renglones elegidos del catálogo cuyo precio no se modificó: se pasan a la nueva moneda
let manuales = 0;
if (m !== antes) leerItems().forEach(it => {
const p = it._tr._cat && buscarClave(it._tr._cat);
const pu = it._tr.querySelector('.pu');
if (p && Number(pu.value) === Number(precioCatalogo(p, antes))) {
const nuevo = precioCatalogo(p, m);
pu.value = nuevo == null ? '' : nuevo;
} else if (it.precio > 0) manuales++;
});
const tc = TC && TC.valor ? 'tipo de cambio ' + TC.valor.toFixed(4) + ' pesos por dólar (' + (TC.fuente === 'manual' ? 'capturado en el catálogo' : 'Google Finance') + ')' : '';
$('notaMoneda').innerHTML = m === 'USD'
? '<span class="aviso-moneda">Cotización en USD:</span> ' + (tc
? 'los precios del catálogo se convierten con el ' + esc(tc) + '. Puedes ajustarlos.'
: 'no hay tipo de cambio en el catálogo; captura los precios en dólares.') +
(manuales ? ' <span class="aviso-moneda">Revisa los precios que capturaste a mano: no se convierten.</span>' : '')
: (manuales ? '<span class="aviso-moneda">Revisa los precios que capturaste a mano: no se convierten.</span>' : '');
pintarDatalists(); 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(),
// Tipo de cambio con que se convirtieron los precios del catálogo (se guarda en la columna V; solo USD)
tipoCambio: monedaActual() === 'USD' && TC && TC.valor ? TC.valor : '',
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() {
// La etiqueta muestra el precio en la moneda de la cotización
const m = monedaActual();
const precio = p => { const x = precioCatalogo(p, m); return x == null ? 'precio en USD a mano' : dinero(x, m); };
$('catalogo').innerHTML = CATALOGO.map(p =>
'<option value="' + esc(p.descripcion) + '" label="' + esc(p.clave + ' · ' + precio(p)) + '">').join('');
$('claves').innerHTML = CATALOGO.map(p =>
'<option value="' + esc(p.clave) + '" label="' + esc(p.descripcion + ' · ' + precio(p)) + '">').join('');
// Órdenes de compra: sin precio de venta (el costo se captura a mano)
$('ocCatalogo').innerHTML = CATALOGO.map(p =>
'<option value="' + esc(p.descripcion) + '" label="' + esc(p.clave + ' · ' + p.unidad) + '">').join('');
$('ocClaves').innerHTML = CATALOGO.map(p =>
'<option value="' + esc(p.clave) + '" label="' + esc(p.descripcion) + '">').join('');
}
function cargarCatalogo() {
$('listaCat').innerHTML = '<tr><td colspan="6">Cargando…</td></tr>';
google.script.run.withSuccessHandler(recibirCatalogo).withFailureHandler(mostrarError).getCatalogo();
}
function recibirCatalogo(r) {
CATALOGO = r.productos; TC = r.tipoCambio || TC;
$('tcCat').textContent = TC && TC.valor ? 'Tipo de cambio: ' + TC.valor.toFixed(4) + ' MXN/USD · ' +
(TC.fuente === 'manual' ? 'capturado a mano' : 'Google Finance') : 'Sin tipo de cambio (revisa la pestaña "Tipo de cambio" del catálogo)';
$('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 class="num">' + (p.precioUsd != null ? dinero(p.precioUsd, 'USD') : '<span class="nota">—</span>') + '</td>' +
'<td>' + (p.claveSat ? esc(p.claveSat) + (p.unidadSat ? ' · ' + esc(p.unidadSat) : '') : '<span class="nota">Pendiente</span>') + '</td></tr>').join('')
: '<tr><td colspan="6">' + (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>'
: !v.puedeAceptar ? '' : '<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 = '';
const cancelado = v.proyecto.estado === 'Cancelado';
const avisoCancelado = '<div class="nota aviso-moneda" style="margin-top:6px">Proyecto cancelado: no se puede marcar la cotización ' +
'aceptada ni registrar el contrato firmado. Para reactivarlo, cambia su Estado a Activo en la hoja Proyectos.</div>';
if (!c) h = cancelado ? avisoCancelado : '<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 if (!v.puedeFirmar) {
h += cancelado ? avisoCancelado : '';
} 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;
// Margen (sin IVA, en la moneda del proyecto): el servidor solo lo manda al rol de cobranza
const mg = v.margen;
$('cobMargen').classList.toggle('oculto', !mg);
$('cobMargen').innerHTML = !mg ? '' : mg.error ? '<div class="nota aviso-moneda">' + esc(mg.error) + '</div>'
: '<div class="sat-titulo" style="grid-column:1/-1;margin:0">Margen del proyecto (sin IVA)</div>' +
[['Contratado sin IVA', dinero(mg.contratado, mg.moneda)]]
.concat(mg.costos.map(x => [x.concepto + ' (' + x.cantidad + ')', '− ' + dinero(x.monto, mg.moneda)]))
.concat([['Margen', dinero(mg.margen, mg.moneda), mg.margen < 0], ['Margen %', mg.porcentaje + '%', mg.margen < 0]])
.map(([k, x, neg]) => '<div class="tile"><span>' + esc(k) + '</span><b' + (neg ? ' class="negativo"' : '') + '>' + x + '</b></div>').join('');
// 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); });
}
// ---- Compras (solo rol de compras) ----
let COM_PROYECTOS = [], COM = null, PROVEEDORES = [], ID_OC = nuevoId();
const CHIP_OC = { Emitida: 'aceptada', Recibida: 'firmado', Cancelada: 'cancelado' };
const CAMPOS_PROV = { prvRazon: 'razonSocial', prvRfc: 'rfc', prvContacto: 'contacto', prvCorreo: 'correo', prvTel: 'telefono' };
const fechaMx = iso => { const m = String(iso || '').match(/^(\d{4})-(\d{2})-(\d{2})$/); return m ? m[3] + '/' + m[2] + '/' + m[1] : String(iso || ''); };
function cargarProyectosCom() {
google.script.run.withSuccessHandler(lista => {
COM_PROYECTOS = lista || [];
$('comProyectosList').innerHTML = COM_PROYECTOS.map(p =>
'<option value="' + esc(etiquetaCob(p)) + '" label="' + esc(p.moneda + ' · ' + (MARCA_TXT[p.marca] || '')) + '">').join('');
if (!COM_PROYECTOS.length && !COM) $('comInfo').textContent = 'No hay proyectos activos con contrato firmado.';
}).withFailureHandler(mostrarError).listarProyectosCompras('');
}
function proyectoComElegido() {
const folio = ($('comProyecto').value.trim().match(/^PRY-\d{4}-\d{4}/i) || [''])[0].toUpperCase();
return (folio && COM_PROYECTOS.find(p => p.folio === folio)) || null;
}
$('comProyecto').addEventListener('change', () => { const p = proyectoComElegido(); if (p) abrirProyectoCom(p.folio); });
function abrirProyectoCom(folio) {
$('comInfo').textContent = 'Cargando…';
google.script.run.withSuccessHandler(pintarCompras).withFailureHandler(mostrarError).getComprasProyecto(folio);
}
/** Pinta la pestaña a partir de la respuesta del servidor (vistaCompras_). Al cambiar de proyecto, limpia el formulario. */
function pintarCompras(v) {
const cambio = !COM || COM.proyecto.folio !== v.proyecto.folio;
COM = v;
$('comProyecto').value = etiquetaCob(v.proyecto);
$('comInfo').innerHTML = '<b>' + esc(v.proyecto.folio) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' + esc(v.proyecto.nombre) +
(v.moneda ? ' · Moneda del proyecto: <b>' + esc(v.moneda) + '</b> · ' + esc(MARCA_TXT[v.marca] || '') : '') +
(v.proyecto.estado !== 'Activo' ? ' · <span class="chip cancelado">' + esc(v.proyecto.estado) + '</span>' : '');
recibirProveedores(v.proveedores);
['comProveedor', 'comModRenglones', 'comForm'].forEach(id => $(id).classList.toggle('oculto', !v.puedeEmitir));
$('comModOrdenes').classList.remove('oculto');
$('comNoEmite').textContent = v.puedeEmitir ? '' : 'Este proyecto no admite órdenes nuevas: debe estar Activo y con contrato firmado.';
if (v.puedeEmitir && cambio) limpiarOc();
$('ocLista').innerHTML = (v.ordenes || []).length ? v.ordenes.map(o => '<tr' + (o.estado === 'Cancelada' ? ' class="fila-cancelada" title="' +
esc('Cancelada por ' + o.canceladoPor + ': ' + o.motivoCancelacion) + '"' : '') + '><td style="white-space:nowrap"><b>' + esc(o.folio) + '</b></td><td>' + esc(fechaMx(o.fecha)) +
'</td><td>' + esc(o.proveedor) + '</td><td class="num">' + dinero(o.total, o.moneda) +
(o.tipoCambio ? '<div class="nota">TC ' + esc(o.tipoCambio) + '</div>' : '') + '</td><td>' + esc(fechaMx(o.fechaEntrega)) +
'<div class="nota">' + esc(o.lugarEntrega) + '</div></td><td><span class="chip ' + (CHIP_OC[o.estado] || '') + '"' +
(o.estado === 'Recibida' ? ' title="' + esc('Recibida el ' + fechaMx(o.fechaRecepcion) + ' · ' + o.recibio) + '"' : '') + '>' + esc(o.estado) + '</span></td><td>' +
(o.pdfUrl ? '<a href="' + esc(o.pdfUrl) + '" target="_blank">Ver</a>' : '') + '</td><td style="white-space:nowrap">' +
(o.estado === 'Emitida' ? '<button type="button" class="btn btn-sec" style="padding:4px 10px;font-size:12px" onclick="recibirOc(\'' + esc(o.folio) + '\')">Recibida</button>' +
'<button type="button" class="quitar" title="Cancelar orden" onclick="cancelarOc(\'' + esc(o.folio) + '\')">×</button>' : '') +
'</td></tr>').join('') : '<tr><td colspan="8" class="nota">Este proyecto no tiene órdenes de compra.</td></tr>';
}
// Proveedores (se llenan solos al emitir órdenes, como los clientes)
function recibirProveedores(lista) {
PROVEEDORES = lista || [];
$('proveedoresList').innerHTML = PROVEEDORES.map(p => '<option value="' + esc(p.etiqueta) + '" label="' +
esc([p.rfc, p.contacto].filter(Boolean).join(' · ') || 'Proveedor registrado') + '">').join('');
}
function autorrellenarProveedor() {
const v = $('prvRazon').value.trim().toLowerCase();
const p = v && PROVEEDORES.find(x => x.etiqueta.toLowerCase() === v);
if (p) Object.keys(CAMPOS_PROV).forEach(id => $(id).value = p[CAMPOS_PROV[id]] || '');
}
$('prvRazon').addEventListener('change', autorrellenarProveedor);
$('prvRazon').addEventListener('input', () => { if (PROVEEDORES.some(x => x.etiqueta === $('prvRazon').value)) autorrellenarProveedor(); });
// Renglones: del catálogo se toman clave, descripción y unidad; el costo nunca es el precio de venta
function agregarRenglonOc() {
const tr = document.createElement('tr');
tr.innerHTML =
'<td><input class="clave" list="ocClaves"></td>' +
'<td><input class="desc" list="ocCatalogo" 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="costo" type="number" min="0" step="0.01" placeholder="Costo" style="text-align:right"></td>' +
'<td class="num imp">$0.00</td>' +
'<td><button class="quitar" title="Quitar">×</button></td>';
const aplicar = p => {
if (!p) return;
tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion;
tr.querySelector('.uni').value = p.unidad;
if (!tr.querySelector('.costo').value) tr.querySelector('.costo').focus();
};
tr.querySelector('.desc').addEventListener('change', e => { aplicar(CATALOGO.find(x => x.descripcion === e.target.value)); recalcularOc(); });
tr.querySelector('.clave').addEventListener('change', e => { aplicar(buscarClave(e.target.value)); recalcularOc(); });
tr.querySelectorAll('input').forEach(i => i.addEventListener('input', recalcularOc));
tr.querySelector('.quitar').onclick = () => { tr.remove(); recalcularOc(); };
$('ocFilas').appendChild(tr);
}
function leerRenglonesOc() {
return [...$('ocFilas').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,
costo: tr.querySelector('.costo').value, // vacío = sin capturar (el servidor lo rechaza)
_tr: tr,
}));
}
/** Vista previa (el servidor recalcula). */
function recalcularOc() {
const m = $('ocMoneda').value === 'USD' ? 'USD' : 'MXN', tasa = Number($('ocIva').value) || 0;
let sub = 0;
leerRenglonesOc().forEach(r => {
const imp = r2(r.cantidad * r2(Number(r.costo) || 0));
r._tr.querySelector('.imp').textContent = dinero(imp, m);
sub += imp;
});
sub = r2(sub);
const iva = r2(sub * tasa);
$('ocSub').textContent = dinero(sub, m); $('ocIvaMonto').textContent = dinero(iva, m); $('ocTot').textContent = dinero(sub + iva, m);
$('ocLblIva').textContent = 'IVA (' + Math.round(tasa * 100) + '%)'; $('ocLblTot').textContent = 'TOTAL ' + m;
}
/** Orden en otra moneda que el proyecto: tipo de cambio obligatorio. */
function pintarTcOc() {
const distinta = !!COM && !!COM.moneda && $('ocMoneda').value !== COM.moneda;
$('ocTcCaja').classList.toggle('oculto', !distinta);
if (!distinta) $('ocTc').value = '';
recalcularOc();
}
function limpiarOc() {
Object.keys(CAMPOS_PROV).concat(['ocTc', 'ocEntrega', 'ocCondiciones', 'ocNotas']).forEach(id => $(id).value = '');
$('ocFilas').innerHTML = ''; agregarRenglonOc();
$('ocMoneda').value = (COM && COM.moneda) || 'MXN';
$('ocIva').value = [0.16, 0.08, 0].indexOf(IVA) !== -1 ? String(IVA) : '0.16';
$('ocLugar').value = (COM && COM.proyecto.ubicacion) || '';
if (COM) $('ocEntrega').min = COM.hoy;
ID_OC = nuevoId();
$('btnOc').disabled = false; $('btnOc').textContent = 'Emitir orden y generar PDF';
pintarTcOc();
}
function guardarOc() {
if (!COM || $('btnOc').disabled) return;
const payload = {
proyectoFolio: COM.proyecto.folio,
proveedor: { razonSocial: $('prvRazon').value, rfc: $('prvRfc').value, contacto: $('prvContacto').value,
correo: $('prvCorreo').value, telefono: $('prvTel').value },
renglones: leerRenglonesOc().map(({_tr, ...r}) => r),
moneda: $('ocMoneda').value, tipoCambio: $('ocTc').value, ivaTasa: $('ocIva').value,
fechaEntrega: $('ocEntrega').value, lugarEntrega: $('ocLugar').value, condicionesPago: $('ocCondiciones').value,
notas: $('ocNotas').value, idSolicitud: ID_OC,
};
$('btnOc').disabled = true; $('btnOc').textContent = 'Generando PDF…';
const listo = () => { $('btnOc').disabled = false; $('btnOc').textContent = 'Emitir orden y generar PDF'; };
google.script.run
.withSuccessHandler(r => {
pintarCompras(r.vista); limpiarOc();
aviso('ok', 'Orden de compra <b>' + esc(r.folio) + '</b> emitida · Total ' + dinero(r.total, r.moneda) +
' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
})
.withFailureHandler(err => { listo(); mostrarError(err); })
.guardarOrdenCompra(payload);
}
function recibirOc(folio) {
const fecha = window.prompt('Fecha en que se recibió completa la orden ' + folio + ' (AAAA-MM-DD):', COM.hoy);
if (fecha == null) return;
accionCob(null, 'marcarOcRecibida', [folio, fecha.trim()], v => { pintarCompras(v); aviso('ok', 'Orden <b>' + esc(folio) + '</b> marcada como recibida.'); });
}
function cancelarOc(folio) {
const motivo = window.prompt('Motivo para cancelar la orden ' + folio + ' (queda registrado; la orden no se borra):');
if (motivo == null) return;
if (!motivo.trim()) { mostrarError('Escribe el motivo de la cancelación.'); return; }
accionCob(null, 'cancelarOrdenCompra', [folio, motivo], v => { pintarCompras(v); aviso('ok', 'Orden <b>' + esc(folio) + '</b> cancelada.'); });
}
// ---- 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');
$('vistaCom').classList.toggle('oculto', t !== 'com');
$('tabCom').classList.toggle('activo', t === 'com');
$('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();
if (t === 'com') cargarProyectosCom();
$('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>