Files
basic-erp/tools/oferta_venta.html
T
GeorgeandClaude Sonnet 5.5 4c8c499a72 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>
2026-10-13 19:18:05 -05:00

997 lines
58 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="es">
<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>