feat(web): login, sesión, interceptor, guards y layout con menú por permisos

- Login contra POST /api/auth/login; SessionStore en localStorage con el
  timeout deslizante que informa el servidor (sessionTimeoutMinutes).
- Interceptor: Bearer en /api, renueva actividad y, ante 401, cierra la
  sesión y vuelve al login recordando la ruta (solo rutas internas).
- Guards: sesión, invitado y permiso por pantalla (data.path). Corrige que
  inject() no puede usarse tras un await en permissionGuard.
- Layout con barra superior (empresa, usuario, tema, salir) y menú lateral
  filtrado por GET /api/user/menu; plegable y con botón en pantallas angostas.
- Menú único en libs/sap-types (MENU, filterMenu); el backend verifica por
  test que las rutas de permiso de sus recursos existan en él.
- Una ruta por pantalla del menú; las no construidas muestran "no disponible".
- Verificado en Chrome headless contra el backend y SAP reales: redirección
  sin sesión, menú de 20 pantallas, navegación, plegado, tema oscuro y
  persistencia, vista móvil, salir, token inválido y credenciales malas.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-13 19:18:05 -05:00
1 parent f1156929c7
commit 4c8c499a72
36 files changed
+1291 -52

No files matched your search

+996
View File
@@ -0,0 +1,996 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Cotizador Aberio · Oferta de ventas</title>
<style>
:root{
box-sizing:border-box;
padding-top:env(safe-area-inset-top,0px);
padding-bottom:env(safe-area-inset-bottom,0px);
--app:#e4e8ec; --shell:#2f4356; --shell-ink:#ffffff; --shell-quiet:#b8c4cf;
--win:#f7f7f7; --panel:#ffffff; --line:#c6ced5; --line-soft:#dde2e7;
--ink:#32363a; --quiet:#6a6d70; --field:#ffffff; --field-ro:#eef1f3; --field-b:#a9b4be;
--focus:#0a6ed1; --gold:#e9a100; --grid-head:#e7eaed; --row-sel:#dcebf9; --row-alt:#fafbfc;
--ok:#107e3e; --ok-bg:#e9f5ee; --warn:#a85d00; --warn-bg:#fdf3e2; --err:#bb0000; --err-bg:#fbeaea;
--btn:#f5f6f7; --btn-b:#a9b4be; --btn-h:#e8ebee;
}
@media (prefers-color-scheme:dark){
:root:not([data-theme="light"]){
--app:#14191e; --shell:#0d1216; --shell-ink:#eef2f5; --shell-quiet:#8d9aa6;
--win:#1d242b; --panel:#232b33; --line:#3a4651; --line-soft:#2e3842;
--ink:#e6eaee; --quiet:#9aa6b1; --field:#1a2128; --field-ro:#252e37; --field-b:#4b5865;
--focus:#5aa9f0; --gold:#f2b632; --grid-head:#2a333c; --row-sel:#203a55; --row-alt:#20282f;
--ok:#5fc48a; --ok-bg:#16301f; --warn:#f0b357; --warn-bg:#352812; --err:#ff7a7a; --err-bg:#3a1717;
--btn:#2a333c; --btn-b:#4b5865; --btn-h:#333e48;
}
}
:root[data-theme="dark"]{
--app:#14191e; --shell:#0d1216; --shell-ink:#eef2f5; --shell-quiet:#8d9aa6;
--win:#1d242b; --panel:#232b33; --line:#3a4651; --line-soft:#2e3842;
--ink:#e6eaee; --quiet:#9aa6b1; --field:#1a2128; --field-ro:#252e37; --field-b:#4b5865;
--focus:#5aa9f0; --gold:#f2b632; --grid-head:#2a333c; --row-sel:#203a55; --row-alt:#20282f;
--ok:#5fc48a; --ok-bg:#16301f; --warn:#f0b357; --warn-bg:#352812; --err:#ff7a7a; --err-bg:#3a1717;
--btn:#2a333c; --btn-b:#4b5865; --btn-h:#333e48;
}
html{scroll-padding-top:env(safe-area-inset-top,0px)}
*,*::before,*::after{box-sizing:inherit}
body{margin:0;background:var(--app);color:var(--ink);font:13px/1.35 "Segoe UI",Tahoma,"Helvetica Neue",Arial,sans-serif}
button,input,select,textarea{font:inherit;color:inherit}
:focus-visible{outline:2px solid var(--focus);outline-offset:1px}
/* Shell */
.shell{background:var(--shell);color:var(--shell-ink);display:flex;align-items:center;gap:16px;padding:8px 16px;flex-wrap:wrap}
.shell .brand{font-weight:600;font-size:14px;letter-spacing:.2px}
.shell .brand small{font-weight:400;color:var(--shell-quiet);margin-left:8px}
.sync{margin-left:auto;display:flex;align-items:center;gap:8px;font-size:12px;color:var(--shell-quiet)}
.sync .dot{width:8px;height:8px;border-radius:50%;background:#4cc38a;display:inline-block}
.sync button{background:transparent;border:1px solid var(--shell-quiet);color:var(--shell-ink);border-radius:3px;padding:2px 10px;cursor:pointer}
.sync button:hover{background:rgba(255,255,255,.08)}
/* Toolbar */
.toolbar{background:var(--panel);border-bottom:1px solid var(--line);display:flex;gap:2px;padding:4px 12px;flex-wrap:wrap;align-items:center}
.tb{background:transparent;border:1px solid transparent;border-radius:3px;padding:3px 8px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;color:var(--ink)}
.tb:hover:not(:disabled){background:var(--btn-h);border-color:var(--line)}
.tb:disabled{opacity:.4;cursor:default}
.tb svg{width:16px;height:16px;flex:none}
.tb-sep{width:1px;height:20px;background:var(--line);margin:0 6px}
.demo{margin-left:auto;font-size:12px;color:var(--quiet)}
/* Window */
.wrap{padding:16px;max-width:1320px;margin:0 auto}
.win{background:var(--win);border:1px solid var(--line);box-shadow:0 1px 0 var(--line-soft)}
.win-title{display:flex;align-items:center;gap:10px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line)}
.win-title h1{margin:0;font-size:14px;font-weight:600}
.badge{font-size:11.5px;padding:1px 8px;border-radius:10px;border:1px solid var(--line);color:var(--quiet)}
.badge.open{color:var(--ok);border-color:var(--ok);background:var(--ok-bg)}
.badge.exp{color:var(--err);border-color:var(--err);background:var(--err-bg)}
.badge.new{color:var(--focus);border-color:var(--focus)}
.win-body{padding:12px 16px}
/* Header form */
.hdr{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);gap:8px 48px}
.col{display:grid;gap:4px;align-content:start}
.f{display:grid;grid-template-columns:150px minmax(0,1fr);align-items:center;gap:8px;min-height:24px}
.f > label,.f > .lbl{color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.f .req::after{content:" *";color:var(--err)}
.in,.sel,.ta{height:23px;border:1px solid var(--field-b);background:var(--field);border-radius:2px;padding:1px 6px;width:100%;min-width:0}
.ta{height:auto;min-height:52px;resize:vertical;padding:4px 6px}
.in[readonly],.sel:disabled,.in:disabled,.ta:disabled{background:var(--field-ro);color:var(--ink)}
.in:focus,.sel:focus,.ta:focus{border-color:var(--focus);outline:none;box-shadow:0 0 0 1px var(--focus)}
.pair{display:grid;grid-template-columns:72px minmax(0,1fr);gap:4px}
.ga{position:relative;padding-left:14px}
.ga::before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);border-left:8px solid var(--gold);border-top:5px solid transparent;border-bottom:5px solid transparent}
.ga.link{cursor:pointer}
.ga.link:hover{text-decoration:underline}
.pick{display:grid;grid-template-columns:minmax(0,1fr) 26px;gap:2px}
.pick button{height:23px;border:1px solid var(--field-b);background:var(--btn);border-radius:2px;cursor:pointer;padding:0}
.pick button:hover{background:var(--btn-h)}
/* Tabs */
.tabs{display:flex;gap:2px;margin-top:14px;border-bottom:1px solid var(--line);padding-left:4px;overflow-x:auto}
.tab{border:1px solid var(--line);border-bottom:none;background:var(--grid-head);padding:4px 16px;border-radius:3px 3px 0 0;cursor:pointer;margin-bottom:-1px;white-space:nowrap}
.tab[aria-selected="true"]{background:var(--panel);font-weight:600;padding-bottom:5px}
.tabpanel{background:var(--panel);border:1px solid var(--line);border-top:none;padding:10px}
.tabpanel[hidden]{display:none}
/* Grid */
.gridwrap{overflow-x:auto;border:1px solid var(--line);background:var(--panel)}
table.grid{border-collapse:collapse;width:100%;min-width:1080px}
.grid th{background:var(--grid-head);font-weight:600;text-align:left;padding:4px 6px;border-right:1px solid var(--line);border-bottom:1px solid var(--line);white-space:nowrap;font-size:12.5px}
.grid td{padding:2px 4px;border-right:1px solid var(--line-soft);border-bottom:1px solid var(--line-soft);white-space:nowrap;height:27px}
.grid tbody tr:nth-child(even) td{background:var(--row-alt)}
.grid tbody tr:focus-within td{background:var(--row-sel)}
.grid td.num,.grid th.num{text-align:right}
.grid td.rn{background:var(--grid-head)!important;color:var(--quiet);text-align:center;width:34px}
.grid .in,.grid .sel{height:22px;border-color:transparent;background:transparent}
.grid .in:hover,.grid .sel:hover{border-color:var(--field-b)}
.grid .in:focus,.grid .sel:focus{background:var(--field);border-color:var(--focus)}
.grid .qty{width:88px;text-align:right}
.grid .code{width:118px}
.grid .uom{width:96px}
.grid .desc{min-width:240px;white-space:normal}
.grid .muted{color:var(--quiet)}
.grid tr.below td.minc{color:var(--err);font-weight:600}
.grid tr.below td .qty{color:var(--err)}
.lock{display:inline-block;width:10px;height:10px;margin-right:4px;vertical-align:-1px;opacity:.55}
.promo{color:var(--ok);font-weight:600}
.del{border:none;background:transparent;color:var(--quiet);cursor:pointer;font-size:15px;line-height:1;padding:2px 6px;border-radius:2px}
.del:hover{color:var(--err);background:var(--err-bg)}
.conv{display:block;font-size:11.5px;color:var(--quiet)}
/* Footer */
.foot{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);gap:8px 48px;margin-top:14px}
.tot .f{grid-template-columns:minmax(0,1fr) 170px}
.tot .in{text-align:right}
.tot .grand .in{font-weight:700;font-size:14px;height:26px}
.flete-row{display:grid;grid-template-columns:auto minmax(0,1fr) 170px;align-items:center;gap:8px}
.chk{display:inline-flex;align-items:center;gap:6px;cursor:pointer}
.hint{font-size:11.5px;color:var(--quiet)}
/* Actions */
.actions{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap;align-items:center}
.btn{height:26px;min-width:84px;padding:0 14px;background:var(--btn);border:1px solid var(--btn-b);border-radius:3px;cursor:pointer}
.btn:hover:not(:disabled){background:var(--btn-h)}
.btn:disabled{opacity:.45;cursor:default}
.btn.main{font-weight:600;border-color:var(--focus);color:var(--focus)}
.spacer{flex:1}
.menu{position:relative}
.menu-list{position:absolute;bottom:30px;right:0;background:var(--panel);border:1px solid var(--line);min-width:250px;z-index:20;box-shadow:0 4px 12px rgba(0,0,0,.15)}
.menu-list[hidden]{display:none}
.menu-list button{display:block;width:100%;text-align:left;background:none;border:none;padding:7px 12px;cursor:pointer}
.menu-list button:hover:not(:disabled){background:var(--row-sel)}
.menu-list button:disabled{opacity:.45;cursor:default}
/* Status bar */
.status{display:flex;align-items:center;gap:8px;min-height:28px;padding:4px 16px;border-top:1px solid var(--line);background:var(--panel);font-size:12.5px}
.status.ok{background:var(--ok-bg);color:var(--ok)}
.status.warn{background:var(--warn-bg);color:var(--warn)}
.status.err{background:var(--err-bg);color:var(--err)}
.status .ic{font-weight:700}
/* Modal */
.modal{position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;padding:16px;z-index:50}
.modal[hidden]{display:none}
.dlg{background:var(--win);border:1px solid var(--line);width:min(880px,100%);max-height:85vh;display:flex;flex-direction:column}
.dlg header{display:flex;align-items:center;padding:8px 12px;background:var(--panel);border-bottom:1px solid var(--line)}
.dlg header h2{margin:0;font-size:14px;font-weight:600}
.dlg header button{margin-left:auto}
.dlg .body{padding:12px;overflow:auto}
.dlg .search{margin-bottom:8px;max-width:340px}
.list tbody tr{cursor:pointer}
.list tbody tr:hover td{background:var(--row-sel)}
.empty{padding:24px;text-align:center;color:var(--quiet)}
@media (max-width:900px){
.hdr,.foot{grid-template-columns:1fr;gap:4px}
.f{grid-template-columns:128px minmax(0,1fr)}
.tot .f{grid-template-columns:minmax(0,1fr) 150px}
.wrap{padding:8px}
.win-body{padding:10px}
.demo{display:none}
}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
</style>
</head>
<body>
<div class="shell">
<div class="brand">Aberio SAC <small>Cotizador · Ventas</small></div>
<div class="sync" aria-live="polite">
<span class="dot" aria-hidden="true"></span>
<span id="syncTxt">SAP Business One · sincronizado</span>
<button type="button" id="btnSync">Sincronizar</button>
</div>
</div>
<div class="toolbar" role="toolbar" aria-label="Acciones del documento">
<button class="tb" type="button" id="tbNew" title="Nueva cotización">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M3.5 1.5h6l3 3v10h-9z"/><path d="M9.5 1.5v3h3M8 7v5M5.5 9.5h5"/></svg>Nueva</button>
<button class="tb" type="button" id="tbFind" title="Buscar cotizaciones registradas">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5l4 4"/></svg>Buscar</button>
<span class="tb-sep" aria-hidden="true"></span>
<button class="tb" type="button" id="tbFirst" title="Primera cotización" aria-label="Primera cotización"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M4 3v10M12 3L7 8l5 5"/></svg></button>
<button class="tb" type="button" id="tbPrev" title="Cotización anterior" aria-label="Cotización anterior"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M10 3L5 8l5 5"/></svg></button>
<button class="tb" type="button" id="tbNext" title="Cotización siguiente" aria-label="Cotización siguiente"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M6 3l5 5-5 5"/></svg></button>
<button class="tb" type="button" id="tbLast" title="Última cotización" aria-label="Última cotización"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M12 3v10M4 3l5 5-5 5"/></svg></button>
<span class="tb-sep" aria-hidden="true"></span>
<button class="tb" type="button" id="tbPdf" title="Generar PDF para el cliente">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3"><path d="M4 6V1.5h8V6M4 12H2.5V6.5h11V12H12"/><path d="M4.5 9.5h7v5h-7z"/></svg>PDF</button>
<span class="demo">Datos de demostración que simulan SAP Business One</span>
</div>
<div class="wrap">
<div class="win">
<div class="win-title">
<h1>Oferta de ventas</h1>
<span class="badge new" id="docBadge">Nueva</span>
</div>
<div class="win-body">
<div class="hdr">
<div class="col">
<div class="f"><label class="req ga link" for="cliCode" id="lblCli">Cliente</label>
<div class="pick"><select class="sel" id="cliCode"></select><button type="button" id="btnCliList" title="Lista de socios de negocio" aria-label="Abrir lista de clientes">…</button></div></div>
<div class="f"><span class="lbl">Nombre</span><input class="in" id="cliName" readonly></div>
<div class="f"><span class="lbl">RUC / DNI</span><input class="in" id="cliRuc" readonly></div>
<div class="f"><label for="cliContact">Persona de contacto</label><select class="sel" id="cliContact"></select></div>
<div class="f"><span class="lbl">Canal / Zona</span>
<div class="pair" style="grid-template-columns:minmax(0,1fr) minmax(0,1fr)"><input class="in" id="cliCanal" readonly><input class="in" id="cliZona" readonly></div></div>
<div class="f"><span class="lbl">Lista de precios</span><input class="in" id="cliList" readonly></div>
<div class="f"><label for="refCli">Nº referencia cliente</label><input class="in" id="refCli" placeholder="Opcional"></div>
</div>
<div class="col">
<div class="f"><span class="lbl">Número</span>
<div class="pair"><input class="in" id="docSerie" value="COT" readonly aria-label="Serie"><input class="in" id="docNum" readonly aria-label="Número correlativo"></div></div>
<div class="f"><span class="lbl">Estado</span><input class="in" id="docEstado" readonly></div>
<div class="f"><span class="lbl">Fecha de contabilización</span><input class="in" id="docFecha" readonly></div>
<div class="f"><span class="lbl">Válido hasta</span><input class="in" id="docValido" readonly></div>
<div class="f"><span class="lbl">Tipo de cambio US$</span><input class="in" id="docTc" readonly></div>
<div class="f"><label for="docPago">Condición de pago</label>
<select class="sel" id="docPago"><option>Contado</option><option>Crédito 15 días</option><option>Crédito 30 días</option></select></div>
</div>
</div>
<div class="tabs" role="tablist" aria-label="Secciones">
<button class="tab" role="tab" id="t-cont" aria-controls="p-cont" aria-selected="true">Contenido</button>
<button class="tab" role="tab" id="t-log" aria-controls="p-log" aria-selected="false">Logística</button>
<button class="tab" role="tab" id="t-fin" aria-controls="p-fin" aria-selected="false">Finanzas</button>
<button class="tab" role="tab" id="t-fle" aria-controls="p-fle" aria-selected="false">Tabla de fletes</button>
</div>
<div class="tabpanel" role="tabpanel" id="p-cont" aria-labelledby="t-cont">
<div class="gridwrap">
<table class="grid" id="grid">
<thead><tr>
<th>#</th><th>Nº de artículo</th><th>Descripción</th><th class="num">Cantidad</th><th>Unidad</th>
<th class="num">Precio unitario</th><th>Mon.</th><th class="num">% Promo</th><th class="num">Precio tras desc.</th>
<th>Impuesto</th><th class="num">Total (S/)</th><th class="num">Mín. compra</th><th><span class="sr" style="position:absolute;left:-9999px">Quitar</span></th>
</tr></thead>
<tbody id="gridBody"></tbody>
</table>
</div>
<p class="hint" style="margin:8px 2px 0">El precio viene de la lista vigente en SAP según canal y zona del cliente, y no se edita a mano. Los artículos en US$ se convierten con el tipo de cambio del día.</p>
</div>
<div class="tabpanel" role="tabpanel" id="p-log" aria-labelledby="t-log" hidden>
<div class="hdr">
<div class="col">
<div class="f"><span class="lbl">Dirección fiscal</span><input class="in" id="dirFiscal" readonly></div>
<div class="f"><label class="req" for="obraSel">Dirección de obra</label><select class="sel" id="obraSel"></select></div>
<div class="f"><span class="lbl">Dirección de entrega</span><input class="in" id="obraDir" readonly></div>
<div class="f"><span class="lbl">Distrito</span><input class="in" id="obraDist" readonly></div>
</div>
<div class="col">
<p class="hint" style="margin:4px 0">La dirección de obra se registra aparte de la fiscal. Su distrito define el flete de referencia.</p>
</div>
</div>
</div>
<div class="tabpanel" role="tabpanel" id="p-fin" aria-labelledby="t-fin" hidden>
<div class="hdr">
<div class="col">
<div class="f"><span class="lbl">Moneda del documento</span><input class="in" value="Soles (S/)" readonly></div>
<div class="f"><span class="lbl">Tipo de cambio aplicado</span><input class="in" id="finTc" readonly></div>
<div class="f"><span class="lbl">Fuente</span><input class="in" value="SAP B1 · tipos de cambio (área de créditos)" readonly></div>
</div>
<div class="col">
<div class="f"><span class="lbl">Promoción vigente</span><input class="in" id="finPromo" readonly></div>
<div class="f"><span class="lbl">Vigencia de la oferta</span><input class="in" value="5 días calendario" readonly></div>
</div>
</div>
</div>
<div class="tabpanel" role="tabpanel" id="p-fle" aria-labelledby="t-fle" hidden>
<p class="hint" style="margin:0 0 8px">Montos de referencia por distrito, recopilados del histórico ya cotizado. Sin IGV, por viaje.</p>
<div class="gridwrap"><table class="grid" style="min-width:520px"><thead><tr><th>Distrito</th><th>Zona</th><th class="num">Flete de referencia (S/)</th></tr></thead><tbody id="fleteBody"></tbody></table></div>
</div>
<div class="foot">
<div class="col">
<div class="f"><label class="ga" for="docAsesor">Empleado de ventas</label><select class="sel" id="docAsesor"></select></div>
<div class="f"><span class="lbl">Titular</span><input class="in" id="docTitular" value="Coordinación comercial" readonly></div>
<div class="f" style="align-items:start"><label for="docComent" style="padding-top:3px">Comentarios</label><textarea class="ta" id="docComent" placeholder="Se imprimen en la cotización"></textarea></div>
</div>
<div class="col tot">
<div class="f"><span class="lbl">Total antes del flete</span><input class="in" id="totSub" readonly></div>
<div class="flete-row">
<label class="chk"><input type="checkbox" id="fleteOn"> Flete</label>
<span class="hint" id="fleteRef"></span>
<input class="in" id="fleteAmt" inputmode="decimal" aria-label="Monto de flete" style="text-align:right">
</div>
<div class="f"><span class="lbl">Impuesto (IGV 18 %)</span><input class="in" id="totIgv" readonly></div>
<div class="f grand"><span class="lbl" style="font-weight:700">Total del documento</span><input class="in" id="totAll" readonly></div>
</div>
</div>
<div class="actions">
<button class="btn main" type="button" id="btnSave">Crear</button>
<button class="btn" type="button" id="btnCancel">Cancelar</button>
<span class="spacer"></span>
<div class="menu">
<button class="btn" type="button" id="btnCopyFrom" aria-haspopup="true" aria-expanded="false">Copiar de</button>
<div class="menu-list" id="menuFrom" hidden>
<button type="button" id="mfLast">Última cotización del cliente</button>
<button type="button" id="mfAny">Cotización registrada…</button>
</div>
</div>
<div class="menu">
<button class="btn" type="button" id="btnCopyTo" aria-haspopup="true" aria-expanded="false">Copiar a</button>
<div class="menu-list" id="menuTo" hidden>
<button type="button" disabled>Pedido de cliente (etapa 5)</button>
</div>
</div>
<button class="btn" type="button" id="btnPdf">Generar PDF</button>
</div>
</div>
<div class="status" id="status" role="status" aria-live="polite"><span class="ic"></span><span id="statusTxt">Elija un cliente para empezar.</span></div>
</div>
</div>
<div class="modal" id="modal" hidden>
<div class="dlg" role="dialog" aria-modal="true" aria-labelledby="mTitle">
<header><h2 id="mTitle"></h2><button class="btn" type="button" id="mClose" style="min-width:0">Cerrar</button></header>
<div class="body" id="mBody"></div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
<script>
(function(){
"use strict";
/* ---------- Datos simulados de SAP Business One ---------- */
const ZONAS = {LS:"Lima Sur", PL:"Playas", SC:"Sur Chico"};
const CANALES = {CON:"Constructora", FER:"Ferretería", CF:"Cliente final"};
const FACTOR_CANAL = {CON:1.00, FER:0.96, CF:1.06};
const IGV = 0.18, VIGENCIA_DIAS = 5;
const PRODUCTOS = [
{code:"CEM-001", fam:"Cemento", name:"Cemento Portland Tipo I 42.5 kg", cur:"PEN", min:100, uoms:[{u:"Bolsa",f:1}], base:{LS:24.20,PL:25.10,SC:25.80}},
{code:"CEM-002", fam:"Cemento", name:"Cemento Portland Tipo IP 42.5 kg", cur:"PEN", min:100, uoms:[{u:"Bolsa",f:1}], base:{LS:23.40,PL:24.30,SC:24.90}},
{code:"CEM-003", fam:"Cemento", name:"Cemento antisalitre Tipo MS 42.5 kg", cur:"PEN", min:100, uoms:[{u:"Bolsa",f:1}], base:{LS:26.30,PL:27.20,SC:27.90}},
{code:"MEZ-001", fam:"Mezclas listas", name:"Concreto seco listo 40 kg", cur:"PEN", min:50, uoms:[{u:"Bolsa",f:1}], base:{LS:19.50,PL:20.20,SC:20.90}},
{code:"PEG-001", fam:"Pegamento", name:"Pegamento para cerámico 25 kg", cur:"PEN", min:20, uoms:[{u:"Bolsa",f:1}], base:{LS:17.80,PL:18.40,SC:19.00}},
{code:"LAD-001", fam:"Ladrillo", name:"Ladrillo King Kong 18 huecos 9x13x24", cur:"PEN", min:1, uoms:[{u:"Millar",f:1},{u:"Unidad",f:0.001}], base:{LS:980,PL:1020,SC:1060}},
{code:"LAD-002", fam:"Ladrillo", name:"Ladrillo pandereta 10x12x24", cur:"PEN", min:1, uoms:[{u:"Millar",f:1},{u:"Unidad",f:0.001}], base:{LS:760,PL:790,SC:830}},
{code:"LAD-003", fam:"Ladrillo", name:"Ladrillo de techo 15x30x30", cur:"PEN", min:0.5, uoms:[{u:"Millar",f:1},{u:"Unidad",f:0.001}], base:{LS:2850,PL:2950,SC:3050}},
{code:"FIE-001", fam:"Fierro", name:"Fierro corrugado 3/8\" x 9 m", cur:"USD", min:1, uoms:[{u:"Tonelada",f:1},{u:"Varilla",f:1/198}], base:{LS:845,PL:858,SC:870}},
{code:"FIE-002", fam:"Fierro", name:"Fierro corrugado 1/2\" x 9 m", cur:"USD", min:1, uoms:[{u:"Tonelada",f:1},{u:"Varilla",f:1/112}], base:{LS:838,PL:851,SC:864}},
{code:"FIE-003", fam:"Fierro", name:"Fierro corrugado 5/8\" x 9 m", cur:"USD", min:1, uoms:[{u:"Tonelada",f:1},{u:"Varilla",f:1/72}], base:{LS:838,PL:851,SC:864}},
{code:"TRE-001", fam:"Trefilería", name:"Alambre negro recocido N° 16", cur:"USD", min:50, uoms:[{u:"Kilo",f:1}], base:{LS:1.42,PL:1.46,SC:1.50}},
{code:"TRE-002", fam:"Trefilería", name:"Clavo para madera 3\"", cur:"USD", min:25, uoms:[{u:"Kilo",f:1}], base:{LS:1.38,PL:1.42,SC:1.46}}
];
const PROD = Object.fromEntries(PRODUCTOS.map(p=>[p.code,p]));
const CLIENTES = [
{code:"C20512345678", name:"Constructora Los Andes S.A.C.", ruc:"20512345678", canal:"CON", zona:"LS", asesor:"Martín Salazar",
fiscal:"Av. Defensores del Morro 1520, Chorrillos, Lima", contactos:["Ing. Carla Mendoza","Luis Ramos (logística)"],
obras:[{id:"O1",nombre:"Residencial Las Brisas",dir:"Calle Los Pinos 230, Urb. Los Huertos de Villa",dist:"Chorrillos"},
{id:"O2",nombre:"Condominio Lurín Verde",dir:"Km 36 Antigua Panamericana Sur",dist:"Lurín"}]},
{code:"C20603456781", name:"Inversiones Pacífico Sur E.I.R.L.", ruc:"20603456781", canal:"CON", zona:"PL", asesor:"Patricia Vega",
fiscal:"Av. Los Pescadores 145, San Bartolo, Lima", contactos:["Arq. Diego Salas"],
obras:[{id:"O1",nombre:"Casa de playa Lote 14",dir:"Playa El Silencio, Mz. B Lt. 14",dist:"Punta Hermosa"},
{id:"O2",nombre:"Edificio Mirador",dir:"Malecón San Martín 312",dist:"San Bartolo"}]},
{code:"C20458765432", name:"Ferretería El Maestro S.R.L.", ruc:"20458765432", canal:"FER", zona:"SC", asesor:"Jorge Ccori",
fiscal:"Jr. Lima 410, Chincha Alta, Ica", contactos:["Sr. Hugo Tasayco"],
obras:[{id:"O1",nombre:"Almacén principal",dir:"Jr. Lima 410",dist:"Chincha Alta"}]},
{code:"C20611223344", name:"Grupo Constructor Cañete S.A.", ruc:"20611223344", canal:"CON", zona:"SC", asesor:"Jorge Ccori",
fiscal:"Av. Mariscal Benavides 820, San Vicente de Cañete", contactos:["Ing. Milagros Huamán","Oficina técnica"],
obras:[{id:"O1",nombre:"Habilitación urbana Santa Rosa",dir:"Fundo Santa Rosa s/n",dist:"San Vicente de Cañete"},
{id:"O2",nombre:"Colegio Imperial",dir:"Av. Ramos 455",dist:"Imperial"}]},
{code:"C10456789012", name:"Rosa Elena Quispe Huamán", ruc:"10456789012", canal:"CF", zona:"LS", asesor:"Martín Salazar",
fiscal:"Mz. F Lt. 12, Sector 3, Villa El Salvador", contactos:["Rosa Elena Quispe"],
obras:[{id:"O1",nombre:"Vivienda tres pisos",dir:"Mz. F Lt. 12, Sector 3",dist:"Villa El Salvador"}]}
];
const CLI = Object.fromEntries(CLIENTES.map(c=>[c.code,c]));
const ASESORES = ["Martín Salazar","Patricia Vega","Jorge Ccori"];
const FLETES = [
["Chorrillos","LS",280],["San Juan de Miraflores","LS",250],["Villa El Salvador","LS",230],["Villa María del Triunfo","LS",260],
["Lurín","LS",300],["Pachacámac","LS",320],["Punta Hermosa","PL",380],["Punta Negra","PL",390],["San Bartolo","PL",400],
["Pucusana","PL",450],["San Vicente de Cañete","SC",650],["Chincha Alta","SC",900],["Pisco","SC",1100]
];
const FLETE = Object.fromEntries(FLETES.map(r=>[r[0],r[2]]));
/* Tipo de cambio y promoción del día, tal como llegarían de SAP */
const HOY = new Date(); HOY.setHours(0,0,0,0);
const TC = 3.745;
const mesNombre = HOY.toLocaleDateString("es-PE",{month:"long",year:"numeric"});
const PROMOS = [
{code:"CEM-001", pct:4, desde:200, label:"4 % en Tipo I desde 200 bolsas"},
{code:"CEM-002", pct:3, desde:300, label:"3 % en Tipo IP desde 300 bolsas"}
];
const PROMO = Object.fromEntries(PROMOS.map(p=>[p.code,p]));
/* ---------- Utilidades ---------- */
const $ = id => document.getElementById(id);
const r2 = n => Math.round((n + Number.EPSILON) * 100) / 100;
const money = n => (n||0).toLocaleString("es-PE",{minimumFractionDigits:2,maximumFractionDigits:2});
const qfmt = n => (n||0).toLocaleString("es-PE",{maximumFractionDigits:3});
const fdate = d => d.toLocaleDateString("es-PE",{day:"2-digit",month:"2-digit",year:"numeric"});
const iso = d => d.getFullYear()+"-"+String(d.getMonth()+1).padStart(2,"0")+"-"+String(d.getDate()).padStart(2,"0");
const fromIso = s => { const [y,m,d] = s.split("-").map(Number); return new Date(y,m-1,d); };
const addDays = (d,n) => { const x = new Date(d); x.setDate(x.getDate()+n); return x; };
const esc = s => String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const parseNum = s => { let t = String(s).trim(); t = /,\d{3}(\D|$)/.test(t) ? t.replace(/,/g,"") : t.replace(/,/g,"."); const n = parseFloat(t.replace(/[^\d.]/g,"")); return isFinite(n) ? n : 0; };
/* ---------- Estado ---------- */
let registry = []; // cotizaciones registradas
let store = null; // capa de persistencia
let state;
function blankState(){
return {
id:null, num:null, fecha:iso(HOY), cliente:"", contacto:"", ref:"", pago:"Contado",
obra:"", asesor:ASESORES[0], coment:"", lines:[{code:"",qty:0,uom:0}],
flete:{on:false, amt:0, manual:false}, editable:true, saved:false
};
}
/* ---------- Motor de precios ---------- */
function lineCalc(l, cli){
const p = PROD[l.code];
if(!p || !cli) return null;
const u = p.uoms[l.uom] || p.uoms[0];
const lista = p.base[cli.zona] * FACTOR_CANAL[cli.canal];
const unit = r2(lista * u.f);
const baseQty = (l.qty||0) * u.f;
const pr = PROMO[p.code];
const pct = (pr && baseQty >= pr.desde) ? pr.pct : 0;
const net = r2(unit * (1 - pct/100));
const totalCur = r2(net * (l.qty||0));
const totalPEN = r2(p.cur === "USD" ? totalCur * TC : totalCur);
const below = (l.qty||0) > 0 && baseQty + 1e-9 < p.min;
return {p,u,unit,pct,net,totalCur,totalPEN,baseQty,below};
}
function totals(){
const cli = CLI[state.cliente];
let sub = 0;
state.lines.forEach(l => { const c = lineCalc(l,cli); if(c) sub += c.totalPEN; });
sub = r2(sub);
const fl = state.flete.on ? r2(state.flete.amt||0) : 0;
const igv = r2((sub + fl) * IGV);
return {sub, fl, igv, total:r2(sub + fl + igv)};
}
/* ---------- Render ---------- */
function fillStatic(){
$("cliCode").innerHTML = '<option value="">— Seleccione cliente —</option>' +
CLIENTES.map(c=>`<option value="${c.code}">${c.code} · ${esc(c.name)}</option>`).join("");
$("docAsesor").innerHTML = ASESORES.map(a=>`<option>${esc(a)}</option>`).join("");
$("docTc").value = `S/ ${TC.toFixed(3)} · ${fdate(HOY)}`;
$("finTc").value = `S/ ${TC.toFixed(3)} por US$ · ${fdate(HOY)}`;
$("finPromo").value = `Cemento, ${mesNombre}: ` + PROMOS.map(p=>p.label).join("; ");
$("fleteBody").innerHTML = FLETES.map(r=>`<tr><td>${esc(r[0])}</td><td>${ZONAS[r[1]]}</td><td class="num">${money(r[2])}</td></tr>`).join("");
}
function render(){
const cli = CLI[state.cliente];
const ed = state.editable;
$("cliCode").value = state.cliente;
$("cliCode").disabled = !ed || state.saved;
$("btnCliList").disabled = !ed || state.saved;
$("cliName").value = cli ? cli.name : "";
$("cliRuc").value = cli ? cli.ruc : "";
$("cliCanal").value = cli ? CANALES[cli.canal] : "";
$("cliZona").value = cli ? ZONAS[cli.zona] : "";
$("cliList").value = cli ? `${cli.canal}-${cli.zona} · ${CANALES[cli.canal]} ${ZONAS[cli.zona]}` : "";
$("cliContact").innerHTML = cli ? cli.contactos.map(c=>`<option>${esc(c)}</option>`).join("") : "";
if(cli){ if(!cli.contactos.includes(state.contacto)) state.contacto = cli.contactos[0]; $("cliContact").value = state.contacto; }
$("cliContact").disabled = !ed || !cli;
$("refCli").value = state.ref; $("refCli").disabled = !ed;
const fecha = fromIso(state.fecha);
$("docNum").value = state.num || nextNumber();
$("docFecha").value = fdate(fecha);
$("docValido").value = fdate(addDays(fecha, VIGENCIA_DIAS));
$("docPago").value = state.pago; $("docPago").disabled = !ed;
const vencida = state.saved && !ed;
$("docEstado").value = !state.saved ? "Nueva" : (vencida ? "Vencida" : "Abierta");
const b = $("docBadge");
b.textContent = !state.saved ? "Nueva" : (vencida ? "Vencida" : "Abierta · vigente");
b.className = "badge " + (!state.saved ? "new" : (vencida ? "exp" : "open"));
// logística
$("dirFiscal").value = cli ? cli.fiscal : "";
$("obraSel").innerHTML = cli ? '<option value="">— Seleccione obra —</option>' + cli.obras.map(o=>`<option value="${o.id}">${esc(o.nombre)}</option>`).join("") : "";
$("obraSel").value = state.obra; $("obraSel").disabled = !ed || !cli;
const obra = cli && cli.obras.find(o=>o.id===state.obra);
$("obraDir").value = obra ? obra.dir : "";
$("obraDist").value = obra ? obra.dist : "";
$("docAsesor").value = state.asesor; $("docAsesor").disabled = !ed;
$("docComent").value = state.coment; $("docComent").disabled = !ed;
renderGrid();
renderTotals();
$("btnSave").textContent = state.saved ? "Actualizar" : "Crear";
$("btnSave").disabled = !ed;
$("mfLast").disabled = !cli;
navState();
}
function renderGrid(){
const cli = CLI[state.cliente];
const ed = state.editable && !!cli;
const opts = fam => PRODUCTOS.filter(p=>p.fam===fam).map(p=>`<option value="${p.code}">${p.code} · ${esc(p.name)}</option>`).join("");
const fams = [...new Set(PRODUCTOS.map(p=>p.fam))];
const prodOptions = '<option value=""></option>' + fams.map(f=>`<optgroup label="${f}">${opts(f)}</optgroup>`).join("");
const lock = '<svg class="lock" viewBox="0 0 10 10" aria-hidden="true"><rect x="1.5" y="4.5" width="7" height="5" rx="1" fill="currentColor"/><path d="M3 4.5V3a2 2 0 014 0v1.5" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>';
$("gridBody").innerHTML = state.lines.map((l,i)=>{
const c = lineCalc(l,cli);
const p = PROD[l.code];
const uomOpts = p ? p.uoms.map((u,k)=>`<option value="${k}">${u.u}</option>`).join("") : "";
const conv = c && c.u.f !== 1 && l.qty ? `<span class="conv">= ${qfmt(c.baseQty)} ${p.uoms[0].u.toLowerCase()}</span>` : "";
return `<tr data-i="${i}" class="${c && c.below ? "below":""}">
<td class="rn">${i+1}</td>
<td><select class="sel code" data-f="code" ${ed?"":"disabled"} aria-label="Artículo línea ${i+1}">${prodOptions}</select></td>
<td class="desc">${p ? esc(p.name) : '<span class="muted">'+(cli?"Elija un artículo":"Elija primero un cliente")+'</span>'}</td>
<td class="num"><input class="in qty" data-f="qty" inputmode="decimal" ${ed&&p?"":"disabled"} value="${l.qty?String(Math.round(l.qty*1000)/1000):""}" aria-label="Cantidad línea ${i+1}"></td>
<td><select class="sel uom" data-f="uom" ${ed&&p&&p.uoms.length>1?"":"disabled"} aria-label="Unidad línea ${i+1}">${uomOpts}</select>${conv}</td>
<td class="num c-unit">${c ? lock + money(c.unit) : ""}</td>
<td class="c-cur">${c ? (p.cur==="USD"?"US$":"S/") : ""}</td>
<td class="num c-pct">${c && c.pct ? '<span class="promo">'+c.pct+' %</span>' : (c ? "0 %" : "")}</td>
<td class="num c-net">${c ? money(c.net) : ""}</td>
<td>${c ? "IGV 18 %" : ""}</td>
<td class="num c-tot">${c ? money(c.totalPEN) : ""}</td>
<td class="num minc">${p ? qfmt(p.min)+" "+p.uoms[0].u.toLowerCase() : ""}</td>
<td>${p && ed ? `<button class="del" type="button" data-del="${i}" aria-label="Quitar línea ${i+1}" title="Quitar línea">×</button>` : ""}</td>
</tr>`;
}).join("");
state.lines.forEach((l,i)=>{
const tr = $("gridBody").children[i];
tr.querySelector('[data-f="code"]').value = l.code;
const us = tr.querySelector('[data-f="uom"]'); if(us) us.value = String(l.uom);
});
}
function updateRow(i){
const cli = CLI[state.cliente], l = state.lines[i], c = lineCalc(l,cli);
const tr = $("gridBody").children[i]; if(!tr || !c) return;
tr.classList.toggle("below", c.below);
tr.querySelector(".c-pct").innerHTML = c.pct ? '<span class="promo">'+c.pct+' %</span>' : "0 %";
tr.querySelector(".c-net").textContent = money(c.net);
tr.querySelector(".c-tot").textContent = money(c.totalPEN);
const td = tr.querySelector('[data-f="uom"]').parentNode;
let cv = td.querySelector(".conv");
if(c.u.f !== 1 && l.qty){ if(!cv){ cv = document.createElement("span"); cv.className="conv"; td.appendChild(cv);} cv.textContent = `= ${qfmt(c.baseQty)} ${c.p.uoms[0].u.toLowerCase()}`; }
else if(cv) cv.remove();
}
function renderTotals(){
const cli = CLI[state.cliente];
const obra = cli && cli.obras.find(o=>o.id===state.obra);
const ref = obra ? FLETE[obra.dist] : undefined;
if(state.flete.on && !state.flete.manual && ref !== undefined) state.flete.amt = ref;
$("fleteOn").checked = state.flete.on;
$("fleteOn").disabled = !state.editable || !cli;
$("fleteAmt").disabled = !state.editable || !state.flete.on;
if(document.activeElement !== $("fleteAmt")) $("fleteAmt").value = state.flete.on ? money(state.flete.amt) : "";
$("fleteRef").textContent = !obra ? "Elija la obra en Logística" :
(ref !== undefined ? `Ref. ${obra.dist}: S/ ${money(ref)}${state.flete.manual && state.flete.amt!==ref ? " · modificado":""}` : `Sin referencia para ${obra.dist}: ingrese el monto`);
const t = totals();
$("totSub").value = "S/ " + money(t.sub);
$("totIgv").value = "S/ " + money(t.igv);
$("totAll").value = "S/ " + money(t.total);
}
function setStatus(type, msg){
const s = $("status");
s.className = "status " + (type||"");
s.querySelector(".ic").textContent = type==="ok" ? "✓" : type==="err" ? "✕" : type==="warn" ? "!" : "";
$("statusTxt").textContent = msg;
}
/* ---------- Validación ---------- */
function validate(){
const cli = CLI[state.cliente];
if(!cli) return "Seleccione un cliente.";
const lines = state.lines.filter(l=>l.code);
if(!lines.length) return "Agregue al menos un artículo.";
for(const l of lines){
const c = lineCalc(l,cli);
if(!(l.qty>0)) return `Ingrese la cantidad de ${c.p.code}.`;
if(c.below) return `${c.p.code}: la cantidad está por debajo del mínimo de compra (${qfmt(c.p.min)} ${c.p.uoms[0].u.toLowerCase()}).`;
}
if(!state.obra) return "Seleccione la dirección de obra en la pestaña Logística.";
if(state.flete.on && !(state.flete.amt>0)) return "Ingrese el monto del flete o desmarque la opción.";
return null;
}
/* ---------- Correlativo y registro ---------- */
const YEAR = HOY.getFullYear();
function nextNumber(){
const pre = `COT-${YEAR}-`;
const max = registry.filter(r=>r.num && r.num.startsWith(pre)).reduce((m,r)=>Math.max(m, parseInt(r.num.slice(pre.length),10)||0), 0);
return pre + String(max+1).padStart(5,"0");
}
function sortedReg(){ return registry.slice().sort((a,b)=>a.num<b.num?-1:1); }
function serialize(){
const cli = CLI[state.cliente];
const obra = cli.obras.find(o=>o.id===state.obra);
const t = totals();
const lines = state.lines.filter(l=>l.code).map(l=>{
const c = lineCalc(l,cli);
return {code:l.code, name:c.p.name, qty:l.qty, uom:l.uom, uomName:c.u.u, cur:c.p.cur, unit:c.unit, pct:c.pct, net:c.net, totalPEN:c.totalPEN};
});
return {
num:state.num, fecha:state.fecha, validoHasta:iso(addDays(fromIso(state.fecha),VIGENCIA_DIAS)),
cliente:cli.code, clienteNombre:cli.name, ruc:cli.ruc, canal:cli.canal, zona:cli.zona, contacto:state.contacto, ref:state.ref,
pago:state.pago, obra:state.obra, obraNombre:obra.nombre, obraDir:obra.dir, obraDist:obra.dist,
asesor:state.asesor, coment:state.coment, tc:TC, lines, flete:{on:state.flete.on, amt:state.flete.on?state.flete.amt:0, manual:state.flete.manual},
sub:t.sub, igv:t.igv, total:t.total, actualizado:new Date().toISOString()
};
}
function loadDoc(d){
const vigente = fromIso(d.validoHasta) >= HOY;
state = {
id:d.num, num:d.num, fecha:d.fecha, cliente:d.cliente, contacto:d.contacto, ref:d.ref||"", pago:d.pago,
obra:d.obra, asesor:d.asesor, coment:d.coment||"",
lines:d.lines.map(l=>({code:l.code, qty:l.qty, uom:l.uom})).concat(vigente?[{code:"",qty:0,uom:0}]:[]),
flete:{on:!!d.flete.on, amt:d.flete.amt||0, manual:!!d.flete.manual}, editable:vigente, saved:true
};
render();
setStatus(vigente?"":"warn", vigente
? `${d.num} cargada. Está vigente hasta el ${fdate(fromIso(d.validoHasta))}: los cambios conservan el correlativo.`
: `${d.num} venció el ${fdate(fromIso(d.validoHasta))}. Use Copiar de para emitir una cotización nueva.`);
}
/* Persistencia: base de datos del artifact, con respaldo local */
async function initStore(){
let db = null;
try { db = window.claude && window.claude.use ? await window.claude.use("db") : null; } catch(e){ db = null; }
if(db){
const col = db.collection("cotizaciones");
store = {
kind:"db",
save: async d => { await col.doc(d.num).set(d); },
};
col.onSnapshot(snap=>{
registry = snap.docs.map(s=>s.data());
if(!state.saved && !state.num) $("docNum").value = nextNumber();
navState();
}, ()=>{ setStatus("warn","No se pudo leer el registro compartido. Las nuevas cotizaciones se guardarán en este navegador."); useLocal(); });
} else useLocal();
}
function useLocal(){
try { registry = JSON.parse(localStorage.getItem("aberio-cotizaciones")||"[]"); } catch(e){ registry = []; }
store = {
kind:"local",
save: async d => {
registry = registry.filter(r=>r.num!==d.num).concat([d]);
try { localStorage.setItem("aberio-cotizaciones", JSON.stringify(registry)); } catch(e){}
}
};
if(!state.saved) $("docNum").value = nextNumber();
navState();
}
async function save(){
const err = validate();
if(err){ setStatus("err", err); return false; }
const nuevo = !state.saved;
if(nuevo) state.num = nextNumber();
const d = serialize();
try {
$("btnSave").disabled = true;
await store.save(d);
if(store.kind==="db"){ registry = registry.filter(r=>r.num!==d.num).concat([d]); }
state.saved = true; state.id = d.num;
render();
setStatus("ok", nuevo ? `Cotización ${d.num} creada y registrada. Vigente hasta el ${fdate(fromIso(d.validoHasta))}.` : `Cotización ${d.num} actualizada. El correlativo se mantiene.`);
return true;
} catch(e){
if(nuevo) state.num = null;
$("btnSave").disabled = false;
setStatus("err", "No se pudo registrar la cotización. Revise la conexión e intente de nuevo.");
return false;
}
}
/* ---------- Navegación ---------- */
function navState(){
const list = sortedReg();
const idx = list.findIndex(r=>r.num===state.num);
$("tbFirst").disabled = !list.length || idx===0;
$("tbPrev").disabled = !list.length || idx===0;
$("tbNext").disabled = !list.length || idx===-1 || idx===list.length-1;
$("tbLast").disabled = !list.length || idx===list.length-1;
}
function nav(where){
const list = sortedReg(); if(!list.length) return;
let idx = list.findIndex(r=>r.num===state.num);
if(where==="first") idx = 0;
else if(where==="last") idx = list.length-1;
else if(where==="prev") idx = idx<=0 ? 0 : idx-1;
else if(where==="next") idx = idx===-1 ? list.length-1 : Math.min(list.length-1, idx+1);
loadDoc(list[idx]);
}
/* ---------- Copiar de ---------- */
function copyFrom(d){
const cli = CLI[d.cliente];
state = blankState();
state.cliente = d.cliente; state.contacto = d.contacto; state.pago = d.pago; state.asesor = d.asesor;
state.obra = cli.obras.some(o=>o.id===d.obra) ? d.obra : "";
state.lines = d.lines.map(l=>({code:l.code, qty:l.qty, uom:l.uom})).concat([{code:"",qty:0,uom:0}]);
state.flete = {on:!!d.flete.on, amt:d.flete.amt||0, manual:!!d.flete.manual};
render();
const before = d.total, after = totals().total;
const diff = r2(after - before);
setStatus("ok", `Se copiaron ${d.lines.length} líneas de ${d.num} con los precios vigentes de hoy` +
(Math.abs(diff)>=0.01 ? ` (el total cambió en S/ ${money(diff)} frente a la original).` : ".") + " Revise y pulse Crear.");
}
/* ---------- Modal ---------- */
function openModal(title, html){
$("mTitle").textContent = title; $("mBody").innerHTML = html; $("modal").hidden = false;
const f = $("mBody").querySelector("input,button,tr[tabindex]") || $("mClose"); f.focus();
}
function closeModal(){ $("modal").hidden = true; }
function showClientList(){
const rows = CLIENTES.map(c=>`<tr tabindex="0" data-cli="${c.code}"><td>${c.code}</td><td>${esc(c.name)}</td><td>${CANALES[c.canal]}</td><td>${ZONAS[c.zona]}</td></tr>`).join("");
openModal("Lista de socios de negocio", `<div class="gridwrap"><table class="grid list" style="min-width:560px"><thead><tr><th>Código</th><th>Nombre</th><th>Canal</th><th>Zona</th></tr></thead><tbody>${rows}</tbody></table></div>`);
}
function showRegistry(mode){
const list = sortedReg().reverse();
const body = list.length ? list.map(r=>{
const venc = fromIso(r.validoHasta) < HOY;
return `<tr tabindex="0" data-num="${r.num}"><td>${r.num}</td><td>${fdate(fromIso(r.fecha))}</td><td>${esc(r.clienteNombre)}</td><td>${esc(r.obraNombre)}</td><td class="num">${money(r.total)}</td><td>${venc?"Vencida":"Vigente"}</td></tr>`;
}).join("") : "";
openModal(mode==="copy" ? "Copiar de una cotización registrada" : "Cotizaciones registradas",
list.length ? `<input class="in search" id="regSearch" placeholder="Filtrar por número, cliente u obra" aria-label="Filtrar">
<div class="gridwrap"><table class="grid list" data-mode="${mode}" style="min-width:680px"><thead><tr><th>Número</th><th>Fecha</th><th>Cliente</th><th>Obra</th><th class="num">Total (S/)</th><th>Estado</th></tr></thead><tbody id="regBody">${body}</tbody></table></div>`
: `<div class="empty">Aún no hay cotizaciones registradas. Cree la primera con el botón Crear.</div>`);
const s = $("regSearch");
if(s) s.addEventListener("input", ()=>{
const q = s.value.toLowerCase();
[...$("regBody").children].forEach(tr=>{ tr.hidden = !tr.textContent.toLowerCase().includes(q); });
});
}
/* ---------- PDF ---------- */
async function makePdf(){
if(!state.saved || (state.editable && isDirty())){
const ok = await save(); if(!ok) return;
}
const d = registry.find(r=>r.num===state.num) || serialize();
if(!window.jspdf){ setStatus("err","No se pudo cargar el generador de PDF. Recargue la página e intente de nuevo."); return; }
const { jsPDF } = window.jspdf;
const doc = new jsPDF({unit:"mm", format:"a4"});
const W = 210, M = 14, navy = [31,58,95], grey = [95,99,104];
doc.setFillColor(...navy); doc.rect(0,0,W,4,"F");
doc.setFont("helvetica","bold"); doc.setFontSize(20); doc.setTextColor(...navy);
doc.text("ABERIO", M, 20);
doc.setFontSize(9); doc.setFont("helvetica","normal"); doc.setTextColor(...grey);
doc.text("ABERIO S.A.C. · RUC [completar]", M, 26);
doc.text("[Dirección comercial] · [Teléfono] · [correo]", M, 30.5);
doc.setDrawColor(...navy); doc.setLineWidth(0.4); doc.rect(128, 10, 68, 26);
doc.setFont("helvetica","bold"); doc.setFontSize(11); doc.setTextColor(...navy);
doc.text("COTIZACIÓN", 162, 17, {align:"center"});
doc.setFontSize(12); doc.setTextColor(20,20,20); doc.text(d.num, 162, 24, {align:"center"});
doc.setFont("helvetica","normal"); doc.setFontSize(8.5); doc.setTextColor(...grey);
doc.text(`Fecha: ${fdate(fromIso(d.fecha))} Válida hasta: ${fdate(fromIso(d.validoHasta))}`, 162, 31, {align:"center"});
const info = [
["Señor(es)", d.clienteNombre], ["RUC / DNI", d.ruc], ["Dirección fiscal", CLI[d.cliente].fiscal],
["Obra", `${d.obraNombre} · ${d.obraDir}, ${d.obraDist}`], ["Contacto", d.contacto],
["Asesor comercial", d.asesor], ["Condición de pago", d.pago]
].concat(d.ref ? [["Referencia", d.ref]] : []);
doc.autoTable({
startY:42, margin:{left:M,right:M}, body:info, theme:"plain",
styles:{fontSize:9, cellPadding:{top:0.8,bottom:0.8,left:0,right:2}, textColor:[30,30,30]},
columnStyles:{0:{fontStyle:"bold", cellWidth:36, textColor:grey}}
});
const body = d.lines.map((l,i)=>[
i+1, l.code, l.name, qfmt(l.qty), l.uomName, (l.cur==="USD"?"US$ ":"S/ ")+money(l.unit),
l.pct ? l.pct+" %" : "—", money(l.totalPEN)
]);
if(d.flete.on) body.push([body.length+1, "FLETE", `Transporte a obra (${d.obraDist})`, "1", "Servicio", "S/ "+money(d.flete.amt), "—", money(d.flete.amt)]);
doc.autoTable({
startY:doc.lastAutoTable.finalY + 5, margin:{left:M,right:M},
head:[["N°","Código","Descripción","Cant.","Unidad","P. unitario","Promo","Total S/"]],
body, theme:"grid",
headStyles:{fillColor:navy, textColor:255, fontSize:8.5, halign:"center"},
styles:{fontSize:8.5, cellPadding:1.8, lineColor:[210,214,219], lineWidth:0.2},
columnStyles:{0:{halign:"center",cellWidth:9},1:{cellWidth:20},3:{halign:"right",cellWidth:15},4:{cellWidth:17},5:{halign:"right",cellWidth:25},6:{halign:"center",cellWidth:14},7:{halign:"right",cellWidth:24}}
});
let y = doc.lastAutoTable.finalY + 2;
const sub = r2(d.sub + (d.flete.on ? d.flete.amt : 0));
doc.autoTable({
startY:y, margin:{left:118,right:M}, theme:"plain",
body:[["Subtotal", "S/ "+money(sub)], ["IGV (18 %)", "S/ "+money(d.igv)], ["TOTAL", "S/ "+money(d.total)]],
styles:{fontSize:9.5, cellPadding:1.4}, columnStyles:{0:{fontStyle:"bold",textColor:grey},1:{halign:"right"}},
didParseCell:h=>{ if(h.row.index===2){ h.cell.styles.fontStyle="bold"; h.cell.styles.fontSize=11; h.cell.styles.textColor=navy; h.cell.styles.fillColor=[234,239,245]; } }
});
y = doc.lastAutoTable.finalY + 8;
const usd = d.lines.some(l=>l.cur==="USD");
const notas = [
`Precios en soles, sin IGV por línea; el IGV se suma al final.`,
usd ? `Fierro y trefilería convertidos con el tipo de cambio del ${fdate(fromIso(d.fecha))}: S/ ${d.tc.toFixed(3)} por US$.` : null,
d.lines.some(l=>l.pct) ? `Incluye la promoción de cemento vigente en la fecha de emisión.` : null,
`Oferta válida por ${VIGENCIA_DIAS} días calendario, hasta el ${fdate(fromIso(d.validoHasta))}. Sujeta a disponibilidad de stock.`,
`Cantidades sujetas al mínimo de compra por producto.`
].filter(Boolean);
doc.setFont("helvetica","bold"); doc.setFontSize(9); doc.setTextColor(...navy); doc.text("Condiciones", M, y);
doc.setFont("helvetica","normal"); doc.setFontSize(8.5); doc.setTextColor(50,50,50);
notas.forEach((n,i)=>{ doc.text("• "+n, M, y + 5 + i*4.4); });
y = y + 5 + notas.length*4.4 + 3;
if(d.coment){
doc.setFont("helvetica","bold"); doc.setTextColor(...navy); doc.text("Comentarios", M, y);
doc.setFont("helvetica","normal"); doc.setTextColor(50,50,50);
doc.text(doc.splitTextToSize(d.coment, W-2*M), M, y+5);
}
const pages = doc.getNumberOfPages();
for(let p=1;p<=pages;p++){
doc.setPage(p); doc.setFontSize(7.5); doc.setTextColor(...grey);
doc.text(`${d.num} · Aberio S.A.C.`, M, 290);
doc.text(`Página ${p} de ${pages}`, W-M, 290, {align:"right"});
}
const blob = doc.output("blob");
const fname = `${d.num}_${d.clienteNombre.replace(/[^A-Za-z0-9áéíóúñÁÉÍÓÚÑ ]/g,"").trim().replace(/\s+/g,"-").slice(0,40)}.pdf`;
let dl = null;
try { dl = window.claude && window.claude.use ? await window.claude.use("downloads") : null; } catch(e){}
if(!dl){ setStatus("warn","La descarga no está disponible en esta vista. Abra la página publicada para guardar el PDF."); return; }
try {
await dl.save({filename:fname, data:blob});
setStatus("ok", `PDF ${fname} generado, listo para enviar al cliente.`);
} catch(e){
if(e && e.code==="declined") setStatus("", "Descarga cancelada.");
else if(e && e.code==="rate_limited") setStatus("warn","Ya hay una descarga pendiente de confirmar.");
else setStatus("err","No se pudo guardar el PDF en esta vista.");
}
}
let snapshot = "";
function isDirty(){ return state.saved && JSON.stringify(serialize().lines)+JSON.stringify(state.flete)+state.coment+state.obra+state.pago+state.contacto+state.ref+state.asesor !== snapshot; }
function takeSnap(){ if(state.saved){ const d = serialize(); snapshot = JSON.stringify(d.lines)+JSON.stringify(state.flete)+state.coment+state.obra+state.pago+state.contacto+state.ref+state.asesor; } }
/* ---------- Eventos ---------- */
function ensureTrailing(){
const last = state.lines[state.lines.length-1];
if(last && last.code){ state.lines.push({code:"",qty:0,uom:0}); return true; }
return false;
}
function selectClient(code){
if(state.saved) return;
state.cliente = code;
const cli = CLI[code];
state.obra = cli && cli.obras.length===1 ? cli.obras[0].id : "";
if(cli) state.asesor = cli.asesor;
state.flete.manual = false;
render();
if(cli){
const prev = sortedReg().filter(r=>r.cliente===code).pop();
setStatus("", `Precios cargados de la lista ${cli.canal}-${cli.zona}.` + (prev ? ` Este cliente tiene la cotización ${prev.num}: puede usar Copiar de › Última cotización del cliente.` : ""));
} else setStatus("", "Elija un cliente para empezar.");
}
$("cliCode").addEventListener("change", e=>selectClient(e.target.value));
$("btnCliList").addEventListener("click", showClientList);
$("lblCli").addEventListener("click", ()=>{ if(!state.saved) showClientList(); });
$("cliContact").addEventListener("change", e=>{ state.contacto = e.target.value; });
$("refCli").addEventListener("input", e=>{ state.ref = e.target.value; });
$("docPago").addEventListener("change", e=>{ state.pago = e.target.value; });
$("docAsesor").addEventListener("change", e=>{ state.asesor = e.target.value; });
$("docComent").addEventListener("input", e=>{ state.coment = e.target.value; });
$("obraSel").addEventListener("change", e=>{ state.obra = e.target.value; state.flete.manual = false; render(); });
$("gridBody").addEventListener("change", e=>{
const tr = e.target.closest("tr"); if(!tr) return;
const i = +tr.dataset.i, f = e.target.dataset.f;
if(f==="code"){
const p = PROD[e.target.value];
state.lines[i] = {code:e.target.value, qty: p ? p.min / p.uoms[0].f : 0, uom:0};
ensureTrailing(); renderGrid(); renderTotals();
const q = $("gridBody").children[i].querySelector('[data-f="qty"]'); if(q && !q.disabled){ q.focus(); q.select(); }
if(p) setStatus("", `${p.code}: precio de lista S/ ${money(lineCalc(state.lines[i],CLI[state.cliente]).unit)}${p.cur==="USD"?" (US$)":""}. Cantidad inicial = mínimo de compra.`);
} else if(f==="uom"){
const l = state.lines[i], p = PROD[l.code];
const oldF = p.uoms[l.uom].f, newF = p.uoms[+e.target.value].f;
l.qty = Math.round((l.qty * oldF / newF) * 1000) / 1000;
l.uom = +e.target.value;
renderGrid(); renderTotals();
}
});
$("gridBody").addEventListener("input", e=>{
if(e.target.dataset.f!=="qty") return;
const i = +e.target.closest("tr").dataset.i;
state.lines[i].qty = parseNum(e.target.value);
updateRow(i); renderTotals();
const c = lineCalc(state.lines[i], CLI[state.cliente]);
if(c && c.below) setStatus("warn", `${c.p.code}: el mínimo de compra es ${qfmt(c.p.min)} ${c.p.uoms[0].u.toLowerCase()}.`);
else if(c && c.pct) setStatus("ok", `Promoción aplicada a ${c.p.code}: ${c.pct} % de descuento.`);
});
$("gridBody").addEventListener("click", e=>{
const b = e.target.closest("[data-del]"); if(!b) return;
state.lines.splice(+b.dataset.del, 1);
if(!state.lines.length || state.lines[state.lines.length-1].code) state.lines.push({code:"",qty:0,uom:0});
renderGrid(); renderTotals();
});
$("fleteOn").addEventListener("change", e=>{
state.flete.on = e.target.checked;
if(state.flete.on){ state.flete.manual = false; state.flete.amt = 0; }
renderTotals();
if(state.flete.on && !(state.flete.amt>0)) { $("fleteAmt").focus(); }
});
$("fleteAmt").addEventListener("input", e=>{ state.flete.amt = parseNum(e.target.value); state.flete.manual = true; renderTotals(); });
$("fleteAmt").addEventListener("blur", ()=>renderTotals());
document.querySelectorAll(".tab").forEach(t=>t.addEventListener("click", ()=>{
document.querySelectorAll(".tab").forEach(x=>x.setAttribute("aria-selected", x===t ? "true":"false"));
document.querySelectorAll(".tabpanel").forEach(p=>p.hidden = p.id !== t.getAttribute("aria-controls"));
}));
$("btnSave").addEventListener("click", async ()=>{ if(await save()) takeSnap(); });
$("btnCancel").addEventListener("click", ()=>{
if(state.saved){ const d = registry.find(r=>r.num===state.num); if(d){ loadDoc(d); takeSnap(); return; } }
state = blankState(); render(); setStatus("", "Formulario limpio. Elija un cliente para empezar.");
});
$("tbNew").addEventListener("click", ()=>{ state = blankState(); render(); setStatus("", "Nueva cotización. Elija un cliente para empezar."); });
$("tbFind").addEventListener("click", ()=>showRegistry("open"));
$("tbFirst").addEventListener("click", ()=>{ nav("first"); takeSnap(); });
$("tbPrev").addEventListener("click", ()=>{ nav("prev"); takeSnap(); });
$("tbNext").addEventListener("click", ()=>{ nav("next"); takeSnap(); });
$("tbLast").addEventListener("click", ()=>{ nav("last"); takeSnap(); });
$("tbPdf").addEventListener("click", makePdf);
$("btnPdf").addEventListener("click", makePdf);
function toggleMenu(btn, menu){
const open = menu.hidden;
document.querySelectorAll(".menu-list").forEach(m=>m.hidden=true);
document.querySelectorAll("[aria-haspopup]").forEach(b=>b.setAttribute("aria-expanded","false"));
menu.hidden = !open; btn.setAttribute("aria-expanded", open?"true":"false");
}
$("btnCopyFrom").addEventListener("click", e=>{ e.stopPropagation(); toggleMenu($("btnCopyFrom"), $("menuFrom")); });
$("btnCopyTo").addEventListener("click", e=>{ e.stopPropagation(); toggleMenu($("btnCopyTo"), $("menuTo")); });
document.addEventListener("click", ()=>{ document.querySelectorAll(".menu-list").forEach(m=>m.hidden=true); });
$("mfLast").addEventListener("click", ()=>{
const prev = sortedReg().filter(r=>r.cliente===state.cliente).pop();
if(!prev){ setStatus("warn", "Este cliente aún no tiene cotizaciones registradas."); return; }
copyFrom(prev);
});
$("mfAny").addEventListener("click", ()=>showRegistry("copy"));
$("mClose").addEventListener("click", closeModal);
$("modal").addEventListener("click", e=>{ if(e.target.id==="modal") closeModal(); });
document.addEventListener("keydown", e=>{ if(e.key==="Escape" && !$("modal").hidden) closeModal(); });
function pickRow(tr){
if(tr.dataset.cli){ closeModal(); selectClient(tr.dataset.cli); return; }
if(tr.dataset.num){
const d = registry.find(r=>r.num===tr.dataset.num); const mode = tr.closest("table").dataset.mode;
closeModal(); if(!d) return;
if(mode==="copy") copyFrom(d); else { loadDoc(d); takeSnap(); }
}
}
$("mBody").addEventListener("click", e=>{ const tr = e.target.closest("tbody tr"); if(tr) pickRow(tr); });
$("mBody").addEventListener("keydown", e=>{ if(e.key==="Enter"){ const tr = e.target.closest("tbody tr"); if(tr) pickRow(tr); } });
let syncAt = new Date(); syncAt.setHours(7,0,0,0); if(syncAt > new Date()) syncAt = new Date();
function syncLabel(){ $("syncTxt").textContent = `SAP Business One · sincronizado ${fdate(syncAt)} ${syncAt.toLocaleTimeString("es-PE",{hour:"2-digit",minute:"2-digit"})}`; }
$("btnSync").addEventListener("click", ()=>{
$("btnSync").disabled = true; $("syncTxt").textContent = "Sincronizando catálogo, precios, tipo de cambio y promociones…";
setTimeout(()=>{ syncAt = new Date(); syncLabel(); $("btnSync").disabled = false; setStatus("ok","Sincronización completa: 13 artículos, 9 listas de precios, tipo de cambio y promociones del mes."); }, 900);
});
/* ---------- Inicio ---------- */
state = blankState();
fillStatic(); syncLabel(); render();
initStore();
})();
</script>
</body>
</html>