mirror of
https://github.com/efrainsosa27/cotizador-helgen.git
synced 2026-10-24 05:16:48 +00:00
vistaCobranza_ manda puedeAceptar y puedeFirmar (falsos si el proyecto está Cancelado o, en el caso de aceptar, si ya está firmado); la interfaz oculta esos botones y muestra un aviso con cómo reactivarlo. El servidor ya rechazaba ambas acciones. Pruebas de cobranza e interfaz ampliadas. Publicar la API antes que la interfaz. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1110 lines
67 KiB
HTML
1110 lines
67 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="es-MX">
|
||
<head>
|
||
<base target="_top">
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<meta name="robots" content="noindex, nofollow">
|
||
<title>Cotizador Helgen</title>
|
||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
|
||
<link rel="icon" type="image/png" sizes="512x512" href="/isologo-512.png">
|
||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||
<meta name="theme-color" content="#2C3C5B">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=League+Spartan:wght@700&family=Roboto+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
/* Brand Guidelines Helgen (Canva): primarios #2C3C5B / #51F3DA, secundario #0AC5A8 */
|
||
:root { --azul:#2C3C5B; --aqua:#51F3DA; --teal:#0AC5A8; --cielo:#F2F4F7; --gris:#6B7280; --borde:#E5E7EB; }
|
||
* { box-sizing:border-box; }
|
||
body { margin:0; font-family:'Roboto Mono',monospace; background:var(--cielo); color:#1F2937; font-size:13px; }
|
||
h1, h2, .btn, nav button { font-family:'League Spartan',Arial,sans-serif; }
|
||
header { background:var(--azul); color:#fff; padding:14px 24px; display:flex; align-items:center; gap:24px; flex-wrap:wrap;
|
||
border-bottom:3px solid var(--aqua); }
|
||
header img { height:30px; display:block; }
|
||
header h1 { font-size:16px; margin:0; letter-spacing:2px; color:var(--aqua); }
|
||
nav button { background:none; border:none; color:#A9B4C8; font-size:14px; cursor:pointer; padding:6px 10px; border-radius:6px; }
|
||
nav button.activo { background:rgba(255,255,255,.12); color:#fff; }
|
||
main { max-width:1100px; margin:24px auto; padding:0 16px; }
|
||
.modulo { background:#fff; border-radius:10px; padding:20px; margin-bottom:16px; border:1px solid var(--borde); }
|
||
.modulo h2 { margin:0 0 14px; font-size:15px; color:var(--azul); border-bottom:2px solid var(--teal); padding-bottom:6px; display:inline-block; }
|
||
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; }
|
||
label { display:block; font-size:12px; color:var(--gris); margin-bottom:4px; }
|
||
input, textarea, select { width:100%; padding:8px 10px; border:1px solid var(--borde); border-radius:6px; font:inherit; }
|
||
select { background:#fff; }
|
||
input:focus, textarea:focus, select:focus { outline:2px solid var(--teal); border-color:transparent; }
|
||
table { width:100%; border-collapse:collapse; }
|
||
th { text-align:left; font-size:11px; text-transform:uppercase; color:var(--gris); padding:6px; border-bottom:1px solid var(--borde); }
|
||
td { padding:4px; }
|
||
td.num, th.num { text-align:right; white-space:nowrap; }
|
||
.btn { background:var(--teal); color:var(--azul); border:none; padding:10px 18px; border-radius:6px; font-weight:bold; cursor:pointer; font-size:14px; }
|
||
.btn:hover { background:var(--aqua); }
|
||
.btn:disabled { opacity:.5; cursor:wait; }
|
||
.btn-sec { background:#fff; color:var(--azul); border:1px solid var(--azul); }
|
||
.btn-sec:hover { background:var(--cielo); }
|
||
.quitar { background:none; border:none; color:#DC2626; cursor:pointer; font-size:18px; }
|
||
.totales { margin-left:auto; width:320px; }
|
||
.totales div { display:flex; justify-content:space-between; padding:6px 0; }
|
||
.totales .total { background:var(--azul); color:var(--aqua); padding:10px 12px; border-radius:6px; font-size:18px; font-weight:bold; }
|
||
.aviso { padding:12px 14px; border-radius:6px; margin-bottom:16px; display:none; }
|
||
.aviso.ok { background:#E6FBF7; border:1px solid var(--teal); display:block; }
|
||
.aviso.error { background:#FEE2E2; border:1px solid #DC2626; display:block; }
|
||
.oculto { display:none; }
|
||
.fila-cat { cursor:pointer; }
|
||
.fila-cat:hover td { background:var(--cielo); }
|
||
.fila-cat.editando td { background:#E6FBF7; }
|
||
.acciones { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:12px; }
|
||
.nota { font-size:12px; color:var(--gris); }
|
||
.sat { margin-top:16px; padding:14px; border:1px dashed var(--borde); border-radius:8px; background:#FAFBFC; }
|
||
.sat-titulo { font-size:12px; font-weight:bold; color:var(--azul); margin-bottom:10px; }
|
||
.sat-op { display:flex; gap:12px; align-items:center; padding:7px 10px; border:1px solid var(--borde); border-radius:6px;
|
||
margin-top:6px; cursor:pointer; background:#fff; }
|
||
.sat-op:hover { border-color:var(--teal); background:#E6FBF7; }
|
||
.sat-op b { min-width:80px; }
|
||
.sat-razon { display:block; color:#5B6B85; font-size:12px; margin-top:2px; }
|
||
/* Proyecto de la cotización (opcional) */
|
||
.proy { margin-top:16px; padding-top:14px; border-top:1px dashed var(--borde); }
|
||
.proy-fila { display:flex; gap:8px; align-items:flex-end; flex-wrap:wrap; }
|
||
.proy-fila > div { flex:1; min-width:240px; }
|
||
a { color:var(--azul); font-weight:bold; }
|
||
/* Sesión con Google (solo cuentas @helgen.tech) */
|
||
.usuario { margin-left:auto; display:flex; align-items:center; gap:12px; font-size:12px; color:#D5DCE8; }
|
||
.usuario.oculto { display:none; }
|
||
.usuario button { background:none; border:1px solid #A9B4C8; color:#fff; border-radius:6px; padding:5px 10px; cursor:pointer;
|
||
font-family:'League Spartan',Arial,sans-serif; font-size:13px; }
|
||
.usuario button:hover { border-color:var(--aqua); color:var(--aqua); }
|
||
#login { position:fixed; inset:0; z-index:10; background:var(--azul); display:flex; align-items:center; justify-content:center;
|
||
padding:16px; overflow-y:auto; }
|
||
#login.oculto { display:none; }
|
||
.login-envoltura { width:100%; max-width:380px; text-align:center; }
|
||
.login-envoltura > img { height:34px; margin-bottom:18px; }
|
||
.login-caja { background:#fff; border-radius:12px; border-top:4px solid var(--aqua); padding:28px 24px; }
|
||
.login-caja h2 { font-family:'League Spartan',Arial,sans-serif; color:var(--azul); font-size:18px; margin:0 0 6px; }
|
||
.login-caja p { color:var(--gris); margin:0 0 18px; }
|
||
#gisBoton { display:flex; justify-content:center; min-height:44px; }
|
||
#loginMsg { margin:14px 0 0; color:#B91C1C; font-size:12px; }
|
||
#loginMsg:empty { display:none; }
|
||
/* Cobranza */
|
||
.chip { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:bold; border:1px solid var(--borde); }
|
||
.chip.firmado { background:#E6FBF7; border-color:var(--teal); color:var(--azul); }
|
||
.chip.aceptada { background:#FFF7E6; border-color:#F5B544; color:#7A4B00; }
|
||
.chip.cancelado { background:#FEE2E2; border-color:#DC2626; color:#991B1B; }
|
||
.tiles { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:10px; margin-bottom:14px; }
|
||
.tile { border:1px solid var(--borde); border-radius:8px; padding:10px 12px; }
|
||
.tile span { display:block; font-size:11px; color:var(--gris); text-transform:uppercase; }
|
||
.tile b { font-size:16px; color:var(--azul); }
|
||
.barra { height:8px; background:var(--cielo); border-radius:4px; overflow:hidden; min-width:80px; }
|
||
.barra i { display:block; height:100%; background:var(--teal); }
|
||
.fila-cancelada td { color:var(--gris); text-decoration:line-through; }
|
||
.fila-cancelada td:last-child { text-decoration:none; }
|
||
.aviso-moneda { color:#B45309; font-weight:bold; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<img src="data:image/png;base64,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="tabCob" class="oculto" onclick="verTab('cob')">Cobranza</button>
|
||
</nav>
|
||
<div id="usuario" class="usuario oculto">
|
||
<span id="usuarioNombre"></span>
|
||
<button type="button" onclick="cerrarSesion()">Cerrar sesión</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Inicio de sesión: tapa la app hasta tener un token de Google de @helgen.tech -->
|
||
<div id="login">
|
||
<div class="login-envoltura">
|
||
<img id="loginLogo" alt="Helgen">
|
||
<div class="login-caja">
|
||
<h2>Cotizador Helgen</h2>
|
||
<p>Inicia sesión con tu cuenta @helgen.tech</p>
|
||
<div id="gisBoton"></div>
|
||
<p id="loginMsg"></p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<main>
|
||
<div id="aviso" class="aviso"></div>
|
||
|
||
<!-- ===== NUEVA COTIZACIÓN ===== -->
|
||
<section id="vistaNueva">
|
||
<div class="modulo">
|
||
<h2>1 · Cliente y proyecto</h2>
|
||
<div class="grid">
|
||
<div><label>Línea de negocio *</label>
|
||
<select id="marca">
|
||
<option value="helgen">Helgen</option>
|
||
<option value="energy">Helgen Energy (paneles solares)</option>
|
||
</select></div>
|
||
<div><label>Moneda *</label>
|
||
<select id="moneda" onchange="cambiarMoneda()">
|
||
<option value="MXN">MXN · pesos mexicanos</option>
|
||
<option value="USD">USD · dólares estadounidenses</option>
|
||
</select></div>
|
||
<div><label>Nombre / Contacto *</label>
|
||
<input id="cNombre" list="clientesList" autocomplete="off" placeholder="Escribe o elige un cliente">
|
||
<datalist id="clientesList"></datalist>
|
||
<div id="cInfo" class="nota" style="margin-top:4px"></div></div>
|
||
<div><label>Empresa</label><input id="cEmpresa"></div>
|
||
<div><label>RFC</label><input id="cRfc" maxlength="13"></div>
|
||
<div><label>Correo</label><input id="cEmail" type="email"></div>
|
||
<div><label>Teléfono</label><input id="cTel"></div>
|
||
<div><label>Dirección / Obra</label><input id="cDir"></div>
|
||
</div>
|
||
|
||
<!-- Proyecto (opcional): vacío = "Sin proyecto" -->
|
||
<div class="proy">
|
||
<div class="proy-fila">
|
||
<div><label>Proyecto (opcional)</label>
|
||
<input id="proyecto" list="proyectosList" autocomplete="off" placeholder="Sin proyecto · busca por folio, cliente o nombre">
|
||
<datalist id="proyectosList"></datalist></div>
|
||
<button type="button" class="btn btn-sec" onclick="abrirNuevoProyecto()">+ Nuevo proyecto</button>
|
||
</div>
|
||
<div id="proyInfo" class="nota" style="margin-top:4px"></div>
|
||
<div id="nuevoProy" class="sat oculto">
|
||
<div class="sat-titulo">Nuevo proyecto · cliente: <span id="npCliente"></span></div>
|
||
<div class="grid">
|
||
<div><label>Nombre del proyecto *</label><input id="npNombre" maxlength="80" placeholder="Ej. Techo solar planta 2"></div>
|
||
<div><label>Ubicación</label><input id="npUbicacion" maxlength="200" placeholder="Ciudad, dirección u obra"></div>
|
||
</div>
|
||
<div class="acciones">
|
||
<button type="button" id="btnProy" class="btn" onclick="crearProyecto()">Crear proyecto</button>
|
||
<button type="button" class="btn btn-sec" onclick="cerrarNuevoProyecto()">Cancelar</button>
|
||
<span class="nota">El cliente es el de "Nombre / Contacto": elígelo de la lista o escribe uno nuevo.</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modulo">
|
||
<h2>2 · Conceptos</h2>
|
||
<datalist id="catalogo"></datalist>
|
||
<datalist id="claves"></datalist>
|
||
<table>
|
||
<thead><tr>
|
||
<th style="width:100px">Clave</th><th>Descripción</th><th style="width:80px">Unidad</th>
|
||
<th class="num" style="width:90px">Cant.</th><th class="num" style="width:130px">P. Unitario</th>
|
||
<th class="num" style="width:130px">Importe</th><th style="width:30px"></th>
|
||
</tr></thead>
|
||
<tbody id="filas"></tbody>
|
||
</table>
|
||
<button class="btn btn-sec" style="margin-top:10px" onclick="agregarFila()">+ Agregar concepto</button>
|
||
<div id="notaMoneda" class="nota" style="margin-top:8px"></div>
|
||
</div>
|
||
|
||
<div class="modulo">
|
||
<h2>3 · Resumen</h2>
|
||
<div style="display:flex; gap:24px; flex-wrap:wrap;">
|
||
<div style="flex:1; min-width:260px;">
|
||
<label>Notas para el cliente (opcional)</label>
|
||
<textarea id="notas" rows="4"></textarea>
|
||
</div>
|
||
<div class="totales">
|
||
<div><span>Subtotal</span><span id="tSub">$0.00</span></div>
|
||
<div><span id="lblIva">IVA (16%)</span><span id="tIva">$0.00</span></div>
|
||
<div class="total"><span id="lblTot">TOTAL MXN</span><span id="tTot">$0.00</span></div>
|
||
<button id="btnGuardar" class="btn" style="width:100%; margin-top:12px" onclick="guardar()">Guardar y generar PDF</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ===== HISTORIAL ===== -->
|
||
<section id="vistaHist" class="oculto">
|
||
<div class="modulo">
|
||
<h2>Historial de cotizaciones</h2>
|
||
<input id="buscar" placeholder="Buscar por folio, cliente, empresa o proyecto…" oninput="buscarDebounce()" style="margin-bottom:12px">
|
||
<table>
|
||
<thead><tr><th>Folio</th><th>Fecha</th><th>Cliente</th><th>Empresa</th><th class="num">Total</th><th>Vendedor</th><th>Proyecto</th><th>PDF</th></tr></thead>
|
||
<tbody id="hist"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ===== CATÁLOGO ===== -->
|
||
<section id="vistaCat" class="oculto">
|
||
<div class="modulo">
|
||
<h2 id="tituloProd">Agregar producto</h2>
|
||
<div class="grid">
|
||
<div><label>Clave *</label><input id="pClave" maxlength="30" placeholder="Ej. CAR-7KW"></div>
|
||
<div style="grid-column:span 2"><label>Descripción *</label><input id="pDesc"></div>
|
||
<div><label>Unidad</label><input id="pUni" value="PZA"></div>
|
||
<div><label>Precio unitario sin IVA (MXN) *</label><input id="pPrecio" type="number" min="0" step="0.01" style="text-align:right"></div>
|
||
</div>
|
||
|
||
<div class="sat">
|
||
<div class="sat-titulo">Datos para facturación (SAT) · uso interno de contabilidad, no aparecen en el PDF</div>
|
||
<div class="grid">
|
||
<div><label>Clave SAT (producto/servicio)</label><input id="pSat" maxlength="8" inputmode="numeric" placeholder="8 dígitos"></div>
|
||
<div><label>Unidad SAT</label><input id="pUniSat" list="unidadesSat" maxlength="3" placeholder="Ej. H87, E48"><datalist id="unidadesSat"></datalist></div>
|
||
<div style="grid-column:span 2"><label>Buscar en el catálogo del SAT</label>
|
||
<div style="display:flex; gap:8px"><input id="satBusca" placeholder="Se toma de la descripción; puedes cambiar las palabras">
|
||
<button class="btn btn-sec" onclick="sugerirSat()" style="white-space:nowrap">Sugerir clave SAT</button></div></div>
|
||
</div>
|
||
<div id="satRes"></div>
|
||
</div>
|
||
<div class="acciones">
|
||
<button id="btnProd" class="btn" onclick="guardarProd()">Guardar en catálogo</button>
|
||
<button class="btn btn-sec" onclick="limpiarProd()">Nuevo / Cancelar</button>
|
||
<span class="nota">Haz clic en un producto de la lista para editarlo.</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modulo">
|
||
<h2 id="tituloLista">Productos</h2>
|
||
<div class="acciones" style="margin:0 0 12px">
|
||
<input id="buscarCat" placeholder="Buscar por clave o descripción…" oninput="pintarCatalogo()" style="flex:1; min-width:220px">
|
||
<span id="tcCat" class="nota"></span>
|
||
<a id="linkCat" href="#" target="_blank" class="oculto">Abrir en Google Sheets</a>
|
||
</div>
|
||
<table>
|
||
<thead><tr><th style="width:130px">Clave</th><th>Descripción</th><th style="width:80px">Unidad</th><th class="num" style="width:150px">Precio sin IVA</th><th class="num" style="width:150px">Precio USD</th><th style="width:110px">Clave SAT</th></tr></thead>
|
||
<tbody id="listaCat"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ===== COBRANZA (solo rol de cobranza; el servidor lo vuelve a revisar en cada llamada) ===== -->
|
||
<section id="vistaCob" class="oculto">
|
||
<div class="modulo">
|
||
<h2>1 · Proyecto y contrato</h2>
|
||
<div class="proy-fila">
|
||
<div><label>Proyecto</label>
|
||
<input id="cobProyecto" list="cobProyectosList" autocomplete="off" placeholder="Busca por folio, cliente o nombre">
|
||
<datalist id="cobProyectosList"></datalist></div>
|
||
<button type="button" class="btn btn-sec" onclick="cargarProyectosCob()">Actualizar lista</button>
|
||
</div>
|
||
<div id="cobInfo" class="nota" style="margin-top:6px">Elige un proyecto para ver su contrato, esquema y pagos.</div>
|
||
<div id="cobDetalle" class="oculto">
|
||
<table style="margin-top:12px">
|
||
<thead><tr><th>Cotización</th><th>Fecha</th><th>Línea</th><th>Moneda</th><th class="num">Subtotal</th><th class="num">Total con IVA</th><th>PDF</th><th></th></tr></thead>
|
||
<tbody id="cobCots"></tbody>
|
||
</table>
|
||
<div id="cobContrato" class="sat"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="cobModEsquema" class="modulo oculto">
|
||
<h2>2 · Esquema de pagos</h2>
|
||
<div id="cobEsqNota" class="nota" style="margin-bottom:8px"></div>
|
||
<table>
|
||
<thead><tr><th style="width:30px">#</th><th>Etapa</th><th class="num" style="width:110px">%</th><th class="num">Monto</th>
|
||
<th class="num">Pagado</th><th class="num">Saldo</th><th style="width:120px">Avance</th><th style="width:30px"></th></tr></thead>
|
||
<tbody id="cobEsquema"></tbody>
|
||
</table>
|
||
<div id="cobEsqAcciones" class="acciones"></div>
|
||
</div>
|
||
|
||
<div id="cobModPagos" class="modulo oculto">
|
||
<h2>3 · Pagos</h2>
|
||
<div id="cobResumen" class="tiles"></div>
|
||
<div id="cobFormPago" class="sat oculto">
|
||
<div class="sat-titulo">Registrar pago · el comprobante es obligatorio (PDF o imagen, máx. 10 MB)</div>
|
||
<div class="grid">
|
||
<div><label>Fecha del pago *</label><input id="pgFecha" type="date"></div>
|
||
<div><label>Monto *</label><input id="pgMonto" type="number" min="0" step="0.01" style="text-align:right"></div>
|
||
<div><label>Moneda del pago *</label><select id="pgMoneda" onchange="pintarTipoCambio()"><option>MXN</option><option>USD</option></select></div>
|
||
<div id="pgTcCaja" class="oculto"><label>Tipo de cambio del día (pesos por dólar) *</label><input id="pgTc" type="number" min="0" step="0.0001" style="text-align:right"></div>
|
||
<div><label>Método *</label><select id="pgMetodo"></select></div>
|
||
<div><label>Referencia</label><input id="pgRef" maxlength="100" placeholder="Clave de rastreo, folio o núm. de cheque"></div>
|
||
<div><label>Etapa *</label><select id="pgEtapa"></select></div>
|
||
<div><label>Comprobante *</label><input id="pgArchivo" type="file" accept="application/pdf,image/jpeg,image/png,image/heic,.pdf,.jpg,.jpeg,.png,.heic"></div>
|
||
</div>
|
||
<div class="acciones">
|
||
<button type="button" id="btnPago" class="btn" onclick="registrarPago()">Registrar pago</button>
|
||
<span class="nota">Si el pago excede su etapa, el excedente pasa a la siguiente.</span>
|
||
</div>
|
||
</div>
|
||
<div id="cobSinPagos" class="nota"></div>
|
||
<table style="margin-top:12px">
|
||
<thead><tr><th>Folio</th><th>Fecha</th><th class="num">Monto</th><th class="num">Aplicado</th><th>Método / referencia</th><th>Etapa</th><th>Registró</th><th>Comprobante</th><th></th></tr></thead>
|
||
<tbody id="cobPagos"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<script id="api-web">
|
||
// Fuera de Apps Script (Cloudflare) no existe google.script.run: esta capa lo imita con fetch al doPost.
|
||
// Cada llamada lleva el ID token de Google; el servidor lo verifica (aud, dominio) antes de ejecutar nada.
|
||
// Dentro de Apps Script no hace nada y se usa el original.
|
||
(function () {
|
||
if (window.google && google.script && google.script.run) return;
|
||
const API_URL = 'https://script.google.com/macros/s/AKfycbwBjF7ra_4O8INsxBq0eh-4r-BlSsJ4LLxcJgVCPRpx1U1a-Pz1CUSdYtvj_Mh0S6y2/exec';
|
||
// Client ID de OAuth (no es secreto). Debe ser el mismo que CLIENT_ID_GOOGLE en Code.js (proyecto de Apps Script 1GsH8Dja…).
|
||
const CLIENT_ID_GOOGLE = '884526916417-cn5fh45io0nnpvlad2439thkpius4063.apps.googleusercontent.com';
|
||
const DOMINIO = 'helgen.tech';
|
||
const CLAVE_SESION = 'cotizadorHelgenSesion';
|
||
const $ = id => document.getElementById(id);
|
||
|
||
// ---- Sesión ----
|
||
let token = null, expira = 0, gisListo = false;
|
||
const pendientes = []; // llamadas que esperan a que se inicie sesión
|
||
|
||
// Solo para mostrar el nombre y saber cuándo expira; quien decide es el servidor al verificar el token
|
||
function leerToken(t) {
|
||
const b = t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
||
const bytes = Uint8Array.from(atob(b + '==='.slice((b.length + 3) % 4)), c => c.charCodeAt(0));
|
||
return JSON.parse(new TextDecoder().decode(bytes));
|
||
}
|
||
const vigente = () => !!token && expira * 1000 > Date.now() + 60000; // margen de 1 min
|
||
|
||
function pintarUsuario(datos) {
|
||
const nombre = [datos.given_name, datos.family_name].filter(Boolean).join(' ') || datos.name || datos.email;
|
||
$('usuarioNombre').textContent = nombre;
|
||
$('usuarioNombre').title = datos.email || '';
|
||
$('usuario').classList.remove('oculto');
|
||
}
|
||
|
||
function guardarSesion(t) {
|
||
const datos = leerToken(t);
|
||
token = t; expira = Number(datos.exp) || 0;
|
||
try { sessionStorage.setItem(CLAVE_SESION, t); } catch (e) {}
|
||
pintarUsuario(datos);
|
||
}
|
||
|
||
function olvidarSesion() {
|
||
token = null; expira = 0;
|
||
try { sessionStorage.removeItem(CLAVE_SESION); } catch (e) {}
|
||
$('usuario').classList.add('oculto');
|
||
}
|
||
|
||
function mostrarLogin(mensaje) {
|
||
const yaVisible = !$('login').classList.contains('oculto');
|
||
$('loginMsg').textContent = mensaje || '';
|
||
$('login').classList.remove('oculto');
|
||
if (gisListo && !yaVisible) google.accounts.id.prompt(); // One Tap; el botón queda como respaldo
|
||
}
|
||
|
||
function pedirSesion(mensaje) {
|
||
olvidarSesion();
|
||
mostrarLogin(mensaje);
|
||
}
|
||
|
||
// Respuesta de Google Identity Services al elegir cuenta
|
||
function alIniciarSesion(resp) {
|
||
let datos;
|
||
try { datos = leerToken(resp.credential); } catch (e) { mostrarLogin('No se pudo leer la sesión. Intenta de nuevo.'); return; }
|
||
if (datos.hd !== DOMINIO) { mostrarLogin('Solo se permiten cuentas @' + DOMINIO + '.'); return; }
|
||
guardarSesion(resp.credential);
|
||
$('login').classList.add('oculto');
|
||
pendientes.splice(0).forEach(f => f());
|
||
}
|
||
|
||
window.cerrarSesion = function () {
|
||
if (gisListo) google.accounts.id.disableAutoSelect();
|
||
pedirSesion('Cerraste sesión.');
|
||
};
|
||
|
||
// ---- Llamadas al servidor ----
|
||
function llamar(funcion, args, alExito, alFallar, esReintento) {
|
||
// El error lleva el codigo del servidor (p. ej. 'PROYECTO') para que la interfaz reaccione
|
||
const fallar = (msg, codigo) => {
|
||
const e = new Error(msg); if (codigo) e.codigo = codigo;
|
||
if (alFallar) alFallar(e); else console.error(msg);
|
||
};
|
||
if (!/^https:\/\//.test(API_URL)) { fallar('Falta configurar la URL del servidor (API_URL).'); return; }
|
||
if (!vigente()) {
|
||
pendientes.push(() => llamar(funcion, args, alExito, alFallar, esReintento));
|
||
if (token) pedirSesion('Tu sesión expiró. Vuelve a iniciar sesión.'); else mostrarLogin();
|
||
return;
|
||
}
|
||
// text/plain: es una "solicitud simple" y el navegador no hace la consulta CORS previa (Apps Script no la responde)
|
||
fetch(API_URL, { method: 'POST', headers: { 'Content-Type': 'text/plain;charset=utf-8' },
|
||
body: JSON.stringify({ funcion: funcion, args: args, token: token }) })
|
||
.then(r => {
|
||
if (!r.ok) return { ok: false, error: 'El servidor respondió ' + r.status + '.' };
|
||
return r.json().catch(() => ({ ok: false, error: 'Respuesta inválida del servidor.' }));
|
||
}, err => ({ ok: false, error: 'No se pudo conectar con el servidor. Revisa tu conexión e intenta de nuevo. (' + err.message + ')' }))
|
||
.then(res => {
|
||
if (res && res.ok) { if (alExito) alExito(res.datos); return; }
|
||
if (res && res.codigo === 'SESION') {
|
||
// Una vez: se pide sesión de nuevo y la llamada se repite al entrar (guardar es idempotente por idSolicitud)
|
||
if (!esReintento) {
|
||
pendientes.push(() => llamar(funcion, args, alExito, alFallar, true));
|
||
pedirSesion(res.error);
|
||
return;
|
||
}
|
||
pedirSesion(res.error);
|
||
}
|
||
fallar((res && res.error) || 'Respuesta inválida del servidor.', res && res.codigo);
|
||
});
|
||
}
|
||
|
||
// Igual que google.script.run: withSuccessHandler / withFailureHandler encadenables y luego la función
|
||
function corredor(alExito, alFallar) {
|
||
return new Proxy({}, {
|
||
get(_, nombre) {
|
||
if (nombre === 'withSuccessHandler') return f => corredor(f, alFallar);
|
||
if (nombre === 'withFailureHandler') return f => corredor(alExito, f);
|
||
if (typeof nombre !== 'string' || nombre === 'then') return undefined;
|
||
return (...args) => llamar(nombre, args, alExito, alFallar);
|
||
},
|
||
});
|
||
}
|
||
const script = { run: corredor(null, null) };
|
||
window.google = window.google || {};
|
||
google.script = script;
|
||
|
||
// ---- Google Identity Services (se carga con el <script> de accounts.google.com, abajo) ----
|
||
window.iniciarGis = function () {
|
||
if (!google.script) google.script = script; // por si la biblioteca de Google reemplazó window.google
|
||
if (CLIENT_ID_GOOGLE.indexOf('PEGAR_AQUI') === 0) { mostrarLogin('Falta configurar el Client ID de Google (CLIENT_ID_GOOGLE).'); return; }
|
||
google.accounts.id.initialize({
|
||
client_id: CLIENT_ID_GOOGLE,
|
||
callback: alIniciarSesion,
|
||
hd: DOMINIO, // Google solo ofrece cuentas del dominio (el servidor lo vuelve a revisar)
|
||
auto_select: true, // si ya entró antes, vuelve a entrar sin clics
|
||
cancel_on_tap_outside: false,
|
||
use_fedcm_for_prompt: true,
|
||
context: 'signin',
|
||
});
|
||
google.accounts.id.renderButton($('gisBoton'), {
|
||
theme: 'outline', size: 'large', shape: 'pill', text: 'signin_with', locale: 'es-419', width: 280,
|
||
});
|
||
gisListo = true;
|
||
if (!$('login').classList.contains('oculto')) google.accounts.id.prompt();
|
||
};
|
||
window.errorGis = function () {
|
||
mostrarLogin('No se pudo cargar el inicio de sesión de Google. Revisa tu conexión y recarga la página.');
|
||
};
|
||
|
||
// Al cargar: el logo de la pantalla de entrada es el mismo del encabezado; se reutiliza una sesión vigente
|
||
$('loginLogo').src = document.querySelector('header img').src;
|
||
try {
|
||
const guardado = sessionStorage.getItem(CLAVE_SESION);
|
||
if (guardado) guardarSesion(guardado);
|
||
} catch (e) { olvidarSesion(); }
|
||
if (vigente()) $('login').classList.add('oculto'); else olvidarSesion();
|
||
})();
|
||
</script>
|
||
<script src="https://accounts.google.com/gsi/client" async onload="iniciarGis()" onerror="errorGis()"></script>
|
||
|
||
<script>
|
||
let IVA = 0.16, CATALOGO = [], CLIENTES = [], PROYECTOS = [], PERMISOS = {};
|
||
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
|
||
// ---- Arranque ----
|
||
google.script.run.withSuccessHandler(d => {
|
||
IVA = d.ivaTasa; CATALOGO = d.catalogo; TC = d.tipoCambio || TC;
|
||
recibirClientes(d.clientes);
|
||
recibirProyectos(d.proyectos);
|
||
// La pestaña Cobranza solo se muestra con permiso; el servidor lo revisa en cada llamada
|
||
PERMISOS = d.permisos || {};
|
||
$('tabCob').classList.toggle('oculto', !PERMISOS.cobranza);
|
||
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
|
||
pintarDatalists();
|
||
agregarFila();
|
||
}).withFailureHandler(mostrarError).getDatosIniciales();
|
||
|
||
// ---- Conceptos ----
|
||
function agregarFila() {
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML =
|
||
'<td><input class="clave" list="claves"></td>' +
|
||
'<td><input class="desc" list="catalogo" placeholder="Escribe o elige del catálogo"></td>' +
|
||
'<td><input class="uni" value="PZA"></td>' +
|
||
'<td><input class="cant" type="number" min="0" step="any" value="1" style="text-align:right"></td>' +
|
||
'<td><input class="pu" type="number" min="0" step="0.01" value="0" style="text-align:right"></td>' +
|
||
'<td class="num imp">$0.00</td>' +
|
||
'<td><button class="quitar" title="Quitar">×</button></td>';
|
||
// Al elegir por descripción o por clave se llenan clave, unidad y precio desde el catálogo
|
||
const aplicar = p => {
|
||
if (!p) return;
|
||
tr.querySelector('.clave').value = p.clave; tr.querySelector('.desc').value = p.descripcion;
|
||
tr.querySelector('.uni').value = p.unidad;
|
||
// 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('');
|
||
}
|
||
|
||
function cargarCatalogo() {
|
||
$('listaCat').innerHTML = '<tr><td colspan="6">Cargando…</td></tr>';
|
||
google.script.run.withSuccessHandler(recibirCatalogo).withFailureHandler(mostrarError).getCatalogo();
|
||
}
|
||
|
||
function recibirCatalogo(r) {
|
||
CATALOGO = r.productos; TC = r.tipoCambio || TC;
|
||
$('tcCat').textContent = TC && TC.valor ? 'Tipo de cambio: ' + TC.valor.toFixed(4) + ' MXN/USD · ' +
|
||
(TC.fuente === 'manual' ? 'capturado a mano' : 'Google Finance') : 'Sin tipo de cambio (revisa la pestaña "Tipo de cambio" del catálogo)';
|
||
$('linkCat').href = r.url; $('linkCat').classList.remove('oculto');
|
||
if (r.unidadesSat && r.unidadesSat.length) {
|
||
$('unidadesSat').innerHTML = r.unidadesSat.map(u => '<option value="' + esc(u[0]) + '" label="' + esc(u[1]) + '">').join('');
|
||
}
|
||
pintarDatalists(); pintarCatalogo();
|
||
}
|
||
|
||
function pintarCatalogo() {
|
||
const q = $('buscarCat').value.toLowerCase().trim();
|
||
const lista = CATALOGO.filter(p => !q || (p.clave + ' ' + p.descripcion).toLowerCase().includes(q));
|
||
$('tituloLista').textContent = 'Productos (' + CATALOGO.length + ')';
|
||
$('listaCat').innerHTML = lista.length ? lista.map(p =>
|
||
'<tr class="fila-cat' + (p.clave === CLAVE_EDITANDO ? ' editando' : '') + '" data-clave="' + esc(p.clave) + '">' +
|
||
'<td><b>' + esc(p.clave) + '</b></td><td>' + esc(p.descripcion) + '</td><td>' + esc(p.unidad) + '</td>' +
|
||
'<td class="num">' + mxn(p.precio) + '</td>' +
|
||
'<td class="num">' + (p.precioUsd != null ? dinero(p.precioUsd, 'USD') : '<span class="nota">—</span>') + '</td>' +
|
||
'<td>' + (p.claveSat ? esc(p.claveSat) + (p.unidadSat ? ' · ' + esc(p.unidadSat) : '') : '<span class="nota">Pendiente</span>') + '</td></tr>').join('')
|
||
: '<tr><td colspan="6">' + (CATALOGO.length ? 'Sin resultados.' : 'El catálogo está vacío. Agrega el primer producto arriba.') + '</td></tr>';
|
||
$('listaCat').querySelectorAll('.fila-cat').forEach(tr => tr.onclick = () => editarProd(tr.dataset.clave));
|
||
}
|
||
|
||
function editarProd(clave) {
|
||
const p = buscarClave(clave); if (!p) return;
|
||
CLAVE_EDITANDO = p.clave;
|
||
$('pClave').value = p.clave; $('pDesc').value = p.descripcion; $('pUni').value = p.unidad; $('pPrecio').value = p.precio;
|
||
$('pSat').value = p.claveSat || ''; $('pUniSat').value = p.unidadSat || ''; $('satBusca').value = ''; $('satRes').innerHTML = '';
|
||
$('tituloProd').textContent = 'Editar producto ' + p.clave;
|
||
$('btnProd').textContent = 'Guardar cambios';
|
||
pintarCatalogo(); window.scrollTo(0, 0); $('pDesc').focus();
|
||
}
|
||
|
||
function limpiarProd() {
|
||
CLAVE_EDITANDO = '';
|
||
['pClave','pDesc','pPrecio','pSat','pUniSat','satBusca'].forEach(id => $(id).value = ''); $('pUni').value = 'PZA';
|
||
$('satRes').innerHTML = '';
|
||
$('tituloProd').textContent = 'Agregar producto';
|
||
$('btnProd').textContent = 'Guardar en catálogo'; $('btnProd').disabled = false;
|
||
pintarCatalogo();
|
||
}
|
||
|
||
function guardarProd() {
|
||
if ($('btnProd').disabled) return;
|
||
const prod = { clave:$('pClave').value, descripcion:$('pDesc').value, unidad:$('pUni').value,
|
||
precio:$('pPrecio').value, claveSat:$('pSat').value, unidadSat:$('pUniSat').value,
|
||
claveOriginal:CLAVE_EDITANDO };
|
||
const editando = !!CLAVE_EDITANDO;
|
||
$('btnProd').disabled = true;
|
||
google.script.run.withSuccessHandler(r => {
|
||
recibirCatalogo(r);
|
||
aviso('ok', 'Producto <b>' + esc(r.clave) + '</b> ' + (editando ? 'actualizado' : 'agregado') + ' en el catálogo.');
|
||
limpiarProd();
|
||
}).withFailureHandler(err => { $('btnProd').disabled = false; mostrarError(err); }).guardarProducto(prod);
|
||
}
|
||
|
||
// ---- Sugerencias de clave SAT ----
|
||
function sugerirSat() {
|
||
const q = $('satBusca').value.trim() || $('pDesc').value.trim();
|
||
if (!q) { aviso('error', 'Escribe la descripción del producto o palabras para buscar en el catálogo del SAT.'); return; }
|
||
$('satBusca').value = q;
|
||
$('satRes').innerHTML = '<div class="nota" style="margin-top:8px">Buscando en el catálogo del SAT…</div>';
|
||
google.script.run.withSuccessHandler(lista => {
|
||
$('satRes').innerHTML = lista.length
|
||
? '<div class="nota" style="margin-top:10px">' + (lista[0].razon ? 'Sugeridas con IA. ' : '') +
|
||
'Elige la que mejor describa el producto (contabilidad debe validarla):</div>' +
|
||
lista.map(o => '<div class="sat-op" data-clave="' + esc(o.clave) + '"><b>' + esc(o.clave) + '</b><span>' + esc(o.descripcion) +
|
||
(o.razon ? '<small class="sat-razon">' + esc(o.razon) + '</small>' : '') + '</span></div>').join('')
|
||
: '<div class="nota" style="margin-top:8px">Sin coincidencias. Prueba con otras palabras (p. ej. el tipo de producto o "servicio de …").</div>';
|
||
$('satRes').querySelectorAll('.sat-op').forEach(d => d.onclick = () => {
|
||
$('pSat').value = d.dataset.clave; $('satRes').innerHTML = '';
|
||
});
|
||
}).withFailureHandler(err => { $('satRes').innerHTML = ''; mostrarError(err); }).sugerirClavesSat(q);
|
||
}
|
||
$('satBusca').addEventListener('keydown', e => { if (e.key === 'Enter') sugerirSat(); });
|
||
|
||
|
||
// ---- Cobranza (solo rol de cobranza) ----
|
||
let COB_PROYECTOS = [], COB = null, ESQ = [], ID_PAGO = nuevoId();
|
||
const MARCA_TXT = { helgen: 'Helgen', energy: 'Helgen Energy' };
|
||
const etiquetaCob = p => p.folio + ' — ' + p.cliente + ' — ' + p.nombre;
|
||
|
||
function cargarProyectosCob() {
|
||
google.script.run.withSuccessHandler(lista => {
|
||
COB_PROYECTOS = lista || [];
|
||
$('cobProyectosList').innerHTML = COB_PROYECTOS.map(p =>
|
||
'<option value="' + esc(etiquetaCob(p)) + '" label="' + esc((p.contrato ? 'Contrato: ' + p.contrato : 'Sin aceptada') +
|
||
(p.estado !== 'Activo' ? ' · ' + p.estado : '')) + '">').join('');
|
||
}).withFailureHandler(mostrarError).listarProyectosCobranza('');
|
||
}
|
||
|
||
function proyectoCobElegido() {
|
||
const folio = ($('cobProyecto').value.trim().match(/^PRY-\d{4}-\d{4}/i) || [''])[0].toUpperCase();
|
||
return (folio && COB_PROYECTOS.find(p => p.folio === folio)) || null;
|
||
}
|
||
$('cobProyecto').addEventListener('change', () => { const p = proyectoCobElegido(); if (p) abrirProyectoCob(p.folio); });
|
||
|
||
function abrirProyectoCob(folio) {
|
||
$('cobInfo').textContent = 'Cargando…';
|
||
google.script.run.withSuccessHandler(pintarCobranza).withFailureHandler(mostrarError).getCobranzaProyecto(folio);
|
||
}
|
||
|
||
/** Pinta todo a partir de la respuesta del servidor (vistaCobranza_). */
|
||
function pintarCobranza(v) {
|
||
COB = v;
|
||
const c = v.contrato, r = v.resumen, firmado = c && c.estado === 'Firmado';
|
||
const mon = c ? c.moneda : 'MXN';
|
||
$('cobProyecto').value = etiquetaCob(v.proyecto);
|
||
$('cobInfo').innerHTML = '<b>' + esc(v.proyecto.folio) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' + esc(v.proyecto.nombre) +
|
||
(v.proyecto.estado !== 'Activo' ? ' · <span class="chip cancelado">' + esc(v.proyecto.estado) + '</span>' : '') +
|
||
' · Contrato: ' + (c ? '<span class="chip ' + (firmado ? 'firmado' : 'aceptada') + '">' + esc(c.estado) + '</span>' : 'sin cotización aceptada');
|
||
$('cobDetalle').classList.remove('oculto');
|
||
|
||
// Cotizaciones del proyecto: una sola aceptada
|
||
$('cobCots').innerHTML = v.cotizaciones.length ? v.cotizaciones.map(q => {
|
||
const esAceptada = c && c.cotizacion === q.folio;
|
||
return '<tr><td><b>' + esc(q.folio) + '</b></td><td>' + esc(q.fecha) + '</td><td>' + esc(MARCA_TXT[q.marca]) + '</td><td>' + esc(q.moneda) +
|
||
'</td><td class="num">' + dinero(q.subtotal, q.moneda) + '</td><td class="num">' + dinero(q.total, q.moneda) + '</td><td>' +
|
||
(q.pdf ? '<a href="' + esc(q.pdf) + '" target="_blank">Ver</a>' : '') + '</td><td>' +
|
||
(esAceptada ? '<span class="chip ' + (firmado ? 'firmado' : 'aceptada') + '">Aceptada</span>'
|
||
: !v.puedeAceptar ? '' : '<button type="button" class="btn btn-sec" onclick="aceptarCot(\'' + esc(q.folio) + '\')">Marcar aceptada</button>') +
|
||
'</td></tr>';
|
||
}).join('') : '<tr><td colspan="8" class="nota">Este proyecto no tiene cotizaciones.</td></tr>';
|
||
|
||
// Contrato
|
||
let h = '';
|
||
const cancelado = v.proyecto.estado === 'Cancelado';
|
||
const avisoCancelado = '<div class="nota aviso-moneda" style="margin-top:6px">Proyecto cancelado: no se puede marcar la cotización ' +
|
||
'aceptada ni registrar el contrato firmado. Para reactivarlo, cambia su Estado a Activo en la hoja Proyectos.</div>';
|
||
if (!c) h = cancelado ? avisoCancelado : '<span class="nota">Marca la cotización aceptada para definir el monto contratado.</span>';
|
||
else {
|
||
h = '<div class="sat-titulo">Monto contratado: ' + dinero(c.total, mon) + ' (con IVA) · subtotal ' + dinero(c.subtotal, mon) +
|
||
' · ' + esc(MARCA_TXT[c.marca]) + '</div>';
|
||
if (firmado) {
|
||
h += '<div>Contrato firmado el <b>' + esc(c.fechaFirma) + '</b> · registró ' + esc(c.firmadoPor) +
|
||
'. La cotización aceptada, el monto y el esquema ya no se pueden cambiar.</div>';
|
||
if (v.puedeDesbloquear) {
|
||
h += '<div style="margin-top:10px"><label>Motivo del desbloqueo (queda registrado) *</label>' +
|
||
'<input id="cobMotivo" maxlength="500" placeholder="Ej. Se firmó con la cotización equivocada"></div>' +
|
||
'<div class="acciones"><button type="button" class="btn btn-sec" id="btnDesbloquear" onclick="desbloquearCob()">Desbloquear contrato</button></div>';
|
||
}
|
||
} else if (!v.puedeFirmar) {
|
||
h += cancelado ? avisoCancelado : '';
|
||
} else {
|
||
h += '<div class="proy-fila" style="margin-top:8px"><div style="max-width:220px"><label>Fecha de firma *</label>' +
|
||
'<input id="cobFechaFirma" type="date" max="' + esc(v.hoy) + '" value="' + esc(v.hoy) + '"></div>' +
|
||
'<button type="button" class="btn" id="btnFirmar" onclick="firmarCob()">Contrato firmado</button></div>' +
|
||
'<div class="nota" style="margin-top:6px">Al firmar se bloquean la cotización aceptada, el monto contratado y el esquema de pagos. ' +
|
||
'Después ya no se podrán agregar cotizaciones al proyecto.</div>';
|
||
}
|
||
}
|
||
$('cobContrato').innerHTML = h;
|
||
|
||
// Esquema
|
||
$('cobModEsquema').classList.toggle('oculto', !c);
|
||
ESQ = (v.esquema || []).map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje }));
|
||
$('cobEsqNota').textContent = !c ? '' : firmado ? 'Esquema bloqueado por el contrato firmado.'
|
||
: v.esquemaPideMotivo ? 'Helgen Energy: solo el administrador cambia este esquema; el motivo queda registrado. Deben sumar 100%.'
|
||
: v.esquemaEditable ? 'Helgen: puedes cambiar nombres, porcentajes y número de etapas hasta firmar el contrato. Deben sumar 100%.'
|
||
: 'Helgen Energy: esquema 60% / 30% / 10%. Solo el administrador lo puede cambiar.';
|
||
pintarEsquema();
|
||
|
||
// Pagos
|
||
$('cobModPagos').classList.toggle('oculto', !c);
|
||
$('cobResumen').innerHTML = r ? [['Contratado (con IVA)', dinero(r.contratado, mon)], ['Subtotal (referencia)', dinero(r.subtotal, mon)],
|
||
['Pagado', dinero(r.pagado, mon)], ['Saldo', dinero(r.saldo, mon)], ['Avance', r.avance + '%']]
|
||
.map(([k, x]) => '<div class="tile"><span>' + k + '</span><b>' + x + '</b></div>').join('') : '';
|
||
$('cobFormPago').classList.toggle('oculto', !v.puedePagar);
|
||
$('cobSinPagos').textContent = c && !v.puedePagar ? 'Los pagos se registran cuando el contrato está firmado.' : '';
|
||
if (v.puedePagar) {
|
||
$('pgFecha').max = v.hoy; if (!$('pgFecha').value) $('pgFecha').value = v.hoy;
|
||
$('pgMoneda').value = mon;
|
||
$('pgMetodo').innerHTML = (v.metodos || []).map(m => '<option>' + esc(m) + '</option>').join('');
|
||
$('pgEtapa').innerHTML = (r ? r.etapas : []).map((e, i) => '<option value="' + (i + 1) + '">' + (i + 1) + ' · ' + esc(e.etapa) +
|
||
' (saldo ' + dinero(e.saldo, mon) + ')</option>').join('');
|
||
const pendiente = r && r.etapas.findIndex(e => e.saldo > 0);
|
||
if (pendiente >= 0) $('pgEtapa').value = String(pendiente + 1);
|
||
pintarTipoCambio();
|
||
}
|
||
$('cobPagos').innerHTML = (v.pagos || []).map(p => '<tr' + (p.estado === 'Cancelado' ? ' class="fila-cancelada" title="' +
|
||
esc('Cancelado por ' + p.canceladoPor + ': ' + p.motivoCancelacion) + '"' : '') + '><td><b>' + esc(p.folio) + '</b></td><td>' + esc(p.fecha) +
|
||
'</td><td class="num">' + dinero(p.monto, p.monedaPago) + (p.tipoCambio ? '<div class="nota">TC ' + esc(p.tipoCambio) + '</div>' : '') +
|
||
'</td><td class="num">' + dinero(p.montoAplicado, p.monedaProyecto) + '</td><td>' + esc(p.metodo) + (p.referencia ? ' · ' + esc(p.referencia) : '') +
|
||
'</td><td>' + esc(p.etapa) + '</td><td>' + esc(p.registro) + '</td><td>' +
|
||
(p.comprobanteUrl ? '<a href="' + esc(p.comprobanteUrl) + '" target="_blank">Ver</a>' : '') + '</td><td>' +
|
||
(p.estado === 'Vigente' ? '<button type="button" class="quitar" title="Cancelar pago" onclick="cancelarPagoCob(\'' + esc(p.folio) + '\')">×</button>'
|
||
: '<span class="chip cancelado">Cancelado</span>') + '</td></tr>').join('');
|
||
}
|
||
|
||
function pintarEsquema() {
|
||
const v = COB, edit = v && v.esquemaEditable, mon = v && v.contrato ? v.contrato.moneda : 'MXN';
|
||
const total = v && v.contrato ? v.contrato.total : 0;
|
||
const res = v && v.resumen ? v.resumen.etapas : [];
|
||
const suma = r2(ESQ.reduce((s, e) => s + (Number(e.porcentaje) || 0), 0));
|
||
// Vista previa de montos (el servidor los recalcula): la última etapa se queda con los centavos
|
||
let acum = 0;
|
||
const montos = ESQ.map((e, i) => { const m = i === ESQ.length - 1 ? r2(total - acum) : r2(total * (Number(e.porcentaje) || 0) / 100); acum = r2(acum + m); return m; });
|
||
$('cobEsquema').innerHTML = ESQ.map((e, i) => {
|
||
const x = res[i] || {};
|
||
return '<tr><td>' + (i + 1) + '</td><td>' + (edit ? '<input value="' + esc(e.etapa) + '" maxlength="40" oninput="editarEtapa(' + i + ',\'etapa\',this.value)">' : esc(e.etapa)) +
|
||
'</td><td class="num">' + (edit ? '<input type="number" min="0" max="100" step="0.01" style="text-align:right" value="' + esc(e.porcentaje) +
|
||
'" oninput="editarEtapa(' + i + ',\'porcentaje\',this.value)">' : esc(e.porcentaje) + '%') +
|
||
'</td><td class="num">' + dinero(montos[i], mon) + '</td><td class="num">' + (x.pagado != null ? dinero(x.pagado, mon) : '') +
|
||
'</td><td class="num">' + (x.saldo != null ? dinero(x.saldo, mon) : '') + '</td><td>' +
|
||
(x.avance != null ? '<div class="barra" title="' + x.avance + '%"><i style="width:' + Math.max(0, Math.min(100, x.avance)) + '%"></i></div>' : '') +
|
||
'</td><td>' + (edit && ESQ.length > 1 ? '<button type="button" class="quitar" title="Quitar etapa" onclick="quitarEtapa(' + i + ')">×</button>' : '') + '</td></tr>';
|
||
}).join('') + (ESQ.length ? '<tr><td></td><td><b>Suma</b></td><td class="num"><b' + (Math.abs(suma - 100) > 0.001 ? ' class="aviso-moneda"' : '') + '>' +
|
||
suma + '%</b></td><td class="num"><b>' + dinero(total, mon) + '</b></td><td colspan="4"></td></tr>' : '');
|
||
$('cobEsqAcciones').innerHTML = edit ? (v.esquemaPideMotivo ? '<div style="flex:1;min-width:260px"><label>Motivo del cambio (queda registrado) *</label>' +
|
||
'<input id="cobMotivoEsq" maxlength="500" placeholder="Ej. El cliente negoció 50% de anticipo"></div>' : '') +
|
||
'<button type="button" class="btn btn-sec" onclick="agregarEtapa()">+ Agregar etapa</button>' +
|
||
'<button type="button" class="btn" id="btnEsquema" onclick="guardarEsquemaCob()">Guardar esquema</button>' : '';
|
||
}
|
||
// Al editar un nombre no se vuelve a pintar (no se pierde el cursor); el % sí actualiza montos y suma
|
||
function editarEtapa(i, campo, valor) { ESQ[i][campo] = valor; if (campo === 'porcentaje') pintarEsquemaSinFoco(); }
|
||
function pintarEsquemaSinFoco() { const a = document.activeElement, idx = a && a.closest && a.closest('tr') ? [...$('cobEsquema').children].indexOf(a.closest('tr')) : -1;
|
||
pintarEsquema(); if (idx >= 0) { const inp = $('cobEsquema').children[idx].querySelector('input[type=number]'); if (inp) { inp.focus(); const l = inp.value.length; try { inp.setSelectionRange(l, l); } catch (e) {} } } }
|
||
function agregarEtapa() { if (ESQ.length >= 10) { mostrarError('Máximo 10 etapas.'); return; } ESQ.push({ etapa: '', porcentaje: 0 }); pintarEsquema(); }
|
||
function quitarEtapa(i) { ESQ.splice(i, 1); pintarEsquema(); }
|
||
|
||
// Botones que llaman al servidor: se desactivan mientras responde
|
||
function accionCob(boton, funcion, args, alExito) {
|
||
const b = boton && $(boton);
|
||
if (b) { if (b.disabled) return; b.disabled = true; }
|
||
const listo = () => { if (b) b.disabled = false; };
|
||
google.script.run.withSuccessHandler(x => { listo(); (alExito || pintarCobranza)(x); })
|
||
.withFailureHandler(err => { listo(); mostrarError(err); })[funcion](...args);
|
||
}
|
||
|
||
function aceptarCot(folio, confirmado) {
|
||
accionCob(null, 'marcarAceptada', [COB.proyecto.folio, folio, !!confirmado], v => {
|
||
if (v && v.confirmar) { if (window.confirm(v.mensaje)) aceptarCot(folio, true); return; }
|
||
pintarCobranza(v); aviso('ok', 'Cotización <b>' + esc(folio) + '</b> marcada como aceptada.');
|
||
});
|
||
}
|
||
function guardarEsquemaCob() {
|
||
const motivo = COB.esquemaPideMotivo ? $('cobMotivoEsq').value.trim() : '';
|
||
if (COB.esquemaPideMotivo && !motivo) { mostrarError('Escribe el motivo del cambio al esquema de Helgen Energy.'); return; }
|
||
accionCob('btnEsquema', 'guardarEsquema', [COB.proyecto.folio, ESQ.map(e => ({ etapa: e.etapa, porcentaje: e.porcentaje })), motivo],
|
||
v => { pintarCobranza(v); aviso('ok', 'Esquema de pagos guardado.'); });
|
||
}
|
||
function firmarCob() {
|
||
const fecha = $('cobFechaFirma').value;
|
||
if (!fecha) { mostrarError('Captura la fecha de firma.'); return; }
|
||
if (!window.confirm('¿Registrar el contrato de ' + COB.proyecto.folio + ' como firmado el ' + fecha + '? ' +
|
||
'Después ya no se podrán cambiar la cotización aceptada, el monto ni el esquema de pagos.')) return;
|
||
accionCob('btnFirmar', 'firmarContrato', [COB.proyecto.folio, fecha], v => { pintarCobranza(v); cargarProyectosCob(); aviso('ok', 'Contrato firmado.'); });
|
||
}
|
||
function desbloquearCob() {
|
||
const motivo = $('cobMotivo').value.trim();
|
||
if (!motivo) { mostrarError('Escribe el motivo del desbloqueo.'); return; }
|
||
accionCob('btnDesbloquear', 'desbloquearContrato', [COB.proyecto.folio, motivo], v => { pintarCobranza(v); cargarProyectosCob(); aviso('ok', 'Contrato desbloqueado. Quedó registrado el motivo.'); });
|
||
}
|
||
function cancelarPagoCob(folio) {
|
||
const motivo = window.prompt('Motivo para cancelar el pago ' + folio + ' (queda registrado; el pago no se borra):');
|
||
if (motivo == null) return;
|
||
if (!motivo.trim()) { mostrarError('Escribe el motivo de la cancelación.'); return; }
|
||
accionCob(null, 'cancelarPago', [folio, motivo], v => { pintarCobranza(v); aviso('ok', 'Pago <b>' + esc(folio) + '</b> cancelado.'); });
|
||
}
|
||
|
||
function pintarTipoCambio() {
|
||
const distinta = COB && COB.contrato && $('pgMoneda').value !== COB.contrato.moneda;
|
||
$('pgTcCaja').classList.toggle('oculto', !distinta);
|
||
if (!distinta) $('pgTc').value = '';
|
||
}
|
||
|
||
/** Lee el archivo como base64 (sin el prefijo data:). */
|
||
function leerArchivo(archivo) {
|
||
return new Promise((ok, mal) => {
|
||
const lector = new FileReader();
|
||
lector.onload = () => ok(String(lector.result).split(',')[1] || '');
|
||
lector.onerror = () => mal(new Error('No se pudo leer el comprobante.'));
|
||
lector.readAsDataURL(archivo);
|
||
});
|
||
}
|
||
|
||
function registrarPago() {
|
||
const archivo = $('pgArchivo').files && $('pgArchivo').files[0];
|
||
if (!archivo) { mostrarError('Adjunta el comprobante de pago (PDF o imagen).'); return; }
|
||
if (archivo.size > 10 * 1024 * 1024) { mostrarError('El comprobante pesa más de 10 MB. Reduce la imagen o el PDF.'); return; }
|
||
if ($('btnPago').disabled) return;
|
||
$('btnPago').disabled = true; $('btnPago').textContent = 'Subiendo comprobante…';
|
||
const listo = () => { $('btnPago').disabled = false; $('btnPago').textContent = 'Registrar pago'; };
|
||
leerArchivo(archivo).then(datos => {
|
||
google.script.run.withSuccessHandler(r => {
|
||
listo(); ID_PAGO = nuevoId();
|
||
['pgMonto', 'pgTc', 'pgRef', 'pgArchivo'].forEach(id => $(id).value = '');
|
||
pintarCobranza(r.vista);
|
||
aviso('ok', 'Pago <b>' + esc(r.folio) + '</b> registrado · <a href="' + esc(r.url) + '" target="_blank">Ver comprobante</a>');
|
||
}).withFailureHandler(err => { listo(); mostrarError(err); }).registrarPago({
|
||
proyectoFolio: COB.proyecto.folio, fecha: $('pgFecha').value, monto: $('pgMonto').value, monedaPago: $('pgMoneda').value,
|
||
tipoCambio: $('pgTc').value, metodo: $('pgMetodo').value, referencia: $('pgRef').value, etapaOrden: Number($('pgEtapa').value),
|
||
comprobante: { nombre: archivo.name, datos: datos }, idSolicitud: ID_PAGO,
|
||
});
|
||
}, err => { listo(); mostrarError(err); });
|
||
}
|
||
|
||
// ---- UI ----
|
||
function verTab(t) {
|
||
$('vistaNueva').classList.toggle('oculto', t !== 'nueva');
|
||
$('vistaHist').classList.toggle('oculto', t !== 'hist');
|
||
$('vistaCat').classList.toggle('oculto', t !== 'cat');
|
||
$('vistaCob').classList.toggle('oculto', t !== 'cob');
|
||
$('tabCob').classList.toggle('activo', t === 'cob');
|
||
$('tabCat').classList.toggle('activo', t === 'cat');
|
||
$('tabNueva').classList.toggle('activo', t === 'nueva');
|
||
$('tabHist').classList.toggle('activo', t === 'hist');
|
||
if (t === 'hist') cargarHistorial();
|
||
if (t === 'cat') cargarCatalogo();
|
||
if (t === 'cob') cargarProyectosCob();
|
||
$('aviso').className = 'aviso';
|
||
}
|
||
function aviso(tipo, html) { const a = $('aviso'); a.className = 'aviso ' + tipo; a.innerHTML = html; window.scrollTo(0, 0); }
|
||
function mostrarError(err) {
|
||
aviso('error', esc(err && err.message ? err.message : err));
|
||
$('btnGuardar').disabled = false; $('btnGuardar').textContent = 'Guardar y generar PDF';
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|