cotizador-helgen/web/cotizador/index.html
Efrain (Helgen) fdb273793f Compras: requisiciones de materiales y pago a proveedores
- Requisiciones (ROL_REQUISICION): el trabajador pide materiales sin precios para un proyecto
  activo con contrato firmado, adjunta la cotización del proveedor (PDF/JPG/PNG/HEIC, 10 MB) y
  solo ve las suyas (el servidor filtra por el correo del token), sin montos, cliente, OC ni pagos.
- SOLO_REQUISICION: cuentas que solo ven la pestaña Requisiciones; doPost les niega lo demás.
- Estados: Solicitada → Autorizada (genera la OC) → Pagada → Cerrada; Rechazada, Devuelta
  (corregir y reenviar con el mismo folio) y Cancelada. Historial visible para el solicitante.
- Toda OC sale de una requisición autorizada: se quitan la emisión directa y el botón "Recibida".
  La OC guarda req_folio y su PDF lleva "Requisición: REQ-…". Cancelar la OC regresa la
  requisición a Solicitada.
- Pago a proveedor (PGP-AAAA-NNNN, hoja PagosProveedores en el libro de Compras): comprobante
  obligatorio, monto = total de la OC ±1.00, tipo de cambio si es otra moneda. OC y requisición
  a Pagada; cancelar el pago las regresa (no si ya está cerrada). Una OC pagada no se cancela.
- Cierre: "Recibí el material" (solicitante) o compras con motivo; la OC queda Recibida.
- Pruebas: tests/requisiciones.test.js nueva; compras, interfaz, cobranza y sesión actualizadas.

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

