chore: esqueleto del monorepo (npm workspaces)
Raíz con workspaces apps/* y libs/*, reglas del proyecto base y flujo por branch de cliente en CLAUDE.md, y el mockup de Oferta de ventas. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e4417705e7
7 files changed
+1067
No files matched your search
@@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.angular/
|
||||
coverage/
|
||||
*.log
|
||||
logs/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.sqlite
|
||||
*.sqlite-journal
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
@@ -0,0 +1,21 @@
|
||||
# basic-erp
|
||||
|
||||
ERP base para SAP Business One (Service Layer). Monorepo npm workspaces:
|
||||
|
||||
- `apps/api` — NestJS: proxy hacia el Service Layer, login, sesión SAP compartida, permisos de menú (SQLite).
|
||||
- `apps/web` — Angular: componentes reutilizables estilo SAP Business One.
|
||||
- `libs/sap-types` — tipos TypeScript compartidos entre api y web.
|
||||
- `tools/` — mockups y colecciones de pruebas.
|
||||
|
||||
## Reglas del proyecto base
|
||||
|
||||
- Solo campos **estándar** de SAP Business One. Ningún campo de usuario (`U_*`) salvo `U_WEB_USER` / `U_WEB_PASS` de `EmployeesInfo`, usados para el login web.
|
||||
- Los formularios se construyen con componentes reutilizables y configuración declarativa; no se duplica markup por documento.
|
||||
- Los colores y el aspecto salen de variables CSS (temas). Tema por defecto: claro, con la paleta de `tools/Cotizador Aberio · Oferta de ventas.html`.
|
||||
|
||||
## Flujo de trabajo por cliente (branches)
|
||||
|
||||
1. `main` es la versión base/genérica, sin personalizaciones de cliente.
|
||||
2. Cada cliente tiene su branch (`cliente/<nombre-cliente>`); ahí viven sus personalizaciones y fixes.
|
||||
3. Los cambios de una branch de cliente no se mezclan con `main` ni con otro cliente, salvo backport genérico decidido explícitamente.
|
||||
4. Antes de crear branches, mergear a `main`, o hacer merge/backport entre clientes, confirmar con el usuario. No hacer `git push` sin confirmación.
|
||||
@@ -0,0 +1,12 @@
|
||||
# basic-erp
|
||||
|
||||
ERP base para SAP Business One sobre **NestJS + Angular** (TypeScript en ambos lados).
|
||||
|
||||
```
|
||||
apps/api NestJS (proxy Service Layer, auth, permisos)
|
||||
apps/web Angular (componentes reutilizables)
|
||||
libs/sap-types Tipos compartidos
|
||||
tools/ Mockups y pruebas
|
||||
```
|
||||
|
||||
Requisitos: Node 22+ y npm 10+. `npm install` en la raíz instala todos los workspaces.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "basic-erp",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "ERP base para SAP Business One (Service Layer): NestJS + Angular",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"libs/*"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
@@ -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=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[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>
|
||||
Reference in new issue
Block a user