mirror of
https://github.com/efrainsosa27/cotizador-helgen.git
synced 2026-10-24 05:16:48 +00:00
- bootstrap(): una sola llamada al entrar con roles y listas según el rol; las pestañas se dibujan al instante con los roles de la última sesión (localStorage, solo interfaz). getDatosIniciales queda como alias. - CacheService por versión de colección (proyectos, contratos, cotizaciones, catálogo, requisiciones, oc, pagosCobranza, pagosProveedores): doPost sube la versión tras cada escritura (con candado); las hojas que se editan a mano se revisan por huella cada pocos minutos desde la consulta de fondo. Memoria por petición para propiedades y cachés. - sincronizar(versiones, contexto): devuelve solo lo que cambió y respeta roles; la interfaz consulta cada 20 s con la pestaña visible, al cambiar de pestaña y al abrir un buscador de proyectos; si alguien está capturando, avisa en vez de repintar. Los cambios de rol se aplican sin recargar. - Requisiciones, OC y pago a proveedores en proyectos Activos y Cerrados con o sin contrato (moneda de la aceptada o MXN); Cancelados no. Cobranza sin aceptada muestra costo de OC y "Sin contrato". Pagos de clientes siguen exigiendo contrato firmado. - Clave de proyecto (columna L): folio visible PRY-AAAA-NNNN-XXX, sugerida con el nombre y editable al crear; la llave interna sigue siendo PRY-AAAA-NNNN. asignarClavesProyectos() (vista previa) y asignarClavesProyectosAplicar() asignan clave a los existentes y renombran su carpeta principal. Buscadores por clave. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2084 lines
131 KiB
HTML
2084 lines
131 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 !important; } /* gana sobre .acciones, .tiles, .grid… (display) aunque estén definidas después */
|
||
.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, clave, 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>Clave (3 letras) *</label><input id="npClave" maxlength="3" autocomplete="off" style="text-transform:uppercase" placeholder="Se sugiere con el nombre">
|
||
<div id="npFolio" class="nota">El folio será PRY-AAAA-NNNN-XXX. La clave ya no se cambia después.</div></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, clave, 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, clave, 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 o cerrado) *</label>
|
||
<input id="reqProyecto" list="reqProyectosList" autocomplete="off" placeholder="Busca por folio, clave 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"><tr><td colspan="7" class="nota">Cargando…</td></tr></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" id="comModOrdenes">
|
||
<h2>2 · Órdenes por proyecto</h2>
|
||
<div class="proy-fila">
|
||
<div><label>Proyecto (activo o cerrado)</label>
|
||
<input id="comProyecto" list="comProyectosList" autocomplete="off" placeholder="Busca por folio, clave, 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, correo = '';
|
||
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; correo = String(datos.email || '').toLowerCase();
|
||
try { sessionStorage.setItem(CLAVE_SESION, t); } catch (e) {}
|
||
pintarUsuario(datos);
|
||
// La interfaz dibuja al instante las pestañas de la última sesión de este correo (solo interfaz)
|
||
document.dispatchEvent(new CustomEvent('sesion-lista', { detail: correo }));
|
||
}
|
||
|
||
function olvidarSesion() {
|
||
token = null; expira = 0; correo = '';
|
||
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;
|
||
// Para la sincronización: no consultar sin sesión (si no, las consultas se forman en cola y abren el inicio de sesión)
|
||
window.sesionVigente = vigente;
|
||
window.correoSesion = () => correo;
|
||
|
||
// ---- 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 = {}, SOLO_REQ = false;
|
||
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: una sola llamada (bootstrap) ----
|
||
// Las pestañas se dibujan al instante con los roles de la última sesión de este correo (localStorage) y se corrigen al
|
||
// llegar el bootstrap. Es solo interfaz: el servidor vuelve a revisar los permisos en cada llamada.
|
||
let VERS = {}, BOOT = false, TAB = 'nueva';
|
||
const CARGADO = { reqProy: false, misReq: false, comProy: false, comReqs: false, cobProy: false };
|
||
const CLAVE_ROLES = 'cotizadorHelgenRoles:';
|
||
function leerRolesGuardados(correo) {
|
||
try { return correo ? JSON.parse(localStorage.getItem(CLAVE_ROLES + correo) || 'null') : null; } catch (e) { return null; }
|
||
}
|
||
function guardarRoles(permisos, solo) {
|
||
const correo = window.correoSesion ? correoSesion() : '';
|
||
try { if (correo) localStorage.setItem(CLAVE_ROLES + correo, JSON.stringify({ permisos: permisos, soloRequisicion: !!solo })); } catch (e) {}
|
||
}
|
||
|
||
/** Muestra u oculta las pestañas según los permisos; si la visible ya no corresponde, va a la primera permitida. */
|
||
function aplicarPermisos(permisos, solo) {
|
||
PERMISOS = permisos || {}; SOLO_REQ = !!solo;
|
||
['tabNueva', 'tabHist', 'tabCat'].forEach(id => $(id).classList.toggle('oculto', SOLO_REQ));
|
||
$('tabReq').classList.toggle('oculto', !PERMISOS.requisicion);
|
||
$('tabCob').classList.toggle('oculto', SOLO_REQ || !PERMISOS.cobranza);
|
||
$('tabCom').classList.toggle('oculto', SOLO_REQ || !PERMISOS.compras);
|
||
const boton = { nueva: 'tabNueva', hist: 'tabHist', cat: 'tabCat', req: 'tabReq', cob: 'tabCob', com: 'tabCom' }[TAB];
|
||
if ($(boton).classList.contains('oculto')) verTab(SOLO_REQ ? 'req' : 'nueva');
|
||
}
|
||
function rolesDeUltimaSesion(correo) {
|
||
if (BOOT) return;
|
||
const r = leerRolesGuardados(correo);
|
||
if (r) aplicarPermisos(r.permisos, r.soloRequisicion);
|
||
}
|
||
document.addEventListener('sesion-lista', e => rolesDeUltimaSesion(e.detail));
|
||
// Al terminar de cargar este script (verTab usa variables que se declaran más abajo)
|
||
setTimeout(() => { if (window.correoSesion && correoSesion()) rolesDeUltimaSesion(correoSesion()); }, 0);
|
||
|
||
google.script.run.withSuccessHandler(recibirBootstrap).withFailureHandler(mostrarError).bootstrap();
|
||
|
||
function recibirBootstrap(d) {
|
||
BOOT = true;
|
||
VERS = d.versiones || {};
|
||
IVA = d.ivaTasa; CATALOGO = d.catalogo || []; TC = d.tipoCambio || TC;
|
||
aplicarPermisos(d.permisos, d.soloRequisicion);
|
||
guardarRoles(d.permisos, d.soloRequisicion);
|
||
pintarCatalogoReq();
|
||
if (d.req) {
|
||
if (d.req.proyectos) recibirProyectosReq(d.req.proyectos);
|
||
if (d.req.mias) pintarMisReq(d.req.mias);
|
||
}
|
||
if (d.com) {
|
||
if (d.com.proyectos) recibirProyectosCom(d.com.proyectos);
|
||
if (d.com.requisiciones) { CARGADO.comReqs = true; pintarListaReqCom(d.com.requisiciones); }
|
||
}
|
||
if (d.cob) {
|
||
if (d.cob.proyectos) recibirProyectosCob(d.cob.proyectos);
|
||
if (d.cob.resumen) { RESUMEN = d.cob.resumen; pintarResumenCob(); }
|
||
}
|
||
if ((d.errores || []).length) console.warn('Carga inicial incompleta: ' + d.errores.join(' · '));
|
||
if (!d.soloRequisicion) {
|
||
recibirClientes(d.clientes);
|
||
recibirProyectos(d.proyectos);
|
||
$('lblIva').textContent = 'IVA (' + Math.round(IVA * 100) + '%)';
|
||
pintarDatalists();
|
||
if (!$('filas').querySelectorAll('tr').length) agregarFila();
|
||
}
|
||
verTab(TAB, true); // completa lo que falte de la pestaña visible (si el usuario ya la abrió mientras cargaba)
|
||
iniciarSincronizacion();
|
||
}
|
||
|
||
// ---- Sincronización sin recargar ----
|
||
// El servidor guarda una versión por colección (proyectos, requisiciones, órdenes, pagos…) que sube en cada escritura.
|
||
// Cada 20 s con la pestaña del navegador visible, al cambiar de pestaña y al abrir un buscador de proyectos, la interfaz
|
||
// manda sus versiones y recibe solo lo que cambió (el servidor decide qué puede ver cada rol).
|
||
const CADA_SYNC = 20000;
|
||
let SYNC_EN_CURSO = false, SYNC_OTRA = false, SYNC_INICIADA = false;
|
||
function contextoSync() {
|
||
return { cobResumen: !!RESUMEN, cobProyecto: COB ? COB.proyecto.folio : '', comProyecto: COM ? COM.proyecto.folio : '',
|
||
req: REQC ? REQC.requisicion.folio : '', rcBuscar: COM_FILTRO.buscar, rcEstado: COM_FILTRO.estado };
|
||
}
|
||
function sincronizarAhora() {
|
||
if (!BOOT || !(window.sesionVigente && sesionVigente())) return; // sin sesión no se forman consultas en cola
|
||
if (SYNC_EN_CURSO) { SYNC_OTRA = true; return; }
|
||
SYNC_EN_CURSO = true;
|
||
const fin = () => { SYNC_EN_CURSO = false; if (SYNC_OTRA) { SYNC_OTRA = false; sincronizarAhora(); } };
|
||
google.script.run.withSuccessHandler(r => { try { aplicarSync(r); } finally { fin(); } })
|
||
.withFailureHandler(err => { fin(); console.warn('Sincronización: ' + (err && err.message ? err.message : err)); })
|
||
.sincronizar(VERS, contextoSync());
|
||
}
|
||
function iniciarSincronizacion() {
|
||
if (SYNC_INICIADA) return;
|
||
SYNC_INICIADA = true;
|
||
setInterval(() => { if (document.visibilityState === 'visible') sincronizarAhora(); }, CADA_SYNC);
|
||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') sincronizarAhora(); });
|
||
['proyecto', 'resProyecto', 'cobProyecto', 'comProyecto', 'reqProyecto'].forEach(id => $(id).addEventListener('focus', sincronizarAhora));
|
||
}
|
||
|
||
function aplicarSync(r) {
|
||
if (!r) return;
|
||
const cambioRol = JSON.stringify(r.permisos) !== JSON.stringify(PERMISOS) || !!r.soloRequisicion !== SOLO_REQ;
|
||
if (cambioRol) {
|
||
// Cambió un rol (Script Properties): las pestañas se ajustan sin recargar la página
|
||
aplicarPermisos(r.permisos, r.soloRequisicion);
|
||
guardarRoles(r.permisos, r.soloRequisicion);
|
||
}
|
||
VERS = r.versiones || VERS;
|
||
if (r.catalogo) {
|
||
CATALOGO = r.catalogo; if (r.tipoCambio) TC = r.tipoCambio;
|
||
pintarCatalogoReq(); if (!SOLO_REQ) pintarDatalists();
|
||
}
|
||
if (r.clientes) recibirClientes(r.clientes);
|
||
if (r.proyectos) recibirProyectos(r.proyectos);
|
||
if (r.reqProyectos) recibirProyectosReq(r.reqProyectos);
|
||
if (r.misReq) pintarMisReq(r.misReq);
|
||
if (r.comProyectos) recibirProyectosCom(r.comProyectos);
|
||
if (r.comRequisiciones) pintarListaReqCom(r.comRequisiciones);
|
||
if (r.requisicion && REQC && REQC.requisicion.folio === r.requisicion.requisicion.folio) {
|
||
sinInterrumpir('comVistaReq', () => pintarReqCom(r.requisicion));
|
||
}
|
||
if (r.comprasProyecto && COM && COM.proyecto.folio === r.comprasProyecto.proyecto.folio) {
|
||
sinInterrumpir('comModOrdenes', () => pintarCompras(r.comprasProyecto));
|
||
}
|
||
if (r.cobProyectos) recibirProyectosCob(r.cobProyectos);
|
||
if (r.cobranzaProyecto && COB && COB.proyecto.folio === r.cobranzaProyecto.proyecto.folio) {
|
||
sinInterrumpir('cobVistaProyecto', () => pintarCobranza(r.cobranzaProyecto));
|
||
}
|
||
if (r.resumen) { RESUMEN = r.resumen; pintarResumenCob(); }
|
||
if (cambioRol) {
|
||
// Pestaña recién permitida: se pide lo que la sincronización no trajo
|
||
if (PERMISOS.requisicion && !CARGADO.reqProy) cargarProyectosReq();
|
||
if (PERMISOS.requisicion && !CARGADO.misReq) cargarMisReq();
|
||
if (PERMISOS.compras && !SOLO_REQ && !CARGADO.comProy) cargarProyectosCom();
|
||
if (PERMISOS.compras && !SOLO_REQ && !CARGADO.comReqs) cargarRequisicionesCom();
|
||
if (PERMISOS.cobranza && !SOLO_REQ && !CARGADO.cobProy) cargarProyectosCob();
|
||
}
|
||
}
|
||
|
||
// Una vista donde alguien está capturando no se repinta sola: se avisa y se actualiza con un clic
|
||
const SUCIO = {}, PENDIENTE = {};
|
||
function sinInterrumpir(id, pintar) {
|
||
const vista = $(id), activo = document.activeElement;
|
||
const escribiendo = vista && activo && vista.contains(activo) && /^(INPUT|TEXTAREA|SELECT)$/.test(activo.tagName);
|
||
if (!SUCIO[id] && !escribiendo) { pintar(); return; }
|
||
PENDIENTE[id] = pintar;
|
||
let a = vista.querySelector('.aviso-sync');
|
||
if (!a) { a = document.createElement('div'); a.className = 'aviso-sync nota aviso-moneda'; a.style.marginBottom = '8px'; vista.prepend(a); }
|
||
a.innerHTML = 'Otra persona cambió esta información. <a href="#">Actualizar</a> (se pierde lo que no hayas guardado).';
|
||
a.querySelector('a').onclick = ev => { ev.preventDefault(); const f = PENDIENTE[id]; listoVista(id); if (f) f(); };
|
||
}
|
||
/** La vista se acaba de pintar con datos del servidor: ya no hay captura pendiente ni aviso. */
|
||
function listoVista(id) {
|
||
SUCIO[id] = false; delete PENDIENTE[id];
|
||
const a = $(id) && $(id).querySelector('.aviso-sync');
|
||
if (a) a.remove();
|
||
}
|
||
['comVistaReq', 'cobVistaProyecto'].forEach(id => $(id).addEventListener('input', () => { SUCIO[id] = true; }));
|
||
|
||
// ---- 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 => {
|
||
const pr = r.proyectoFolio && PROYECTOS.find(p => p.folio === r.proyectoFolio);
|
||
aviso('ok', 'Cotización <b>' + esc(r.folio) + '</b> guardada · Total ' + dinero(r.total, r.moneda) +
|
||
(r.proyectoFolio ? ' · Proyecto <b>' + esc(pr ? folioVis(pr) : 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-TSP — Cliente — Nombre" (así se puede buscar por folio, clave, cliente o nombre).
|
||
// La llave interna sigue siendo PRY-AAAA-NNNN (p.folio); la clave solo se muestra.
|
||
const folioVis = p => (p && (p.folioVisible || p.folio)) || '';
|
||
const etiquetaProyecto = p => folioVis(p) + ' — ' + 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 = ''; $('npClave').value = ''; CLAVE_A_MANO = false; pintarFolioNuevo();
|
||
}
|
||
|
||
// Clave del proyecto (3 letras): se sugiere con el nombre (misma regla que claveSugerida en Code.js) y se puede editar;
|
||
// el servidor la vuelve a validar y después ya no cambia
|
||
const PALABRAS_SIN_CLAVE = ['DE', 'DEL', 'LA', 'LAS', 'EL', 'LOS', 'Y', 'EN', 'PARA'];
|
||
function claveSugerida(nombre) {
|
||
const palabras = String(nombre || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase()
|
||
.replace(/[^A-Z]+/g, ' ').trim().split(' ').filter(p => p && PALABRAS_SIN_CLAVE.indexOf(p) === -1);
|
||
let c = '';
|
||
if (palabras.length === 1) c = palabras[0].slice(0, 3);
|
||
else if (palabras.length === 2) c = palabras[0].slice(0, 2) + palabras[1].charAt(0);
|
||
else if (palabras.length > 2) c = palabras.slice(0, 3).map(p => p.charAt(0)).join('');
|
||
return (c + 'XXX').slice(0, 3);
|
||
}
|
||
let CLAVE_A_MANO = false;
|
||
function pintarFolioNuevo() {
|
||
const c = $('npClave').value.trim().toUpperCase();
|
||
$('npFolio').textContent = /^[A-Z]{3}$/.test(c) ? 'El folio será PRY-' + new Date().getFullYear() + '-NNNN-' + c + ' (el número lo asigna el sistema). La clave ya no se cambia después.'
|
||
: 'La clave debe tener exactamente 3 letras (A–Z, sin acentos ni Ñ).';
|
||
}
|
||
$('npNombre').addEventListener('input', () => { if (!CLAVE_A_MANO) $('npClave').value = $('npNombre').value.trim() ? claveSugerida($('npNombre').value) : ''; pintarFolioNuevo(); });
|
||
$('npClave').addEventListener('input', () => {
|
||
$('npClave').value = $('npClave').value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 3);
|
||
CLAVE_A_MANO = !!$('npClave').value; pintarFolioNuevo();
|
||
});
|
||
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 (!/^[A-Z]{3}$/.test($('npClave').value.trim().toUpperCase())) { mostrarError('La clave del proyecto debe tener exactamente 3 letras (A–Z).'); 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(folioVis(p)) + '</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, clave: $('npClave').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 => folioVis(p) + ' — ' + p.cliente + ' — ' + p.nombre;
|
||
|
||
function recibirProyectosCob(lista) {
|
||
COB_PROYECTOS = lista || []; CARGADO.cobProy = true;
|
||
$('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('');
|
||
}
|
||
function cargarProyectosCob() {
|
||
google.script.run.withSuccessHandler(recibirProyectosCob).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');
|
||
// Ya cargado: se queda (la sincronización lo mantiene al día); si no, se pide
|
||
if (RESUMEN) pintarResumenCob(); else if (BOOT) 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 || (folioVis(p) + ' ' + 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(folioVis(p)) + '</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(folioVis(p)), 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);
|
||
listoVista('cobVistaProyecto');
|
||
$('cobInfo').innerHTML = '<b>' + esc(folioVis(v.proyecto)) + '</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>'
|
||
// Sin cotización aceptada no hay contratado: solo el costo de las órdenes de compra (cada moneda aparte)
|
||
: mg.sinContrato ? '<div class="sat-titulo" style="grid-column:1/-1;margin:0">Costo de órdenes de compra (sin IVA)</div>' +
|
||
mg.costos.map(x => [x.concepto + ' (' + x.cantidad + ')', dinero(x.monto, x.moneda)]).concat([['Margen', 'Sin contrato']])
|
||
.map(([k, x]) => '<div class="tile"><span>' + esc(k) + '</span><b>' + esc(x) + '</b></div>').join('')
|
||
: '<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('');
|
||
}
|
||
|
||
// Proyectos Activos y Cerrados (con o sin contrato); la opción es "PRY-2026-0002-DRO — Nombre"
|
||
function recibirProyectosReq(lista) {
|
||
REQ_PROYECTOS = lista || []; CARGADO.reqProy = true;
|
||
$('reqProyectosList').innerHTML = REQ_PROYECTOS.map(p => '<option value="' + esc(folioVis(p) + ' — ' + p.nombre) + '"' +
|
||
(p.estado && p.estado !== 'Activo' ? ' label="' + esc(p.estado) + '"' : '') + '>').join('');
|
||
}
|
||
function cargarProyectosReq() {
|
||
google.script.run.withSuccessHandler(recibirProyectosReq).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 || []; CARGADO.misReq = true;
|
||
$('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.proyectoVisible || 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.proyectoVisible || 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';
|
||
if (BOOT && !CARGADO.comReqs) cargarRequisicionesCom(); // ya cargada: la sincronización la mantiene al día
|
||
}
|
||
// Filtro con que se pidió la lista (la sincronización la vuelve a pedir igual, aunque haya texto sin buscar)
|
||
const COM_FILTRO = { buscar: '', estado: '' };
|
||
function cargarRequisicionesCom() {
|
||
COM_FILTRO.buscar = $('rcBuscar').value; COM_FILTRO.estado = $('rcEstado').value;
|
||
google.script.run.withSuccessHandler(l => { CARGADO.comReqs = true; pintarListaReqCom(l); }).withFailureHandler(mostrarError)
|
||
.listarRequisiciones(COM_FILTRO.buscar, COM_FILTRO.estado);
|
||
}
|
||
$('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.proyectoVisible || 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;
|
||
listoVista('comVistaReq');
|
||
$('comVistaLista').classList.add('oculto'); $('comVistaReq').classList.remove('oculto');
|
||
$('rcTitulo').textContent = '1 · Requisición ' + rq.folio;
|
||
$('rcInfo').innerHTML = chipReq(rq.estado) + ' · <b>' + esc(folioVis(v.proyecto)) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' +
|
||
esc(v.proyecto.nombre) + (v.proyecto.estado && v.proyecto.estado !== 'Activo' ? ' · ' + esc(v.proyecto.estado) : '') +
|
||
(v.moneda ? ' · Moneda del proyecto: <b>' + esc(v.moneda) + '</b> · ' + (v.sinContrato ? 'sin contrato' : 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 está cancelado: 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)
|
||
// Proyectos Activos y Cerrados, con o sin contrato (sin contrato, las órdenes van en MXN)
|
||
function recibirProyectosCom(lista) {
|
||
COM_PROYECTOS = lista || []; CARGADO.comProy = true;
|
||
$('comProyectosList').innerHTML = COM_PROYECTOS.map(p =>
|
||
'<option value="' + esc(etiquetaCob(p)) + '" label="' + esc(p.moneda + ' · ' + (p.sinContrato ? 'sin contrato' : (MARCA_TXT[p.marca] || '')) +
|
||
(p.estado !== 'Activo' ? ' · ' + p.estado : '')) + '">').join('');
|
||
if (!COM_PROYECTOS.length && !COM) $('comInfo').textContent = 'No hay proyectos activos ni cerrados.';
|
||
}
|
||
function cargarProyectosCom() {
|
||
google.script.run.withSuccessHandler(recibirProyectosCom).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(folioVis(v.proyecto)) + '</b> · ' + esc(v.proyecto.cliente) + ' · ' + esc(v.proyecto.nombre) +
|
||
(v.moneda ? ' · Moneda del proyecto: <b>' + esc(v.moneda) + '</b> · ' + (v.sinContrato ? 'sin contrato' : 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, recienCargado) {
|
||
TAB = 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') verResumenCob();
|
||
if (t === 'com') verListaCom();
|
||
if (t === 'req' && !$('reqFilas').querySelectorAll('tr').length) limpiarReq();
|
||
// Lo que trajo el bootstrap ya está; si una lista no llegó, se pide. Lo demás lo pone al día la sincronización
|
||
if (BOOT) {
|
||
if (t === 'req') { if (!CARGADO.reqProy) cargarProyectosReq(); if (!CARGADO.misReq) cargarMisReq(); }
|
||
if (t === 'cob' && !CARGADO.cobProy) cargarProyectosCob();
|
||
if (t === 'com' && !CARGADO.comProy) cargarProyectosCom();
|
||
if (!recienCargado) sincronizarAhora(); // justo después del bootstrap no hace falta
|
||
}
|
||
$('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>
|