1892 lines
118 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,iVBORw0KGgoAAAANSUhEUgAAAMgAAAAuCAYAAABtRVYBAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAACqxJREFUeNrsXQm0VVMYPjezlZSZkldEKE1mqkcJ8coUGaqXZFhJWMZWkiXLkBYyLCypRMsQFS2ieu9FhtKI1cpQvVQrsZKZDO/6fue/+t+29xnuPeee87T/tb519t1nz3v/e/j/f5+bcVJE2Wx2dzxa889DgZ2BP4EvgJ+B1ZlMZpVjyVKRKJMwQ9DjVOAEoBPDr0wfAwuBmcDrYJjvbDda+l8RGKMhMAT4NlsY1QBjgBLbqpbq/ArCK8YgYCTQUBNkI68QvwLzhP/+QHOgGWBihmeAG7GifG271VJdXDUOABZrVoEvgFuBZgHT2Qu4AJivSWsT0Nu2tqW6xhyn8OCVtAjoVGC6LYCnNIzyPK9WliylnjkGKoN3LXBpxHl0AGYp+VTQWcf2gKU0M0c/ZdC+AxwcU17bAjdqVqmG/+P2rTAIL46yoy8a2jbGzuuKxzjhNRXoH5dYFumSvmQU8v0Gz0cdV4fSDngRfr3w/nuf8rbF4wHNqyWIe12ecalcJyUgZNnBDu10z2z7A6vFjPZqkfM/EvhJ5D8hQJxSw2xcWUDcbMz1rDRke6IdhdFQvZjSfQRoyu5FQI9iVgqz9gI8rhJefTFozrDdbSlxBsFAHCAYgrZTXTBgi14x5DkRjzuF10SUbTvb5ZaSXkGGCffAJE1BkPdwx1U8EjUCxmwtu1w7tFPIICy+LeGfCzBAJ6egjkOFuzfKaA+wlhJbQQYJ9/1pqCCYdDoeb/FPEvkOtN1uqegMgpm5CR7t+SeZp09R3h9QtP1FNttA8Ror3JfZbreUxApysnC/gJn7dzFg6bEKz7IiMEdHPNYo3i867n0SojYIs5/tekvFZpA+wj1bedfScZVar2JwlsfIHEPweBtoAHdGbLPo8ZwI2tp2vaUgFKUm/UB+/uG4F5ok7SPc41jLfhUG7o8RMQadLR4DLhTedCNxmfj9AXA5u3sCbybd+GQe47gicTq7kTk/bUNrgA+Br4An0EYzIs6TJqshPEkcz94k6VsO3I38lvB2uDdPoBkGbQPewPvFHmmX4HElcCxA5i5kzUAWDBRnDjAe8as94l+Lx/bCi9wrEWcS93F/4CLgSGAz8D736xNe6aaCpL2V5p1O07weODmCfLsDazTpd1bCneilHffQhtdwWVcCXwEb+Pdazpd+/xJWk45Xl3N6frTKpBkPo0mH3zbA+AD5UZo3Gd4NN535gGcDXnKbDDTyGUNqeahvvvZJd4op3TQwR+MQg0+9RfhUPgo8GnvA4x4NpjLIjuLd/BAMUhAZyv5kHkn1y5dB8HtnoCqC6gzXlIHu+awImc4yndDGEJauSfwYMN3FUVv3RHUGaS7cG33C0hIsNdykeV/OBn9BmaMNHp8BV7AXGSre5dRWUv4k42AJ/k38bJ/gZPK4YxY10+q71PDuacTtmWe2ZLzZOYa67IlHpdL/OaItYhWwTvOOtr9TEX/7ANnQ1qp+wCLRGBqVRgaR6egaRGrTD2UNN3VYtWCw2UGYhMO8AbRgL0qjG9IcpuxflxRJOBFmQPUQTC2JLJ2boQ6dAKofWQB/qinz3XnkSTZocQlGHnLca9C1suQzziFsydxcmbjkYB4aIq+fmRlfAj7xCHdJGrdYx4tlbpZhO5Sjl4T/dsA08W4BUM8jn5147y/3nfXF+5XsX+2zx92cxBbLsBVZYyjrwYYky8JssciS2qN4s3K3OknqBwzW3PzUbrHg7mYIc5OhPmM1YVcH2GJluV/bKGHHeYTfNW1SrBXCvY36ksSsdLnHcXUl58J9GPyWASTx6snnllKgg+N++qfKkA+tOo3ZPR3xz1Zm59xspjsHyQPcvBB1Ww886Wy5e6GzvGzmN3PxJSbdVuQbYs4Q5ekCvBYi/JkG/5lov26ij4iZH0ZZXmOpk99FM9Pd/98N9flS49cUYZsj75U+efVAmE+UMdUfcalue2jC78vSs1RKsT42vL/YdJDH79vFu5M88pDhSqWYF/hcvGuiids5TylWJPdBlLIXQpUizQqvFURZ2VVq7FGfewKsIHMiqk+pzwrykUc5ZxriHJa2MwjR5/xsxbJ2dRUhRd1c/lnK26mjgeOEfoJog0cec4V7NDNGe14xDmL/schrrSauXKLnJzCH1I8hTb97BMeYVkW00TqPeJMC5N0xojqU+Lzf6HMuyaddEmEQ+UnQww1hBosKd+CtzntAzvSDFEnLPPKYw9KrnCRqk+MqJduKrdMVhrhyVpmRAIMkIRgwic9/84lXEwFzFqNdsnWp054V7tO0LZrJkGSpKzOFSmQa73n3m++d91LOPP+KQYFTEeYvzVJMJu7SFGZRAoPVZDVwXyYchbnjbtJ6l/gcZLsWkHbTkPV5Os0MEqWpyUzhJglHA1T+Bx2TsAadDtgtuZKL4B/o4IlwH9FnfuAkxdlu7F0F/yqPaOc4rtnDP6uQ3wccYqIPDP7dgZs99tmk+5iBMm/OI8+5HrP/CN2ExCYd1wRIm8TQ7TT+1LcTDHWh7VQT1GVuzFvMRL857XVYfUcclMpTVC4p6jw35EE7so82GExiiB5UZ3Q+X70sviVWrirWAop53/Y4II+RGm24u5CQJaCY9yJDmD90ghYWkuRMa6aqYluPQ3plwH6V1CqtDFIuCrkkJWXqLu2/8hjkUTLIAI/B9z0zygjWGdRownzK5vxhGKQ8QrXOcKU+FR5hl3JdRrBARkevFMgg0+oUg3Chl4uC3pyC8iwU5bktSQbhsG8VMEC/lJfBQthiVcbEIAcxY+dL98bEIJFdZ4hDsiLNIW4p5k1CTQOOdrbYXa3A3vfOFMwh57NAIh8pYZnuXBeA6LwX+YqOstDNUVLe5vNhjmcQv9AJtE5JsXKNNkEMADrwTUmIOUizfr3wSsVX3/krLxcAt4aIRrZaRyHu0gLypHPB9IBR3g+RNjFeKw+BgEokIBmEeP2i6Oa6JMWSdD1LOOgSVTsMVvpGVV82ZygGc3RQJCkj+WNyXlQN3GHwd/KMmzUMKtIzkLb6eZbGlTruJaCcMjH3Pykk+XpONbMQNN5xdUMqrTYwSRnyPMvZculIGhrO4VWGlISkVzouBJOQ0rEj0iZpYRn3vTyEE2PTakNGpq8g/CZDUmHbf5Kjt37e4KSd+ILSX2JfOFHzMYU48m2rGNzRBZ0dnTpA/AHuTBHzq6czOdd8BDxHQ0Ok/Y9xqWPJs5H6KA1M4r29Y8yvl8Ic8+1fIPxn0Pbxm6jwfraBQc6zrRh9p1ymiCzXAadHnAfpDB7T/NVCA9sD/64Uffn6bq5tGhrCDvOQOrW1rRlPB5Vq7hmQuLNFBDPiYM013snF3KqkvO378aRkusc9gPvnXsFAOlpvWzPejipRdBI5mkfmFGHOCZwWKaA2Kmn9ClxnW7tWW90VkQ7k6q2x/ZL4l1sybScFkW6JpzvZZP9Pf4JDkhEyod+JJSL0bM0SEt0ffr7uuOLDassWtbefjnsdoJDt0VR5Oc1S/J3WiLSywGcRzGzTQt7I2xrbe78Cvmoy1usatKV4O47EmmcD9MWO6hCdRrY99HdrR9hWDNzWuwA38N9uB6EKVrZu1ZRJWSce7bj3tlvqXgOk7FuO5X6FHfJ5tzH1eWfG7k7tz7C+67ia7gq08ULbWpYsWfKkvwUYAPUGAHHeqGd4AAAAAElFTkSuQmCC" 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="tabReq" class="oculto" onclick="verTab('req')">Requisiciones</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">
<!-- Resumen de todos los proyectos (al entrar a la pestaña); clic en un renglón abre el proyecto -->
<div id="cobVistaResumen">
<div class="modulo">
<h2>1 · Resumen de cobranza</h2>
<!-- Todos los proyectos (también sin cotización aceptada): desde aquí se marca la primera aceptada -->
<div style="margin-bottom:12px"><label>Abrir un proyecto (todos, también sin cotización aceptada)</label>
<input id="resProyecto" list="cobProyectosList" autocomplete="off" placeholder="Busca por folio, cliente o nombre"></div>
<div id="resTotales" class="nota">Cargando…</div>
<div class="acciones">
<input id="resBuscar" placeholder="Buscar por folio, cliente o nombre…" oninput="pintarResumenCob()" style="flex:1; min-width:220px">
<select id="resMoneda" onchange="pintarResumenCob()" style="width:auto">
<option value="">Todas las monedas</option><option value="MXN">MXN</option><option value="USD">USD</option>
</select>
<select id="resMarca" onchange="pintarResumenCob()" style="width:auto">
<option value="">Todas las líneas</option><option value="helgen">Helgen</option><option value="energy">Helgen Energy</option>
</select>
<label style="display:flex; align-items:center; gap:6px; margin:0; white-space:nowrap">
<input type="checkbox" id="resSaldo" onchange="pintarResumenCob()" style="width:auto"> Solo con saldo</label>
<button type="button" class="btn btn-sec" onclick="cargarResumenCob()">Actualizar</button>
<button type="button" class="btn btn-sec" onclick="descargarCsvCob()">Descargar CSV</button>
</div>
</div>
<div class="modulo">
<h2>2 · Proyectos</h2>
<div style="overflow-x:auto">
<table style="font-size:12px">
<thead><tr><th>Proyecto</th><th>Contrato</th><th class="num">Contratado</th><th class="num">Cobrado</th>
<th class="num">Saldo</th><th style="width:90px">Avance</th><th>Siguiente etapa · último pago</th>
<th class="num">Costo OC</th><th class="num">Margen</th></tr></thead>
<tbody id="resLista"></tbody>
</table>
</div>
<div class="nota" style="margin-top:8px">Haz clic en un proyecto para ver su contrato, esquema y pagos. Contratado, cobrado y saldo
con IVA; costo de órdenes de compra y margen sin IVA. Los contratos "Por firmar" no suman a los totales; los proyectos
cancelados no aparecen.</div>
</div>
</div>
<div id="cobVistaProyecto" 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="verResumenCob()">← Resumen</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>
</div>
</section>
<!-- ===== COMPRAS (solo rol de compras; el servidor lo vuelve a revisar en cada llamada) ===== -->
<section id="vistaReq" class="oculto">
<div class="modulo">
<h2 id="reqTitulo">1 · Nueva requisición</h2>
<div id="reqEditando" class="nota oculto" style="margin-bottom:10px"></div>
<div class="proy-fila">
<div><label>Proyecto (activo y con contrato firmado) *</label>
<input id="reqProyecto" list="reqProyectosList" autocomplete="off" placeholder="Busca por folio o nombre del proyecto">
<datalist id="reqProyectosList"></datalist></div>
<button type="button" class="btn btn-sec" onclick="cargarProyectosReq()">Actualizar lista</button>
</div>
<datalist id="reqCatalogo"></datalist>
<table style="margin-top:14px">
<thead><tr>
<th>Descripción del material *</th><th class="num" style="width:100px">Cantidad *</th><th style="width:90px">Unidad</th>
<th style="width:220px">Notas</th><th style="width:30px"></th>
</tr></thead>
<tbody id="reqFilas"></tbody>
</table>
<button class="btn btn-sec" style="margin-top:10px" onclick="agregarRenglonReq()">+ Agregar renglón</button>
<div style="margin-top:14px"><label id="reqLblArchivo">Cotización del proveedor (PDF o foto, máx. 10 MB) *</label>
<input id="reqArchivo" type="file" accept="application/pdf,image/jpeg,image/png,image/heic,.heic"></div>
<div class="acciones">
<button type="button" id="btnReq" class="btn" onclick="enviarRequisicion()">Enviar requisición</button>
<button type="button" id="btnReqCancelarEdicion" class="btn btn-sec oculto" onclick="limpiarReq()">Cancelar corrección</button>
<span class="nota">No lleva precios: compras los toma de la cotización.</span>
</div>
</div>
<div class="modulo">
<h2>2 · Mis requisiciones</h2>
<div class="acciones" style="margin-top:0"><button type="button" class="btn btn-sec" onclick="cargarMisReq()">Actualizar</button></div>
<div id="reqLista" style="margin-top:10px"><div class="nota">Cargando…</div></div>
</div>
</section>
<section id="vistaCom" class="oculto">
<div id="comVistaLista">
<div class="modulo">
<h2>1 · Requisiciones</h2>
<div class="proy-fila">
<div><label>Buscar (folio, proyecto, solicitante u orden)</label><input id="rcBuscar" autocomplete="off"></div>
<div style="max-width:220px;min-width:180px"><label>Estado</label>
<select id="rcEstado" onchange="cargarRequisicionesCom()">
<option value="">Todas</option><option value="Solicitada">Solicitada (por revisar)</option>
<option value="Autorizada">Autorizada (por pagar)</option><option value="Pagada">Pagada (por recibir)</option>
<option value="Devuelta">Devuelta</option><option value="Cerrada">Cerrada</option>
<option value="Rechazada">Rechazada</option><option value="Cancelada">Cancelada</option>
</select></div>
<button type="button" class="btn btn-sec" onclick="cargarRequisicionesCom()">Buscar</button>
</div>
<table style="margin-top:14px">
<thead><tr><th>Folio</th><th>Fecha</th><th>Proyecto</th><th>Solicitante</th><th class="num">Renglones</th><th>Estado</th><th>Orden</th></tr></thead>
<tbody id="rcLista"></tbody>
</table>
<div class="nota" style="margin-top:8px">Toda orden de compra sale de una requisición: ábrela para autorizarla, pagarla o cerrarla.</div>
</div>
<div class="modulo">
<h2>2 · Órdenes por proyecto</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 ver sus órdenes de compra.</div>
<table style="margin-top:14px">
<thead><tr><th>Folio</th><th>Fecha</th><th>Requisición</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>
</div>
<div id="comVistaReq" class="oculto">
<div class="modulo">
<div class="acciones" style="margin:0 0 10px"><button type="button" class="btn btn-sec" onclick="verListaCom()">← Requisiciones</button></div>
<h2 id="rcTitulo">1 · Requisición</h2>
<div id="rcInfo" class="nota"></div>
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(320px,1fr));margin-top:12px;align-items:start">
<div>
<table>
<thead><tr><th>#</th><th>Descripción</th><th class="num">Cant.</th><th>Unidad</th><th>Notas</th></tr></thead>
<tbody id="rcRenglones"></tbody>
</table>
<div id="rcEventos" class="sat"></div>
<div id="rcRevision" class="acciones oculto">
<button type="button" class="btn btn-sec" onclick="revisarReqCom('devolverRequisicion')">Devolver para corregir</button>
<button type="button" class="btn btn-sec" onclick="revisarReqCom('rechazarRequisicion')">Rechazar</button>
</div>
</div>
<div>
<div class="nota" style="margin-bottom:6px">Cotización del proveedor · <a id="rcCotLink" href="#" target="_blank">Abrir en Drive</a></div>
<iframe id="rcCotVisor" title="Cotización del proveedor" style="width:100%;height:520px;border:1px solid var(--borde);border-radius:6px;background:#fff"></iframe>
</div>
</div>
</div>
<div id="comModOc" class="modulo oculto">
<h2>2 · Orden de compra</h2>
<div id="comProveedor" class="grid">
<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>
<datalist id="ocCatalogo"></datalist>
<datalist id="ocClaves"></datalist>
<div class="grid" style="margin:14px 0 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">Los renglones vienen de la requisición (las notas van en la descripción). Captura el costo de la cotización: 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 class="grid" style="margin-top:14px">
<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()">Autorizar y emitir orden (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="comModPago" class="modulo oculto">
<h2>3 · Orden, pago y cierre</h2>
<div id="rcOrden" class="tiles"></div>
<div id="rcFormPago" class="sat oculto">
<div class="sat-titulo">Registrar el pago al proveedor (uno por orden)</div>
<div class="grid">
<div><label>Fecha del pago *</label><input id="ppFecha" type="date"></div>
<div><label>Monto *</label><input id="ppMonto" type="number" min="0" step="0.01" style="text-align:right"></div>
<div><label>Moneda del pago *</label>
<select id="ppMoneda" onchange="pintarTcPago()"><option value="MXN">MXN</option><option value="USD">USD</option></select></div>
<div id="ppTcCaja" class="oculto"><label>Tipo de cambio del día (pesos por dólar) *</label>
<input id="ppTc" type="number" min="0" step="0.0001" style="text-align:right"></div>
<div><label>Método *</label>
<select id="ppMetodo"><option>Transferencia</option><option>Efectivo</option><option>Cheque</option></select></div>
<div><label>Referencia (opcional solo en efectivo)</label><input id="ppRef" maxlength="100"></div>
<div><label>Comprobante (PDF o imagen, máx. 10 MB) *</label>
<input id="ppArchivo" type="file" accept="application/pdf,image/jpeg,image/png,image/heic,.heic"></div>
</div>
<div class="acciones"><button type="button" id="btnPagoProv" class="btn" onclick="registrarPagoProv()">Registrar pago</button>
<span class="nota">El monto debe ser el total de la orden (±1.00).</span></div>
</div>
<table style="margin-top:12px">
<thead><tr><th>Pago</th><th>Fecha</th><th class="num">Monto</th><th>Método</th><th>Estado</th><th>Comprobante</th><th></th></tr></thead>
<tbody id="rcPagos"></tbody>
</table>
<div id="rcCierre" class="sat oculto">
<div class="sat-titulo">Cerrar en lugar de quien la pidió</div>
<div class="nota" style="margin-bottom:8px">Normalmente la cierra el solicitante con "Recibí el material". Hazlo tú solo si no puede (p. ej. ya no está en la obra).</div>
<div class="grid">
<div><label>Motivo *</label><input id="rcMotivoCierre" maxlength="500" placeholder="Ej. El trabajador ya no está en la obra"></div>
<div><label>Notas de la recepción (opcional)</label><input id="rcNotasCierre" maxlength="500" placeholder="Ej. Faltó 1 pieza"></div>
</div>
<div class="acciones"><button type="button" id="btnCerrarCom" class="btn btn-sec" onclick="cerrarReqCom()">Cerrar requisición (material recibido)</button></div>
</div>
</div>
</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;
PERMISOS = d.permisos || {};
$('tabReq').classList.toggle('oculto', !PERMISOS.requisicion);
pintarCatalogoReq();
if (d.soloRequisicion) {
// Cuenta que solo pide materiales: sin cotizaciones, historial ni catálogo (el servidor se los niega)
['tabNueva', 'tabHist', 'tabCat', 'tabCob', 'tabCom'].forEach(id => $(id).classList.add('oculto'));
verTab('req');
return;
}
recibirClientes(d.clientes);
recibirProyectos(d.proyectos);
// Las pestañas Cobranza, Compras y Requisiciones solo se muestran con permiso; el servidor lo revisa en cada llamada
$('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); });
// Desde el resumen: cualquier proyecto (el resumen solo lista los que tienen contrato)
$('resProyecto').addEventListener('change', () => {
const folio = ($('resProyecto').value.trim().match(/^PRY-\d{4}-\d{4}/i) || [''])[0].toUpperCase();
const p = folio && COB_PROYECTOS.find(x => x.folio === folio);
if (p) { $('resProyecto').value = ''; abrirProyectoCob(p.folio); }
});
function abrirProyectoCob(folio) {
$('cobVistaResumen').classList.add('oculto'); $('cobVistaProyecto').classList.remove('oculto');
$('cobInfo').textContent = 'Cargando…';
google.script.run.withSuccessHandler(pintarCobranza).withFailureHandler(mostrarError).getCobranzaProyecto(folio);
}
// ---- Resumen de cobranza (todos los proyectos; lo calcula el servidor con las mismas cifras que cada proyecto) ----
let RESUMEN = null;
function verResumenCob() {
$('cobVistaProyecto').classList.add('oculto'); $('cobVistaResumen').classList.remove('oculto');
cargarResumenCob();
}
function cargarResumenCob() {
if (!RESUMEN) $('resTotales').textContent = 'Cargando…';
google.script.run.withSuccessHandler(r => { RESUMEN = r; pintarResumenCob(); })
.withFailureHandler(mostrarError).resumenCobranzaGeneral();
}
/** Renglones del resumen que pasan los filtros (búsqueda, moneda, línea, solo con saldo). */
function filtrarResumenCob() {
const q = $('resBuscar').value.trim().toLowerCase(), m = $('resMoneda').value, l = $('resMarca').value, conSaldo = $('resSaldo').checked;
return ((RESUMEN && RESUMEN.proyectos) || []).filter(p => (!q || (p.folio + ' ' + p.cliente + ' ' + p.nombre).toLowerCase().includes(q)) &&
(!m || p.moneda === m) && (!l || p.marca === l) && (!conSaldo || (p.estadoContrato === 'Firmado' && p.saldo > 0.004)));
}
function pintarResumenCob() {
if (!RESUMEN) return;
const tile = (k, x, clase) => '<div class="tile"><span>' + esc(k) + '</span><b' + (clase ? ' class="' + clase + '"' : '') + '>' + x + '</b></div>';
// Totales: uno por moneda, solo contratos firmados (los filtros aplican a la tabla, no a los totales)
$('resTotales').innerHTML = RESUMEN.totales.length ? RESUMEN.totales.map(t =>
'<div class="sat-titulo" style="margin-top:4px">' + esc(t.moneda) + ' · ' + t.proyectos + (t.proyectos === 1 ? ' contrato firmado' : ' contratos firmados') +
(t.porFirmar ? ' · ' + t.porFirmar + ' por firmar (' + dinero(t.montoPorFirmar, t.moneda) + ', no suman)' : '') + '</div>' +
'<div class="tiles">' + tile('Contratado (con IVA)', dinero(t.contratado, t.moneda)) + tile('Cobrado', dinero(t.cobrado, t.moneda)) +
tile('Por cobrar', dinero(t.porCobrar, t.moneda)) + tile('Avance', t.avance + '%') +
tile('Proyectos con saldo', t.conSaldo + ' de ' + t.proyectos) +
tile('Margen (sin IVA)', t.margen == null ? '—' : dinero(t.margen, t.moneda) + (t.margenPorcentaje == null ? '' : ' · ' + t.margenPorcentaje + '%'),
t.margen != null && t.margen < 0 ? 'negativo' : '') + '</div>').join('')
: '<span class="nota">Todavía no hay proyectos con cotización aceptada.</span>';
const lista = filtrarResumenCob();
$('resLista').innerHTML = lista.length ? lista.map(p => {
const firmado = p.estadoContrato === 'Firmado';
return '<tr class="fila-cat" data-folio="' + esc(p.folio) + '"><td style="min-width:190px"><b style="white-space:nowrap">' + esc(p.folio) + '</b>' +
' <span class="nota">· ' + esc(MARCA_TXT[p.marca] || '') + '</span><div class="nota">' + esc(p.cliente) + ' · ' + esc(p.nombre) +
(p.estadoProyecto !== 'Activo' ? ' · ' + esc(p.estadoProyecto) : '') + '</div></td><td>' +
'<span class="chip ' + (firmado ? 'firmado' : 'aceptada') + '" style="white-space:nowrap">' + esc(p.estadoContrato) + '</span></td>' +
'<td class="num">' + dinero(p.contratado, p.moneda) + '</td><td class="num">' + dinero(p.pagado, p.moneda) + '</td>' +
'<td class="num"><b>' + dinero(p.saldo, p.moneda) + '</b></td>' +
'<td><div class="barra" title="' + p.avance + '%"><i style="width:' + Math.max(0, Math.min(100, p.avance)) + '%"></i></div><span class="nota">' + p.avance + '%</span></td>' +
'<td>' + (p.siguienteEtapa ? esc(p.siguienteEtapa) + ' · ' + dinero(p.siguienteSaldo, p.moneda) : 'Pagado') +
'<div class="nota">' + (p.ultimoPago ? 'Último pago ' + esc(fechaMx(p.ultimoPago)) : 'Sin pagos') + '</div></td>' +
'<td class="num">' + (p.costoOc == null ? '—' : dinero(p.costoOc, p.moneda) + '<div class="nota">' + p.ordenes + (p.ordenes === 1 ? ' orden' : ' órdenes') + '</div>') + '</td>' +
'<td class="num">' + (p.margen == null ? '<span class="nota aviso-moneda" title="' + esc(p.margenError) + '">Revisar</span>'
: '<b' + (p.margen < 0 ? ' class="aviso-moneda"' : '') + '>' + dinero(p.margen, p.moneda) + '</b><div class="nota">' + p.margenPorcentaje + '%</div>') + '</td></tr>';
}).join('') : '<tr><td colspan="9" class="nota">' + ((RESUMEN.proyectos || []).length ? 'Ningún proyecto coincide con los filtros.' : 'Sin proyectos con contrato.') + '</td></tr>';
$('resLista').querySelectorAll('tr[data-folio]').forEach(tr => tr.onclick = () => abrirProyectoCob(tr.dataset.folio));
}
/** CSV de los renglones filtrados (UTF-8 con BOM para que Excel lea los acentos). Texto que empiece con = + - @ va con apóstrofo. */
function csvResumenCob(lista) {
const texto = s => { const v = String(s ?? ''); return '"' + (/^[=+\-@]/.test(v) ? "'" + v : v).replace(/"/g, '""') + '"'; };
const num = n => n == null ? '' : r2(n).toFixed(2);
const enc = ['Folio', 'Proyecto', 'Cliente', 'Línea de negocio', 'Contrato', 'Moneda', 'Contratado con IVA', 'Cobrado', 'Saldo', 'Avance %',
'Siguiente etapa', 'Saldo siguiente etapa', 'Último pago', 'Órdenes de compra', 'Costo OC sin IVA', 'Margen sin IVA', 'Margen %'];
const filas = lista.map(p => [texto(p.folio), texto(p.nombre), texto(p.cliente), texto(MARCA_TXT[p.marca] || ''), texto(p.estadoContrato),
texto(p.moneda), num(p.contratado), num(p.pagado), num(p.saldo), String(p.avance), texto(p.siguienteEtapa),
p.siguienteEtapa ? num(p.siguienteSaldo) : '', texto(p.ultimoPago), String(p.ordenes), num(p.costoOc), num(p.margen),
p.margenPorcentaje == null ? '' : String(p.margenPorcentaje)].join(','));
return '' + [enc.map(texto).join(',')].concat(filas).join('\r\n');
}
function descargarCsvCob() {
if (!RESUMEN) { mostrarError('Espera a que cargue el resumen.'); return; }
const url = URL.createObjectURL(new Blob([csvResumenCob(filtrarResumenCob())], { type: 'text/csv;charset=utf-8' }));
const a = document.createElement('a');
a.href = url; a.download = 'cobranza-' + (RESUMEN.hoy || 'resumen') + '.csv';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/** 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); });
}
// ---- Requisiciones (quien pide materiales: el servidor solo le manda las suyas, sin montos) ----
let REQ_PROYECTOS = [], MIS_REQ = [], REQ_EDITANDO = null, ID_REQ = nuevoId();
const CHIP_REQ = { Solicitada: 'aceptada', Devuelta: 'aceptada', Autorizada: 'firmado', Pagada: 'firmado', Cerrada: 'firmado',
Rechazada: 'cancelado', Cancelada: 'cancelado' };
const chipReq = e => '<span class="chip ' + (CHIP_REQ[e] || '') + '">' + esc(e) + '</span>';
const fechaMx = iso => { const m = String(iso || '').match(/^(\d{4})-(\d{2})-(\d{2})(.*)$/); return m ? m[3] + '/' + m[2] + '/' + m[1] + m[4].slice(0, 6) : String(iso || ''); };
const botonChico = (texto, accion, sec) => '<button type="button" class="btn' + (sec === false ? '' : ' btn-sec') +
'" style="padding:4px 10px;font-size:12px" onclick="' + accion + '">' + texto + '</button>';
/** Sugerencias del catálogo para la descripción (sin precios). */
function pintarCatalogoReq() {
$('reqCatalogo').innerHTML = CATALOGO.map(p =>
'<option value="' + esc(p.descripcion) + '" label="' + esc(p.clave + ' · ' + p.unidad) + '">').join('');
}
function cargarProyectosReq() {
google.script.run.withSuccessHandler(lista => {
REQ_PROYECTOS = lista || [];
$('reqProyectosList').innerHTML = REQ_PROYECTOS.map(p => '<option value="' + esc(p.folio + ' — ' + p.nombre) + '">').join('');
}).withFailureHandler(mostrarError).listarProyectosRequisicion('');
}
function cargarMisReq() {
google.script.run.withSuccessHandler(pintarMisReq).withFailureHandler(mostrarError).misRequisiciones();
}
function proyectoReqElegido() {
const folio = ($('reqProyecto').value.trim().match(/^PRY-\d{4}-\d{4}/i) || [''])[0].toUpperCase();
return (folio && REQ_PROYECTOS.find(p => p.folio === folio)) || null;
}
function agregarRenglonReq(r) {
const d = r || {};
const tr = document.createElement('tr');
tr.innerHTML =
'<td><input class="desc" list="reqCatalogo" maxlength="500" placeholder="Ej. Cable THW calibre 10"></td>' +
'<td><input class="cant" type="number" min="0" step="any" style="text-align:right"></td>' +
'<td><input class="uni" maxlength="20"></td>' +
'<td><input class="notas" maxlength="300" placeholder="Opcional"></td>' +
'<td><button class="quitar" title="Quitar">×</button></td>';
tr.querySelector('.desc').value = d.descripcion || '';
tr.querySelector('.cant').value = d.cantidad == null ? '1' : d.cantidad;
tr.querySelector('.uni').value = d.unidad || 'PZA';
tr.querySelector('.notas').value = d.notas || '';
// Del catálogo solo se toma la unidad
tr.querySelector('.desc').addEventListener('change', e => {
const p = CATALOGO.find(x => x.descripcion === e.target.value);
if (p) tr.querySelector('.uni').value = p.unidad;
});
tr.querySelector('.quitar').onclick = () => tr.remove();
$('reqFilas').appendChild(tr);
}
function leerRenglonesReq() {
return [...$('reqFilas').querySelectorAll('tr')].map(tr => ({
descripcion: tr.querySelector('.desc').value, cantidad: tr.querySelector('.cant').value,
unidad: tr.querySelector('.uni').value, notas: tr.querySelector('.notas').value,
})).filter(r => r.descripcion.trim());
}
function limpiarReq() {
REQ_EDITANDO = null; ID_REQ = nuevoId();
$('reqProyecto').value = ''; $('reqProyecto').disabled = false;
$('reqFilas').innerHTML = ''; agregarRenglonReq();
$('reqArchivo').value = '';
$('reqTitulo').textContent = '1 · Nueva requisición';
$('reqEditando').classList.add('oculto');
$('reqLblArchivo').textContent = 'Cotización del proveedor (PDF o foto, máx. 10 MB) *';
$('btnReq').disabled = false; $('btnReq').textContent = 'Enviar requisición';
$('btnReqCancelarEdicion').classList.add('oculto');
}
function enviarRequisicion() {
if ($('btnReq').disabled) return;
const archivo = $('reqArchivo').files && $('reqArchivo').files[0];
const proyecto = REQ_EDITANDO ? null : proyectoReqElegido();
if (!REQ_EDITANDO && !proyecto) { mostrarError('Elige un proyecto de la lista.'); return; }
const renglones = leerRenglonesReq();
if (!renglones.length) { mostrarError('Agrega al menos un renglón con descripción y cantidad.'); return; }
if (!REQ_EDITANDO && !archivo) { mostrarError('Adjunta la cotización del proveedor (PDF o imagen).'); return; }
if (archivo && archivo.size > 10 * 1024 * 1024) { mostrarError('La cotización pesa más de 10 MB. Reduce la imagen o el PDF.'); return; }
const texto = $('btnReq').textContent;
$('btnReq').disabled = true; $('btnReq').textContent = 'Enviando…';
const listo = () => { $('btnReq').disabled = false; $('btnReq').textContent = texto; };
const enviar = datos => google.script.run.withSuccessHandler(r => {
const corregida = !!REQ_EDITANDO;
limpiarReq(); pintarMisReq(r.requisiciones);
aviso('ok', 'Requisición <b>' + esc(r.folio) + '</b> ' + (corregida ? 'corregida y reenviada' : 'enviada') + '. Compras la revisará.');
}).withFailureHandler(err => { listo(); mostrarError(err); }).guardarRequisicion({
folio: REQ_EDITANDO || '', proyectoFolio: proyecto ? proyecto.folio : '', renglones: renglones,
cotizacion: datos ? { nombre: archivo.name, datos: datos } : null, idSolicitud: ID_REQ,
});
if (archivo) leerArchivo(archivo).then(enviar, err => { listo(); mostrarError(err); });
else enviar(null);
}
function pintarMisReq(lista) {
MIS_REQ = lista || [];
$('reqLista').innerHTML = !MIS_REQ.length ? '<div class="nota">Todavía no tienes requisiciones.</div>' : MIS_REQ.map(r =>
'<div class="sat" style="margin-top:10px">' +
'<div class="sat-titulo">' + esc(r.folio) + ' · ' + chipReq(r.estado) + ' · ' + esc(r.proyectoFolio) + ' — ' + esc(r.proyectoNombre) + '</div>' +
(r.motivo && ['Devuelta', 'Rechazada', 'Cancelada'].indexOf(r.estado) !== -1 ? '<div class="nota"><b>Motivo:</b> ' + esc(r.motivo) + '</div>' : '') +
'<ul style="margin:6px 0;padding-left:18px">' + r.renglones.map(x => '<li>' + esc(x.cantidad) + ' ' + esc(x.unidad) + ' — ' +
esc(x.descripcion) + (x.notas ? ' <span class="nota">(' + esc(x.notas) + ')</span>' : '') + '</li>').join('') + '</ul>' +
'<div class="nota">' + r.eventos.map(e => esc(fechaMx(e.fecha)) + ' · <b>' + esc(e.estado) + '</b> · ' + esc(e.nombre) +
(e.nota ? ': ' + esc(e.nota) : '')).join('<br>') + '</div>' +
'<div class="acciones">' +
(r.puedeCerrar ? botonChico('Recibí el material', "recibiMaterial('" + esc(r.folio) + "')", false) : '') +
(r.puedeEditar ? botonChico('Corregir y reenviar', "editarReq('" + esc(r.folio) + "')") : '') +
(r.puedeCancelar ? botonChico('Cancelar requisición', "cancelarReq('" + esc(r.folio) + "')") : '') +
'</div></div>').join('');
}
/** Devuelta: el solicitante corrige renglones (y si quiere, la cotización) y la reenvía con el mismo folio. */
function editarReq(folio) {
const r = MIS_REQ.find(x => x.folio === folio);
if (!r) return;
REQ_EDITANDO = folio; ID_REQ = nuevoId();
$('reqProyecto').value = r.proyectoFolio + ' — ' + r.proyectoNombre; $('reqProyecto').disabled = true;
$('reqFilas').innerHTML = ''; r.renglones.forEach(agregarRenglonReq);
$('reqArchivo').value = '';
$('reqTitulo').textContent = '1 · Corregir ' + folio;
$('reqEditando').innerHTML = '<b>Compras la devolvió:</b> ' + esc(r.motivo);
$('reqEditando').classList.remove('oculto');
$('reqLblArchivo').textContent = 'Cotización nueva (opcional: si no adjuntas otra, se queda la anterior)';
$('btnReq').textContent = 'Reenviar requisición';
$('btnReqCancelarEdicion').classList.remove('oculto');
window.scrollTo(0, 0);
}
function cancelarReq(folio) {
const motivo = window.prompt('Motivo para cancelar la requisición ' + folio + ' (queda registrado; no se borra):');
if (motivo == null) return;
if (!motivo.trim()) { mostrarError('Escribe el motivo de la cancelación.'); return; }
accionCob(null, 'cancelarRequisicion', [folio, motivo], l => { pintarMisReq(l); aviso('ok', 'Requisición <b>' + esc(folio) + '</b> cancelada.'); });
}
function recibiMaterial(folio) {
const notas = window.prompt('¿Recibiste el material de ' + folio + '? Si algo faltó, escríbelo aquí (opcional) y acepta:', '');
if (notas == null) return;
accionCob(null, 'cerrarRequisicion', [folio, notas], l => { pintarMisReq(l); aviso('ok', 'Requisición <b>' + esc(folio) + '</b> cerrada: material recibido.'); });
}
// ---- Compras (solo rol de compras): requisiciones → orden de compra → pago → cierre ----
let COM_PROYECTOS = [], COM = null, REQC = null, PROVEEDORES = [], ID_OC = nuevoId(), ID_PGP = nuevoId();
const CHIP_OC = { Emitida: 'aceptada', Pagada: 'firmado', Recibida: 'firmado', Cancelada: 'cancelado' };
const CAMPOS_PROV = { prvRazon: 'razonSocial', prvRfc: 'rfc', prvContacto: 'contacto', prvCorreo: 'correo', prvTel: 'telefono' };
function verListaCom() {
REQC = null;
$('comVistaReq').classList.add('oculto'); $('comVistaLista').classList.remove('oculto');
$('rcCotVisor').src = 'about:blank';
cargarRequisicionesCom();
}
function cargarRequisicionesCom() {
google.script.run.withSuccessHandler(pintarListaReqCom).withFailureHandler(mostrarError)
.listarRequisiciones($('rcBuscar').value, $('rcEstado').value);
}
$('rcBuscar').addEventListener('keydown', e => { if (e.key === 'Enter') cargarRequisicionesCom(); });
function pintarListaReqCom(lista) {
$('rcLista').innerHTML = (lista || []).length ? lista.map(r => '<tr class="fila-cat" onclick="abrirReqCom(\'' + esc(r.folio) + '\')">' +
'<td style="white-space:nowrap"><b>' + esc(r.folio) + '</b></td><td>' + esc(fechaMx(r.fecha)) + '</td><td>' + esc(r.proyectoFolio) +
'<div class="nota">' + esc(r.proyectoNombre) + '</div></td><td>' + esc(r.solicitante) + '</td><td class="num">' + esc(r.renglones) +
'</td><td>' + chipReq(r.estado) + '</td><td>' + esc(r.ocFolio) + '</td></tr>').join('')
: '<tr><td colspan="7" class="nota">No hay requisiciones con ese filtro.</td></tr>';
}
function abrirReqCom(folio) {
google.script.run.withSuccessHandler(pintarReqCom).withFailureHandler(mostrarError).getRequisicion(folio);
}
/** Detalle de una requisición (vistaRequisicion_): renglones y cotización lado a lado; orden, pago y cierre según su estado. */
function pintarReqCom(v) {
const rq = v.requisicion;
const cambio = !REQC || REQC.requisicion.folio !== rq.folio || REQC.requisicion.estado !== rq.estado;
REQC = v;
$('comVistaLista').classList.add('oculto'); $('comVistaReq').classList.remove('oculto');
$('rcTitulo').textContent = '1 · Requisición ' + rq.folio;
$('rcInfo').innerHTML = chipReq(rq.estado) + ' · <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] || '') : '') +
'<br>Pidió: ' + esc(rq.solicitante) + ' (' + esc(rq.solicitanteEmail) + ') el ' + esc(fechaMx(rq.fecha)) +
(rq.estado === 'Solicitada' && !v.puedeAutorizar ? '<br><span class="aviso-moneda">El proyecto ya no está activo con contrato firmado: no se puede autorizar (recházala o devuélvela).</span>' : '');
$('rcRenglones').innerHTML = v.renglones.map(x => '<tr><td>' + esc(x.renglon) + '</td><td>' + esc(x.descripcion) + '</td><td class="num">' +
esc(x.cantidad) + '</td><td>' + esc(x.unidad) + '</td><td>' + esc(x.notas) + '</td></tr>').join('');
$('rcEventos').innerHTML = '<div class="sat-titulo">Historial</div><div class="nota">' + v.eventos.map(e => esc(fechaMx(e.fecha)) +
' · <b>' + esc(e.estado) + '</b> · ' + esc(e.nombre) + (e.nota ? ': ' + esc(e.nota) : '')).join('<br>') + '</div>';
$('rcRevision').classList.toggle('oculto', !v.puedeRevisar);
const visor = rq.cotizacionId ? 'https://drive.google.com/file/d/' + encodeURIComponent(rq.cotizacionId) + '/preview' : 'about:blank';
if ($('rcCotVisor').src !== visor) $('rcCotVisor').src = visor;
$('rcCotLink').href = rq.cotizacionUrl || '#';
$('comModOc').classList.toggle('oculto', !v.puedeAutorizar);
if (v.puedeAutorizar && cambio) precargarOc(v);
const o = v.orden;
$('comModPago').classList.toggle('oculto', !o);
if (o) {
$('rcOrden').innerHTML =
'<div class="tile"><span>Orden de compra</span><b>' + esc(o.folio) + '</b> ' + '<span class="chip ' + (CHIP_OC[o.estado] || '') + '">' + esc(o.estado) + '</span>' +
(o.pdfUrl ? '<div class="nota"><a href="' + esc(o.pdfUrl) + '" target="_blank">Abrir PDF</a></div>' : '') + '</div>' +
'<div class="tile"><span>Total</span><b>' + dinero(o.total, o.moneda) + '</b></div>' +
'<div class="tile"><span>Proveedor</span><b style="font-size:13px">' + esc(o.proveedor) + '</b><div class="nota">' + esc(o.condicionesPago) + '</div></div>' +
'<div class="tile"><span>Entrega</span><b style="font-size:13px">' + esc(fechaMx(o.fechaEntrega)) + '</b><div class="nota">' + esc(o.lugarEntrega) + '</div>' +
(o.estado === 'Recibida' ? '<div class="nota">Recibida el ' + esc(fechaMx(o.fechaRecepcion)) + ' · ' + esc(o.recibio) + '</div>' : '') + '</div>' +
(o.estado === 'Emitida' ? '<div class="tile"><span>¿Ya no va?</span>' + botonChico('Cancelar orden', "cancelarOcDesdeReq('" + esc(o.folio) + "')") + '</div>' : '');
}
$('rcFormPago').classList.toggle('oculto', !v.puedePagar);
if (v.puedePagar && cambio) {
$('ppFecha').value = v.hoy; $('ppFecha').max = v.hoy; $('ppFecha').min = o.fecha || '';
$('ppMonto').value = o.total; $('ppMoneda').value = o.moneda; $('ppMetodo').value = 'Transferencia';
['ppTc', 'ppRef', 'ppArchivo'].forEach(id => $(id).value = '');
ID_PGP = nuevoId();
pintarTcPago();
}
$('rcPagos').innerHTML = v.pagos.length ? 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(fechaMx(p.fecha)) +
'</td><td class="num">' + dinero(p.monto, p.monedaPago) + (p.tipoCambio ? '<div class="nota">TC ' + esc(p.tipoCambio) + ' = ' + dinero(p.montoAplicado, p.monedaOc) + '</div>' : '') +
'</td><td>' + esc(p.metodo) + (p.referencia ? '<div class="nota">' + esc(p.referencia) + '</div>' : '') + '</td><td>' + esc(p.estado) + '</td><td>' +
(p.comprobanteUrl ? '<a href="' + esc(p.comprobanteUrl) + '" target="_blank">Ver</a>' : '') + '</td><td>' +
(p.estado === 'Vigente' && v.puedeCancelarPago ? '<button type="button" class="quitar" title="Cancelar pago" onclick="cancelarPagoProv(\'' + esc(p.folio) + '\')">×</button>' : '') +
'</td></tr>').join('') : '<tr><td colspan="7" class="nota">Sin pagos.</td></tr>';
$('rcCierre').classList.toggle('oculto', !v.puedeCerrar);
if (v.puedeCerrar && cambio) { $('rcMotivoCierre').value = ''; $('rcNotasCierre').value = ''; }
}
function revisarReqCom(funcion) {
const folio = REQC.requisicion.folio, rechazo = funcion === 'rechazarRequisicion';
const motivo = window.prompt((rechazo ? 'Motivo para rechazar ' + folio + ' (es final)' : 'Qué debe corregir quien pidió ' + folio) +
'. Lo verá el solicitante:');
if (motivo == null) return;
if (!motivo.trim()) { mostrarError('Escribe el motivo.'); return; }
accionCob(null, funcion, [folio, motivo], v => { pintarReqCom(v); aviso('ok', 'Requisición <b>' + esc(folio) + '</b> ' + (rechazo ? 'rechazada.' : 'devuelta para corregir.')); });
}
// 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: vienen de la requisición; del catálogo se toman clave, descripción y unidad; el costo nunca es el precio de venta
function agregarRenglonOc(r) {
const d = r || {};
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>';
if (d.descripcion) tr.querySelector('.desc').value = d.descripcion;
if (d.unidad) tr.querySelector('.uni').value = d.unidad;
if (d.cantidad != null) tr.querySelector('.cant').value = d.cantidad;
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 = !!REQC && !!REQC.moneda && $('ocMoneda').value !== REQC.moneda;
$('ocTcCaja').classList.toggle('oculto', !distinta);
if (!distinta) $('ocTc').value = '';
recalcularOc();
}
/** Formulario de la orden precargado con los renglones de la requisición (las notas van en la descripción). */
function precargarOc(v) {
recibirProveedores(v.proveedores);
Object.keys(CAMPOS_PROV).concat(['ocTc', 'ocEntrega', 'ocCondiciones', 'ocNotas']).forEach(id => $(id).value = '');
$('ocFilas').innerHTML = '';
v.renglones.forEach(r => agregarRenglonOc({ descripcion: r.descripcion + (r.notas ? ' — ' + r.notas : ''), unidad: r.unidad, cantidad: r.cantidad }));
if (!v.renglones.length) agregarRenglonOc();
$('ocMoneda').value = v.moneda || 'MXN';
$('ocIva').value = [0.16, 0.08, 0].indexOf(IVA) !== -1 ? String(IVA) : '0.16';
$('ocLugar').value = v.proyecto.ubicacion || '';
$('ocEntrega').min = v.hoy;
ID_OC = nuevoId();
$('btnOc').disabled = false; $('btnOc').textContent = 'Autorizar y emitir orden (PDF)';
pintarTcOc();
}
function guardarOc() {
if (!REQC || $('btnOc').disabled) return;
const folio = REQC.requisicion.folio;
const payload = {
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 = 'Autorizar y emitir orden (PDF)'; };
google.script.run
.withSuccessHandler(r => {
pintarReqCom(r.vista);
aviso('ok', 'Requisición <b>' + esc(r.folio) + '</b> autorizada · Orden de compra <b>' + esc(r.ocFolio) + '</b> · Total ' +
dinero(r.total, r.moneda) + ' · <a href="' + esc(r.url) + '" target="_blank">Abrir PDF</a>');
})
.withFailureHandler(err => { listo(); mostrarError(err); })
.autorizarRequisicion(folio, payload);
}
/** Pago en otra moneda que la orden: tipo de cambio obligatorio. */
function pintarTcPago() {
const distinta = !!REQC && !!REQC.orden && $('ppMoneda').value !== REQC.orden.moneda;
$('ppTcCaja').classList.toggle('oculto', !distinta);
if (!distinta) $('ppTc').value = '';
}
function registrarPagoProv() {
if (!REQC || $('btnPagoProv').disabled) return;
const archivo = $('ppArchivo').files && $('ppArchivo').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; }
const folio = REQC.requisicion.folio;
$('btnPagoProv').disabled = true; $('btnPagoProv').textContent = 'Subiendo comprobante…';
const listo = () => { $('btnPagoProv').disabled = false; $('btnPagoProv').textContent = 'Registrar pago'; };
leerArchivo(archivo).then(datos => {
google.script.run.withSuccessHandler(r => {
listo(); pintarReqCom(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); }).registrarPagoProveedor({
reqFolio: folio, fecha: $('ppFecha').value, monto: $('ppMonto').value, monedaPago: $('ppMoneda').value,
tipoCambio: $('ppTc').value, metodo: $('ppMetodo').value, referencia: $('ppRef').value,
comprobante: { nombre: archivo.name, datos: datos }, idSolicitud: ID_PGP,
});
}, err => { listo(); mostrarError(err); });
}
function cancelarPagoProv(folio) {
const motivo = window.prompt('Motivo para cancelar el pago ' + folio + ' (no se borra; la requisición vuelve a Autorizada):');
if (motivo == null) return;
if (!motivo.trim()) { mostrarError('Escribe el motivo de la cancelación.'); return; }
accionCob(null, 'cancelarPagoProveedor', [folio, motivo], v => { pintarReqCom(v); aviso('ok', 'Pago <b>' + esc(folio) + '</b> cancelado.'); });
}
function cerrarReqCom() {
const folio = REQC.requisicion.folio;
if (!$('rcMotivoCierre').value.trim()) { mostrarError('Escribe por qué cierras la requisición en lugar de quien la pidió.'); return; }
accionCob('btnCerrarCom', 'cerrarRequisicionCompras', [folio, $('rcMotivoCierre').value, $('rcNotasCierre').value],
v => { pintarReqCom(v); aviso('ok', 'Requisición <b>' + esc(folio) + '</b> cerrada · orden recibida.'); });
}
function cancelarOcDesdeReq(folioOc) {
const motivo = window.prompt('Motivo para cancelar la orden ' + folioOc + ' (no se borra; la requisición vuelve a Solicitada para autorizarla de nuevo):');
if (motivo == null) return;
if (!motivo.trim()) { mostrarError('Escribe el motivo de la cancelación.'); return; }
const folio = REQC.requisicion.folio;
accionCob(null, 'cancelarOrdenCompra', [folioOc, motivo], () => { abrirReqCom(folio); aviso('ok', 'Orden <b>' + esc(folioOc) + '</b> cancelada.'); });
}
// Órdenes por proyecto (consulta)
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);
}
/** Órdenes de un proyecto (vistaCompras_). Las acciones (pagar, cerrar) se hacen desde su requisición. */
function pintarCompras(v) {
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>' : '');
$('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>' + (o.reqFolio ? '<a href="#" onclick="abrirReqCom(\'' + esc(o.reqFolio) + '\');return false">' + esc(o.reqFolio) + '</a>' : '') +
'</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="quitar" title="Cancelar orden" onclick="cancelarOc(\'' + esc(o.folio) + '\')">×</button>' : '') +
'</td></tr>').join('') : '<tr><td colspan="9" class="nota">Este proyecto no tiene órdenes de compra.</td></tr>';
}
function cancelarOc(folio) {
const motivo = window.prompt('Motivo para cancelar la orden ' + folio + ' (no se borra; su requisición vuelve a Solicitada):');
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');
$('vistaReq').classList.toggle('oculto', t !== 'req');
$('tabReq').classList.toggle('activo', t === 'req');
$('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(); verResumenCob(); }
if (t === 'com') { cargarProyectosCom(); verListaCom(); }
if (t === 'req') { cargarProyectosReq(); cargarMisReq(); if (!$('reqFilas').querySelectorAll('tr').length) limpiarReq(); }
$('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>