ERP a la medida (Python stdlib + SQLite + vanilla JS SPA). Incluye server.py, index.html, utilidades y documentación: README, MODELO_DATOS, API, INSTALACION, CONTEXTO, NEGOCIO, WEB, ONBOARDING, VALOR_SISTEMA, CLAUDE. Secretos y datos (art4hotel.db, secret.key, ACCESOS.html, uploads/, backups/) excluidos vía .gitignore. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
9772 lines
555 KiB
HTML
9772 lines
555 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="es">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<title>art 4 hotel — Hub</title>
|
||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=DM+Sans:wght@400;500;700&family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root{
|
||
/* Art4Hotel Brand Palette */
|
||
--bg:#FAF7F0;--s1:#FFFFFF;--s2:#F5F0E5;--s3:#E8DFC8;
|
||
--bd:#D4C5A9;--bd2:#C4B494;
|
||
--ac:#5C6B4F;--acd:rgba(92,107,79,.08);--acg:rgba(92,107,79,.18);
|
||
--tx:#2C2C2C;--t2:#8A8075;--t3:#A89F94;
|
||
--gn:#5C6B4F;--gnd:rgba(92,107,79,.1);
|
||
--yl:#A68B3C;--yld:rgba(166,139,60,.1);
|
||
--or:#6B4F3C;--ord:rgba(107,79,60,.1);
|
||
--bl:#4A7B6B;--bld:rgba(74,123,107,.1);
|
||
--pr:#7B5C8A;--prd:rgba(123,92,138,.1);
|
||
--rd:#8B4049;--rdd:rgba(139,64,73,.1);
|
||
--cy:#3D7B8A;--cyd:rgba(61,123,138,.1);
|
||
--r:8px;
|
||
/* Brand specific */
|
||
--olive:#5C6B4F;--olive-dark:#3D4A33;
|
||
--brown:#6B4F3C;--brown-dark:#4A3628;
|
||
--sand:#D4C5A9;--sand-light:#E8DFC8;
|
||
--cream:#FAF7F0;--charcoal:#2C2C2C;
|
||
--warm-gray:#8A8075;
|
||
}
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{font-family:'Outfit',sans-serif;background:var(--bg);color:var(--tx);min-height:100vh;font-weight:400}
|
||
::-webkit-scrollbar{width:5px;height:5px}::-webkit-scrollbar-thumb{background:var(--bd);border-radius:3px}
|
||
|
||
/* NAV */
|
||
.nav{background:var(--olive-dark);border-bottom:none;padding:0 16px;display:flex;align-items:center;height:48px;gap:8px;position:sticky;top:0;z-index:100;box-shadow:0 1px 8px rgba(61,74,51,.12)}
|
||
.nav-logo{font-weight:700;font-size:14px;color:var(--sand);letter-spacing:-.3px;white-space:nowrap;font-family:'Outfit',sans-serif}
|
||
.nav-logo span{color:var(--sand-light);font-weight:300;opacity:.6;margin-left:4px;font-size:11px}
|
||
.tabs{display:flex;gap:1px;margin-left:12px;overflow-x:auto;-webkit-overflow-scrolling:touch}
|
||
.tab{padding:6px 11px;border-radius:6px;font-size:11px;font-weight:500;color:rgba(212,197,169,.7);cursor:pointer;border:none;background:none;transition:.15s;white-space:nowrap;font-family:'Outfit',sans-serif}
|
||
.nav-tab-toggle{display:none;align-items:center;gap:8px;background:var(--olive);color:#fff;border:none;padding:7px 12px;border-radius:6px;font-size:12px;font-family:inherit;cursor:pointer;font-weight:600;min-width:140px;justify-content:space-between}
|
||
.nav-tab-toggle:hover{background:#3D4A33}
|
||
.nav-tab-toggle-arrow{font-size:9px;transition:transform .15s}
|
||
.nav-tab-toggle.open .nav-tab-toggle-arrow{transform:rotate(180deg)}
|
||
.tab:hover{color:var(--sand);background:rgba(255,255,255,.08)}
|
||
.tab.on{color:#fff;background:var(--olive)}
|
||
.nav-r{margin-left:auto;display:flex;align-items:center;gap:6px}
|
||
.dot{width:6px;height:6px;border-radius:50%;background:var(--sand);animation:p 2s infinite}
|
||
@keyframes p{0%,100%{opacity:1}50%{opacity:.4}}
|
||
.clk{font-size:10px;color:var(--sand-light);font-variant-numeric:tabular-nums;opacity:.7}
|
||
|
||
/* PAGES */
|
||
.pg{display:none;padding:12px}.pg.on{display:block}
|
||
|
||
/* BTNS */
|
||
.btn{padding:6px 12px;border-radius:6px;font-size:11px;font-weight:500;border:1px solid var(--bd);background:var(--s1);color:var(--tx);cursor:pointer;transition:.15s}
|
||
.btn:hover{border-color:var(--olive);color:var(--olive);background:var(--acd)}
|
||
.btn-ac{background:var(--olive);color:#fff;border-color:var(--olive)}
|
||
.btn-ac:hover{opacity:.9;background:var(--olive-dark);color:#fff}
|
||
.btn-sm{padding:4px 8px;font-size:10px}
|
||
|
||
/* KPI */
|
||
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;margin-bottom:14px}
|
||
.kpi{background:var(--s1);border:1px solid var(--bd);border-radius:var(--r);padding:12px;text-align:center;transition:.2s;cursor:pointer;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||
.kpi:hover{border-color:var(--olive);box-shadow:0 2px 8px rgba(92,107,79,.1)}
|
||
.kpi b{font-size:26px;display:block;line-height:1;font-variant-numeric:tabular-nums;font-weight:700}
|
||
.kpi small{font-size:8px;color:var(--t2);text-transform:uppercase;letter-spacing:1px;display:block;margin-top:3px;font-weight:500}
|
||
.kpi em{font-size:9px;color:var(--t3);font-style:normal;display:block;margin-top:2px}
|
||
|
||
/* CARDS */
|
||
.crd{background:var(--s1);border:1px solid var(--bd);border-radius:var(--r);overflow:hidden;margin-bottom:12px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
||
.crd-h{padding:9px 12px;border-bottom:1px solid var(--sand-light);font-size:12px;font-weight:600;display:flex;align-items:center;gap:8px;color:var(--olive-dark)}
|
||
.crd-b{padding:10px 12px}
|
||
.row2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||
.row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}
|
||
@media(max-width:900px){.row2,.row3{grid-template-columns:1fr}}
|
||
|
||
/* BARS */
|
||
.bar{display:flex;align-items:center;gap:6px;margin-bottom:5px}
|
||
.bar-l{font-size:10px;width:100px;color:var(--t2);text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.bar-t{flex:1;height:18px;background:var(--bd);border-radius:4px;overflow:hidden}
|
||
.bar-f{height:100%;border-radius:4px;display:flex;align-items:center;justify-content:flex-end;padding-right:5px;font-size:8px;font-weight:700;color:var(--bg);min-width:20px;transition:width .5s}
|
||
.bar-v{font-size:9px;width:45px;color:var(--t2);text-align:right}
|
||
|
||
/* KANBAN */
|
||
.kb-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;flex-wrap:wrap;gap:8px}
|
||
.kb-title{font-size:16px;font-weight:700}
|
||
.kb{display:flex;gap:8px;overflow-x:auto;padding-bottom:10px;min-height:calc(100vh - 150px)}
|
||
.kb-col{min-width:230px;width:230px;flex-shrink:0;display:flex;flex-direction:column}
|
||
@media(min-width:900px){
|
||
#kb-ordenes{min-height:0;max-height:55vh}
|
||
#kb-ordenes .kb-col{height:100%}
|
||
#kb-ordenes .kb-cb{overflow-y:auto}
|
||
.kb-col[data-stage="En 2 Mares"]{min-width:474px;width:474px}
|
||
.kb-col[data-stage="En 2 Mares"] .kb-cb{display:grid;grid-template-columns:1fr 1fr;gap:4px;align-content:start}
|
||
.kb-col[data-stage="En 2 Mares"] .kc{margin-bottom:0}
|
||
}
|
||
.kb-ch{padding:7px 10px;border-radius:var(--r) var(--r) 0 0;display:flex;align-items:center;justify-content:space-between;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px}
|
||
.kb-cnt{font-size:9px;padding:2px 6px;border-radius:7px}
|
||
.kb-cb{flex:1;padding:5px;border:1px solid var(--bd);border-top:none;border-radius:0 0 var(--r) var(--r);background:var(--s2);min-height:60px;transition:.15s}
|
||
.kb-cb.over{background:var(--sand-light);border-color:var(--olive)}
|
||
|
||
.kc{background:var(--s1);border:1px solid var(--bd);border-radius:7px;padding:8px 10px;margin-bottom:4px;cursor:grab;transition:.15s;position:relative;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||
.kc:hover{border-color:var(--olive);transform:translateY(-1px);box-shadow:0 3px 8px rgba(92,107,79,.08)}
|
||
.kc.drag{opacity:.35}
|
||
.kc-t{font-size:11px;font-weight:600;margin-bottom:2px;padding-right:36px}
|
||
.kc-m{font-size:10px;color:var(--t2);line-height:1.3}
|
||
.kc-tags{display:flex;gap:2px;margin-top:4px;flex-wrap:wrap}
|
||
.tag{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:600}
|
||
.kc-acts{position:absolute;top:4px;right:4px;display:none;gap:2px}
|
||
.kc:hover .kc-acts{display:flex}
|
||
.kc-btn{width:18px;height:18px;border-radius:4px;border:none;background:var(--sand-light);color:var(--t2);cursor:pointer;font-size:9px;display:flex;align-items:center;justify-content:center}
|
||
.kc-btn:hover{background:var(--rdd);color:var(--rd)}
|
||
.kc-btn.edit:hover{background:var(--acd);color:var(--olive)}
|
||
.kc-urg{position:absolute;top:4px;right:4px;font-size:9px}
|
||
|
||
/* TIMELINE */
|
||
.tl{display:flex;gap:10px;padding:8px 0;position:relative}
|
||
.tl:not(:last-child)::after{content:'';position:absolute;left:12px;top:28px;bottom:-8px;width:1px;background:var(--bd)}
|
||
.tl-d{width:8px;height:8px;border-radius:50%;margin-top:3px;flex-shrink:0;border:2px solid}
|
||
.tl-t{font-size:11px;font-weight:600}
|
||
.tl-ds{font-size:10px;color:var(--t2);margin-top:1px}
|
||
.tl-tm{font-size:8px;color:var(--t3);margin-top:2px}
|
||
|
||
/* INVENTORY TABLE */
|
||
.inv-tbl{width:100%;border-collapse:collapse;font-size:11px}
|
||
.inv-tbl th{text-align:left;padding:6px 8px;font-size:9px;color:var(--t2);text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--bd);position:sticky;top:0;background:var(--s1)}
|
||
.inv-tbl td{padding:5px 8px;border-bottom:1px solid var(--bd)}
|
||
.inv-tbl tr:hover td{background:var(--s2)}
|
||
.stock-ok{color:var(--gn)}.stock-low{color:var(--or)}.stock-crit{color:var(--rd);font-weight:700}
|
||
|
||
/* MODAL */
|
||
.mo-bg{display:none;position:fixed;inset:0;background:rgba(44,44,44,.4);backdrop-filter:blur(2px);z-index:200;align-items:center;justify-content:center}
|
||
.mo-bg.show{display:flex}
|
||
.mo{background:var(--cream);border:1px solid var(--sand);border-radius:12px;padding:18px;width:94%;max-width:480px;max-height:90vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,.12)}
|
||
.mo h3{font-size:15px;margin-bottom:12px;display:flex;align-items:center;justify-content:space-between;color:var(--olive-dark);font-weight:600}
|
||
.mo-x{background:none;border:none;color:var(--t3);font-size:20px;cursor:pointer;padding:0 4px}
|
||
.mo-x:hover{color:var(--tx)}
|
||
.fg{margin-bottom:8px}
|
||
.fg label{font-size:8px;font-weight:600;color:var(--t2);text-transform:uppercase;letter-spacing:.5px;display:block;margin-bottom:3px}
|
||
.fg input,.fg select,.fg textarea{width:100%;padding:7px 10px;background:#fff;border:1px solid var(--bd);border-radius:6px;color:var(--tx);font-family:inherit;font-size:12px;outline:none}
|
||
.fg input:focus,.fg select:focus,.fg textarea:focus{border-color:var(--olive);box-shadow:0 0 0 2px rgba(92,107,79,.1)}
|
||
.fg textarea{min-height:44px;resize:vertical}
|
||
.fg-row{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
||
.fg-row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px}
|
||
.mo-sub{width:100%;padding:9px;background:var(--olive);color:#fff;border:none;border-radius:7px;font-weight:600;font-size:12px;cursor:pointer;margin-top:4px;font-family:'Outfit',sans-serif}
|
||
.mo-sub:hover{background:var(--olive-dark)}
|
||
|
||
#toast{position:fixed;bottom:16px;right:16px;background:var(--olive-dark);border:none;color:#fff;padding:8px 16px;border-radius:8px;font-size:11px;font-weight:500;display:none;z-index:300;box-shadow:0 4px 16px rgba(61,74,51,.25)}
|
||
.empty{text-align:center;padding:24px;color:var(--t3);font-size:11px}
|
||
|
||
/* Alert row */
|
||
.alert-row{display:flex;align-items:center;gap:6px;padding:5px 8px;border-radius:5px;background:var(--rdd);margin-bottom:3px;font-size:10px}
|
||
.alert-row .alert-sku{font-weight:700;color:var(--rd)}
|
||
|
||
/* VIEW TOGGLE */
|
||
.view-toggle{display:flex;gap:2px;background:var(--sand-light);border-radius:6px;padding:2px}
|
||
.vt-btn{padding:4px 10px;border-radius:4px;font-size:10px;font-weight:500;color:var(--t2);cursor:pointer;border:none;background:none;transition:.15s}
|
||
.vt-btn:hover{color:var(--tx)}
|
||
.vt-btn.on{background:var(--s1);color:var(--olive);box-shadow:0 1px 3px rgba(0,0,0,.06)}
|
||
|
||
/* GRID TABLE */
|
||
.grid-tbl{width:100%;border-collapse:collapse;font-size:10px}
|
||
.grid-tbl th{text-align:left;padding:5px 6px;font-size:8px;color:var(--olive);text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--sand);position:sticky;top:0;background:var(--cream);cursor:pointer;user-select:none;white-space:nowrap;font-weight:600}
|
||
.grid-tbl th:hover{color:var(--olive-dark)}
|
||
.grid-tbl td{padding:4px 6px;border-bottom:1px solid var(--sand-light);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.grid-tbl tr:hover td{background:var(--sand-pale)}
|
||
.grid-tbl tr.editing td{background:var(--sand-light);white-space:normal}
|
||
.grid-tbl .cell-edit{width:100%;padding:3px 5px;background:#fff;border:1px solid var(--olive);border-radius:4px;color:var(--tx);font-family:inherit;font-size:10px;outline:none}
|
||
.grid-tbl .cell-edit-ta{width:100%;min-height:34px;padding:3px 5px;background:#fff;border:1px solid var(--olive);border-radius:4px;color:var(--tx);font-family:inherit;font-size:10px;outline:none;resize:vertical}
|
||
.grid-tbl .row-actions{display:flex;gap:2px;white-space:nowrap}
|
||
.grid-tbl .rbtn{padding:2px 7px;border-radius:4px;border:none;font-size:9px;font-weight:600;cursor:pointer;transition:.15s}
|
||
.rbtn-save{background:var(--gnd);color:var(--olive)}.rbtn-save:hover{background:var(--olive);color:#fff}
|
||
.rbtn-cancel{background:var(--sand-light);color:var(--t2)}.rbtn-cancel:hover{background:var(--sand);color:var(--tx)}
|
||
.rbtn-edit{background:var(--acd);color:var(--olive)}.rbtn-edit:hover{background:var(--olive);color:#fff}
|
||
.rbtn-del{background:var(--rdd);color:var(--rd)}.rbtn-del:hover{background:var(--rd);color:#fff}
|
||
|
||
.stage-pill{padding:2px 6px;border-radius:4px;font-size:8px;font-weight:700;display:inline-block}
|
||
|
||
/* Search */
|
||
.search-box{padding:5px 10px;background:#fff;border:1px solid var(--bd);border-radius:6px;color:var(--tx);font-size:11px;outline:none;width:180px;transition:.2s}
|
||
.search-box:focus{border-color:var(--olive);width:220px;box-shadow:0 0 0 2px rgba(92,107,79,.08)}
|
||
|
||
/* FILES */
|
||
.file-badge{display:inline-flex;align-items:center;gap:2px;padding:1px 5px;border-radius:3px;font-size:8px;font-weight:600;cursor:pointer;background:var(--bld);color:var(--bl)}
|
||
.file-badge:hover{background:var(--bl);color:var(--bg)}
|
||
.file-item{display:flex;align-items:center;gap:6px;padding:3px 5px;border-radius:4px;font-size:9px;transition:.15s;cursor:pointer}
|
||
.file-item:hover{background:var(--s3)}
|
||
.file-icon{width:24px;height:24px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:12px;flex-shrink:0}
|
||
.file-thumb{width:24px;height:24px;border-radius:4px;object-fit:cover;flex-shrink:0}
|
||
.file-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--tx)}
|
||
.file-size{color:var(--t3);font-size:8px}
|
||
.file-del{width:14px;height:14px;border-radius:3px;border:none;background:transparent;color:var(--t3);cursor:pointer;font-size:9px;display:none;align-items:center;justify-content:center}
|
||
.file-item:hover .file-del{display:flex}
|
||
.file-del:hover{background:var(--rdd);color:var(--rd)}
|
||
.upload-zone{border:2px dashed var(--bd);border-radius:7px;padding:12px;text-align:center;cursor:pointer;transition:.2s;margin-top:5px}
|
||
.upload-zone:hover{border-color:var(--ac);background:var(--acd)}
|
||
.upload-zone.dragover{border-color:var(--ac);background:var(--acg)}
|
||
.upload-zone input{display:none}
|
||
.upload-label{font-size:10px;color:var(--t2)}
|
||
|
||
/* Lightbox */
|
||
.lightbox{display:none;position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:300;align-items:center;justify-content:center;flex-direction:column}
|
||
.lightbox.show{display:flex}
|
||
.lightbox img{max-width:92%;max-height:78vh;border-radius:8px;box-shadow:0 8px 32px rgba(0,0,0,.5)}
|
||
.lightbox-bar{display:flex;gap:6px;margin-top:10px}
|
||
.lightbox-btn{padding:7px 14px;border-radius:7px;border:none;font-size:11px;font-weight:600;cursor:pointer}
|
||
.lb-download{background:var(--ac);color:var(--bg)}
|
||
.lb-close{background:var(--bd);color:var(--tx)}
|
||
.lightbox-name{color:var(--t2);font-size:11px;margin-top:6px}
|
||
|
||
/* ENTREGAS */
|
||
.entrega-card{background:var(--s1);border:1px solid var(--bd);border-radius:var(--r);padding:12px;margin-bottom:8px;transition:.2s}
|
||
.entrega-card:hover{border-color:var(--ac)}
|
||
.entrega-card.urgente{border-left:3px solid var(--rd)}
|
||
.entrega-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
|
||
.entrega-checks{display:flex;gap:10px;margin-top:6px;flex-wrap:wrap}
|
||
.entrega-check{display:flex;align-items:center;gap:4px;font-size:10px;color:var(--t2);cursor:pointer}
|
||
.entrega-check input{accent-color:var(--ac);cursor:pointer}
|
||
.entrega-check.done{color:var(--gn);text-decoration:line-through}
|
||
|
||
/* Doc buttons in entregas */
|
||
.doc-btn{padding:5px 10px;border-radius:6px;font-size:10px;font-weight:600;border:1px solid var(--bd);background:var(--s2);color:var(--t2);cursor:pointer;transition:.15s;display:inline-flex;align-items:center;gap:3px}
|
||
.doc-btn:hover{border-color:var(--ac);color:var(--ac);background:var(--acd)}
|
||
.doc-btn.has-file{border-color:var(--gn);color:var(--gn);background:var(--gnd)}
|
||
|
||
/* Cost inline fields */
|
||
.cost-field{display:flex;flex-direction:column;gap:1px}
|
||
.cost-field label{font-size:7px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px}
|
||
.cost-field input{width:72px;padding:4px 6px;background:var(--bg);border:1px solid var(--bd);border-radius:4px;color:var(--tx);font-size:11px;font-family:inherit;outline:none;font-variant-numeric:tabular-nums}
|
||
.cost-field input:focus{border-color:var(--ac)}
|
||
.cost-field input::-webkit-inner-spin-button{opacity:0;width:0}
|
||
|
||
/* VENTAS */
|
||
.v-metric{text-align:center;padding:14px;background:var(--s1);border:1px solid var(--bd);border-radius:var(--r)}
|
||
.v-metric b{font-size:24px;display:block;margin-bottom:2px}
|
||
.v-metric small{font-size:8px;color:var(--t2);text-transform:uppercase;letter-spacing:1px}
|
||
|
||
/* CATALOGO */
|
||
.cat-section{margin-bottom:16px}
|
||
.cat-section h4{font-size:12px;font-weight:700;color:var(--ac);margin-bottom:6px;display:flex;align-items:center;gap:8px}
|
||
.cat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:6px}
|
||
.cat-item{background:var(--s2);border:1px solid var(--bd);border-radius:7px;padding:8px 10px;font-size:11px;display:flex;justify-content:space-between;align-items:center}
|
||
.cat-item:hover{border-color:var(--bd2)}
|
||
.cat-item-name{font-weight:600}
|
||
.cat-item-meta{font-size:9px;color:var(--t3)}
|
||
|
||
/* PRICE CALC */
|
||
.price-calc{background:var(--s2);border:1px solid var(--bd);border-radius:var(--r);padding:12px;margin-top:10px}
|
||
.price-row{display:flex;justify-content:space-between;font-size:11px;padding:3px 0;border-bottom:1px solid var(--bd)}
|
||
.price-row:last-child{border:none}
|
||
.price-total{font-weight:700;font-size:13px;color:var(--ac)}
|
||
.price-util{color:var(--gn);font-weight:700}
|
||
.price-label{color:var(--t2)}
|
||
|
||
/* WIZARD (mobile order) */
|
||
.wizard{max-width:480px;margin:0 auto}
|
||
.wizard-step{display:none;animation:fadeIn .2s}
|
||
.wizard-step.active{display:block}
|
||
.wizard-nav{display:flex;gap:6px;justify-content:space-between;margin-top:12px;padding-bottom:8px}
|
||
.wizard-nav .btn{min-height:40px;min-width:90px;font-size:13px;touch-action:manipulation}
|
||
.wizard-dots{display:flex;gap:4px;justify-content:center;margin-bottom:12px}
|
||
.wizard-dot{width:8px;height:8px;border-radius:50%;background:var(--bd)}
|
||
.wizard-dot.on{background:var(--ac)}
|
||
#mo-wizard .mo{touch-action:pan-y;-webkit-overflow-scrolling:touch}
|
||
#mo-wizard .mo-x{min-width:36px;min-height:36px;display:flex;align-items:center;justify-content:center}
|
||
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
||
|
||
/* QUICK VIEW */
|
||
.qv-row{display:flex;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--sand-light);font-size:12px}
|
||
.qv-row:last-child{border:none}
|
||
.qv-label{color:var(--t2);font-size:10px;font-weight:500}
|
||
.qv-value{font-weight:600;text-align:right;max-width:60%}
|
||
.qv-section{margin-top:10px;padding-top:8px;border-top:1px solid var(--bd)}
|
||
.qv-section-title{font-size:9px;font-weight:700;color:var(--olive);text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
|
||
|
||
/* ENTREGA BTN on kanban card */
|
||
.kc-entrega{display:flex;align-items:center;gap:3px;margin-top:5px;padding:4px 8px;border-radius:5px;border:1px solid var(--gn);background:var(--gnd);color:var(--gn);font-size:9px;font-weight:700;cursor:pointer;transition:.15s;width:fit-content}
|
||
.kc-entrega:hover{background:var(--gn);color:#fff}
|
||
|
||
/* Entregas date group */
|
||
.entrega-date-group{margin-bottom:16px}
|
||
.entrega-date-header{font-size:12px;font-weight:700;color:var(--olive-dark);padding:6px 10px;background:var(--sand-light);border-radius:6px;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between}
|
||
.entrega-date-header small{font-size:10px;font-weight:400;color:var(--t2)}
|
||
/* Entregas — linear month nav */
|
||
.ent-linear-nav{display:flex;align-items:center;gap:6px;background:var(--s1);border:1px solid var(--bd);border-radius:var(--r);padding:6px 8px;margin-bottom:10px;max-width:340px}
|
||
.ent-arrow{background:var(--s2);border:1px solid var(--bd);border-radius:6px;width:32px;height:32px;cursor:pointer;color:var(--t2);font-size:13px;display:flex;align-items:center;justify-content:center;transition:.15s}
|
||
.ent-arrow:hover:not(:disabled){background:var(--olive);color:#fff;border-color:var(--olive)}
|
||
.ent-arrow:disabled{opacity:.3;cursor:default}
|
||
.ent-month-select{flex:1;font-size:13px;font-weight:600;padding:7px 10px;border:1px solid var(--bd);border-radius:6px;background:var(--s1);color:var(--olive-dark);cursor:pointer;font-family:inherit;outline:none}
|
||
.ent-month-select:focus{border-color:var(--olive)}
|
||
/* Entregas — clientes nav */
|
||
.ent-nav-cli{background:var(--s1);border:1px solid var(--bd);border-radius:var(--r);padding:10px;margin-bottom:10px}
|
||
.ent-filter-row{display:flex;align-items:center;gap:4px;flex-wrap:wrap;margin-bottom:6px}
|
||
.ent-filter-label{font-size:9px;font-weight:600;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;margin-right:4px}
|
||
.ent-filter-chip{padding:3px 9px;font-size:10px;border-radius:12px;border:1px solid var(--bd);background:var(--s2);color:var(--t2);cursor:pointer;font-weight:500;transition:.15s;font-family:inherit}
|
||
.ent-filter-chip:hover{border-color:var(--olive);color:var(--olive)}
|
||
.ent-filter-chip.on{background:var(--olive);color:#fff;border-color:var(--olive)}
|
||
.ent-cli-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:4px;margin-top:6px;max-height:200px;overflow-y:auto}
|
||
.ent-cli-item{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 10px;background:var(--s2);border:1px solid transparent;border-radius:6px;cursor:pointer;text-align:left;transition:.15s;font-family:inherit;color:var(--tx)}
|
||
.ent-cli-item:hover{border-color:var(--olive);background:var(--acd)}
|
||
.ent-cli-item.on{background:var(--olive);color:#fff;border-color:var(--olive)}
|
||
.ent-cli-item.on span{color:#fff!important;opacity:.9}
|
||
.ent-cli-edit{font-size:12px;opacity:.4;padding:4px 6px;border-radius:4px;cursor:pointer;transition:.15s;margin-left:4px}
|
||
.ent-cli-item:hover .ent-cli-edit{opacity:1}
|
||
.ent-cli-edit:hover{background:rgba(255,255,255,.2)}
|
||
.ent-cli-item.on .ent-cli-edit{opacity:.85}
|
||
/* Entregas split layout (when in cliente view) */
|
||
#entregas-wrapper{display:flex;flex-direction:column}
|
||
#entregas-wrapper.cli-split{display:grid;grid-template-columns:300px 1fr;gap:14px;align-items:start}
|
||
#entregas-wrapper.cli-split #entregas-nav-panel{position:sticky;top:8px;max-height:calc(100vh - 130px);overflow:hidden;display:flex;flex-direction:column}
|
||
@media (max-width:800px){#entregas-wrapper.cli-split{grid-template-columns:1fr}#entregas-wrapper.cli-split #entregas-nav-panel{position:static;max-height:none}}
|
||
.ent-nav-cli.split{padding:0;background:transparent;border:none;display:flex;flex-direction:column;height:100%;min-height:0}
|
||
.ent-nav-cli.split .ent-filter-row{padding:0 0 6px;margin-bottom:6px;border-bottom:1px solid var(--bd)}
|
||
.ent-cli-listbox{background:var(--s1);border:1px solid var(--bd);border-radius:var(--r);overflow-y:auto;flex:1;min-height:0}
|
||
.ent-cli-listbox .ent-cli-item{margin:0;padding:8px 12px;border-radius:0;border-bottom:1px solid var(--bd);border-left:none;border-right:none;border-top:none}
|
||
.ent-cli-listbox .ent-cli-item:last-child{border-bottom:none}
|
||
.ent-cli-listbox .ent-cli-item.on{background:var(--olive);color:#fff;border-left:3px solid var(--olive-dark);padding-left:9px}
|
||
/* Collapsible groups in edit modal */
|
||
.edit-group{border:1px solid var(--bd);border-radius:6px;margin-bottom:6px;overflow:hidden;background:var(--s2)}
|
||
.edit-group-toggle{width:100%;background:var(--s2);border:none;padding:8px 12px;text-align:left;cursor:pointer;font-size:11px;font-weight:600;color:var(--t2);display:flex;justify-content:space-between;align-items:center;font-family:inherit;text-transform:uppercase;letter-spacing:.5px}
|
||
.edit-group-toggle:hover{background:var(--acd);color:var(--olive)}
|
||
.edit-group-toggle.open{background:var(--acd);color:var(--olive-dark)}
|
||
.edit-group-toggle.open .edit-group-arrow{transform:rotate(180deg)}
|
||
.edit-group-arrow{transition:transform .15s}
|
||
.edit-group-body{padding:8px 10px;background:var(--s1)}
|
||
/* Stage pill selector */
|
||
.stage-pills{display:flex;gap:4px;flex-wrap:wrap;padding:4px;background:var(--s2);border-radius:8px}
|
||
.stage-pill{flex:1 1 auto;min-width:88px;padding:8px 10px;font-size:11px;font-weight:600;border:1px solid transparent;background:transparent;color:var(--t2);cursor:pointer;border-radius:6px;transition:.15s;font-family:inherit;white-space:nowrap;text-align:center}
|
||
.stage-pill:hover{background:var(--s1);color:var(--olive)}
|
||
.stage-pill.on{background:var(--olive);color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.15)}
|
||
/* Urgente toggle */
|
||
.urgente-toggle{padding:9px 16px;border-radius:20px;border:1px solid var(--bd);background:var(--s2);color:var(--t2);font-weight:600;font-size:11px;cursor:pointer;transition:.15s;font-family:inherit;white-space:nowrap;display:inline-flex;align-items:center;gap:5px}
|
||
.urgente-toggle:hover{border-color:var(--olive);color:var(--olive)}
|
||
.urgente-toggle.on{background:var(--rdd);border-color:var(--rd);color:var(--rd)}
|
||
/* CRM layout */
|
||
.crm-layout{display:grid;grid-template-columns:340px 1fr;gap:12px;align-items:start}
|
||
@media (max-width:900px){.crm-layout{grid-template-columns:1fr}}
|
||
.crm-list-wrap{background:var(--s1);border:1px solid var(--bd);border-radius:var(--r);padding:6px;max-height:calc(100vh - 180px);overflow-y:auto}
|
||
.crm-cli-row{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:4px 7px;border-radius:5px;cursor:pointer;border:1px solid transparent;margin-bottom:1px;transition:.15s}
|
||
.crm-cli-row:hover{background:var(--acd);border-color:var(--olive)}
|
||
.crm-cli-row.on{background:var(--olive);color:#fff;border-color:var(--olive)}
|
||
.crm-cli-row.on .crm-cli-meta,.crm-cli-row.on .crm-cli-stat{color:rgba(255,255,255,.9)!important}
|
||
.crm-cli-name{font-weight:600;font-size:12px;line-height:1.2}
|
||
.crm-cli-meta{font-size:9px;color:var(--t3);margin-top:0;line-height:1.2}
|
||
.crm-cli-stat{font-size:10px;color:var(--olive);font-weight:600;text-align:right;white-space:nowrap}
|
||
.crm-detail{background:var(--s1);border:1px solid var(--bd);border-radius:var(--r);padding:14px;min-height:300px}
|
||
.crm-detail-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--t3);font-size:13px;min-height:300px}
|
||
.crm-detail-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;padding-bottom:10px;border-bottom:2px solid var(--olive);margin-bottom:12px}
|
||
.crm-detail-head h2{margin:0;font-size:18px;color:var(--olive-dark)}
|
||
.crm-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-bottom:14px}
|
||
.crm-info-cell{background:var(--s2);padding:8px 10px;border-radius:6px}
|
||
.crm-info-cell .lbl{font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;display:block;margin-bottom:2px}
|
||
.crm-info-cell .val{font-size:13px;font-weight:600;color:var(--tx)}
|
||
.crm-stats-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px;margin-bottom:14px}
|
||
.crm-stat-card{background:linear-gradient(135deg,var(--sand-light),var(--s2));padding:10px 12px;border-radius:8px;border:1px solid var(--bd)}
|
||
.crm-stat-card .stat-lbl{font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px}
|
||
.crm-stat-card .stat-val{font-size:18px;font-weight:700;color:var(--olive-dark);margin-top:2px}
|
||
.crm-section-title{font-size:11px;font-weight:600;color:var(--t2);text-transform:uppercase;letter-spacing:.5px;margin:14px 0 6px;display:flex;justify-content:space-between;align-items:center}
|
||
/* Compras kanban */
|
||
.compras-kb{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;align-items:start}
|
||
@media (max-width:1100px){.compras-kb{grid-template-columns:repeat(2,1fr)}}
|
||
@media (max-width:600px){.compras-kb{grid-template-columns:1fr}}
|
||
.compras-kb-3{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;align-items:start}
|
||
@media (max-width:900px){.compras-kb-3{grid-template-columns:1fr}}
|
||
.compras-col-h .col-sub{display:block;font-size:8px;font-weight:400;color:var(--t3);margin-top:1px;letter-spacing:0;text-transform:none}
|
||
.cc-badges{display:flex;flex-wrap:wrap;gap:3px;margin-top:6px}
|
||
.cmp-badge{font-size:9px;padding:2px 7px;border-radius:10px;font-weight:600}
|
||
.cmp-badge.fact{background:#fef9c3;color:#854d0e}
|
||
.cmp-badge.cobr{background:#dbeafe;color:#1d4ed8}
|
||
.cmp-badge.cobrada{background:#dcfce7;color:#15803d}
|
||
.compras-col{background:var(--s2);border-radius:8px;padding:8px;min-height:200px}
|
||
.compras-col-h{font-size:11px;font-weight:700;color:var(--olive-dark);padding:4px 8px 8px;display:flex;justify-content:space-between;align-items:center;letter-spacing:.5px;text-transform:uppercase}
|
||
.compras-col-h .cnt{font-size:10px;color:var(--t2);background:var(--s1);padding:1px 8px;border-radius:10px}
|
||
.compras-card{background:var(--s1);border:1px solid var(--bd);border-radius:6px;padding:10px;margin-bottom:6px;cursor:pointer;transition:.15s;border-left:4px solid var(--olive)}
|
||
.compras-card:hover{border-color:var(--olive);box-shadow:0 2px 6px rgba(0,0,0,.06)}
|
||
.compras-card .cc-head{display:flex;justify-content:space-between;align-items:center;font-weight:700;font-size:12px;color:var(--olive-dark)}
|
||
.compras-card .cc-cli{font-size:11px;color:var(--t2);margin-top:2px}
|
||
.compras-card .cc-meta{font-size:9px;color:var(--t3);margin-top:4px;display:flex;gap:8px;flex-wrap:wrap}
|
||
.compras-card .cc-actions{display:flex;gap:4px;margin-top:6px;flex-wrap:wrap}
|
||
.compras-card .cc-actions button{font-size:10px;padding:3px 8px;border-radius:4px;border:1px solid var(--olive);background:var(--acd);color:var(--olive);cursor:pointer;font-weight:600}
|
||
.compras-card .cc-actions button:hover{background:var(--olive);color:#fff}
|
||
.compras-card.col-prod{border-left-color:#6b7280}
|
||
.compras-card.col-fact{border-left-color:#eab308}
|
||
.compras-card.col-cobr{border-left-color:#3b82f6}
|
||
.compras-card.col-cobrado{border-left-color:#16a34a;opacity:.85}
|
||
/* Bodega + Tránsito panels (below production kanban) */
|
||
.ord-panel{margin-top:14px;background:var(--s1);border:1px solid var(--bd);border-radius:var(--r);overflow:hidden}
|
||
.ord-panel-head{padding:8px 12px;background:var(--sand-light);display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;border-bottom:1px solid var(--bd)}
|
||
.ord-panel-head h3{margin:0;font-size:13px;font-weight:700;color:var(--olive-dark);display:flex;align-items:center;gap:6px}
|
||
.ord-panel-head .panel-meta{font-size:10px;color:var(--t2);font-weight:500}
|
||
.ord-panel-filter{display:flex;flex-wrap:wrap;gap:4px;padding:6px 12px;background:var(--s2);border-bottom:1px solid var(--bd)}
|
||
.ord-panel-filter .fchip{padding:3px 9px;font-size:10px;border-radius:12px;border:1px solid var(--bd);background:var(--s1);color:var(--t2);cursor:pointer;font-weight:500;transition:.15s;font-family:inherit;white-space:nowrap}
|
||
.ord-panel-filter .fchip:hover{border-color:var(--olive);color:var(--olive)}
|
||
.ord-panel-filter .fchip.on{background:var(--olive);color:#fff;border-color:var(--olive)}
|
||
.ord-panel-body{padding:10px 12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:6px;min-height:80px}
|
||
.ord-panel-body.empty-state{display:flex;align-items:center;justify-content:center;color:var(--t3);font-size:11px;padding:24px}
|
||
.bodega-card{background:var(--s1);border:1px solid var(--bd);border-radius:6px;padding:8px 10px;font-size:11px;cursor:pointer;transition:.15s;border-left:3px solid var(--bl);position:relative}
|
||
.bodega-card:hover{border-color:var(--olive);box-shadow:0 2px 4px rgba(0,0,0,.06)}
|
||
.bodega-card.vehiculo{border-left-color:var(--cy,#06b6d4)}
|
||
.bodega-card.transito{border-left-color:#a855f7}
|
||
.bodega-card.urgente{box-shadow:inset 3px 0 0 var(--rd)}
|
||
.bodega-card .bc-head{display:flex;justify-content:space-between;align-items:center;font-weight:700}
|
||
.bodega-card .bc-cli{font-size:10px;color:var(--t2);margin-top:1px}
|
||
.bodega-card .bc-prod{font-size:10px;color:var(--t2);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.bodega-card .bc-qty{font-size:13px;font-weight:700;color:var(--olive)}
|
||
.bodega-card .bc-tags{display:flex;gap:3px;margin-top:4px;flex-wrap:wrap}
|
||
.bodega-card .bc-tags .tag{font-size:8px;padding:1px 5px}
|
||
.bodega-card .bc-eta{font-size:9px;color:#7c3aed;font-weight:600;margin-top:3px;padding:2px 5px;background:#f3e8ff;border-radius:3px;display:inline-block}
|
||
.bodega-card .bc-notes{font-size:9px;color:var(--t3);margin-top:3px;font-style:italic;line-height:1.3}
|
||
.bodega-card .bc-stage-badge{font-size:8px;padding:1px 5px;border-radius:3px;font-weight:600}
|
||
.bodega-empty{color:var(--t3);font-size:11px;padding:20px;text-align:center;width:100%}
|
||
/* Bodega full view (fullscreen modal) */
|
||
#mo-bodega-full{z-index:180}
|
||
#mo-bodega-full.show{align-items:flex-start;padding:0}
|
||
#mo-bodega-full .mo{max-width:none;width:100%;min-height:100vh;padding:0;border-radius:0;background:var(--bg);box-shadow:none}
|
||
.bf-toolbar{position:sticky;top:0;background:var(--olive);color:#fff;padding:10px 16px;display:flex;justify-content:space-between;align-items:center;gap:8px;z-index:10;box-shadow:0 2px 6px rgba(0,0,0,.1);flex-wrap:wrap}
|
||
.bf-toolbar-left h2{margin:0;font-size:15px;display:flex;align-items:center;gap:8px}
|
||
.bf-meta{font-size:11px;opacity:.85;font-weight:400}
|
||
.bf-toolbar-right{display:flex;gap:6px;align-items:center}
|
||
.bf-toolbar-right .btn{background:rgba(255,255,255,.15);border:1px solid rgba(255,255,255,.4);color:#fff}
|
||
.bf-toolbar-right .btn:hover{background:rgba(255,255,255,.25)}
|
||
.bf-sort{font-size:12px;padding:7px 10px;border:1px solid rgba(255,255,255,.4);border-radius:6px;background:rgba(255,255,255,.1);color:#fff;font-family:inherit;outline:none;cursor:pointer}
|
||
.bf-sort option{color:var(--tx);background:#fff}
|
||
.bf-filter-row{background:var(--s1);padding:8px 16px;display:flex;flex-wrap:wrap;gap:4px;border-bottom:1px solid var(--bd);position:sticky;top:54px;z-index:9}
|
||
#bf-body{padding:16px}
|
||
.bf-group{margin-bottom:20px}
|
||
.bf-group-head{font-size:13px;color:var(--olive-dark);padding:6px 12px;background:var(--sand-light);border-radius:6px;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center}
|
||
.bf-group-head span{font-size:10px;color:var(--t2);font-weight:400}
|
||
.bf-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:8px}
|
||
.bodega-card.with-photo{padding:10px}
|
||
.bodega-card.with-photo .bc-thumb{width:64px;height:64px;border-radius:6px;object-fit:cover;border:1px solid var(--bd);cursor:zoom-in}
|
||
.bodega-card.with-photo .bc-head{font-size:12px;font-weight:700}
|
||
.bodega-card.with-photo .bc-cli{font-size:11px}
|
||
.bodega-card.with-photo .bc-prod{font-size:11px;color:var(--t2);margin-top:2px}
|
||
/* Lista de Ordenes (Entregas view) */
|
||
.lista-oc-wrap{display:flex;flex-direction:column;gap:4px}
|
||
.lista-oc-row{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--s1);border:1px solid var(--bd);border-radius:8px;cursor:pointer;transition:.15s;border-left:4px solid var(--olive)}
|
||
.lista-oc-row:hover{border-color:var(--olive);box-shadow:0 1px 4px rgba(0,0,0,.06)}
|
||
.lista-oc-row.incompleta{border-left-color:#eab308;background:#fefce8}
|
||
.lista-oc-main{flex:1;min-width:0}
|
||
.lista-oc-head{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||
.lista-oc-meta{display:flex;gap:10px;font-size:10px;color:var(--t2);margin-top:3px;flex-wrap:wrap}
|
||
.lista-oc-issues{font-size:10px;color:#854d0e;margin-top:4px;font-weight:600}
|
||
.lista-oc-right{text-align:right;display:flex;flex-direction:column;align-items:flex-end;gap:2px}
|
||
.lista-oc-total{font-size:14px;font-weight:700;color:var(--olive-dark)}
|
||
.lista-oc-arrow{font-size:18px;color:var(--t3);font-weight:300}
|
||
/* Ordenes view 2-col layout */
|
||
.ordenes-layout{display:grid;grid-template-columns:1fr 300px;gap:14px;align-items:start}
|
||
@media (max-width:900px){.ordenes-layout{grid-template-columns:1fr}}
|
||
.ordenes-list-h{font-size:11px;font-weight:700;color:var(--olive-dark);text-transform:uppercase;letter-spacing:.5px;padding:0 4px 6px;display:flex;align-items:center;gap:6px}
|
||
.ordenes-list-col{min-width:0}
|
||
.ordenes-todo-col{display:flex;flex-direction:column;gap:8px;position:sticky;top:8px}
|
||
.todo-card{background:var(--s1);border:1px solid var(--bd);border-radius:8px;overflow:hidden}
|
||
.todo-card-h{padding:6px 10px;font-size:11px;font-weight:700;display:flex;justify-content:space-between;align-items:center}
|
||
.todo-cnt{font-size:9px;padding:1px 7px;border-radius:10px;font-weight:700}
|
||
.todo-card-body{display:flex;flex-direction:column}
|
||
.todo-item{display:flex;align-items:center;gap:6px;padding:6px 10px;border-bottom:1px solid var(--bd);cursor:pointer;transition:.15s}
|
||
.todo-item:last-child{border-bottom:none}
|
||
.todo-item:hover{background:var(--acd)}
|
||
.todo-empty{background:var(--s1);border:1px solid var(--bd);border-radius:8px;padding:18px;text-align:center;font-size:11px;color:var(--gn);line-height:1.5}
|
||
/* Entregados panel rows */
|
||
.er-month-group{margin-bottom:10px}
|
||
.er-month-h{font-size:11px;font-weight:700;color:#15803d;padding:4px 8px;background:#bbf7d0;border-radius:5px;margin-bottom:5px;display:inline-block}
|
||
.entregado-row{display:flex;align-items:center;gap:10px;padding:7px 10px;background:var(--s1);border:1px solid var(--bd);border-radius:6px;margin-bottom:3px;cursor:pointer;transition:.15s;border-left:3px solid #22c55e}
|
||
.entregado-row:hover{border-color:#15803d;box-shadow:0 1px 3px rgba(0,0,0,.06)}
|
||
.entregado-row.cancel{border-left-color:var(--rd);opacity:.65}
|
||
.er-date{font-size:10px;color:var(--t3);font-weight:600;min-width:80px;font-variant-numeric:tabular-nums}
|
||
.er-main{flex:1;min-width:0}
|
||
.er-head{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||
.er-id{font-weight:700;font-size:12px}
|
||
.er-oc{font-size:9px;color:var(--olive);background:var(--acd);padding:2px 6px;border-radius:3px;cursor:pointer;font-weight:600;transition:.15s}
|
||
.er-oc:hover{background:var(--olive);color:#fff}
|
||
.er-cancel-badge{font-size:9px;padding:1px 6px;border-radius:3px;background:var(--rdd);color:var(--rd);font-weight:600}
|
||
.er-sub{font-size:10px;color:var(--t2);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.er-qty{font-size:14px;font-weight:700;color:#15803d;text-align:right;min-width:50px}
|
||
.entregado-row.cancel .er-qty{color:var(--rd)}
|
||
/* Product thumbnails */
|
||
.prod-thumb{width:40px;height:40px;object-fit:cover;border-radius:6px;border:1px solid var(--bd);cursor:pointer;transition:.15s;display:block}
|
||
.prod-thumb:hover{transform:scale(1.05);border-color:var(--olive);box-shadow:0 2px 6px rgba(0,0,0,.15)}
|
||
.prod-thumb-empty{width:40px;height:40px;border-radius:6px;border:1px dashed var(--bd);background:var(--s2);color:var(--t3);display:flex;align-items:center;justify-content:center;font-size:14px}
|
||
/* Compact photo-status icon (when thumbs hidden) */
|
||
.prod-foto-status{width:22px;height:22px;border-radius:5px;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;font-size:13px;transition:.15s;border:1px solid transparent}
|
||
.prod-foto-status.has{background:var(--gnd);color:var(--gn);border-color:var(--gn)}
|
||
.prod-foto-status.missing{background:#fff3cd;color:#d97706;border-color:#facc15}
|
||
.prod-foto-status:hover{transform:scale(1.1)}
|
||
/* ══ CATÁLOGOS ══ */
|
||
.cat-list-card{background:var(--s1);border:1px solid var(--bd);border-radius:10px;padding:14px;display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:8px;cursor:pointer;transition:.15s}
|
||
.cat-list-card:hover{border-color:var(--olive);box-shadow:0 3px 10px rgba(92,107,79,.1)}
|
||
.cat-list-card .cl-title{font-family:'Playfair Display',serif;font-style:italic;font-size:18px;color:var(--olive-dark);font-weight:600}
|
||
.cat-list-card .cl-meta{font-size:11px;color:var(--t2);margin-top:2px}
|
||
.cat-list-card .cl-tags{display:flex;gap:4px;margin-top:4px}
|
||
.cat-list-card .cl-tag{font-size:9px;padding:2px 8px;border-radius:3px;background:var(--sand-light);color:var(--olive-dark);text-transform:uppercase;letter-spacing:.3px;font-weight:600}
|
||
.cat-toggles{display:flex;gap:10px;flex-wrap:wrap;padding:10px;background:var(--s2);border-radius:8px;margin:10px 0}
|
||
.cat-toggle{display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer;user-select:none}
|
||
.cat-toggle input{margin:0;width:16px;height:16px;accent-color:var(--olive)}
|
||
.cat-items-list{display:flex;flex-direction:column;gap:6px;margin-top:8px}
|
||
.cat-item-row{background:#fff;border:1px solid var(--bd);border-radius:6px;padding:8px 10px;display:flex;gap:8px;align-items:center}
|
||
.cat-item-row img{width:36px;height:36px;object-fit:cover;border-radius:4px;border:1px solid var(--bd);flex-shrink:0}
|
||
.cat-item-empty{width:36px;height:36px;border-radius:4px;background:var(--s2);border:1px dashed var(--bd);display:flex;align-items:center;justify-content:center;color:var(--t3);font-size:12px;flex-shrink:0}
|
||
.cat-item-info{flex:1;min-width:0}
|
||
.cat-item-name{font-size:12px;font-weight:600;color:var(--olive-dark)}
|
||
.cat-item-meta{font-size:10px;color:var(--t2)}
|
||
.cat-item-actions{display:flex;gap:4px}
|
||
.cat-precio-inline{display:flex;align-items:center;gap:2px;background:var(--s2);border-radius:6px;padding:3px 6px;flex-shrink:0}
|
||
.cat-precio-inline span{font-size:11px;color:var(--olive);font-weight:700}
|
||
.cat-precio-inline input{width:62px;border:none;background:#fff;border-radius:4px;padding:4px 6px;font-family:inherit;font-size:12px;font-weight:600;color:var(--olive-dark);text-align:right;outline:none}
|
||
.cat-precio-inline input:focus{box-shadow:0 0 0 1px var(--olive)}
|
||
.cat-precio-inline small{font-size:9px;color:var(--t3)}
|
||
.cat-picker{max-height:280px;overflow-y:auto;border:1px solid var(--bd);border-radius:8px;padding:6px;background:#fff;margin-top:4px}
|
||
.cat-picker-section{font-size:9px;color:var(--olive);text-transform:uppercase;letter-spacing:.5px;font-weight:700;padding:6px 4px 4px;border-top:1px solid var(--sand-light)}
|
||
.cat-picker-section:first-child{border-top:none}
|
||
.cat-picker-row{display:flex;align-items:center;gap:8px;padding:5px 6px;border-radius:4px;cursor:pointer;border:1px solid transparent}
|
||
.cat-picker-row:hover{background:var(--acd);border-color:var(--olive)}
|
||
.cat-picker-row img{width:32px;height:32px;object-fit:cover;border-radius:4px}
|
||
.cat-picker-row .cp-name{flex:1;font-size:12px;font-weight:600}
|
||
.cat-picker-row .cp-tag{font-size:9px;padding:1px 5px;border-radius:3px;background:var(--olive);color:#fff;text-transform:uppercase;letter-spacing:.3px}
|
||
|
||
/* Preview imprimible — slides A4 VERTICAL (portrait) */
|
||
.cat-slides{max-width:720px;margin:0 auto;display:flex;flex-direction:column;gap:14px}
|
||
.cat-slide{background:#fff;width:100%;aspect-ratio:1/1.414;padding:32px;box-shadow:0 4px 18px rgba(0,0,0,.15);position:relative;page-break-after:always;display:flex;flex-direction:column}
|
||
/* Portada — minimalista editorial sobre fondo blanco, logo como protagonista */
|
||
.cat-slide.portada{justify-content:flex-start;align-items:stretch;text-align:center;background:#FFFFFF;overflow:hidden;padding:0;color:#3D4A33}
|
||
/* Líneas decorativas finas estilo marco */
|
||
.cat-slide.portada::before{content:'';position:absolute;left:40px;right:40px;top:40px;height:1px;background:#D4C5A9;opacity:.6;pointer-events:none}
|
||
.cat-slide.portada::after{content:'';position:absolute;left:40px;right:40px;bottom:40px;height:1px;background:#D4C5A9;opacity:.6;pointer-events:none}
|
||
.cs-frame{position:relative;z-index:2;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:space-between;padding:60px 50px;text-align:center}
|
||
/* Top — label sutil + año */
|
||
.cat-slide.portada .cs-top{width:100%;display:flex;justify-content:space-between;align-items:center;flex-shrink:0}
|
||
.cat-slide.portada .cs-segmento{font-family:'DM Sans','Outfit',sans-serif;font-size:10px;color:#8A8075;text-transform:uppercase;letter-spacing:5px;font-weight:500}
|
||
.cat-slide.portada .cs-year{font-family:'Playfair Display',serif;font-style:italic;font-size:13px;color:#8A8075;font-weight:400;letter-spacing:1px}
|
||
/* Mid — LOGO como héroe + título */
|
||
.cat-slide.portada .cs-mid{flex:1;display:flex;flex-direction:column;justify-content:center;align-items:center;width:100%;gap:0}
|
||
.cat-slide.portada .cs-hero-logo{width:88%;max-width:480px;margin-bottom:42px}
|
||
.cat-slide.portada .cs-hero-logo svg{display:block;width:100%;height:auto}
|
||
.cat-slide.portada .cs-ornament{display:flex;align-items:center;gap:14px;color:#6B4F3C;margin-bottom:18px}
|
||
.cat-slide.portada .cs-ornament-line{width:36px;height:1px;background:#D4C5A9}
|
||
.cat-slide.portada .cs-ornament-dot{width:6px;height:6px;background:#6B4F3C;border-radius:50%;opacity:.7}
|
||
.cat-slide.portada .cs-eyebrow{font-family:'DM Sans','Outfit',sans-serif;font-size:9px;color:#6B4F3C;text-transform:uppercase;letter-spacing:6px;font-weight:600;margin-bottom:16px}
|
||
.cat-slide.portada .cs-title{font-family:'Playfair Display',serif;font-style:italic;font-size:42px;color:#3D4A33;line-height:1.1;margin:0;font-weight:400;letter-spacing:-.5px;max-width:560px}
|
||
.cat-slide.portada .cs-tagline{font-family:'Playfair Display',serif;font-style:italic;font-size:14px;color:#8A8075;margin-top:14px;font-weight:300;line-height:1.5;max-width:440px}
|
||
.cat-slide.portada .cs-divider{width:50px;height:1px;background:#D4C5A9;margin:28px 0 22px}
|
||
.cat-slide.portada .cs-cliente-label{font-family:'DM Sans','Outfit',sans-serif;font-size:9px;color:#8A8075;text-transform:uppercase;letter-spacing:3px;font-weight:500;margin-bottom:6px}
|
||
.cat-slide.portada .cs-cliente{font-family:'Playfair Display',serif;font-style:italic;font-size:20px;color:#3D4A33;font-weight:400}
|
||
/* Bottom — fecha y tagline final centrados */
|
||
.cat-slide.portada .cs-bot{width:100%;display:flex;flex-direction:column;align-items:center;gap:8px;flex-shrink:0}
|
||
.cat-slide.portada .cs-fecha{font-family:'DM Sans','Outfit',sans-serif;font-size:10px;color:#8A8075;text-transform:uppercase;letter-spacing:4px;font-weight:500}
|
||
.cat-slide.portada .cs-tagline-foot{font-family:'DM Sans','Outfit',sans-serif;font-size:8px;color:#8A8075;text-transform:uppercase;letter-spacing:4px;font-weight:500;opacity:.7}
|
||
.cat-slide-head{font-family:'Playfair Display',serif;font-style:italic;font-size:22px;color:var(--olive-dark);margin-bottom:12px;text-align:center}
|
||
/* Grid 2 productos apilados — cada uno: foto izquierda + info derecha */
|
||
.cat-grid{flex:1;display:grid;grid-template-rows:1fr 1fr;grid-template-columns:1fr;gap:14px;min-height:0}
|
||
.cat-product{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(0,1fr);gap:26px;align-items:start;padding:0;border:none;background:transparent;min-height:0;min-width:0;height:100%}
|
||
.cat-product-img{width:100%;height:100%;object-fit:cover;border-radius:8px;background:var(--s2);box-shadow:0 4px 14px rgba(0,0,0,.1);max-height:380px}
|
||
.cat-product-img-empty{width:100%;height:100%;min-height:300px;border-radius:8px;background:var(--s2);display:flex;align-items:center;justify-content:center;color:var(--t3);font-size:40px}
|
||
.cat-product-info{display:flex;flex-direction:column;gap:4px;min-width:0;padding:0;justify-content:flex-start}
|
||
.cat-product-info h3{font-family:'Playfair Display',serif;font-style:italic;font-size:24px;color:var(--olive-dark);margin:0;line-height:1.15;font-weight:400;letter-spacing:-.3px}
|
||
.cat-product-info .cp-desc{font-size:12px;color:var(--t2);line-height:1.4;margin-top:2px}
|
||
.cat-product-info .cp-pers{font-size:12px;color:var(--olive-dark);margin-top:4px}
|
||
.cat-product-info .cp-pers-label{font-family:'Outfit',sans-serif;font-size:9px;color:var(--olive);text-transform:uppercase;letter-spacing:1.2px;font-weight:700;display:block;margin-bottom:1px}
|
||
.cat-product-info .cp-pers-list{font-family:'Playfair Display',serif;font-style:italic;font-size:14px;color:var(--olive-dark);font-weight:500}
|
||
.cat-product-info .cp-price{font-family:'Playfair Display',serif;font-style:italic;font-size:22px;color:var(--olive);font-weight:600;margin-top:6px}
|
||
.cat-product-info .cp-min{font-size:11px;color:var(--t2);font-style:italic}
|
||
.cat-product-info .cp-clientes{font-size:10px;color:var(--olive-dark);margin-top:8px;padding-top:6px;border-top:1px solid var(--sand);font-weight:600;letter-spacing:.2px}
|
||
.cat-product-info .cp-clientes-label{font-family:'Outfit',sans-serif;font-size:9px;color:var(--olive);text-transform:uppercase;letter-spacing:1.2px;font-weight:700;display:block;margin-bottom:2px}
|
||
.cat-product-info .cp-clientes-list{font-family:'Playfair Display',serif;font-style:italic;font-size:13px;color:var(--olive-dark);font-weight:500;letter-spacing:0}
|
||
.cat-slide-foot{position:absolute;bottom:24px;left:40px;right:40px;display:flex;justify-content:space-between;align-items:center;gap:12px;padding-top:10px;border-top:1px solid var(--sand)}
|
||
.cat-foot-terms{font-size:11px;color:var(--olive-dark)}
|
||
.cat-foot-terms b{color:var(--olive);font-weight:700}
|
||
.cat-foot-contact{font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:1px;white-space:nowrap}
|
||
.cat-foot-terminos{position:absolute;bottom:11px;left:40px;right:40px;font-size:7.5px;color:var(--t3);line-height:1.3;text-align:center;max-height:18px;overflow:hidden}
|
||
/* Página de términos comerciales */
|
||
.cat-slide-terminos{justify-content:flex-start;background:linear-gradient(180deg,var(--cream),var(--sand-light))}
|
||
.cat-term-head{text-align:center;margin:10px 0 30px}
|
||
.cat-term-title{font-family:'Playfair Display',serif;font-style:italic;font-size:32px;color:var(--olive-dark);font-weight:400;margin-top:6px}
|
||
.cat-term-chips{display:flex;gap:18px;justify-content:center;margin-bottom:30px;flex-wrap:wrap}
|
||
.cat-term-chip{background:#fff;border:1px solid var(--sand);border-radius:12px;padding:18px 28px;text-align:center;min-width:200px;box-shadow:0 4px 14px rgba(0,0,0,.06)}
|
||
.cat-term-chip .ct-lbl{display:block;font-family:'DM Sans',sans-serif;font-size:9px;color:var(--olive);text-transform:uppercase;letter-spacing:2px;font-weight:700;margin-bottom:6px}
|
||
.cat-term-chip .ct-val{display:block;font-family:'Playfair Display',serif;font-style:italic;font-size:24px;color:var(--olive-dark)}
|
||
.cat-term-texto{max-width:640px;margin:0 auto;font-size:13px;line-height:1.7;color:var(--charcoal,#2c2c2c);background:#fff;border-radius:10px;padding:22px 28px;border:1px solid var(--sand)}
|
||
.cat-term-foot{position:absolute;bottom:50px;left:40px;right:40px;text-align:center}
|
||
.cat-term-cta{font-family:'Playfair Display',serif;font-style:italic;font-size:20px;color:var(--olive);margin-bottom:8px}
|
||
.cat-term-contact{font-family:'DM Sans',sans-serif;font-size:11px;color:var(--t2);letter-spacing:1px}
|
||
@media print{
|
||
@page{size:A4;margin:0}
|
||
html,body{background:#fff!important;margin:0!important;padding:0!important;height:auto!important}
|
||
/* Oculta TODO lo que no sea el modal de preview */
|
||
body.cat-printing > *:not(#mo-catalogo-preview){display:none!important}
|
||
/* Restaura el modal a flujo normal */
|
||
body.cat-printing #mo-catalogo-preview{display:block!important;position:static!important;background:#fff!important;height:auto!important;width:auto!important;max-width:none!important;max-height:none!important;padding:0!important;margin:0!important;overflow:visible!important}
|
||
body.cat-printing #mo-catalogo-preview > .mo{position:static!important;background:#fff!important;height:auto!important;width:auto!important;max-width:none!important;max-height:none!important;padding:0!important;margin:0!important;box-shadow:none!important;border:none!important;border-radius:0!important;display:block!important;overflow:visible!important}
|
||
body.cat-printing #cat-pv-toolbar{display:none!important}
|
||
body.cat-printing #cat-pv-body{overflow:visible!important;padding:0!important;background:#fff!important;height:auto!important}
|
||
body.cat-printing .cat-slides{max-width:none!important;gap:0!important;display:block!important}
|
||
body.cat-printing .cat-slide{width:100%!important;height:100vh!important;max-height:100vh!important;aspect-ratio:auto!important;padding:30px!important;box-shadow:none!important;border:none!important;border-radius:0!important;page-break-after:always;break-after:page;page-break-inside:avoid;break-inside:avoid;display:flex!important;flex-direction:column!important}
|
||
body.cat-printing .cat-slide:last-child{page-break-after:auto;break-after:auto}
|
||
body.cat-printing .cat-slide.portada{justify-content:center!important;align-items:center!important}
|
||
}
|
||
|
||
/* ══ MANUAL ══ */
|
||
.manual-wrap{display:grid;grid-template-columns:240px 1fr;gap:20px;align-items:start;position:relative}
|
||
.manual-toc-toggle{display:none;width:100%;background:var(--olive);color:#fff;border:none;padding:10px 14px;border-radius:8px;font-size:13px;font-weight:600;font-family:inherit;cursor:pointer;margin-bottom:10px;align-items:center;justify-content:space-between;gap:8px}
|
||
.manual-toc-toggle .arrow{font-size:10px;transition:transform .15s}
|
||
.manual-toc-toggle.open .arrow{transform:rotate(180deg)}
|
||
.manual-toc{position:sticky;top:60px;background:var(--s1);border:1px solid var(--bd);border-radius:10px;padding:12px 10px;max-height:calc(100vh - 100px);overflow-y:auto;font-size:11px}
|
||
.manual-toc-title{font-size:12px;font-weight:700;color:var(--olive-dark);margin-bottom:10px;font-family:'Playfair Display',serif;font-style:italic}
|
||
.manual-toc a{display:block;padding:5px 8px;color:var(--t2);text-decoration:none;border-radius:5px;margin-bottom:1px;transition:.15s}
|
||
.manual-toc a:hover{background:var(--acd);color:var(--olive-dark)}
|
||
.manual-toc a.active{background:var(--olive);color:#fff;font-weight:600}
|
||
.manual-toc-sec{font-size:9px;color:var(--olive);text-transform:uppercase;letter-spacing:.5px;font-weight:700;margin:10px 0 4px 8px}
|
||
@media(max-width:900px){
|
||
.manual-wrap{grid-template-columns:1fr;gap:0}
|
||
.manual-toc-toggle{display:flex}
|
||
.manual-toc{position:relative;top:0;max-height:0;overflow:hidden;padding:0;border:none;background:transparent;transition:max-height .25s,padding .25s;margin-bottom:0}
|
||
.manual-toc.open{max-height:60vh;overflow-y:auto;padding:12px 10px;background:var(--s1);border:1px solid var(--bd);margin-bottom:10px}
|
||
}
|
||
.manual-content{max-width:780px;background:var(--cream);padding:20px 24px;border-radius:10px;border:1px solid var(--bd);line-height:1.55;color:var(--tx)}
|
||
.manual-content section{padding-bottom:30px;border-bottom:1px solid var(--sand-light);margin-bottom:30px;scroll-margin-top:70px}
|
||
.manual-content section:last-child{border-bottom:none}
|
||
.manual-content h1{font-family:'Playfair Display',serif;font-style:italic;font-size:24px;color:var(--olive-dark);margin:0 0 12px 0;font-weight:600}
|
||
.manual-content h3{font-size:14px;color:var(--olive-dark);margin:18px 0 8px 0;font-weight:700}
|
||
.manual-content h4{font-size:12px;color:var(--olive-dark);margin:12px 0 4px 0}
|
||
.manual-content p{margin:6px 0;font-size:13px}
|
||
.manual-content ul,.manual-content ol{margin:6px 0;padding-left:22px;font-size:13px}
|
||
.manual-content li{margin:4px 0}
|
||
.manual-content code{background:var(--sand-light);color:var(--olive-dark);padding:1px 5px;border-radius:3px;font-family:Consolas,Monaco,monospace;font-size:11px;font-weight:600}
|
||
.m-lead{font-size:13px;color:var(--t2);font-style:italic;padding:8px 0;border-left:3px solid var(--olive);padding-left:12px;margin:8px 0 14px 0}
|
||
.m-callout{padding:10px 14px;border-radius:8px;margin:12px 0;font-size:12px;border:1px solid}
|
||
.m-callout.m-info{background:var(--bld);border-color:var(--bl);color:#0e7490}
|
||
.m-callout.m-tip{background:var(--gnd);border-color:var(--gn);color:#15803d}
|
||
.m-callout.m-warn{background:#fff3cd;border-color:#facc15;color:#a16207}
|
||
.m-tabs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin:14px 0}
|
||
.m-tab-card{background:#fff;border:1px solid var(--bd);border-radius:8px;padding:12px}
|
||
.m-tab-card .m-tab-icon{font-size:22px;margin-bottom:4px}
|
||
.m-tab-card h4{margin:0 0 4px 0;font-size:13px}
|
||
.m-tab-card p{margin:0;font-size:11px;color:var(--t2);line-height:1.4}
|
||
.m-concept{background:#fff;border:1px solid var(--bd);border-left:3px solid var(--olive);border-radius:6px;padding:10px 14px;margin:8px 0}
|
||
.m-concept h3{margin:0 0 4px 0}
|
||
.m-concept p{margin:0;font-size:12px}
|
||
.m-flow{counter-reset:flow-counter;list-style:none;padding-left:0}
|
||
.m-flow li{counter-increment:flow-counter;padding-left:30px;position:relative;margin:5px 0;font-size:12px}
|
||
.m-flow li::before{content:counter(flow-counter);position:absolute;left:0;top:0;width:20px;height:20px;background:var(--olive);color:#fff;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700}
|
||
.m-stages{width:100%;border-collapse:collapse;margin:8px 0;font-size:12px}
|
||
.m-stages th{text-align:left;padding:6px 8px;background:var(--sand-light);color:var(--olive);font-size:10px;text-transform:uppercase;letter-spacing:.5px}
|
||
.m-stages td{padding:5px 8px;border-bottom:1px solid var(--sand-light)}
|
||
.m-faq{background:#fff;border:1px solid var(--bd);border-radius:6px;padding:10px 14px;margin:8px 0}
|
||
.m-faq h4{margin:0 0 4px 0;color:var(--olive-dark);font-size:12px}
|
||
.m-faq p{margin:0;font-size:12px}
|
||
.m-mini-icon{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:4px;font-size:10px;margin:0 2px;vertical-align:middle}
|
||
|
||
/* Dashboard de Ventas — comparativo mensual */
|
||
.cmp-row{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
|
||
.cmp-card{background:var(--s1);border:1px solid var(--bd);border-radius:8px;padding:10px 12px;position:relative}
|
||
.cmp-card.principal{border-color:var(--olive);background:linear-gradient(135deg,#fff,var(--sand-light))}
|
||
.cmp-card-label{font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;font-weight:600;margin-bottom:4px}
|
||
.cmp-card-mes{font-size:10px;color:var(--olive);font-weight:700;margin-bottom:6px}
|
||
.cmp-card-val{font-size:20px;font-weight:700;color:var(--olive-dark);line-height:1.1}
|
||
.cmp-card-sub{font-size:10px;color:var(--t2);margin-top:3px}
|
||
.cmp-trend{position:absolute;top:8px;right:10px;font-size:10px;font-weight:700;padding:2px 6px;border-radius:10px}
|
||
.cmp-trend.up{background:var(--gnd);color:var(--gn)}
|
||
.cmp-trend.down{background:var(--rdd);color:var(--rd)}
|
||
.cmp-trend.flat{background:var(--s2);color:var(--t3)}
|
||
/* Ciclo / tiempos */
|
||
.cycle-summary{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-bottom:8px}
|
||
.cycle-stat{text-align:center;padding:6px 4px;background:var(--s2);border-radius:6px}
|
||
.cycle-stat b{font-size:18px;color:var(--olive-dark);display:block}
|
||
.cycle-stat small{font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px}
|
||
/* Top clientes tabla */
|
||
.tc-tbl{width:100%;font-size:11px;border-collapse:collapse}
|
||
.tc-tbl th{font-size:9px;color:var(--olive);text-transform:uppercase;letter-spacing:.5px;text-align:right;padding:4px 6px;border-bottom:1px solid var(--sand)}
|
||
.tc-tbl th:first-child{text-align:left}
|
||
.tc-tbl td{padding:4px 6px;text-align:right;border-bottom:1px solid var(--sand-light)}
|
||
.tc-tbl td:first-child{text-align:left;font-weight:600}
|
||
.tc-tbl tr:hover td{background:var(--s2)}
|
||
.tc-trend{font-size:9px;font-weight:700;padding:1px 5px;border-radius:8px;display:inline-block}
|
||
/* Tabla de productos — más compacta + botones de acción más grandes + ancho natural */
|
||
table.prod-tbl{width:auto;max-width:100%}
|
||
table.prod-tbl td{padding:2px 4px}
|
||
table.prod-tbl th{padding:3px 4px}
|
||
table.prod-tbl td:nth-child(2){max-width:280px}
|
||
table.prod-tbl .prod-actions{display:flex;gap:3px;justify-content:flex-end;align-items:center}
|
||
table.prod-tbl .prod-actions .kc-btn{width:26px;height:26px;font-size:13px;border-radius:5px}
|
||
.prod-web-toggle{display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:6px;cursor:pointer;font-size:14px;transition:.15s;border:1px solid transparent;background:var(--s2);color:var(--t3)}
|
||
.prod-web-toggle:hover{transform:scale(1.1)}
|
||
.prod-web-toggle.on{background:var(--gnd);border-color:var(--gn)}
|
||
/* ── Producto Quick View (visual) ── */
|
||
.pv-prod-hero{position:relative;background:linear-gradient(180deg,var(--sand-light),var(--cream));padding:24px;text-align:center}
|
||
.pv-prod-hero img{max-width:100%;max-height:340px;border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.12);display:inline-block}
|
||
.pv-prod-hero .pv-noimg{height:240px;display:flex;align-items:center;justify-content:center;color:var(--t3);font-size:40px;border:1px dashed var(--bd);border-radius:10px;background:var(--s1)}
|
||
.pv-prod-x{position:absolute;top:14px;right:16px;width:32px;height:32px;border-radius:50%;border:none;background:rgba(255,255,255,.85);cursor:pointer;font-size:18px;color:var(--t2);display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(0,0,0,.1)}
|
||
.pv-prod-x:hover{background:#fff;color:var(--rd)}
|
||
.pv-prod-webbadge{position:absolute;top:14px;left:16px;font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:4px 10px;border-radius:20px;background:var(--gnd);color:var(--gn);display:flex;align-items:center;gap:4px}
|
||
.pv-prod-info{padding:22px 26px}
|
||
.pv-prod-cat{font-family:'DM Sans','Outfit',sans-serif;font-size:10px;color:var(--olive);text-transform:uppercase;letter-spacing:2px;font-weight:600;margin-bottom:6px}
|
||
.pv-prod-name{font-family:'Playfair Display',serif;font-style:italic;font-size:28px;color:var(--olive-dark);font-weight:400;line-height:1.1;margin-bottom:10px}
|
||
.pv-prod-desc{font-size:13px;color:var(--t2);line-height:1.5;margin-bottom:18px}
|
||
.pv-prod-specs{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:18px}
|
||
.pv-prod-spec{background:var(--s2);border-radius:8px;padding:10px 12px}
|
||
.pv-prod-spec .lbl{font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:1px;font-weight:600;display:block;margin-bottom:3px}
|
||
.pv-prod-spec .val{font-size:13px;color:var(--olive-dark);font-weight:500}
|
||
.pv-prod-pers-block{margin-bottom:20px}
|
||
.pv-prod-pers-label{font-size:9px;color:var(--olive);text-transform:uppercase;letter-spacing:1.5px;font-weight:700;margin-bottom:8px;display:block}
|
||
.pv-prod-pers-tags{display:flex;flex-wrap:wrap;gap:6px}
|
||
.pv-prod-pers-tag{font-size:11px;padding:4px 12px;border-radius:20px;background:var(--olive);color:#fff;font-weight:500}
|
||
.pv-prod-pers-empty{font-size:12px;color:var(--t3);font-style:italic}
|
||
.pv-prod-actions{display:flex;gap:8px;align-items:center}
|
||
.pv-prod-actions .btn-edit-main{flex:1;background:var(--olive);color:#fff;border:none;padding:13px;border-radius:8px;font-family:inherit;font-size:14px;font-weight:600;cursor:pointer;transition:.15s;display:flex;align-items:center;justify-content:center;gap:8px}
|
||
.pv-prod-actions .btn-edit-main:hover{background:var(--olive-dark)}
|
||
.pv-prod-actions .btn-sec{padding:13px 16px;border-radius:8px;border:1px solid var(--bd);background:#fff;cursor:pointer;font-family:inherit;font-size:14px;color:var(--t2);transition:.15s}
|
||
.pv-prod-actions .btn-sec:hover{border-color:var(--olive);color:var(--olive)}
|
||
.pv-prod-actions .btn-sec.danger:hover{border-color:var(--rd);color:var(--rd)}
|
||
/* Multi-select de personalizaciones en el editor */
|
||
.pe-pers-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:4px}
|
||
.pe-pers-opt{display:flex;align-items:center;gap:8px;padding:9px 12px;border:1px solid var(--bd);border-radius:7px;cursor:pointer;font-size:13px;transition:.15s;user-select:none}
|
||
.pe-pers-opt:hover{border-color:var(--olive);background:var(--s2)}
|
||
.pe-pers-opt.on{border-color:var(--olive);background:var(--acd);color:var(--olive-dark);font-weight:600}
|
||
.pe-pers-opt .chk{width:16px;height:16px;border-radius:4px;border:1.5px solid var(--bd);display:flex;align-items:center;justify-content:center;font-size:11px;color:#fff;flex-shrink:0}
|
||
.pe-pers-opt.on .chk{background:var(--olive);border-color:var(--olive)}
|
||
.pe-sec-label{font-size:12px;font-weight:700;color:var(--olive-dark);margin-bottom:10px;display:flex;align-items:center;gap:8px}
|
||
.pe-sec-label span{font-size:9px;font-weight:500;color:var(--t3);text-transform:uppercase;letter-spacing:1px}
|
||
/* Galería de ejemplos (pedidos con foto de avance) */
|
||
.pv-ejemplos{border-top:1px solid var(--sand);margin-top:4px;padding-top:18px}
|
||
.pv-ejemplos-label{font-size:9px;color:var(--olive);text-transform:uppercase;letter-spacing:1.5px;font-weight:700;margin-bottom:3px;display:block}
|
||
.pv-ejemplos-sub{font-size:11px;color:var(--t3);margin-bottom:12px}
|
||
.pv-ejemplos-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px}
|
||
.pv-ejemplo{border:1px solid var(--bd);border-radius:8px;overflow:hidden;cursor:pointer;transition:.15s;background:#fff;position:relative}
|
||
.pv-ejemplo:hover{border-color:var(--olive);box-shadow:0 4px 12px rgba(92,107,79,.12);transform:translateY(-2px)}
|
||
.pv-ejemplo img{width:100%;aspect-ratio:1;object-fit:cover;display:block;background:var(--s2)}
|
||
.pv-ejemplo-info{padding:7px 9px}
|
||
.pv-ejemplo-trab{font-size:10px;font-weight:700;color:var(--olive-dark)}
|
||
.pv-ejemplo-cli{font-size:9px;color:var(--t2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.pv-ejemplo.is-web{border-color:var(--gn);box-shadow:0 0 0 1px var(--gn)}
|
||
.pv-ejemplo-webdot{position:absolute;top:6px;right:6px;width:24px;height:24px;border-radius:50%;background:rgba(255,255,255,.92);display:flex;align-items:center;justify-content:center;font-size:12px;box-shadow:0 1px 4px rgba(0,0,0,.18);cursor:pointer;z-index:2;transition:.15s;color:var(--t3)}
|
||
.pv-ejemplo-webdot:hover{transform:scale(1.12)}
|
||
.pv-ejemplo-webdot.on{background:var(--gnd)}
|
||
.pv-ejemplo-cli-real{font-size:8px;color:var(--t3);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.pv-ejemplo-etiqueta{width:100%;margin-top:4px;font-size:10px;padding:3px 5px;border:1px solid var(--olive);border-radius:4px;font-family:inherit;color:var(--olive-dark);outline:none}
|
||
.pv-ejemplo-etiqueta::placeholder{color:var(--t3)}
|
||
.pv-ejemplos-empty{font-size:12px;color:var(--t3);font-style:italic;padding:8px 0}
|
||
.kc-thumb{width:44px;height:44px;object-fit:cover;border-radius:5px;border:1px solid var(--bd);flex-shrink:0;cursor:zoom-in;transition:.15s}
|
||
.kc-thumb:hover{transform:scale(1.1);border-color:var(--olive)}
|
||
.bc-thumb{width:38px;height:38px;object-fit:cover;border-radius:5px;border:1px solid var(--bd);flex-shrink:0;cursor:zoom-in;transition:.15s}
|
||
.bc-thumb:hover{border-color:var(--olive)}
|
||
/* Compact grid for entrega cards */
|
||
.entrega-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:8px}
|
||
@media(min-width:900px){
|
||
#entregas-list .day-group{display:flex;gap:10px;align-items:flex-start;margin-bottom:10px}
|
||
#entregas-list .day-group .day-header{flex:0 0 180px;width:180px;align-self:stretch;flex-direction:column;align-items:flex-start;gap:4px;margin-bottom:0}
|
||
#entregas-list .day-group .day-header small{font-size:9px}
|
||
#entregas-list .day-group .entrega-grid{flex:1;min-width:0;display:flex;flex-wrap:wrap;gap:6px}
|
||
#entregas-list .day-group .entrega-grid > *{flex:1 1 300px;min-width:300px;max-width:380px}
|
||
}
|
||
/* Connected OC: left bar instead of wrapper */
|
||
.oc-stack{display:flex;flex-direction:column;gap:0;border-left:4px solid var(--olive);border-radius:6px;overflow:hidden;background:rgba(92,107,79,.04)}
|
||
.oc-stack .oc-stack-head{padding:6px 10px;display:flex;justify-content:space-between;align-items:center;background:rgba(92,107,79,.1);cursor:pointer;font-size:11px;border-bottom:1px solid rgba(92,107,79,.2)}
|
||
.oc-stack .oc-stack-head:hover{background:rgba(92,107,79,.18)}
|
||
.oc-stack .oc-stack-folio{font-weight:700;color:var(--olive-dark);display:flex;align-items:center;gap:6px}
|
||
.oc-stack .entrega-card{border:none;border-radius:0;margin-bottom:0;border-bottom:1px solid var(--bd);padding:8px 10px}
|
||
.oc-stack .entrega-card:last-child{border-bottom:none}
|
||
/* Compact day group */
|
||
.day-group{margin-bottom:14px}
|
||
.day-group .day-header{font-size:11px;font-weight:700;color:var(--olive-dark);padding:5px 10px;background:var(--sand-light);border-radius:6px;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between}
|
||
.day-group .day-header small{font-size:9px;font-weight:500;color:var(--t2)}
|
||
/* Footer totals */
|
||
.ent-footer-totals{margin-top:12px;padding:10px 14px;background:linear-gradient(135deg,var(--sand-light),var(--s2));border-radius:8px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;border:1px solid var(--bd)}
|
||
.ent-footer-totals .ftl{display:flex;flex-direction:column}
|
||
.ent-footer-totals .ftl-label{font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px}
|
||
.ent-footer-totals .ftl-val{font-size:14px;font-weight:700;color:var(--olive-dark)}
|
||
/* Entregas nav panels (legacy) */
|
||
.ent-nav{background:var(--s1);border:1px solid var(--bd);border-radius:var(--r);padding:12px;margin-bottom:12px}
|
||
.ent-nav-title{font-size:10px;font-weight:600;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px}
|
||
.ent-month-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:4px}
|
||
.ent-month-btn{padding:8px 4px;border-radius:6px;font-size:11px;font-weight:500;color:var(--t2);cursor:pointer;border:1px solid transparent;background:var(--s2);text-align:center;transition:.15s;line-height:1.2}
|
||
.ent-month-btn:hover{border-color:var(--ac);color:var(--ac)}
|
||
.ent-month-btn.on{background:var(--olive);color:#fff;border-color:var(--olive);font-weight:700}
|
||
.ent-month-btn.empty{opacity:.35;cursor:default}
|
||
.ent-month-btn .mm{font-size:12px;font-weight:600;display:block}.ent-month-btn .cnt{font-size:8px;opacity:.7}
|
||
.ent-year-nav{display:flex;align-items:center;justify-content:center;gap:12px;margin-bottom:8px}
|
||
.ent-year-nav button{background:none;border:none;cursor:pointer;font-size:16px;color:var(--t2);padding:2px 6px;border-radius:4px}
|
||
.ent-year-nav button:hover{color:var(--olive);background:var(--acd)}
|
||
.ent-year-nav span{font-size:14px;font-weight:700;color:var(--olive-dark);min-width:50px;text-align:center}
|
||
.ent-cli-chips{display:flex;flex-wrap:wrap;gap:6px}
|
||
.ent-cli-chip{padding:6px 12px;border-radius:20px;font-size:11px;font-weight:500;color:var(--t2);cursor:pointer;border:1px solid var(--bd);background:var(--s2);transition:.15s;display:flex;align-items:center;gap:5px}
|
||
.ent-cli-chip:hover{border-color:var(--ac);color:var(--ac)}
|
||
.ent-cli-chip.on{background:var(--olive);color:#fff;border-color:var(--olive);font-weight:700}
|
||
.ent-cli-chip .cli-count{font-size:9px;opacity:.7;font-weight:400}
|
||
.ent-cli-chip .cli-vol{font-size:9px;background:rgba(255,255,255,.2);padding:1px 5px;border-radius:8px}
|
||
.ent-cli-chip .cli-edit{font-size:10px;opacity:.4;margin-left:2px;transition:.15s}
|
||
.ent-cli-chip:hover .cli-edit{opacity:1}
|
||
.ent-cli-chip.on .cli-edit{opacity:.7}
|
||
/* OC group in entregas */
|
||
.oc-group-card{background:var(--s1);border:2px solid var(--olive);border-radius:var(--r);margin-bottom:8px;overflow:hidden}
|
||
.oc-group-header{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:linear-gradient(135deg,var(--sand-light),var(--s2));cursor:pointer;gap:6px}
|
||
.oc-group-header:hover{background:var(--acd)}
|
||
.oc-group-lines{padding:4px 8px 8px}
|
||
.oc-group-lines .entrega-card{border-left:3px solid var(--olive);margin-bottom:4px}
|
||
/* OC management view */
|
||
.oc-mgmt-card{background:var(--s1);border:1px solid var(--bd);border-radius:var(--r);padding:14px;margin-bottom:10px;transition:.2s}
|
||
.oc-mgmt-card:hover{border-color:var(--olive)}
|
||
.oc-mgmt-card.active{border-color:var(--olive);border-width:2px}
|
||
.oc-mgmt-card.entregada{opacity:.7}
|
||
.oc-mgmt-header{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}
|
||
.oc-mgmt-lines{margin-top:8px;padding-top:8px;border-top:1px solid var(--bd)}
|
||
.oc-line-row{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;border-radius:6px;font-size:11px;margin-bottom:3px;background:var(--s2)}
|
||
.oc-line-row:hover{background:var(--acd)}
|
||
.oc-line-row .stage-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:4px}
|
||
.oc-unlinked{padding:10px;margin-top:12px;background:var(--s2);border-radius:var(--r);border:1px dashed var(--bd)}
|
||
.oc-unlinked-title{font-size:11px;font-weight:600;color:var(--t2);margin-bottom:6px}
|
||
.oc-link-btn{font-size:10px;padding:3px 8px;border-radius:4px;border:1px solid var(--olive);background:var(--acd);color:var(--olive);cursor:pointer;font-weight:600;transition:.15s}
|
||
.oc-link-btn:hover{background:var(--olive);color:#fff}
|
||
/* OC cost summary */
|
||
.oc-costs-summary{margin-top:8px;padding:8px 10px;background:var(--s2);border-radius:6px;font-size:11px}
|
||
.oc-cost-row{display:flex;justify-content:space-between;padding:2px 0;color:var(--t2)}
|
||
.oc-cost-row.oc-cost-sep{border-top:1px solid var(--bd);padding-top:5px;margin-top:3px;color:var(--tx);font-weight:600}
|
||
.oc-cost-row.oc-cost-total{font-weight:700;color:var(--olive-dark);font-size:12px}
|
||
/* Orden detail visualizer */
|
||
.od-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;padding-bottom:10px;border-bottom:2px solid var(--olive);margin-bottom:12px}
|
||
.od-head-left{flex:1;min-width:0}
|
||
.od-head h2{margin:0;font-size:18px;font-weight:700;color:var(--olive-dark);display:flex;align-items:center;gap:8px}
|
||
.od-head .od-cli{font-size:13px;color:var(--t2);margin-top:2px}
|
||
.od-status-pill{font-size:9px;padding:3px 8px;border-radius:10px;font-weight:600;display:inline-block}
|
||
.od-section{margin-bottom:14px}
|
||
.od-section-title{font-size:10px;font-weight:600;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;display:flex;justify-content:space-between;align-items:center}
|
||
.od-pedidos-box{background:var(--s2);border-radius:8px;padding:8px;max-height:240px;overflow-y:auto}
|
||
.od-pedido-row{display:flex;align-items:center;gap:8px;padding:8px 10px;background:var(--s1);border-radius:6px;margin-bottom:4px;font-size:12px;cursor:pointer;transition:.15s;border:1px solid transparent}
|
||
.od-pedido-row:hover{border-color:var(--olive);background:var(--acd)}
|
||
.od-pedido-row .stage-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
|
||
.od-pedido-row .pr-id{font-weight:700;min-width:90px}
|
||
.od-pedido-row .pr-prod{flex:1;color:var(--t2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.od-pedido-row .pr-qty{font-weight:600;min-width:55px;text-align:right}
|
||
.od-pedido-row .pr-stage{font-size:9px;padding:2px 6px;border-radius:4px;white-space:nowrap}
|
||
.od-config-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||
.od-config-grid .fg{margin:0}
|
||
.od-config-grid .fg label{font-size:9px;color:var(--t2);margin-bottom:2px;display:block}
|
||
.od-config-grid .fg input,.od-config-grid .fg select,.od-config-grid .fg textarea{font-size:13px;padding:8px 10px;width:100%;box-sizing:border-box;border:1px solid var(--bd);border-radius:6px;background:var(--s1);font-family:inherit;outline:none}
|
||
.od-config-grid .fg input:focus,.od-config-grid .fg select:focus,.od-config-grid .fg textarea:focus{border-color:var(--olive)}
|
||
.od-totales{background:linear-gradient(135deg,var(--sand-light),var(--s2));border-radius:8px;padding:10px 14px;margin-top:8px}
|
||
.od-tot-row{display:flex;justify-content:space-between;padding:3px 0;font-size:12px;color:var(--t2)}
|
||
.od-tot-row.sep{border-top:1px solid var(--bd);padding-top:6px;margin-top:4px;color:var(--tx);font-weight:600}
|
||
.od-tot-row.big{font-size:14px;font-weight:700;color:var(--olive-dark)}
|
||
.od-tot-row.util{font-size:13px;font-weight:700;border-top:1px solid var(--bd);padding-top:6px;margin-top:4px}
|
||
.od-actions{display:flex;gap:6px;margin-top:14px;flex-wrap:wrap}
|
||
/* Rich pedido cards in OC visualizer */
|
||
.od-pedidos-list{display:flex;flex-direction:column;gap:6px;max-height:none;overflow:visible}
|
||
.od-pedido-card{display:flex;gap:10px;align-items:flex-start;padding:8px;background:var(--s1);border:1px solid var(--bd);border-radius:8px;cursor:pointer;transition:.15s;position:relative}
|
||
.od-pedido-card:hover{border-color:var(--olive);box-shadow:0 1px 4px rgba(0,0,0,.05)}
|
||
.odp-photo{width:54px;height:54px;object-fit:cover;border-radius:6px;border:1px solid var(--bd);flex-shrink:0;cursor:zoom-in}
|
||
.odp-photo-empty{width:54px;height:54px;border-radius:6px;border:1px dashed var(--bd);background:var(--s2);display:flex;align-items:center;justify-content:center;font-size:20px;opacity:.4;flex-shrink:0}
|
||
.odp-info{flex:1;min-width:0}
|
||
.odp-head{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||
.odp-head .stage-dot{width:8px;height:8px;border-radius:50%;display:inline-block}
|
||
.odp-prod{font-size:11px;color:var(--t2);margin-top:2px;font-weight:500}
|
||
.odp-meta{font-size:10px;color:var(--t3);margin-top:2px;font-style:italic}
|
||
.odp-bottom{display:flex;align-items:center;gap:10px;margin-top:4px}
|
||
.odp-qty{font-size:11px}
|
||
.odp-cost{font-size:10px;color:var(--olive);font-weight:600;margin-left:auto}
|
||
.odp-del{position:absolute;top:6px;right:6px;color:var(--rd)!important;opacity:0;transition:.15s}
|
||
.od-pedido-card:hover .odp-del{opacity:1}
|
||
.pr-stage{font-size:9px;padding:2px 6px;border-radius:3px;font-weight:600}
|
||
/* Quick edit (subtotal/IVA/factura always visible) */
|
||
.od-quick-edit{background:linear-gradient(135deg,var(--sand-light),var(--s2));border-radius:8px;padding:10px 12px}
|
||
.qe-row{display:grid;grid-template-columns:1.5fr .8fr 1.5fr;gap:8px;margin-bottom:10px}
|
||
@media (max-width:520px){.qe-row{grid-template-columns:1fr 1fr;}.qe-row .qe-narrow{grid-column:1/-1}}
|
||
.qe-field label{font-size:9px;color:var(--t2);font-weight:600;text-transform:uppercase;letter-spacing:.5px;display:block;margin-bottom:3px}
|
||
.qe-field input{width:100%;font-size:14px;font-weight:600;padding:8px 10px;border:1px solid var(--bd);border-radius:6px;background:var(--s1);font-family:inherit;outline:none;box-sizing:border-box}
|
||
.qe-field input:focus{border-color:var(--olive)}
|
||
.qe-totals{display:flex;flex-direction:column;gap:2px;padding:8px 4px 0;border-top:1px solid var(--bd)}
|
||
.qe-tot-row{display:flex;justify-content:space-between;font-size:11px;color:var(--t2);padding:1px 0}
|
||
.qe-tot-row.big{font-size:14px;font-weight:700;color:var(--olive-dark);margin-top:3px}
|
||
.qe-tot-row.util{font-size:12px;font-weight:700;padding-top:5px;border-top:1px solid var(--bd);margin-top:3px}
|
||
/* Details toggle */
|
||
.od-details-toggle{width:100%;background:var(--s2);border:1px solid var(--bd);padding:8px 12px;font-size:11px;color:var(--t2);font-weight:600;cursor:pointer;border-radius:6px;margin:10px 0;font-family:inherit;text-align:center;transition:.15s}
|
||
.od-details-toggle:hover{background:var(--acd);color:var(--olive)}
|
||
/* ══ PROPUESTAS ══ */
|
||
.pp-list{display:flex;flex-direction:column;gap:6px}
|
||
.pp-row{display:flex;align-items:center;gap:12px;padding:12px 14px;background:var(--s1);border:1px solid var(--bd);border-radius:8px;cursor:pointer;transition:.15s;border-left:4px solid var(--olive)}
|
||
.pp-row:hover{border-color:var(--olive);box-shadow:0 2px 6px rgba(0,0,0,.06)}
|
||
.pp-row-main{flex:1;min-width:0}
|
||
.pp-row-head{display:flex;align-items:center;gap:8px;margin-bottom:3px}
|
||
.pp-num{font-weight:700;font-size:13px;color:var(--olive-dark)}
|
||
.pp-status{font-size:9px;padding:2px 8px;border-radius:10px;font-weight:600}
|
||
.pp-row-cli{font-size:12px;color:var(--tx)}
|
||
.pp-row-meta{font-size:10px;color:var(--t3);margin-top:2px}
|
||
.pp-row-right{text-align:right}
|
||
.pp-row-total{font-size:16px;font-weight:700;color:var(--olive-dark)}
|
||
/* Propuesta editor */
|
||
.pp-cli-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||
@media (max-width:600px){.pp-cli-grid{grid-template-columns:1fr}}
|
||
.pp-cli-grid .fg{margin:0}
|
||
.pp-cli-grid .fg label{font-size:9px;color:var(--t2);font-weight:600;text-transform:uppercase;letter-spacing:.5px;display:block;margin-bottom:3px}
|
||
.pp-cli-grid .fg input,.pp-cli-grid .fg select{font-size:13px;padding:8px 10px;width:100%;box-sizing:border-box;border:1px solid var(--bd);border-radius:6px;background:var(--s1);outline:none;font-family:inherit}
|
||
.pp-items-list{display:flex;flex-direction:column;gap:6px}
|
||
.pp-item{display:flex;gap:10px;align-items:flex-start;padding:8px;background:var(--s2);border:1px solid var(--bd);border-radius:8px}
|
||
.pp-item-photo{width:60px;height:60px;object-fit:cover;border-radius:6px;border:1px solid var(--bd);flex-shrink:0;cursor:zoom-in}
|
||
.pp-item-photo-empty{width:60px;height:60px;border-radius:6px;border:1px dashed var(--bd);background:var(--s1);display:flex;align-items:center;justify-content:center;font-size:22px;opacity:.4;flex-shrink:0}
|
||
.pp-item-main{flex:1;min-width:0}
|
||
.pp-item-name{font-weight:600;font-size:13px}
|
||
.pp-item-desc{font-size:10px;color:var(--t3);margin-top:1px}
|
||
.pp-item-controls{display:flex;gap:8px;align-items:center;margin-top:6px;flex-wrap:wrap}
|
||
.pp-item-controls label{font-size:9px;color:var(--t2);font-weight:600;display:flex;flex-direction:column;gap:2px}
|
||
.pp-item-controls input{width:80px;font-size:13px;padding:5px 8px;border:1px solid var(--bd);border-radius:5px;background:var(--s1);font-family:inherit}
|
||
.pp-item-sub{margin-left:auto;font-size:13px;font-weight:700;color:var(--olive-dark);align-self:flex-end}
|
||
/* Per-item tipo_trabajo + descripcion fields */
|
||
.pp-item-fields{display:grid;grid-template-columns:1fr 2fr;gap:6px;margin-top:6px}
|
||
@media (max-width:520px){.pp-item-fields{grid-template-columns:1fr}}
|
||
.pp-item-field{display:flex;flex-direction:column;gap:2px;font-size:9px;color:var(--t2);font-weight:600;text-transform:uppercase;letter-spacing:.3px}
|
||
.pp-item-field select,.pp-item-field input{font-size:12px;padding:5px 8px;border:1px solid var(--bd);border-radius:5px;background:var(--s1);font-family:inherit;outline:none;font-weight:500;text-transform:none;letter-spacing:0;color:var(--tx)}
|
||
.pp-item-field select:focus,.pp-item-field input:focus{border-color:var(--olive)}
|
||
.pp-pv-doc .item-details{font-size:11px;color:#555;margin-top:6px;line-height:1.5;font-style:italic}
|
||
/* Product picker modal */
|
||
.pp-prod-row{display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;cursor:pointer;border:1px solid transparent;transition:.15s}
|
||
.pp-prod-row:hover{background:var(--acd);border-color:var(--olive)}
|
||
.pp-prod-photo{width:44px;height:44px;object-fit:cover;border-radius:5px;border:1px solid var(--bd);flex-shrink:0}
|
||
.pp-prod-photo-empty{width:44px;height:44px;border-radius:5px;border:1px dashed var(--bd);background:var(--s2);display:flex;align-items:center;justify-content:center;opacity:.4;flex-shrink:0;font-size:18px}
|
||
/* Proyectos */
|
||
.proy-cli-group{margin-bottom:16px}
|
||
.proy-cli-header{font-size:12px;color:var(--olive-dark);padding:6px 10px;background:var(--sand-light);border-radius:6px;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between}
|
||
.proy-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:8px}
|
||
.proy-card{display:flex;gap:10px;align-items:flex-start;padding:10px;background:var(--s1);border:1px solid var(--bd);border-radius:8px;cursor:pointer;transition:.15s;border-left:3px solid var(--olive)}
|
||
.proy-card:hover{border-color:var(--olive);box-shadow:0 2px 6px rgba(0,0,0,.06)}
|
||
.proy-photo{width:56px;height:56px;object-fit:cover;border-radius:6px;border:1px solid var(--bd);flex-shrink:0}
|
||
.proy-photo-empty{width:56px;height:56px;border-radius:6px;border:1px dashed var(--bd);background:var(--s2);display:flex;align-items:center;justify-content:center;font-size:22px;opacity:.4;flex-shrink:0}
|
||
.proy-info{flex:1;min-width:0}
|
||
.proy-nombre{font-weight:600;font-size:12px;line-height:1.3;color:var(--tx)}
|
||
.proy-meta{display:flex;gap:4px;align-items:center;flex-wrap:wrap;margin-top:4px}
|
||
.proy-tag{font-size:9px;padding:1px 6px;border-radius:3px;font-weight:600}
|
||
.proy-cost{font-size:11px;color:var(--olive);font-weight:700;margin-top:4px}
|
||
.proy-stats{display:flex;gap:8px;font-size:9px;color:var(--t3);margin-top:4px}
|
||
.proy-sug-panel{background:linear-gradient(135deg,var(--sand-light),var(--s2));border:1px solid var(--bd);border-radius:8px;padding:10px 12px;margin-bottom:16px}
|
||
.proy-sug-header{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap}
|
||
.proy-sug-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:6px}
|
||
.proy-sug-card{background:var(--s1);border:1px dashed var(--olive);border-radius:6px;padding:8px 10px;cursor:pointer;transition:.15s}
|
||
.proy-sug-card:hover{border-style:solid;border-color:var(--olive);box-shadow:0 2px 6px rgba(0,0,0,.08);background:var(--acd)}
|
||
.psg-head{display:flex;justify-content:space-between;align-items:center;font-size:12px}
|
||
.psg-head b{color:var(--olive-dark)}
|
||
.psg-count{font-size:10px;background:var(--olive);color:#fff;padding:1px 7px;border-radius:10px;font-weight:700}
|
||
.psg-prod{font-size:11px;color:var(--t2);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.psg-meta{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:4px}
|
||
/* Proyecto viewer */
|
||
.pv-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:6px;margin-bottom:14px}
|
||
.pv-stat{background:var(--s2);border:1px solid var(--bd);padding:8px 10px;border-radius:6px;display:flex;flex-direction:column;gap:1px}
|
||
.pv-stat .lbl{font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;font-weight:600}
|
||
.pv-stat .val{font-size:16px;font-weight:700;color:var(--olive-dark)}
|
||
.pv-details{display:flex;flex-direction:column;gap:0;background:var(--s1);border:1px solid var(--bd);border-radius:8px;overflow:hidden}
|
||
.pv-row{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid var(--bd);font-size:12px}
|
||
.pv-row:last-child{border-bottom:none}
|
||
.pv-lbl{font-size:10px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;font-weight:600;flex-shrink:0}
|
||
.pv-val{color:var(--tx);font-weight:500;text-align:right}
|
||
.pv-pedido-row{display:flex;align-items:center;gap:8px;padding:7px 10px;background:var(--s1);border:1px solid var(--bd);border-radius:6px;margin-bottom:3px;cursor:pointer;font-size:11px;transition:.15s}
|
||
.pv-pedido-row:hover{border-color:var(--olive);background:var(--acd)}
|
||
.lp-proy-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:6px;cursor:pointer;border:1px solid transparent;margin-bottom:4px;transition:.15s}
|
||
.lp-proy-row:hover{background:var(--acd);border-color:var(--olive)}
|
||
.lp-proy-photo{width:48px;height:48px;object-fit:cover;border-radius:5px;border:1px solid var(--bd);flex-shrink:0}
|
||
.lp-proy-photo-empty{width:48px;height:48px;border-radius:5px;border:1px dashed var(--bd);background:var(--s2);display:flex;align-items:center;justify-content:center;font-size:20px;opacity:.4;flex-shrink:0}
|
||
/* Wizard step 2: quick-start con proyectos */
|
||
.wiz-quick-banner{background:linear-gradient(135deg,var(--sand-light),var(--s2));border:1px solid var(--olive);border-radius:10px;padding:12px 14px}
|
||
.wiz-quick-banner.selected{background:linear-gradient(135deg,#dcfce7,var(--sand-light));border-color:var(--gn,#16a34a)}
|
||
.wiz-quick-title{display:flex;align-items:center;gap:6px;font-size:13px;font-weight:700;color:var(--olive-dark);margin-bottom:8px;flex-wrap:wrap}
|
||
.wiz-quick-sub{font-size:10px;color:var(--t3);font-weight:400;letter-spacing:.3px;margin-left:auto}
|
||
.wiz-quick-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:6px}
|
||
.wiz-proy-card{background:var(--s1);border:1px solid var(--bd);border-radius:8px;padding:8px;cursor:pointer;transition:.15s;display:flex;flex-direction:column;gap:6px}
|
||
.wiz-proy-card:hover{border-color:var(--olive);box-shadow:0 2px 8px rgba(0,0,0,.08);transform:translateY(-1px)}
|
||
.wiz-proy-card.selected{border-color:var(--gn,#16a34a);background:#dcfce7}
|
||
.wiz-proy-card img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:5px;border:1px solid var(--bd)}
|
||
.wiz-proy-empty{width:100%;aspect-ratio:1;border-radius:5px;background:var(--s2);display:flex;align-items:center;justify-content:center;font-size:24px;opacity:.4}
|
||
.wiz-proy-name{font-size:11px;font-weight:600;line-height:1.3;color:var(--tx)}
|
||
.wiz-proy-meta{display:flex;gap:4px;flex-wrap:wrap;align-items:center}
|
||
.wiz-quick-hint{font-size:10px;color:var(--t2);text-align:center;margin-top:8px;padding-top:8px;border-top:1px dashed var(--bd)}
|
||
.wiz-manual-btn{background:none;border:none;color:var(--olive);font-weight:600;cursor:pointer;font-size:10px;font-family:inherit;text-decoration:underline;text-underline-offset:2px}
|
||
.wiz-selected-row{display:flex;align-items:center;gap:10px}
|
||
.wiz-selected-photo{width:52px;height:52px;object-fit:cover;border-radius:6px;border:1px solid var(--bd);flex-shrink:0}
|
||
.wiz-selected-photo-empty{width:52px;height:52px;border-radius:6px;background:var(--s2);display:flex;align-items:center;justify-content:center;font-size:22px;opacity:.4;flex-shrink:0}
|
||
.wiz-unlink{background:rgba(0,0,0,.08);border:none;width:28px;height:28px;border-radius:50%;cursor:pointer;color:var(--t2);font-size:14px;flex-shrink:0;transition:.15s}
|
||
.wiz-unlink:hover{background:var(--rd);color:#fff}
|
||
/* ══ Pedido Quick View — propuesta style ══ */
|
||
.qv-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;padding-bottom:12px;border-bottom:2px solid var(--olive);margin-bottom:14px}
|
||
.qv-head-main{flex:1;min-width:0}
|
||
.qv-head-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||
.qv-badge{font-size:10px;padding:3px 8px;border-radius:4px;background:var(--olive);color:#fff;font-weight:700;letter-spacing:.5px}
|
||
.qv-head h2{margin:0;font-size:20px;font-weight:700;color:var(--olive-dark)}
|
||
.qv-head-cli{font-size:13px;color:var(--t2);margin-top:4px;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||
.qv-head-cli .cli-link{font-weight:600;color:var(--olive)}
|
||
.qv-urg{font-size:9px;padding:2px 8px;border-radius:10px;background:var(--rdd);color:var(--rd);font-weight:700;letter-spacing:.3px}
|
||
.qv-stage-pill{font-size:10px;padding:3px 10px;border-radius:10px;font-weight:600}
|
||
.qv-tag{font-size:9px;padding:1px 6px;border-radius:3px;background:var(--s2);color:var(--t2);font-weight:600}
|
||
.qv-photo-block{margin:0 -18px 14px;text-align:center;background:linear-gradient(180deg,var(--s2) 0%,#fff 100%);padding:12px 18px;position:relative}
|
||
.qv-photo-block img{max-width:100%;max-height:380px;border-radius:8px;cursor:zoom-in;box-shadow:0 4px 14px rgba(0,0,0,.12)}
|
||
.qv-photo-upload{display:inline-flex;align-items:center;gap:6px;margin-top:8px;padding:6px 12px;border:1px solid var(--olive);background:#fff;color:var(--olive-dark);border-radius:6px;cursor:pointer;font-family:inherit;font-size:12px;font-weight:600;transition:.15s}
|
||
.qv-photo-upload:hover{background:var(--acd);box-shadow:0 2px 6px rgba(0,0,0,.08)}
|
||
.qv-photo-empty{display:flex;align-items:center;justify-content:center;height:180px;color:var(--t3);font-size:12px;border:1px dashed var(--bd);border-radius:8px;background:var(--s1)}
|
||
.veh-panel{background:linear-gradient(135deg,#cffafe,#ecfeff);border:1px solid #67e8f9;border-radius:10px;padding:10px 12px}
|
||
.veh-panel-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}
|
||
.veh-panel-title{font-size:12px;font-weight:700;color:#0e7490;display:flex;align-items:center;gap:6px;letter-spacing:.3px;text-transform:uppercase}
|
||
.veh-panel-count{background:#0e7490;color:#fff;font-size:10px;padding:2px 8px;border-radius:10px;font-weight:700}
|
||
.veh-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:6px}
|
||
.veh-card{background:#fff;border:1px solid #a5f3fc;border-radius:8px;padding:8px 10px;display:flex;gap:8px;align-items:center;cursor:pointer;transition:.15s}
|
||
.veh-card:hover{border-color:#0e7490;box-shadow:0 2px 8px rgba(14,116,144,.15)}
|
||
.veh-card-info{flex:1;min-width:0}
|
||
.veh-card-id{font-size:11px;font-weight:700;color:#0e7490}
|
||
.veh-card-cli{font-size:11px;color:var(--olive-dark);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.veh-card-prod{font-size:10px;color:var(--t2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.veh-card-qty{font-size:10px;color:var(--t2);font-weight:600}
|
||
.veh-entregar{background:var(--ac);color:#fff;border:none;padding:5px 10px;border-radius:5px;cursor:pointer;font-family:inherit;font-size:11px;font-weight:600;white-space:nowrap}
|
||
.veh-entregar:hover{background:var(--olive-dark)}
|
||
.qv-product-hero{background:linear-gradient(135deg,var(--sand-light),var(--s2));border-radius:10px;padding:14px 18px;margin-bottom:12px;display:flex;flex-direction:column;gap:4px;position:relative}
|
||
.qv-product-name{font-family:'Playfair Display',serif;font-style:italic;font-size:22px;font-weight:400;color:var(--olive-dark);line-height:1.2;letter-spacing:-.3px;padding-right:80px}
|
||
.qv-product-meta{font-size:10px;color:var(--t2);text-transform:uppercase;letter-spacing:1px;font-weight:600}
|
||
.qv-qty{position:absolute;top:50%;right:18px;transform:translateY(-50%);text-align:right;display:flex;align-items:baseline;gap:4px}
|
||
.qv-qty b{font-size:28px;font-weight:700;color:var(--olive-dark);font-variant-numeric:tabular-nums}
|
||
.qv-qty span{font-size:11px;color:var(--t2);text-transform:uppercase;letter-spacing:1px;font-weight:600}
|
||
.qv-links{display:flex;flex-direction:column;gap:6px;margin-bottom:12px}
|
||
.qv-link-card{display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--s1);border:1px solid var(--bd);border-left:3px solid var(--olive);border-radius:8px;cursor:pointer;transition:.15s}
|
||
.qv-link-card:hover{border-color:var(--olive);background:var(--acd);transform:translateX(2px)}
|
||
.qv-link-icon{font-size:16px}
|
||
.qv-link-label{font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;font-weight:600}
|
||
.qv-link-val{font-size:12px;color:var(--olive-dark);font-weight:600;margin-top:1px}
|
||
.qv-link-arrow{margin-left:auto;font-size:20px;color:var(--t3);font-weight:300}
|
||
.qv-section{margin-bottom:12px}
|
||
.qv-section-h{font-size:10px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;font-weight:600;margin-bottom:6px}
|
||
.qv-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:6px}
|
||
.qv-info-cell{background:var(--s2);padding:6px 10px;border-radius:6px;display:flex;flex-direction:column;gap:1px}
|
||
.qv-info-cell .lbl{font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:.3px;font-weight:600}
|
||
.qv-info-cell .val{font-size:13px;color:var(--tx);font-weight:500}
|
||
.qv-notes{font-size:12px;color:var(--t2);background:var(--s2);padding:8px 12px;border-radius:6px;white-space:pre-wrap;line-height:1.5;font-style:italic}
|
||
/* ══ PROPUESTA PREVIEW (fullscreen in-page, mobile-friendly) ══ */
|
||
#mo-pp-preview.show{align-items:flex-start;justify-content:center;padding:0;overflow-y:auto}
|
||
#mo-pp-preview .mo{max-width:none;width:100%;padding:0;background:#fff;min-height:100vh;border-radius:0;box-shadow:none}
|
||
.pp-pv-toolbar{position:sticky;top:0;background:#5C6B4F;padding:10px 16px;display:flex;justify-content:space-between;align-items:center;z-index:10;box-shadow:0 2px 8px rgba(0,0,0,.15);gap:8px;flex-wrap:wrap}
|
||
.pp-pv-toolbar h3{font-size:12px;color:#fff;font-weight:500;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
|
||
.pp-pv-toolbar button{padding:8px 14px;border:1px solid rgba(255,255,255,.4);background:rgba(255,255,255,.1);color:#fff;font-family:inherit;font-size:12px;font-weight:600;border-radius:6px;cursor:pointer}
|
||
.pp-pv-toolbar button:hover{background:rgba(255,255,255,.25)}
|
||
.pp-pv-toolbar button.primary{background:#fff;color:#5C6B4F}
|
||
.pp-pv-doc{max-width:820px;margin:0 auto;background:#fff;padding:24px 48px 32px;color:#2C2C2C;font-family:'Outfit',sans-serif;font-size:13px;line-height:1.45}
|
||
@media (max-width:600px){.pp-pv-doc{padding:16px 18px 24px}.pp-pv-toolbar{padding:8px 12px}.pp-pv-toolbar h3{display:none}}
|
||
.pp-pv-doc *{box-sizing:border-box}
|
||
.pp-pv-doc .top{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:14px;border-bottom:2px solid #5C6B4F;margin-bottom:18px;gap:16px;flex-wrap:wrap}
|
||
.pp-pv-doc .brand-logo{max-width:180px;height:auto;display:block}
|
||
.pp-pv-doc .brand-tagline{font-family:'Playfair Display',serif;font-style:italic;font-size:10px;color:#888;margin-top:4px;letter-spacing:.3px}
|
||
.pp-pv-doc .num-block{text-align:right}
|
||
.pp-pv-doc .label{font-size:9px;color:#999;text-transform:uppercase;letter-spacing:2px;font-weight:600;margin-bottom:3px}
|
||
.pp-pv-doc .num-block .num{font-size:17px;font-weight:700;color:#2C2C2C;letter-spacing:1px}
|
||
.pp-pv-doc .num-block .date{font-size:11px;color:#888;margin-top:2px}
|
||
.pp-pv-doc .client{margin-bottom:18px}
|
||
.pp-pv-doc .client h2{font-family:'Playfair Display',serif;font-style:italic;font-weight:400;font-size:24px;color:#5C6B4F;margin:4px 0;letter-spacing:-.5px;line-height:1.1}
|
||
.pp-pv-doc .client .contact{font-size:13px;color:#555;margin-top:2px}
|
||
.pp-pv-doc .client .meta{font-size:11px;color:#888;margin-top:1px}
|
||
.pp-pv-doc .client .meta.loc{font-size:12px;color:#5C6B4F;margin-top:3px}
|
||
.pp-pv-doc .client .loc-label{font-size:9px;color:#888;text-transform:uppercase;letter-spacing:1.5px;font-weight:600}
|
||
.pp-pv-doc .intro{background:#FAF7F0;border-left:3px solid #5C6B4F;padding:10px 16px;margin-bottom:18px;font-size:11px;line-height:1.5;color:#555}
|
||
.pp-pv-doc .items-section{margin-bottom:24px}
|
||
.pp-pv-doc .items-label{font-size:9px;color:#888;text-transform:uppercase;letter-spacing:2px;font-weight:600;margin-bottom:8px;padding-bottom:6px;border-bottom:1px solid #D4C5A9}
|
||
.pp-pv-doc .item-card{display:grid;grid-template-columns:170px 1fr;gap:20px;padding:14px 0;border-bottom:1px solid #f0ebe0;align-items:center;page-break-inside:avoid}
|
||
.pp-pv-doc .item-card:last-child{border-bottom:none}
|
||
.pp-pv-doc .item-img img{width:170px;height:170px;object-fit:cover;border-radius:8px;border:1px solid #f0ebe0;display:block}
|
||
.pp-pv-doc .item-img-empty{width:170px;height:170px;border-radius:8px;border:1px dashed #d4c5a9;background:#faf7f0;display:flex;align-items:center;justify-content:center;font-size:36px;opacity:.3}
|
||
.pp-pv-doc .item-info{min-width:0}
|
||
.pp-pv-doc .prod{font-family:'Playfair Display',serif;font-style:italic;font-size:20px;font-weight:400;color:#2C2C2C;letter-spacing:-.3px;line-height:1.2}
|
||
.pp-pv-doc .desc{font-size:10px;color:#888;margin-top:4px;letter-spacing:.3px;text-transform:uppercase}
|
||
.pp-pv-doc .item-pricing{display:flex;gap:24px;margin-top:12px;padding-top:10px;border-top:1px solid #f0ebe0;align-items:baseline;flex-wrap:wrap}
|
||
.pp-pv-doc .pq{display:flex;flex-direction:column;gap:3px}
|
||
.pp-pv-doc .pq-label{font-size:9px;color:#999;text-transform:uppercase;letter-spacing:1.5px;font-weight:600}
|
||
.pp-pv-doc .pq-val{font-size:16px;color:#2C2C2C;font-variant-numeric:tabular-nums}
|
||
.pp-pv-doc .pq-sub{margin-left:auto;text-align:right}
|
||
.pp-pv-doc .pq-sub .pq-val{font-size:19px;font-weight:700;color:#5C6B4F}
|
||
@media (max-width:600px){.pp-pv-doc .item-card{grid-template-columns:1fr;gap:14px}.pp-pv-doc .item-img img,.pp-pv-doc .item-img-empty{width:100%;max-width:280px;height:auto;aspect-ratio:1}.pp-pv-doc .prod{font-size:18px}.pp-pv-doc .pq-sub{margin-left:0;text-align:left}}
|
||
.pp-pv-doc .totals{margin-left:auto;width:320px;max-width:100%;padding:18px 22px;background:#FAF7F0;border-radius:6px;border:1px solid #E8DFC8;margin-bottom:28px}
|
||
@media (max-width:600px){.pp-pv-doc .totals{width:100%}}
|
||
.pp-pv-doc .tot-row{display:flex;justify-content:space-between;padding:4px 0;font-size:12px;color:#444}
|
||
.pp-pv-doc .tot-row.disc{color:#b91c1c}
|
||
.pp-pv-doc .tot-big{border-top:2px solid #5C6B4F;margin-top:8px;padding-top:12px;font-size:19px;font-weight:700;color:#5C6B4F}
|
||
.pp-pv-doc .notes{padding:16px 20px;background:#fafafa;border-radius:6px;font-size:12px;color:#555;margin-bottom:28px;line-height:1.7}
|
||
.pp-pv-doc .footer{padding-top:24px;border-top:1px solid #f0ebe0;font-size:10px;color:#999;text-align:center}
|
||
.pp-pv-doc .vigencia{font-size:11px;color:#555}
|
||
.pp-pv-doc .thanks{margin-top:10px;font-family:'Playfair Display',serif;font-style:italic;font-size:14px;color:#5C6B4F}
|
||
@media print{
|
||
/* Hide every direct child of body EXCEPT the preview modal */
|
||
body{background:#fff!important;margin:0!important;padding:0!important}
|
||
body > *{display:none!important}
|
||
body > #mo-pp-preview{display:block!important;position:static!important;background:#fff!important;backdrop-filter:none!important;inset:auto!important;padding:0!important;overflow:visible!important;height:auto!important;width:auto!important}
|
||
#mo-pp-preview .mo{display:block!important;max-height:none!important;height:auto!important;overflow:visible!important;border:none!important;border-radius:0!important;padding:0!important;margin:0!important;width:100%!important;max-width:none!important;box-shadow:none!important;background:#fff!important}
|
||
#pp-preview-body{height:auto!important;overflow:visible!important}
|
||
.pp-pv-toolbar{display:none!important}
|
||
.pp-pv-doc{padding:0!important;max-width:none!important;margin:0!important;background:#fff!important;overflow:visible!important;height:auto!important}
|
||
/* Each product card stays on a single page when possible */
|
||
.pp-pv-doc .item-card{page-break-inside:avoid;break-inside:avoid}
|
||
/* Other sections also avoid awkward breaks */
|
||
.pp-pv-doc .client,.pp-pv-doc .totals,.pp-pv-doc .notes,.pp-pv-doc .footer,.pp-pv-doc .top{page-break-inside:avoid;break-inside:avoid}
|
||
@page{margin:1.2cm;size:letter}
|
||
}
|
||
/* Inventario view (Productos > Inventario sub-tab) */
|
||
.inv-summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-bottom:12px}
|
||
.inv-stat{background:var(--s1);border:1px solid var(--bd);border-radius:8px;padding:10px 12px;display:flex;flex-direction:column;gap:2px}
|
||
.inv-stat.warn{background:#fef3c7;border-color:#fcd34d}
|
||
.inv-stat.crit{background:#fee2e2;border-color:#fca5a5}
|
||
.inv-stat .lbl{font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;font-weight:600}
|
||
.inv-stat .val{font-size:20px;font-weight:700;color:var(--olive-dark)}
|
||
.inv-stat.warn .val{color:#92400e}
|
||
.inv-stat.crit .val{color:#b91c1c}
|
||
.inv-list{display:flex;flex-direction:column;gap:5px}
|
||
.inv-row{display:flex;align-items:center;gap:14px;padding:10px 12px;background:var(--s1);border:1px solid var(--bd);border-radius:8px;cursor:pointer;transition:.15s}
|
||
.inv-row:hover{border-color:var(--olive);box-shadow:0 1px 4px rgba(0,0,0,.05)}
|
||
.inv-thumb{width:48px;height:48px;object-fit:cover;border-radius:6px;border:1px solid var(--bd);flex-shrink:0}
|
||
.inv-thumb-empty{width:48px;height:48px;border-radius:6px;border:1px dashed var(--bd);background:var(--s2);display:flex;align-items:center;justify-content:center;font-size:20px;opacity:.4;flex-shrink:0}
|
||
.inv-row-main{flex:1;min-width:0}
|
||
.inv-row-name{font-weight:600;font-size:13px;color:var(--tx)}
|
||
.inv-row-meta{font-size:10px;color:var(--t2);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.inv-num-block{text-align:center;min-width:70px}
|
||
.inv-num-block .inv-num-lbl{font-size:8px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;font-weight:600}
|
||
.inv-num-block .inv-num-val{font-size:15px;font-weight:700;color:var(--tx);margin-top:1px;font-variant-numeric:tabular-nums}
|
||
.inv-stock-input,.inv-reorden-input{font-size:15px;font-weight:700;color:var(--olive-dark);text-align:center;width:64px;padding:5px 4px;border:1px solid var(--bd);border-radius:5px;background:var(--s1);font-family:inherit;font-variant-numeric:tabular-nums;outline:none;transition:.15s;margin-top:1px}
|
||
.inv-stock-input:hover,.inv-reorden-input:hover{border-color:var(--olive);background:var(--acd)}
|
||
.inv-stock-input:focus,.inv-reorden-input:focus{border-color:var(--olive);background:#fff;box-shadow:0 0 0 2px var(--acd)}
|
||
.inv-reorden-input{font-size:13px;color:var(--t2);font-weight:500;width:54px}
|
||
.inv-stock-input::-webkit-inner-spin-button,.inv-reorden-input::-webkit-inner-spin-button{opacity:.4}
|
||
.prod-view-dropdown{font-size:13px;font-weight:600;padding:7px 28px 7px 12px;border:1px solid var(--olive);border-radius:6px;background:var(--s1);color:var(--olive-dark);cursor:pointer;font-family:inherit;outline:none;appearance:none;-webkit-appearance:none;background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><path fill='%235C6B4F' d='M3 4.5l3 3 3-3'/></svg>");background-repeat:no-repeat;background-position:right 8px center;transition:.15s}
|
||
.prod-view-dropdown:hover{background-color:var(--acd)}
|
||
.prod-view-dropdown:focus{box-shadow:0 0 0 2px var(--acd)}
|
||
/* "no-thumbs" mode — performance toggle */
|
||
body.no-thumbs .kc-thumb,
|
||
body.no-thumbs .bc-thumb,
|
||
body.no-thumbs .prod-thumb,
|
||
body.no-thumbs .inv-thumb,
|
||
body.no-thumbs .odp-photo,
|
||
body.no-thumbs .proy-photo,
|
||
body.no-thumbs .pp-prod-photo,
|
||
body.no-thumbs .ent-cli-edit{display:none!important}
|
||
body.no-thumbs .prod-thumb-empty,
|
||
body.no-thumbs .inv-thumb-empty,
|
||
body.no-thumbs .odp-photo-empty,
|
||
body.no-thumbs .proy-photo-empty,
|
||
body.no-thumbs .pp-prod-photo-empty{display:none!important}
|
||
@media (max-width:700px){
|
||
.inv-num-block{min-width:54px}
|
||
.inv-row{gap:8px;padding:8px 10px}
|
||
}
|
||
/* ══ PREVIEW / PRINT ══ */
|
||
.pp-preview{padding:0;background:#fff}
|
||
.pp-preview-toolbar{padding:10px 14px;background:var(--s2);border-bottom:1px solid var(--bd);display:flex;gap:6px;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:10}
|
||
.pp-preview-doc{padding:40px 48px;background:#fff;color:#1c1c1c;font-family:'Outfit',sans-serif}
|
||
.pp-pv-header{display:flex;justify-content:space-between;align-items:flex-start;padding-bottom:24px;border-bottom:2px solid #5C6B4F;margin-bottom:30px}
|
||
.pp-pv-logo{font-size:28px;font-weight:700;letter-spacing:-.5px;color:#5C6B4F;line-height:1}
|
||
.pp-pv-tagline{font-size:10px;color:#666;margin-top:4px;letter-spacing:1px;text-transform:uppercase}
|
||
.pp-pv-num-block{text-align:right}
|
||
.pp-pv-label{font-size:9px;color:#888;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:3px;font-weight:600}
|
||
.pp-pv-num-block .pp-pv-num{font-size:18px;font-weight:700;color:#1c1c1c}
|
||
.pp-pv-date{font-size:11px;color:#666;margin-top:2px}
|
||
.pp-pv-client{margin-bottom:32px}
|
||
.pp-pv-client h2{font-size:22px;font-weight:600;margin:4px 0 6px;color:#1c1c1c}
|
||
.pp-pv-client .pp-pv-meta{font-size:11px;color:#666;margin-top:2px}
|
||
.pp-pv-table{width:100%;border-collapse:collapse;margin-bottom:24px}
|
||
.pp-pv-table thead th{font-size:10px;color:#888;text-transform:uppercase;letter-spacing:1px;padding:8px 6px;border-bottom:1px solid #d4c5a9;font-weight:600;text-align:left}
|
||
.pp-pv-table tbody td{padding:14px 6px;border-bottom:1px solid #eee;vertical-align:middle}
|
||
.pp-pv-img{width:80px}
|
||
.pp-pv-img img{width:70px;height:70px;object-fit:cover;border-radius:6px;border:1px solid #eee}
|
||
.pp-pv-prod{font-size:13px;font-weight:600;color:#1c1c1c}
|
||
.pp-pv-desc{font-size:10px;color:#888;margin-top:2px}
|
||
.pp-pv-num{text-align:right;font-variant-numeric:tabular-nums}
|
||
.pp-pv-totals{margin-left:auto;width:50%;min-width:300px;padding:14px 16px;background:#faf7f0;border-radius:8px;border:1px solid #e8dfc8;margin-bottom:24px}
|
||
@media (max-width:600px){.pp-pv-totals{width:100%}}
|
||
.pp-pv-tot-row{display:flex;justify-content:space-between;padding:4px 0;font-size:12px;color:#444}
|
||
.pp-pv-tot-big{border-top:2px solid #5C6B4F;margin-top:6px;padding-top:10px;font-size:18px;font-weight:700;color:#5C6B4F}
|
||
.pp-pv-notes{padding:14px 16px;background:#fafafa;border-radius:8px;font-size:12px;color:#444;margin-bottom:24px;line-height:1.6}
|
||
.pp-pv-footer{padding-top:20px;border-top:1px solid #eee;font-size:10px;color:#888;text-align:center}
|
||
.pp-pv-thanks{margin-top:8px;font-style:italic;font-size:12px;color:#5C6B4F}
|
||
/* Clickable client name */
|
||
.cli-link{cursor:pointer;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:2px;transition:.15s}
|
||
.cli-link:hover{color:var(--olive);text-decoration-style:solid}
|
||
|
||
/* Nav como dropdown en móvil/tablet */
|
||
@media(max-width:700px){
|
||
.nav{padding:0 8px;height:44px;gap:8px}
|
||
.nav-tab-toggle{display:flex;flex:0 1 auto;max-width:60vw}
|
||
.nav-tab-toggle #nav-current{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.tabs{position:fixed;top:44px;left:0;right:0;flex-direction:column;background:var(--olive-dark);padding:0;gap:2px;margin-left:0;max-height:0;overflow:hidden;transition:max-height .2s,padding .2s;z-index:99;box-shadow:none;border-bottom:none}
|
||
.tabs.open{max-height:calc(100vh - 44px);overflow-y:auto;padding:8px;box-shadow:0 4px 12px rgba(0,0,0,.25);border-bottom:1px solid rgba(255,255,255,.1)}
|
||
.tab{width:100%;text-align:left;padding:11px 14px;font-size:13px;border-radius:6px;margin:0!important}
|
||
.tab[data-pg="ayuda"]{margin-top:8px!important;border-top:1px solid rgba(255,255,255,.1);padding-top:14px!important}
|
||
.nav-r{display:none}
|
||
/* El contenedor principal NO debe quedar oculto bajo el nav sticky */
|
||
body{padding-top:0}
|
||
}
|
||
|
||
/* MOBILE */
|
||
@media(max-width:600px){
|
||
.pg{padding:8px}
|
||
.kb-head{gap:4px}
|
||
.kb-title{font-size:14px}
|
||
.kb-col{min-width:200px;width:200px}
|
||
.search-box{width:120px;font-size:10px}
|
||
.search-box:focus{width:160px}
|
||
.row2,.row3{grid-template-columns:1fr}
|
||
.kpis{grid-template-columns:repeat(3,1fr);gap:4px}
|
||
.pg{padding:8px}
|
||
.kb-head{gap:4px}
|
||
.kb-title{font-size:14px}
|
||
.kb-col{min-width:200px;width:200px}
|
||
.search-box{width:120px;font-size:10px}
|
||
.search-box:focus{width:160px}
|
||
.row2,.row3{grid-template-columns:1fr}
|
||
.kpis{grid-template-columns:repeat(3,1fr);gap:4px}
|
||
.kpi{padding:8px}
|
||
.kpi b{font-size:20px}
|
||
.kpi small{font-size:7px}
|
||
.nav-logo span{display:none}
|
||
.btn{padding:5px 8px;font-size:10px}
|
||
.mo{padding:14px;max-width:100%;border-radius:10px}
|
||
.fg input,.fg select,.fg textarea{padding:8px 10px;font-size:13px}
|
||
.entrega-checks{gap:6px}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<nav class="nav">
|
||
<div class="nav-logo">A4H<span>Hub</span></div>
|
||
<button class="nav-tab-toggle" onclick="toggleNavMenu(event)" aria-label="Menú">
|
||
<span id="nav-current">Operaciones</span>
|
||
<span class="nav-tab-toggle-arrow">▾</span>
|
||
</button>
|
||
<div class="tabs" id="nav-tabs">
|
||
<button class="tab on" data-pg="ordenes">Operaciones</button>
|
||
<button class="tab" data-pg="compras">Ventas</button>
|
||
<button class="tab" data-pg="clientes">Clientes</button>
|
||
<button class="tab" data-pg="propuestas">Propuestas</button>
|
||
<button class="tab" data-pg="catalogo">Productos</button>
|
||
<button class="tab" data-pg="ayuda" style="margin-left:auto;opacity:.85">❓ Manual</button>
|
||
</div>
|
||
<div class="nav-r"><div class="dot"></div><div class="clk" id="clk"></div>
|
||
<button onclick="logoutHub()" title="Cerrar sesión" style="background:none;border:none;color:var(--sand);cursor:pointer;font-size:14px;padding:4px 6px;opacity:.75">⎋</button>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- ══ DASHBOARD ══ -->
|
||
<div class="pg" id="pg-dashboard">
|
||
<div class="kpis" id="kpis"></div>
|
||
<div class="row2">
|
||
<div class="crd"><div class="crd-h">Ordenes por Stage</div><div class="crd-b" id="ch-stages"></div></div>
|
||
<div class="crd"><div class="crd-h">Clientes Activos (piezas en proceso)</div><div class="crd-b" id="ch-clientes"></div></div>
|
||
</div>
|
||
<div class="row2" style="margin-top:12px">
|
||
<div class="crd"><div class="crd-h">Alertas de Stock</div><div class="crd-b" id="ch-alertas"></div></div>
|
||
<div class="crd"><div class="crd-h">Actividad Reciente</div><div class="crd-b" id="ch-timeline" style="max-height:200px;overflow-y:auto"></div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══ VENTAS ══ -->
|
||
<!-- pg-ventas eliminado: ahora vive dentro de pg-compras como sub-vista Dashboard -->
|
||
|
||
<!-- ══ ORDENES ══ -->
|
||
<div class="pg on" id="pg-ordenes">
|
||
<div class="kb-head">
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||
<div class="kb-title">Operaciones</div>
|
||
<div class="view-toggle">
|
||
<button class="vt-btn on" onclick="setView('ordenes','kanban',this)">Kanban</button>
|
||
<button class="vt-btn" onclick="setView('ordenes','tabla',this)">Tabla</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap">
|
||
<input class="search-box" placeholder="Buscar..." id="search-ordenes" oninput="filterTable('ordenes')">
|
||
<button class="btn" onclick="toggleFotos()" id="btn-fotos" title="Mostrar u ocultar fotos para más velocidad">🖼</button>
|
||
<button class="btn" onclick="toggleTransito()" id="btn-transito">+ Tránsito</button>
|
||
<button class="btn" onclick="toggleMuestras()" id="btn-muestras">+ Muestras</button>
|
||
<button class="btn" onclick="toggleEntregados()" id="btn-entregados">+ Entregados</button>
|
||
<button class="btn btn-ac" onclick="openNewOrdenCreate()">+ Nuevo</button>
|
||
<button class="btn" onclick="openWizard()" title="Wizard antiguo" style="font-size:10px;opacity:.7">classic</button>
|
||
</div>
|
||
</div>
|
||
<div id="ordenes-kanban">
|
||
<div class="kb" id="kb-ordenes"></div>
|
||
<div id="kb-bodega" class="ord-panel"></div>
|
||
<div id="kb-muestras" class="ord-panel"></div>
|
||
<div id="kb-entregados" class="ord-panel"></div>
|
||
</div>
|
||
<div id="ordenes-tabla" style="display:none;overflow-x:auto"><table class="grid-tbl" id="tbl-ordenes"><thead></thead><tbody></tbody></table></div>
|
||
<div id="ordenes-ocs" style="display:none"></div>
|
||
</div>
|
||
|
||
<!-- ══ VENTAS (unificada: Dashboard + Por OC + Por Entregas) ══ -->
|
||
<div class="pg" id="pg-compras">
|
||
<div class="kb-head">
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||
<div class="kb-title">Ventas</div>
|
||
<div class="view-toggle">
|
||
<button class="vt-btn on" onclick="setVentasView('dashboard',this)">📊 Dashboard</button>
|
||
<button class="vt-btn" onclick="setVentasView('oc',this)">📋 Por OC</button>
|
||
<button class="vt-btn" onclick="setVentasView('entregas',this)">📦 Por Entregas</button>
|
||
</div>
|
||
</div>
|
||
<div id="ventas-toolbar" style="display:flex;gap:6px;align-items:center;flex-wrap:wrap"></div>
|
||
</div>
|
||
|
||
<!-- Sub-vista: Dashboard -->
|
||
<div id="ventas-sub-dashboard">
|
||
<div id="v-comparativo"></div>
|
||
<div class="row2" style="margin-top:10px">
|
||
<div class="crd"><div class="crd-h">⏱ Tiempos de flujo · inicio → entrega</div><div class="crd-b" id="v-cycle"></div></div>
|
||
<div class="crd"><div class="crd-h">🎯 Tipo de Trabajo (entregados)</div><div class="crd-b" id="v-trabajos"></div></div>
|
||
</div>
|
||
<div class="crd" style="margin-top:10px"><div class="crd-h">🏆 Top clientes · comparativo mensual</div><div class="crd-b" id="v-top-clientes"></div></div>
|
||
<div class="crd" style="margin-top:10px"><div class="crd-h">📦 Productos · precio promedio + volumen</div><div class="crd-b" id="v-productos-pricing" style="max-height:400px;overflow-y:auto"></div></div>
|
||
</div>
|
||
|
||
<!-- Sub-vista: Por OC (kanban / workflow de cobranza) -->
|
||
<div id="ventas-sub-oc" style="display:none">
|
||
<div id="compras-body"></div>
|
||
</div>
|
||
|
||
<!-- Sub-vista: Por Entregas (movida del antiguo pg-entregas) -->
|
||
<div id="ventas-sub-entregas" style="display:none">
|
||
<div class="view-toggle" style="margin-bottom:10px">
|
||
<button class="vt-btn on" onclick="setEntregaView('fecha',this)">Por Fecha</button>
|
||
<button class="vt-btn" onclick="setEntregaView('cliente',this)">Por Cliente</button>
|
||
<button class="vt-btn" onclick="setEntregaView('ordenes',this)">Ordenes</button>
|
||
</div>
|
||
<div id="entregas-wrapper">
|
||
<div id="entregas-nav-panel"></div>
|
||
<div id="entregas-pane">
|
||
<div id="entregas-vehiculo-panel" style="margin-bottom:10px"></div>
|
||
<div id="entregas-summary" style="margin-bottom:8px"></div>
|
||
<div id="entregas-list"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══ CLIENTES (CRM) ══ -->
|
||
<div class="pg" id="pg-clientes">
|
||
<div class="kb-head">
|
||
<div class="kb-title">Clientes</div>
|
||
<div style="display:flex;gap:4px;align-items:center;flex-wrap:wrap">
|
||
<input class="search-box" placeholder="Buscar cliente..." id="search-clientes" oninput="renderClientesCrm()">
|
||
<button class="btn btn-ac" onclick="addCatItem('clientes')">+ Nuevo Cliente</button>
|
||
</div>
|
||
</div>
|
||
<div class="crm-layout">
|
||
<div id="crm-list"></div>
|
||
<div id="crm-detail"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══ PROYECTOS RECURRENTES ══ -->
|
||
<!-- (Proyectos vive ahora dentro de Productos como sub-vista) -->
|
||
|
||
<!-- ══ Vincular pedido a proyecto ══ -->
|
||
<div class="mo-bg" id="mo-link-proy"><div class="mo" style="max-width:480px">
|
||
<h3>Vincular pedido a proyecto <button class="mo-x" onclick="closeMo('mo-link-proy')">×</button></h3>
|
||
<div id="lp-pedido-info" style="font-size:11px;color:var(--t2);padding:8px 10px;background:var(--s2);border-radius:6px;margin-bottom:10px"></div>
|
||
<div style="font-size:10px;color:var(--t3);margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px;font-weight:600">Proyectos del mismo cliente</div>
|
||
<div id="lp-proy-list" style="max-height:50vh;overflow-y:auto"></div>
|
||
</div></div>
|
||
|
||
<!-- ══ Proyecto Viewer (read-only quick view) ══ -->
|
||
<div class="mo-bg" id="mo-proyecto-view"><div class="mo" style="max-width:520px">
|
||
<div id="proy-view-body"></div>
|
||
</div></div>
|
||
|
||
<!-- ══ Proyecto Editor ══ -->
|
||
<div class="mo-bg" id="mo-proyecto"><div class="mo" style="max-width:520px">
|
||
<div id="proy-header"></div>
|
||
<div id="proy-body"></div>
|
||
</div></div>
|
||
|
||
<!-- ══ Producto Quick View (visual, read-only) ══ -->
|
||
<div class="mo-bg" id="mo-producto-view"><div class="mo" style="max-width:640px;padding:0;overflow:hidden;max-height:92vh;display:flex;flex-direction:column">
|
||
<div id="pv-prod-body" style="overflow-y:auto"></div>
|
||
</div></div>
|
||
|
||
<!-- ══ Producto Editor (enfocado) ══ -->
|
||
<div class="mo-bg" id="mo-producto-edit"><div class="mo" style="max-width:600px">
|
||
<div id="pe-prod-header"></div>
|
||
<div id="pe-prod-body"></div>
|
||
</div></div>
|
||
|
||
<!-- ══ PROPUESTAS ══ -->
|
||
<div class="pg" id="pg-propuestas">
|
||
<div class="kb-head">
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||
<div class="kb-title">Propuestas</div>
|
||
<div class="view-toggle">
|
||
<button class="vt-btn on" onclick="setPropuestasView('cotizaciones',this)">📝 Cotizaciones</button>
|
||
<button class="vt-btn" onclick="setPropuestasView('catalogos',this)">📚 Catálogos</button>
|
||
</div>
|
||
</div>
|
||
<div id="propuestas-toolbar" style="display:flex;gap:4px;align-items:center;flex-wrap:wrap"></div>
|
||
</div>
|
||
<!-- Sub-vista: Cotizaciones -->
|
||
<div id="propuestas-sub-cotizaciones">
|
||
<div id="propuestas-content"></div>
|
||
</div>
|
||
<!-- Sub-vista: Catálogos -->
|
||
<div id="propuestas-sub-catalogos" style="display:none">
|
||
<div id="catalogos-content"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══ Catálogo Detail / Editor ══ -->
|
||
<div class="mo-bg" id="mo-catalogo"><div class="mo" style="max-width:880px">
|
||
<div id="cat-header"></div>
|
||
<div id="cat-body"></div>
|
||
</div></div>
|
||
|
||
<!-- ══ Catálogo Preview / Imprimible ══ -->
|
||
<div class="mo-bg" id="mo-catalogo-preview"><div class="mo" style="max-width:100%;width:100%;max-height:100%;height:100%;border-radius:0;padding:0;display:flex;flex-direction:column">
|
||
<div class="pp-pv-toolbar" id="cat-pv-toolbar"></div>
|
||
<div id="cat-pv-body" style="flex:1;overflow-y:auto;background:#e5e7eb;padding:20px"></div>
|
||
</div></div>
|
||
|
||
<!-- ══ Propuesta Detail / Editor ══ -->
|
||
<div class="mo-bg" id="mo-propuesta"><div class="mo" style="max-width:780px">
|
||
<div id="pp-header"></div>
|
||
<div id="pp-body"></div>
|
||
</div></div>
|
||
|
||
<!-- ══ Propuesta Vista Previa (preview/print) ══ -->
|
||
<div class="mo-bg" id="mo-pp-preview"><div class="mo">
|
||
<div id="pp-preview-body"></div>
|
||
</div></div>
|
||
|
||
<!-- ══ Selector de productos para propuesta ══ -->
|
||
<div class="mo-bg" id="mo-pp-prods"><div class="mo" style="max-width:560px">
|
||
<h3>Agregar productos <button class="mo-x" onclick="closeMo('mo-pp-prods')">×</button></h3>
|
||
<input class="search-box" placeholder="Buscar producto..." id="pp-prods-search" oninput="renderPpProds()" style="width:100%;margin-bottom:8px">
|
||
<div id="pp-prods-list" style="max-height:50vh;overflow-y:auto"></div>
|
||
</div></div>
|
||
|
||
<!-- pg-entregas eliminado: ahora vive dentro de pg-compras como sub-vista Por Entregas -->
|
||
|
||
<!-- ══ INVENTARIO ══ -->
|
||
<div class="pg" id="pg-inventario">
|
||
<div class="kb-head">
|
||
<div class="kb-title">Inventario</div>
|
||
<button class="btn btn-ac" onclick="openMo('mo-inv')">+ Nuevo SKU</button>
|
||
</div>
|
||
<div style="overflow-x:auto"><table class="inv-tbl" id="inv-table"><thead></thead><tbody></tbody></table></div>
|
||
<div class="row3" style="margin-top:16px" id="inv-dashboard"></div>
|
||
</div>
|
||
|
||
<!-- ══ CATALOGO ══ -->
|
||
<div class="pg" id="pg-catalogo">
|
||
<div class="kb-head">
|
||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||
<div class="kb-title">Productos</div>
|
||
<select id="prod-view-select" class="prod-view-dropdown" onchange="setProdView(this.value)">
|
||
<option value="catalogo">Catálogo</option>
|
||
<option value="proyectos">Proyectos</option>
|
||
<option value="inventario">Inventario</option>
|
||
</select>
|
||
</div>
|
||
<div id="prod-head-actions" style="display:flex;gap:4px;align-items:center;flex-wrap:wrap"></div>
|
||
</div>
|
||
<div id="prod-content"></div>
|
||
</div>
|
||
|
||
<!-- ══ MANUAL / AYUDA ══ -->
|
||
<div class="pg" id="pg-ayuda">
|
||
<div class="manual-wrap">
|
||
<button class="manual-toc-toggle" onclick="toggleManualToc()">
|
||
<span>📚 Índice del manual</span>
|
||
<span class="arrow">▾</span>
|
||
</button>
|
||
<aside class="manual-toc">
|
||
<div class="manual-toc-title">📚 Manual A4H Hub</div>
|
||
<a href="#m-bienvenida">Bienvenida</a>
|
||
<a href="#m-tour">Tour del sistema</a>
|
||
<a href="#m-conceptos">Conceptos clave</a>
|
||
|
||
<div class="manual-toc-sec">🛠 Cómo hacer…</div>
|
||
<a href="#m-propuesta">Crear una propuesta</a>
|
||
<a href="#m-convertir">Convertir propuesta → orden</a>
|
||
<a href="#m-oc-manual">Crear OC manual</a>
|
||
<a href="#m-pedido-suelto">Crear pedido suelto</a>
|
||
<a href="#m-kanban">Mover pedidos en el kanban</a>
|
||
<a href="#m-recoger">Recoger del taller</a>
|
||
<a href="#m-bodega">Ver pedidos en bodega</a>
|
||
<a href="#m-entregar">Registrar una entrega</a>
|
||
<a href="#m-foto">Subir foto de avance</a>
|
||
<a href="#m-costos">Registrar costos y precios</a>
|
||
|
||
<div class="manual-toc-sec">📋 Por área</div>
|
||
<a href="#m-ventas">💼 Seguimiento de ventas</a>
|
||
<a href="#m-contabilidad">📊 Contabilidad y facturación</a>
|
||
<a href="#m-precios">💰 Costos y precios mínimos</a>
|
||
<a href="#m-catalogo">🎨 Catálogo y diseño</a>
|
||
|
||
<div class="manual-toc-sec">💬 Soporte</div>
|
||
<a href="#m-faq">Preguntas frecuentes</a>
|
||
<a href="#m-vocab">Vocabulario</a>
|
||
</aside>
|
||
|
||
<div class="manual-content">
|
||
|
||
<section id="m-bienvenida">
|
||
<h1>Bienvenido al A4H Hub</h1>
|
||
<p class="m-lead">Esta es la herramienta interna donde gestionamos todo: <b>propuestas, pedidos, producción, bodega, entregas y facturación</b>. Si tienes una duda mientras trabajas, regresa a este manual y busca la sección que necesites.</p>
|
||
<div class="m-callout m-info">
|
||
<b>Cómo usar este manual:</b> usa el menú de la izquierda para saltar a la sección que te interese. Cada función tiene un instructivo corto + un ejemplo real.
|
||
</div>
|
||
<h3>Reglas básicas para todos</h3>
|
||
<ul>
|
||
<li>Si no estás seguro, <b>pregúntale a Clod antes de borrar</b>. Editar es seguro, borrar es definitivo.</li>
|
||
<li>Cada pedido vive ligado a una <b>Orden de Compra (OC)</b> — excepto resurtidos, muestras y reposiciones.</li>
|
||
<li><b>Captura todo en el sistema, no en WhatsApp.</b> Cuando un cliente confirma algo (cantidad, color, fecha, precio), entra al Hub y lo dejas guardado. Los mensajes resumidos se olvidan y faltan datos al producir.</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section id="m-tour">
|
||
<h1>Tour del sistema</h1>
|
||
<p>Hay 5 pestañas principales arriba:</p>
|
||
<div class="m-tabs-grid">
|
||
<div class="m-tab-card"><div class="m-tab-icon">🛠</div><h4>Operaciones</h4><p>Kanban de producción. Aquí movemos pedidos entre etapas: Nuevo → 2 Mares/Sofía → Almacén → Vehículo → Entregado.</p></div>
|
||
<div class="m-tab-card"><div class="m-tab-icon">💼</div><h4>Ventas</h4><p>3 vistas: <b>Dashboard</b> (KPIs y comparativos), <b>Por OC</b> (workflow de cobranza), <b>Por Entregas</b> (entregas históricas).</p></div>
|
||
<div class="m-tab-card"><div class="m-tab-icon">👥</div><h4>Clientes</h4><p>Ficha de cada cliente con su historial, contacto, zona de entrega y condiciones de pago.</p></div>
|
||
<div class="m-tab-card"><div class="m-tab-icon">📝</div><h4>Propuestas</h4><p>Cotizaciones antes de cerrar. Cuando se aceptan, se convierten en órdenes.</p></div>
|
||
<div class="m-tab-card"><div class="m-tab-icon">📦</div><h4>Productos</h4><p>Catálogo de bolsas y accesorios + proyectos recurrentes (recetas autorizadas) + inventario.</p></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="m-conceptos">
|
||
<h1>Conceptos clave</h1>
|
||
<p>Antes de operar el sistema, entiende estas 4 ideas:</p>
|
||
|
||
<div class="m-concept">
|
||
<h3>📝 Propuesta</h3>
|
||
<p>Cotización <b>antes</b> de cerrar venta. Lista de productos con precio, cantidad, descripción del logo. Tiene status: <code>borrador</code>, <code>enviada</code>, <code>aceptada</code>, <code>rechazada</code>.</p>
|
||
</div>
|
||
|
||
<div class="m-concept">
|
||
<h3>💼 Orden de Compra (OC)</h3>
|
||
<p>El "contrato" con el cliente. Agrupa varios pedidos del mismo cliente bajo un mismo número de factura. Toda OC tiene precio, IVA, fecha de entrega, condiciones de pago.</p>
|
||
</div>
|
||
|
||
<div class="m-concept">
|
||
<h3>🛠 Pedido</h3>
|
||
<p>Una línea de producción: <b>1 producto × 1 personalización × 1 cantidad</b>. Vive dentro de una OC (excepto resurtidos/muestras/defectos). Pasa por los stages del kanban.</p>
|
||
</div>
|
||
|
||
<div class="m-concept">
|
||
<h3>📐 Proyecto recurrente</h3>
|
||
<p>Una <b>receta autorizada</b> = cliente + producto + tipo de trabajo + logo. Se reusa entre pedidos para no volver a definir el diseño. Por ej., "Bolsa Cabo Bello + Logo Flora Mango Serigrafía Negro".</p>
|
||
</div>
|
||
|
||
<h3>Flujo típico de una venta</h3>
|
||
<ol class="m-flow">
|
||
<li><b>Propuesta</b> → cliente acepta</li>
|
||
<li>Botón <code>➡ Convertir a Orden</code> → se crea <b>OC + pedidos</b></li>
|
||
<li>Pedidos arrancan en stage <code>Nuevo</code></li>
|
||
<li>Clod los lleva al taller → stage <code>En 2 Mares</code> o <code>En Taller Sofía</code></li>
|
||
<li>Clod los recoge → stage <code>En Almacén</code></li>
|
||
<li>Se cargan al carro → <code>En Vehículo</code></li>
|
||
<li>Se entregan → <code>Entregado</code></li>
|
||
<li>Sandra registra factura/recibo, cliente paga</li>
|
||
</ol>
|
||
</section>
|
||
|
||
<section id="m-propuesta">
|
||
<h1>Crear una propuesta</h1>
|
||
<ol>
|
||
<li>Tab <b>Propuestas</b> → botón <code>+ Nueva Propuesta</code></li>
|
||
<li>Llena cliente (puedes escribir nombre nuevo o elegir uno registrado), empresa, email, teléfono</li>
|
||
<li>Agrega <b>items</b>: click en producto del catálogo o crea uno nuevo. Para cada item indica cantidad, precio unitario, color, tipo de personalización</li>
|
||
<li>Ajusta <b>descuento %</b> e <b>IVA %</b> si aplica</li>
|
||
<li>Cambia status según corresponda: <code>borrador</code> mientras la armas, <code>enviada</code> cuando la mandaste al cliente, <code>aceptada</code> cuando cierra</li>
|
||
<li><code>👁 Vista previa</code> te muestra cómo la verá el cliente — puedes imprimir o guardar PDF</li>
|
||
</ol>
|
||
<div class="m-callout m-tip">
|
||
<b>Tip:</b> si el cliente recorta cantidades o productos antes de aceptar, <b>edita la propuesta primero</b>. La conversión a orden usará lo que esté guardado al momento de convertir.
|
||
</div>
|
||
</section>
|
||
|
||
<section id="m-convertir">
|
||
<h1>Convertir propuesta aceptada → Orden</h1>
|
||
<ol>
|
||
<li>Abre la propuesta</li>
|
||
<li>Cambia status a <code>aceptada</code></li>
|
||
<li>Aparece el botón verde <code>➡ Convertir a Orden</code></li>
|
||
<li>Click → se crea automáticamente:
|
||
<ul>
|
||
<li><b>1 OC</b> con el cliente, precio total, fecha de hoy</li>
|
||
<li><b>N pedidos</b> (uno por línea de la propuesta) en stage <code>Nuevo</code></li>
|
||
</ul>
|
||
</li>
|
||
<li>El sistema te lleva al editor de la OC nueva — ahí puedes ajustar fecha de entrega, condiciones de pago, etc.</li>
|
||
</ol>
|
||
<div class="m-callout m-info">
|
||
<b>Trazabilidad:</b> la OC queda ligada a la propuesta original. Si más adelante quieres ver la propuesta de donde salió, abre la OC y revisa la nota.
|
||
</div>
|
||
<div class="m-callout m-warn">
|
||
<b>Si ya convertiste y el cliente cambia algo:</b> edita los pedidos manualmente desde Operaciones (borra, agrega, edita). La propuesta queda como histórico de la venta original.
|
||
</div>
|
||
</section>
|
||
|
||
<section id="m-oc-manual">
|
||
<h1>Crear una OC manual (sin propuesta)</h1>
|
||
<p>Cuando un cliente confirma un pedido sin propuesta formal (WhatsApp, llamada), creas la OC directo:</p>
|
||
<ol>
|
||
<li>Tab <b>Ventas</b> → sub-vista <code>📋 Por OC</code></li>
|
||
<li>Botón <code>+ Nueva Orden</code></li>
|
||
<li>Llena: cliente, fecha de OC, precio facturado, IVA, condiciones de pago, número de factura (si ya tienes)</li>
|
||
<li>Guarda → ahora puedes agregarle pedidos uno por uno</li>
|
||
</ol>
|
||
</section>
|
||
|
||
<section id="m-pedido-suelto">
|
||
<h1>Crear un pedido suelto</h1>
|
||
<p>Pedidos que no van bajo una OC: <b>resurtidos</b> (stock propio), <b>muestras</b> (prospección), <b>defectos/faltantes</b> (reposición).</p>
|
||
<ol>
|
||
<li>Tab <b>Operaciones</b> → botón <code>+ Nuevo</code></li>
|
||
<li>Selecciona <b>tipo de orden</b>:
|
||
<ul>
|
||
<li><code>OC</code> — pedido normal con orden de compra</li>
|
||
<li><code>Resurtido</code> — producción libre para bodega</li>
|
||
<li><code>Muestra</code> — 1 pieza para presentar a prospecto</li>
|
||
<li><code>Defecto</code> / <code>Faltante</code> — reposición</li>
|
||
</ul>
|
||
</li>
|
||
<li>Llena producto, cantidad, tipo de trabajo, cliente</li>
|
||
<li>Si es OC → asigna a una OC existente o créala</li>
|
||
</ol>
|
||
</section>
|
||
|
||
<section id="m-kanban">
|
||
<h1>Mover pedidos en el kanban (Operaciones)</h1>
|
||
<p>El kanban tiene columnas que representan dónde está el pedido físicamente:</p>
|
||
<table class="m-stages">
|
||
<tr><th>Stage</th><th>Significado</th></tr>
|
||
<tr><td><code>Nuevo</code></td><td>Recién creado, aún no se ha movido</td></tr>
|
||
<tr><td><code>En 2 Mares</code></td><td>Está físicamente en taller 2 Mares</td></tr>
|
||
<tr><td><code>En Taller Sofía</code></td><td>Está físicamente con Sofía (costura)</td></tr>
|
||
<tr><td><code>En Almacén</code></td><td>Producto terminado, esperando entregar</td></tr>
|
||
<tr><td><code>En Vehículo</code></td><td>Cargado para entregar hoy</td></tr>
|
||
<tr><td><code>Entregado</code></td><td>Cliente lo recibió</td></tr>
|
||
</table>
|
||
<h3>Cómo moverlos</h3>
|
||
<p><b>Opción 1 — arrastrar:</b> agarra la tarjeta con el mouse y suéltala en la columna nueva.</p>
|
||
<p><b>Opción 2 — botones:</b> en cada tarjeta hay botones contextuales:</p>
|
||
<ul>
|
||
<li><code>📦 Recoger</code> aparece si está en 2 Mares o Taller Sofía → mueve a Almacén</li>
|
||
<li><code>✓ Entregar</code> aparece si está En Vehículo → abre modal de entrega</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section id="m-recoger">
|
||
<h1>Recoger del taller</h1>
|
||
<p>Cuando vas físicamente al taller y traes el producto terminado:</p>
|
||
<ol>
|
||
<li>Click en el pedido o botón <code>📦 Recoger</code> de la tarjeta</li>
|
||
<li>Indica <b>cuántas piezas recibiste</b> realmente</li>
|
||
<li>Si todas → pasa a Almacén automáticamente</li>
|
||
<li>Si hay <b>piezas dañadas</b> → crea automáticamente una orden tipo <code>Defecto</code> con la cantidad para reposición</li>
|
||
<li>Si hay <b>piezas faltantes/pendientes</b> → divide el pedido: lo recibido va a Almacén, lo pendiente queda en el stage anterior</li>
|
||
</ol>
|
||
</section>
|
||
|
||
<section id="m-bodega">
|
||
<h1>Ver pedidos en bodega</h1>
|
||
<p>En el tab <b>Operaciones</b> debajo del kanban hay un panel de <b>Bodega</b> que muestra todo lo que está en stage <code>En Almacén</code> + <code>En Vehículo</code>.</p>
|
||
<p><b>Agrupado por:</b></p>
|
||
<ul>
|
||
<li><b>Con Orden</b> — pedidos de hoteles/órdenes específicas, listos para entregar al cliente correcto</li>
|
||
<li><b>Sin Orden</b> — resurtidos, stock libre para POS o venta rápida</li>
|
||
</ul>
|
||
<p>Click en cualquier card para ver detalle.</p>
|
||
</section>
|
||
|
||
<section id="m-entregar">
|
||
<h1>Registrar una entrega</h1>
|
||
<ol>
|
||
<li>Pedidos listos para entregar están en stage <code>En Vehículo</code></li>
|
||
<li>En el tab <b>Ventas → Por Entregas</b> verás un panel arriba con todos los <b>En Vehículo</b></li>
|
||
<li>Botón <code>✓ Entregar</code> en cada uno</li>
|
||
<li>Llena: fecha de entrega, quién recibió (firma), notas si las hay</li>
|
||
<li>Sube foto del producto entregado o recibo firmado</li>
|
||
<li>Guardar → pasa a stage <code>Entregado</code></li>
|
||
</ol>
|
||
</section>
|
||
|
||
<section id="m-foto">
|
||
<h1>Subir foto de avance</h1>
|
||
<p>Foto del producto en proceso o terminado, ligada al pedido:</p>
|
||
<ol>
|
||
<li>Abre el pedido (click en cualquier tarjeta)</li>
|
||
<li>En la vista del pedido, debajo de la foto principal hay un botón <code>📷 Subir foto de avance</code></li>
|
||
<li>Selecciona la foto desde tu celular/computadora</li>
|
||
<li>Se sube automáticamente y queda asociada solo a ese pedido</li>
|
||
</ol>
|
||
<div class="m-callout m-warn">
|
||
<b>Importante:</b> esta foto vive en el pedido específico. <b>NO</b> es la foto del catálogo. Para cambiar la foto general del producto, ve a Productos → Editar producto.
|
||
</div>
|
||
</section>
|
||
|
||
<section id="m-costos">
|
||
<h1>Registrar costos y precios</h1>
|
||
<p>Cada pedido tiene 3 costos internos + el precio que cobramos:</p>
|
||
<table class="m-stages">
|
||
<tr><th>Campo</th><th>Qué es</th></tr>
|
||
<tr><td><code>Costo Producto</code></td><td>Lo que pagamos por el producto base (proveedor)</td></tr>
|
||
<tr><td><code>Costo Trabajo</code></td><td>Lo que cobra 2 Mares / Sofía por la personalización</td></tr>
|
||
<tr><td><code>Costo Logística</code></td><td>Gasolina, viáticos, etc. (se aplica una vez por OC)</td></tr>
|
||
<tr><td><code>Precio Factura</code></td><td>Lo que cobramos al cliente por pza × cantidad</td></tr>
|
||
</table>
|
||
<p><b>Margen:</b> el sistema calcula automáticamente <code>(precio - costo total) / precio</code>.</p>
|
||
|
||
<h3>Precios mínimos / spread por producto</h3>
|
||
<p>En el tab <b>Ventas → Dashboard</b>, sección "📦 Productos · precio promedio + volumen" muestra el <b>precio mín, max y promedio histórico</b> por producto. Útil para cotizar consistentemente.</p>
|
||
<p>Si el spread es <b>>30%</b>, sale alerta ámbar — significa que ese producto se ha vendido a precios muy distintos y vale la pena revisar política.</p>
|
||
</section>
|
||
|
||
<section id="m-ventas">
|
||
<h1>💼 Seguimiento de ventas</h1>
|
||
<p class="m-lead">Aquí entra todo lo de <b>atender clientes, cerrar ventas y capturar pedidos completos</b> en el sistema.</p>
|
||
|
||
<div class="m-callout m-warn">
|
||
<b>Regla de oro:</b> cuando un cliente confirme algo (cantidad, color, fecha, precio, logo), <b>captúralo aquí</b>, no en WhatsApp ni en un mensaje resumido. Los detalles que se pierden cuestan tiempo y dinero al producir.
|
||
</div>
|
||
|
||
<h3>🔁 Flujo típico de una venta</h3>
|
||
<ol>
|
||
<li><b>Cliente pide cotización →</b> abre Propuestas → <code>+ Nueva Propuesta</code></li>
|
||
<li><b>Cliente acepta →</b> cambia status a <code>aceptada</code> y presiona <code>➡ Convertir a Orden</code></li>
|
||
<li><b>Cliente cambia algo antes de aceptar →</b> edita la propuesta y guarda</li>
|
||
<li><b>Cliente cierra sin propuesta formal (WhatsApp directo) →</b> ve a Ventas → Por OC → <code>+ Nueva Orden</code> y captura todo</li>
|
||
</ol>
|
||
|
||
<h3>Información mínima por pedido</h3>
|
||
<p>Antes de guardar cualquier pedido en el sistema, asegúrate de tener:</p>
|
||
<table class="m-stages">
|
||
<tr><th>Dato</th><th>Por qué importa</th></tr>
|
||
<tr><td><b>Cliente</b></td><td>Si es nuevo, créalo en Clientes con zona de entrega y condiciones de pago</td></tr>
|
||
<tr><td><b>Producto exacto</b></td><td>Del catálogo. Si no existe, créalo (no escribas "bolsa de tela" — pide el modelo específico)</td></tr>
|
||
<tr><td><b>Cantidad</b></td><td>Total de piezas</td></tr>
|
||
<tr><td><b>Tipo de personalización</b></td><td>Bordado, Serigrafía, DTF UV, etc. Si el cliente no especifica, pregunta</td></tr>
|
||
<tr><td><b>Logo / descripción</b></td><td>Qué se imprime y dónde. Sube el archivo del logo a la propuesta o pedido</td></tr>
|
||
<tr><td><b>Color del producto base</b></td><td>Natural, negro, crema, etc.</td></tr>
|
||
<tr><td><b>Precio por pieza</b></td><td>Lo que se cobra al cliente</td></tr>
|
||
<tr><td><b>Fecha de entrega esperada</b></td><td>Para que entre en el calendario operativo</td></tr>
|
||
<tr><td><b>Condiciones de pago</b></td><td>Una de las 6 opciones disponibles</td></tr>
|
||
</table>
|
||
|
||
<div class="m-callout m-tip">
|
||
<b>Tip:</b> si el cliente te dice algo importante por WhatsApp (cambio de fecha, descuento acordado, nota de logística), copia y pega ese mensaje en las <b>notas internas</b> del pedido u OC. Así queda registro.
|
||
</div>
|
||
|
||
<h3>Tus secciones clave</h3>
|
||
<ul>
|
||
<li><b>Propuestas:</b> arma cotizaciones con fotos, items con descripción completa</li>
|
||
<li><b>Ventas → Por OC:</b> crea órdenes manuales para ventas que vienen sin propuesta</li>
|
||
<li><b>Clientes:</b> ficha del cliente con histórico, condiciones, contacto</li>
|
||
<li><b>Operaciones:</b> ahí verás los pedidos avanzar después de capturarlos</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<section id="m-contabilidad">
|
||
<h1>📊 Seguimiento de contabilidad y facturación</h1>
|
||
<p class="m-lead">Funciones para cobranza, facturación y reportes financieros.</p>
|
||
|
||
<h3>🔁 Flujo típico</h3>
|
||
<ol>
|
||
<li><b>Cliente paga →</b> Tab <b>Ventas → Por OC</b>, busca la OC, márcala como pagada y registra método de pago</li>
|
||
<li><b>Cliente pide factura →</b> editor de OC, llena <code>N° Factura</code> y demás datos fiscales</li>
|
||
<li><b>Cliente nuevo cierra →</b> Clientes → crea ficha con condiciones de pago acordadas</li>
|
||
<li><b>Reporte fin de mes →</b> Tab <b>Ventas → Dashboard</b>, ahí están los comparativos mes actual vs anterior vs año pasado</li>
|
||
</ol>
|
||
|
||
<h3>Tus secciones clave</h3>
|
||
<ul>
|
||
<li><b>Ventas → Por OC:</b> kanban de cobranza, cada OC con su status</li>
|
||
<li><b>Ventas → Por Entregas:</b> entregas históricas con detalle de factura, editable inline</li>
|
||
<li><b>Ventas → Dashboard:</b> KPIs, comparativos mensuales, top clientes, márgenes, tiempos de flujo</li>
|
||
<li><b>Clientes:</b> ficha con histórico de compras, condiciones, notas</li>
|
||
</ul>
|
||
|
||
<h3>Pasos para facturar una OC</h3>
|
||
<ol>
|
||
<li>Abre la OC desde Ventas → Por OC</li>
|
||
<li>Llena: <code>Precio Factura</code> (subtotal sin IVA), <code>IVA %</code> (default 16), <code>N° Factura</code></li>
|
||
<li>Opcional: <code>Costo Logística</code>, <code>Otros gastos</code></li>
|
||
<li>Sube el PDF de la factura como archivo de la OC (botón 📁 Soporte, tipo "Factura")</li>
|
||
<li>Guarda</li>
|
||
</ol>
|
||
|
||
<h3>Condiciones de pago disponibles</h3>
|
||
<table class="m-stages">
|
||
<tr><td><code>Por definir</code></td><td>Default cuando aún no sabemos</td></tr>
|
||
<tr><td><code>A la entrega</code></td><td>Pago al momento de entregar</td></tr>
|
||
<tr><td><code>Crédito 30 días</code></td><td>Estándar para hoteles</td></tr>
|
||
<tr><td><code>Consignación</code></td><td>Para arrancar con clientes nuevos / probar</td></tr>
|
||
<tr><td><code>Efectivo</code></td><td>Pago en efectivo en el momento</td></tr>
|
||
<tr><td><code>Anticipo 50%</code></td><td>Pedidos especiales o si piden descuento</td></tr>
|
||
</table>
|
||
</section>
|
||
|
||
<section id="m-precios">
|
||
<h1>💰 Costos y precios mínimos</h1>
|
||
<p class="m-lead">Cómo registrar lo que nos cuesta producir y cómo asegurar que no vendemos por debajo.</p>
|
||
|
||
<h3>4 valores económicos por pedido</h3>
|
||
<table class="m-stages">
|
||
<tr><th>Campo</th><th>Qué es</th><th>Quién lo carga</th></tr>
|
||
<tr><td><code>Costo Producto</code></td><td>Lo que pagamos al proveedor por el producto base</td><td>Al recibir mercancía / al cotizar</td></tr>
|
||
<tr><td><code>Costo Trabajo</code></td><td>Lo que cobra 2 Mares / Sofía por la personalización</td><td>Al confirmar trabajo con taller</td></tr>
|
||
<tr><td><code>Costo Logística</code></td><td>Gasolina, viáticos. Aplica una vez por OC, no por pedido</td><td>Al cerrar entrega</td></tr>
|
||
<tr><td><code>Precio Factura</code></td><td>Lo que cobramos al cliente (sin IVA)</td><td>Al cerrar venta</td></tr>
|
||
</table>
|
||
|
||
<h3>Cómo ver precios mínimos por producto</h3>
|
||
<p>En el tab <b>Ventas → Dashboard</b>, sección "📦 Productos · precio promedio + volumen" muestra:</p>
|
||
<ul>
|
||
<li><b>Precio promedio</b> histórico por pieza de cada producto</li>
|
||
<li><b>Mín</b> y <b>Max</b> históricos — útil para no vender por debajo del piso</li>
|
||
<li><b>Costo base promedio</b> — para comparar margen</li>
|
||
<li><b>Spread</b> — diferencia entre mín y max. Si es <b>>30%</b>, sale alerta ámbar (significa precios muy variables, vale revisar política)</li>
|
||
</ul>
|
||
|
||
<div class="m-callout m-tip">
|
||
<b>Tip:</b> antes de cotizar un producto, revisa esta tabla para usar referencia histórica. Si el cliente nuevo quiere precio bajo, compara contra el mín histórico y el costo base para no quedar en pérdida.
|
||
</div>
|
||
|
||
<h3>Margen calculado</h3>
|
||
<p>El sistema calcula automáticamente <code>(precio − costo total) / precio</code>. Aparece en el quickview de cada pedido y en el dashboard. Si ves un margen rojo, ese pedido va en pérdida.</p>
|
||
|
||
<h3>Registrar costos al cotizar</h3>
|
||
<p>Cuando armas una propuesta o pedido nuevo, llena <code>Costo Producto</code> y <code>Costo Trabajo</code> aunque sean estimados — así el sistema puede mostrar margen desde el día 1. Después se ajustan con los costos reales cuando llegue la factura del proveedor.</p>
|
||
</section>
|
||
|
||
<section id="m-catalogo">
|
||
<h1>🎨 Catálogo y diseño</h1>
|
||
<p class="m-lead">Mantener el catálogo limpio, fotos al día y proyectos recurrentes bien definidos.</p>
|
||
|
||
<h3>🔁 Flujo típico</h3>
|
||
<ol>
|
||
<li><b>Producto nuevo →</b> Productos → Catálogo → <code>+ Agregar</code></li>
|
||
<li><b>Falta foto en catálogo →</b> revisa Productos (toggle de fotos OFF por default), busca los que tienen icono ámbar 📷</li>
|
||
<li><b>Cliente cerró diseño recurrente →</b> guarda como Proyecto (cliente + producto + tipo trabajo + logo + foto)</li>
|
||
<li><b>Cotización para cliente nuevo →</b> tab Propuestas, usa productos del catálogo</li>
|
||
</ol>
|
||
|
||
<h3>Secciones clave</h3>
|
||
<ul>
|
||
<li><b>Productos → Catálogo:</b> productos base con foto, costo, color, categoría</li>
|
||
<li><b>Productos → Proyectos:</b> recetas autorizadas reutilizables</li>
|
||
<li><b>Propuestas:</b> editor visual de cotizaciones</li>
|
||
</ul>
|
||
|
||
<h3>Productos sin foto</h3>
|
||
<p>En la tabla de Productos, los que NO tienen foto muestran el icono <span class="m-mini-icon" style="background:#fff3cd;color:#d97706;border:1px solid #facc15">📷</span>. Click ahí para subir la foto base.</p>
|
||
<p>El contador en el header te dice cuántos faltan: <i>"3 sin foto"</i> en ámbar.</p>
|
||
|
||
<h3>Proyectos recurrentes</h3>
|
||
<p>Un proyecto = receta autorizada. Por ejemplo: <i>"Bolsa Cabo Bello + Logo Flora Mango Serigrafía Negro"</i>. Una vez guardado:</p>
|
||
<ul>
|
||
<li>Cualquier pedido futuro de Flora Farms con esa bolsa se vincula <b>automáticamente</b> al proyecto</li>
|
||
<li>Se reusa la foto del producto terminado, el logo aprobado, los costos</li>
|
||
<li>El sistema cuenta "vendido X veces" para identificar qué clientes reordenan</li>
|
||
</ul>
|
||
|
||
<h3>Agregar un tipo de personalización nuevo</h3>
|
||
<p>Si necesitas agregar uno nuevo (ej. "Vinil textil"): Productos → Catálogo → Tipos de Trabajo → <code>+ Agregar</code>. Aparece disponible en pedidos, propuestas y proyectos automáticamente.</p>
|
||
</section>
|
||
|
||
<section id="m-faq">
|
||
<h1>💬 Preguntas frecuentes</h1>
|
||
|
||
<div class="m-faq">
|
||
<h4>"Subí una foto y se vinculó a otro pedido"</h4>
|
||
<p>Probablemente los dos pedidos tienen el <b>mismo orden_id</b>. Esto ya no debería pasar (el sistema valida unicidad), pero si lo ves: dile a Clod, revisa la lista de operaciones y renombra el duplicado.</p>
|
||
</div>
|
||
|
||
<div class="m-faq">
|
||
<h4>"¿Cómo borro una orden?"</h4>
|
||
<p>Abre el pedido/OC, botón rojo 🗑 al final. Antes de borrar revisa que no sea un error de cliente — borrar es definitivo.</p>
|
||
</div>
|
||
|
||
<div class="m-faq">
|
||
<h4>"El cliente cambió la cantidad después de aceptar la propuesta"</h4>
|
||
<p>Si <b>aún no convertiste</b>: edita la propuesta antes de convertir. Si <b>ya convertiste</b>: edita el pedido en Operaciones, o borra el que sobra.</p>
|
||
</div>
|
||
|
||
<div class="m-faq">
|
||
<h4>"¿Cómo manejo un defecto/reposición?"</h4>
|
||
<p>Cuando recoges del taller, usa el botón <code>📦 Recoger</code> y elige "Defecto" con la cantidad dañada. El sistema crea automáticamente un pedido tipo <code>Defecto</code> para reposición.</p>
|
||
</div>
|
||
|
||
<div class="m-faq">
|
||
<h4>"¿Qué hago si un pedido se canceló?"</h4>
|
||
<p>Edita el pedido, cambia stage a <code>Cancelado</code>. No se borra, queda en el histórico con explicación en notas.</p>
|
||
</div>
|
||
|
||
<div class="m-faq">
|
||
<h4>"¿Por qué el dashboard de Ventas muestra menos que lo que vendí?"</h4>
|
||
<p>Solo cuenta pedidos en stage <code>Entregado</code> y con <code>precio_factura</code> registrado. Si te falta dinero, busca pedidos entregados sin precio cargado.</p>
|
||
</div>
|
||
|
||
<div class="m-faq">
|
||
<h4>"No encuentro un cliente / producto"</h4>
|
||
<p>Usa el buscador en cada tab (arriba a la derecha). Si no aparece en Productos, créalo desde el catálogo. Si no aparece en Clientes, créalo desde Clientes.</p>
|
||
</div>
|
||
|
||
<div class="m-faq">
|
||
<h4>"¿Puedo usar esto desde el celular?"</h4>
|
||
<p>Sí, la app es responsive. Algunas vistas (kanban, dashboard) se ven mejor en computadora. Para entregar en campo, móvil funciona bien.</p>
|
||
</div>
|
||
|
||
<div class="m-faq">
|
||
<h4>"Olvidé subir foto de avance, ya está entregado"</h4>
|
||
<p>Sin problema — abre el pedido entregado (filtros: + Entregados en Operaciones), botón <code>📷</code> abajo de la foto para subirla después.</p>
|
||
</div>
|
||
|
||
<div class="m-faq">
|
||
<h4>"¿Cuándo uso 'Resurtido' vs 'OC'?"</h4>
|
||
<p><code>OC</code> = pedido específico para un cliente. <code>Resurtido</code> = producción libre para tener stock disponible en bodega (sin cliente asignado). Útil para tiendas que rotan inventario.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="m-vocab">
|
||
<h1>Vocabulario del sistema</h1>
|
||
<table class="m-stages">
|
||
<tr><th>Término</th><th>Significado</th></tr>
|
||
<tr><td><b>OC</b></td><td>Orden de Compra. Agrupa pedidos de un cliente bajo una factura.</td></tr>
|
||
<tr><td><b>Pedido</b></td><td>Línea de producción individual. 1 producto × 1 personalización × cantidad.</td></tr>
|
||
<tr><td><b>Propuesta</b></td><td>Cotización antes de cerrar venta.</td></tr>
|
||
<tr><td><b>Proyecto recurrente</b></td><td>Receta autorizada reutilizable (cliente+producto+trabajo+logo).</td></tr>
|
||
<tr><td><b>Stage</b></td><td>La etapa en la que está el pedido (Nuevo, En Taller, etc.).</td></tr>
|
||
<tr><td><b>Tipo Trabajo</b></td><td>La personalización: Bordado, Serigrafía, DTF UV, etc.</td></tr>
|
||
<tr><td><b>2 Mares</b></td><td>Taller externo principal de bordado/serigrafía.</td></tr>
|
||
<tr><td><b>Taller Sofía</b></td><td>Taller externo de costura/modificaciones.</td></tr>
|
||
<tr><td><b>Recoger</b></td><td>Ir físicamente al taller y traer producto terminado.</td></tr>
|
||
<tr><td><b>Resurtido</b></td><td>Producción sin OC, para tener stock en bodega.</td></tr>
|
||
<tr><td><b>Muestra</b></td><td>1 pieza para presentar a prospecto antes de cerrar venta.</td></tr>
|
||
<tr><td><b>Defecto</b></td><td>Reposición de piezas dañadas que llegaron del taller.</td></tr>
|
||
<tr><td><b>Faltante</b></td><td>Reposición de piezas que faltaron en una entrega.</td></tr>
|
||
<tr><td><b>Bodega</b></td><td>Pedidos en stage Almacén/Vehículo, agrupados con/sin OC.</td></tr>
|
||
<tr><td><b>Spread (precio)</b></td><td>Diferencia entre precio mínimo y máximo histórico de un producto.</td></tr>
|
||
</table>
|
||
<p style="margin-top:20px;font-size:11px;color:var(--t3);text-align:center">Última actualización: 2026-05-28 · Si encuentras algo desactualizado, dile a Clod.</p>
|
||
</section>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ══ TAREAS ══ -->
|
||
<div class="pg" id="pg-tareas">
|
||
<div class="kb-head">
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<div class="kb-title">Tareas</div>
|
||
<div class="view-toggle">
|
||
<button class="vt-btn on" onclick="setView('tareas','kanban',this)">Kanban</button>
|
||
<button class="vt-btn" onclick="setView('tareas','tabla',this)">Tabla</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:4px;align-items:center">
|
||
<input class="search-box" placeholder="Buscar..." id="search-tareas" oninput="filterTable('tareas')">
|
||
<button class="btn btn-ac" onclick="openMo('mo-tarea')">+ Tarea</button>
|
||
</div>
|
||
</div>
|
||
<div id="tareas-kanban"><div class="kb" id="kb-tareas"></div></div>
|
||
<div id="tareas-tabla" style="display:none;overflow-x:auto"><table class="grid-tbl" id="tbl-tareas"><thead></thead><tbody></tbody></table></div>
|
||
</div>
|
||
|
||
<!-- ══ BITACORA ══ -->
|
||
<div class="pg" id="pg-bitacora">
|
||
<div class="kb-head">
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<div class="kb-title">Bitacora</div>
|
||
<div class="view-toggle">
|
||
<button class="vt-btn on" onclick="setView('bitacora','timeline',this)">Timeline</button>
|
||
<button class="vt-btn" onclick="setView('bitacora','tabla',this)">Tabla</button>
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-ac" onclick="openMo('mo-bita')">+ Registrar</button>
|
||
</div>
|
||
<div id="bitacora-timeline"><div id="bita-list"></div></div>
|
||
<div id="bitacora-tabla" style="display:none;overflow-x:auto"><table class="grid-tbl" id="tbl-bitacora"><thead></thead><tbody></tbody></table></div>
|
||
</div>
|
||
|
||
<!-- ══ MODALS ══ -->
|
||
|
||
<!-- Wizard: Nueva Orden (mobile-friendly step-by-step) -->
|
||
<div class="mo-bg" id="mo-wizard"><div class="mo" style="max-width:440px">
|
||
<h3>Nuevo Pedido <button class="mo-x" onclick="closeWizard()">×</button></h3>
|
||
<div class="wizard-dots" id="wiz-dots"></div>
|
||
<div class="wizard" id="wizard-body">
|
||
<!-- Step 1: Cliente -->
|
||
<div class="wizard-step active" data-step="1">
|
||
<div class="fg"><label>Cliente</label>
|
||
<div style="display:flex;gap:4px">
|
||
<select id="w-cliente" style="font-size:14px;padding:10px;flex:1"><option value="">-- Seleccionar --</option></select>
|
||
<button class="btn" onclick="addClienteInline()" style="white-space:nowrap;font-size:12px">+ Nuevo</button>
|
||
</div>
|
||
</div>
|
||
<div class="fg"><label>Tipo de Orden</label><select id="w-tipo" style="font-size:14px;padding:10px">
|
||
<option value="OC">OC - Orden de compra</option>
|
||
<option value="Resurtido">Resurtido POS</option>
|
||
<option value="Muestra">Muestra</option>
|
||
<option value="Defecto">Defecto / Reclamo</option>
|
||
<option value="Faltante">Faltante (inventario)</option>
|
||
</select></div>
|
||
</div>
|
||
<!-- Step 2: Producto -->
|
||
<div class="wizard-step" data-step="2">
|
||
<!-- Quick-start: trabajo recurrente -->
|
||
<div id="w-proyecto-row" style="display:none;margin-bottom:12px"></div>
|
||
<!-- Hidden dropdown for compatibility -->
|
||
<select id="w-proyecto-sel" style="display:none" onchange="wizApplyProyecto()">
|
||
<option value="">— Manual —</option>
|
||
</select>
|
||
<div class="fg"><label>Producto</label>
|
||
<div style="display:flex;gap:4px">
|
||
<select id="w-producto-sel" style="font-size:14px;padding:10px;flex:1" onchange="wizProductoChange()"><option value="">-- Seleccionar --</option></select>
|
||
<button class="btn" onclick="toggleNewProducto()" style="white-space:nowrap;font-size:12px" id="w-prod-toggle">+ Nuevo</button>
|
||
</div>
|
||
</div>
|
||
<div id="w-new-prod-fields" style="display:none">
|
||
<div class="fg"><label>Nombre del producto</label><input id="w-prod-nombre" placeholder="ej: Bolsa Cabo Bello" style="font-size:14px;padding:10px"></div>
|
||
<div class="fg"><label>Color / Características <span style="font-size:9px;color:var(--t3)">(opcional)</span></label><input id="w-prod-color" placeholder="Beige, Natural, etc" style="font-size:14px;padding:10px"></div>
|
||
<!-- tipo_personalizacion eliminado: usa "Tipo Trabajo" del pedido para no preguntar 2 veces -->
|
||
<input type="hidden" id="w-prod-tipo-pers" value="">
|
||
<input type="hidden" id="w-prod-logo" value="">
|
||
</div>
|
||
<input type="hidden" id="w-producto" value="">
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Cantidad</label><input id="w-cantidad" type="number" value="50" style="font-size:14px;padding:10px"></div>
|
||
<div class="fg"><label>Tipo Trabajo</label><select id="w-trabajo" style="font-size:14px;padding:10px"><option value="">-- Seleccionar --</option></select></div>
|
||
</div>
|
||
<div class="fg"><label>Logo / Instrucciones <span style="font-size:9px;color:var(--t3)">(descripción del diseño y posición)</span></label><textarea id="w-logo" style="font-size:13px;padding:10px;min-height:60px" placeholder="ej: Logo cliente bordado en pecho izquierdo, color crudo"></textarea></div>
|
||
<div class="fg"><label>Stage</label><select id="w-stage" style="font-size:14px;padding:10px">
|
||
<option>Nuevo</option><option>En Tránsito</option><option>En 2 Mares</option><option>En Taller Sofia</option><option>En Almacen</option><option>En Vehiculo</option>
|
||
</select></div>
|
||
</div>
|
||
<!-- Step 3: Precios -->
|
||
<div class="wizard-step" data-step="3">
|
||
<div class="fg-row3">
|
||
<div class="fg"><label>Costo producto ($)</label><input id="w-costo-prod" type="number" step="0.01" value="0" style="font-size:14px;padding:10px" oninput="calcWizPrice()"></div>
|
||
<div class="fg"><label>Costo trabajo ($)</label><input id="w-costo-trab" type="number" step="0.01" value="0" style="font-size:14px;padding:10px" oninput="calcWizPrice()"></div>
|
||
<div class="fg"><label>Logistica ($)</label><input id="w-costo-log" type="number" step="0.01" value="0" style="font-size:14px;padding:10px" oninput="calcWizPrice()"></div>
|
||
</div>
|
||
<div class="fg"><label>Precio factura ($)</label><input id="w-factura" type="number" step="0.01" value="0" style="font-size:14px;padding:10px" oninput="calcWizPrice()"></div>
|
||
<div class="price-calc" id="wiz-price-calc">
|
||
<div class="price-row"><span class="price-label">Costo total</span><span id="wpc-total">$0</span></div>
|
||
<div class="price-row"><span class="price-label">Factura</span><span id="wpc-factura">$0</span></div>
|
||
<div class="price-row"><span class="price-label">Utilidad</span><span class="price-util" id="wpc-util">$0 (0%)</span></div>
|
||
</div>
|
||
</div>
|
||
<!-- Step 4: Detalles -->
|
||
<div class="wizard-step" data-step="4">
|
||
<div class="fg"><label>Orden ID</label><input id="w-orden-id" style="font-size:14px;padding:10px"></div>
|
||
<div class="fg"><label>Vincular a Orden</label>
|
||
<div style="display:flex;gap:4px">
|
||
<select id="w-oc-sel" style="font-size:14px;padding:10px;flex:1"><option value="">-- Sin orden (pedido suelto) --</option></select>
|
||
<button class="btn" onclick="openNewOC()" style="white-space:nowrap;font-size:12px">+ Nueva Orden</button>
|
||
</div>
|
||
<div style="font-size:9px;color:var(--t3);margin-top:2px">Vincula este pedido a una Orden de Compra del cliente</div>
|
||
</div>
|
||
<div class="fg"><label>Notas internas <span style="font-size:9px;color:var(--t3)">(opcional)</span></label><textarea id="w-notas" style="font-size:13px;padding:10px" placeholder="Notas para el equipo"></textarea></div>
|
||
<div class="fg"><label>Urgente</label><select id="w-urgente" style="font-size:14px;padding:10px"><option value="0">No</option><option value="1">Si</option></select></div>
|
||
</div>
|
||
</div>
|
||
<div class="wizard-nav">
|
||
<button class="btn" id="wiz-prev" onclick="wizStep(-1)" style="visibility:hidden">Anterior</button>
|
||
<button class="btn btn-ac" id="wiz-next" onclick="wizStep(1)">Siguiente</button>
|
||
</div>
|
||
</div></div>
|
||
|
||
<!-- Nuevo SKU (legacy) -->
|
||
<div class="mo-bg" id="mo-inv"><div class="mo">
|
||
<h3>Nuevo SKU <button class="mo-x" onclick="closeMo('mo-inv')">×</button></h3>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>SKU</label><input id="f-i-sku" placeholder="BOLSA-TIPO-TALLA"></div>
|
||
<div class="fg"><label>Nombre</label><input id="f-i-nom" placeholder="Nombre del producto"></div>
|
||
</div>
|
||
<div class="fg"><label>Descripcion</label><textarea id="f-i-desc"></textarea></div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Tipo</label><select id="f-i-tipo"><option>Bolsa tote</option><option>Bolsa ecologica</option><option>Bolsa de tela</option><option>Accesorio</option><option>Otro</option></select></div>
|
||
<div class="fg"><label>Talla</label><select id="f-i-talla"><option>CH</option><option>MD</option><option>GR</option><option>XGR</option><option>Unica</option></select></div>
|
||
</div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Stock Inicial</label><input id="f-i-stock" type="number" value="0"></div>
|
||
<div class="fg"><label>Punto Reorden</label><input id="f-i-reorden" type="number" value="10"></div>
|
||
</div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Costo Unitario</label><input id="f-i-costo" type="number" step="0.01" value="0"></div>
|
||
<div class="fg"><label>Proveedor</label><input id="f-i-prov" placeholder="Sandra"></div>
|
||
</div>
|
||
<button class="mo-sub" onclick="crearSKU()">Crear SKU</button>
|
||
</div></div>
|
||
|
||
<!-- Nueva Tarea -->
|
||
<div class="mo-bg" id="mo-tarea"><div class="mo">
|
||
<h3>Nueva Tarea <button class="mo-x" onclick="closeMo('mo-tarea')">×</button></h3>
|
||
<div class="fg"><label>Titulo</label><input id="f-t-tit"></div>
|
||
<div class="fg"><label>Descripcion</label><textarea id="f-t-desc"></textarea></div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Prioridad</label><select id="f-t-prio"><option value="alta">Alta</option><option value="normal" selected>Normal</option><option value="baja">Baja</option></select></div>
|
||
<div class="fg"><label>Categoria</label><select id="f-t-cat"><option>operaciones</option><option>entregas</option><option>ventas</option><option>produccion</option><option>cobranza</option><option>config</option><option>general</option></select></div>
|
||
</div>
|
||
<div class="fg"><label>Asignado</label><select id="f-t-asig"><option>Clod</option><option>Tess</option><option>Andre</option><option>Sandra</option></select></div>
|
||
<div class="fg"><label>Fecha limite</label><input id="f-t-fecha" type="date"></div>
|
||
<button class="mo-sub" onclick="crearTarea()">Crear Tarea</button>
|
||
</div></div>
|
||
|
||
<!-- Bitacora -->
|
||
<!-- ══ Nueva OC ══ -->
|
||
<div class="mo-bg" id="mo-new-oc"><div class="mo" style="max-width:440px">
|
||
<h3>Nueva Orden <button class="mo-x" onclick="closeMo('mo-new-oc')">×</button></h3>
|
||
<div style="font-size:10px;color:var(--t2);margin-bottom:10px">La Orden agrupa varios pedidos que se entregan y facturan juntos</div>
|
||
<div class="fg"><label>Folio Orden</label><input id="noc-id" style="font-size:14px;padding:10px" placeholder="OC-2026-001"></div>
|
||
<div class="fg"><label>Cliente</label>
|
||
<select id="noc-cliente" style="font-size:14px;padding:10px"><option value="">-- Seleccionar --</option></select>
|
||
</div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Fecha Orden</label><input id="noc-fecha" type="date" style="font-size:14px;padding:10px"></div>
|
||
<div class="fg"><label>Condiciones de pago</label><select id="noc-pago" style="font-size:14px;padding:10px">
|
||
<option>Por definir</option><option>A la entrega</option><option>Crédito 30 días</option><option>Consignación</option><option>Efectivo</option><option>Anticipo 50%</option>
|
||
</select></div>
|
||
</div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Subtotal factura ($)</label><input id="noc-factura" type="number" step="0.01" value="0" style="font-size:14px;padding:10px" oninput="calcNocIva()"></div>
|
||
<div class="fg"><label>IVA %</label><input id="noc-iva-pct" type="number" step="0.01" value="16" style="font-size:14px;padding:10px" oninput="calcNocIva()"></div>
|
||
</div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Logistica ($)</label><input id="noc-logistica" type="number" step="0.01" value="0" style="font-size:14px;padding:10px"></div>
|
||
<div class="fg"><label>Otros gastos ($)</label><input id="noc-otros" type="number" step="0.01" value="0" style="font-size:14px;padding:10px"></div>
|
||
</div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Numero factura</label><input id="noc-factura-num" placeholder="A123..." style="font-size:14px;padding:10px"></div>
|
||
<div class="fg"><label>Concepto otros gastos</label><input id="noc-otros-desc" placeholder="Comision, fletes, etc" style="font-size:14px;padding:10px"></div>
|
||
</div>
|
||
<div id="noc-iva-calc" style="font-size:11px;color:var(--t2);background:var(--s2);padding:6px 10px;border-radius:6px;margin-bottom:8px;display:none"></div>
|
||
<div class="fg"><label>Notas</label><textarea id="noc-notas" style="font-size:13px;padding:10px" placeholder="Detalles de la OC..."></textarea></div>
|
||
<button class="mo-sub" onclick="saveNewOC()">✓ Crear OC</button>
|
||
</div></div>
|
||
|
||
<div class="mo-bg" id="mo-bita"><div class="mo">
|
||
<h3>Registrar <button class="mo-x" onclick="closeMo('mo-bita')">×</button></h3>
|
||
<div class="fg"><label>Tipo</label><select id="f-b-tipo"><option value="entrega">Entrega</option><option value="produccion">Produccion</option><option value="hito">Hito</option><option value="nota">Nota</option><option value="problema">Problema</option><option value="decision">Decision</option></select></div>
|
||
<div class="fg"><label>Titulo</label><input id="f-b-tit"></div>
|
||
<div class="fg"><label>Descripcion</label><textarea id="f-b-desc"></textarea></div>
|
||
<button class="mo-sub" onclick="crearBita()">Registrar</button>
|
||
</div></div>
|
||
|
||
<!-- Registro Historico -->
|
||
<div class="mo-bg" id="mo-historico"><div class="mo" style="max-width:460px">
|
||
<h3>Registrar Orden Pasada <button class="mo-x" onclick="closeMo('mo-historico')">×</button></h3>
|
||
<div style="font-size:10px;color:var(--t2);margin-bottom:10px">Para vaciar entregas anteriores con sus fechas reales</div>
|
||
<div class="fg"><label>Cliente</label>
|
||
<div style="display:flex;gap:4px">
|
||
<select id="h-cliente" style="font-size:14px;padding:10px;flex:1"><option value="">-- Seleccionar --</option></select>
|
||
<button class="btn" onclick="editClienteFromH()" id="h-cli-edit" style="font-size:12px;display:none" title="Editar cliente">✎</button>
|
||
<button class="btn" onclick="addClienteInlineH()" style="white-space:nowrap;font-size:12px">+ Nuevo</button>
|
||
</div>
|
||
</div>
|
||
<div class="fg"><label>Producto</label>
|
||
<div style="display:flex;gap:4px">
|
||
<select id="h-producto" style="font-size:14px;padding:10px;flex:1"><option value="">-- Seleccionar --</option></select>
|
||
<button class="btn" onclick="addProductoInlineH()" style="white-space:nowrap;font-size:12px">+ Nuevo</button>
|
||
</div>
|
||
</div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Cantidad</label><input id="h-cantidad" type="number" value="50" style="font-size:14px;padding:10px"></div>
|
||
<div class="fg"><label>Tipo Trabajo</label><select id="h-trabajo" style="font-size:14px;padding:10px"><option value="">--</option></select></div>
|
||
</div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Fecha inicio</label><input id="h-fecha-inicio" type="date" style="font-size:14px;padding:10px"></div>
|
||
<div class="fg"><label>Fecha entrega</label><input id="h-fecha-entrega" type="date" style="font-size:14px;padding:10px"></div>
|
||
</div>
|
||
<div class="fg"><label>Recibio</label><input id="h-recibio" placeholder="Quien recibio" style="font-size:14px;padding:10px"></div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Costo producto $/pza</label><input id="h-costo-prod" type="number" step="0.01" value="0" style="font-size:14px;padding:10px"></div>
|
||
<div class="fg"><label>Costo trabajo $/pza</label><input id="h-costo-trab" type="number" step="0.01" value="0" style="font-size:14px;padding:10px"></div>
|
||
</div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Logistica $</label><input id="h-costo-log" type="number" step="0.01" value="0" style="font-size:14px;padding:10px"></div>
|
||
<div class="fg"><label>Factura $</label><input id="h-factura" type="number" step="0.01" value="0" style="font-size:14px;padding:10px"></div>
|
||
</div>
|
||
<div class="fg"><label>Vincular a Orden</label>
|
||
<div style="display:flex;gap:4px">
|
||
<select id="h-oc-sel" style="font-size:14px;padding:10px;flex:1"><option value="">-- Sin orden --</option></select>
|
||
<button class="btn" onclick="openNewOCFromHist()" style="white-space:nowrap;font-size:12px">+ Nueva Orden</button>
|
||
</div>
|
||
</div>
|
||
<div class="fg"><label>Notas (opcional)</label><textarea id="h-notas" placeholder="Referencia, detalles..." style="font-size:13px;padding:10px"></textarea></div>
|
||
<div id="h-saved-lines" style="margin-bottom:8px"></div>
|
||
<div style="display:flex;gap:6px">
|
||
<button class="mo-sub" onclick="guardarHistorico(false)" style="flex:1">✓ Registrar y cerrar</button>
|
||
<button class="mo-sub" onclick="guardarHistorico(true)" style="flex:1;background:var(--olive-dark)">✓ Registrar + otra línea</button>
|
||
</div>
|
||
</div></div>
|
||
|
||
<!-- Quick View -->
|
||
<div class="mo-bg" id="mo-quickview"><div class="mo" style="max-width:680px">
|
||
<div id="qv-header"></div>
|
||
<div id="qv-body"></div>
|
||
<div id="qv-actions" style="display:flex;gap:6px;margin-top:14px;flex-wrap:wrap;padding-top:12px;border-top:1px solid var(--bd)"></div>
|
||
</div></div>
|
||
|
||
<!-- ══ Editor de Pedido (propuesta-style) ══ -->
|
||
<div class="mo-bg" id="mo-pedido-edit"><div class="mo" style="max-width:680px">
|
||
<div id="pe-header"></div>
|
||
<div id="pe-body"></div>
|
||
</div></div>
|
||
|
||
<!-- ══ Orden Detail (visualizador) ══ -->
|
||
<div class="mo-bg" id="mo-orden"><div class="mo" style="max-width:560px">
|
||
<div id="od-header"></div>
|
||
<div id="od-body"></div>
|
||
</div></div>
|
||
|
||
<!-- ══ Nueva Orden / Pedidos (estilo propuesta) ══ -->
|
||
<div class="mo-bg" id="mo-orden-create"><div class="mo" style="max-width:820px">
|
||
<div id="oc-create-header"></div>
|
||
<div id="oc-create-body"></div>
|
||
</div></div>
|
||
|
||
<!-- ══ Selector de productos para pedidos (reusa flujo propuesta) ══ -->
|
||
<div class="mo-bg" id="mo-oc-prods"><div class="mo" style="max-width:560px">
|
||
<h3>Agregar producto <button class="mo-x" onclick="closeMo('mo-oc-prods')">×</button></h3>
|
||
<div style="display:flex;gap:6px;margin-bottom:8px">
|
||
<input class="search-box" placeholder="Buscar producto..." id="oc-prods-search" oninput="renderOcProds()" style="flex:1">
|
||
<button class="btn btn-ac" onclick="toggleNewOcProdForm()" id="oc-new-prod-btn">+ Nuevo</button>
|
||
</div>
|
||
<!-- Inline form to create a new catalog product on the fly -->
|
||
<div id="oc-new-prod-form" style="display:none;background:var(--s2);border:1px dashed var(--olive);border-radius:8px;padding:10px 12px;margin-bottom:10px">
|
||
<div style="font-size:10px;color:var(--olive-dark);text-transform:uppercase;letter-spacing:.5px;font-weight:600;margin-bottom:6px">Nuevo producto al catálogo</div>
|
||
<div class="od-config-grid" style="grid-template-columns:2fr 1fr">
|
||
<div class="fg"><label>Nombre del producto</label><input id="oc-np-nombre" placeholder="ej: Termo cafe color madera"></div>
|
||
<div class="fg"><label>Categoría</label><select id="oc-np-cat">
|
||
<option>bolsa</option><option>accesorio</option><option>taza</option><option>termo</option><option>textil</option><option>otro</option>
|
||
</select></div>
|
||
<div class="fg"><label>Costo base $/pza <span style="font-weight:400;color:var(--t3);font-size:9px">(opcional)</span></label><input id="oc-np-costo" type="number" step="0.01" value="0"></div>
|
||
<div class="fg"><label>Color / Material <span style="font-weight:400;color:var(--t3);font-size:9px">(opcional)</span></label><input id="oc-np-color" placeholder="Natural, beige, etc"></div>
|
||
</div>
|
||
<div style="display:flex;gap:6px;margin-top:8px">
|
||
<button class="btn btn-ac" onclick="saveNewOcProducto()">✓ Crear y agregar</button>
|
||
<button class="btn" onclick="toggleNewOcProdForm()">Cancelar</button>
|
||
</div>
|
||
</div>
|
||
<div id="oc-prods-list" style="max-height:50vh;overflow-y:auto"></div>
|
||
</div></div>
|
||
|
||
<!-- ══ Bodega vista expandida ══ -->
|
||
<div class="mo-bg" id="mo-bodega-full"><div class="mo">
|
||
<div id="bf-toolbar"></div>
|
||
<div id="bf-body"></div>
|
||
</div></div>
|
||
|
||
<!-- ══ Eliminar Pedido (borrar o cancelar) ══ -->
|
||
<div class="mo-bg" id="mo-del-pedido"><div class="mo" style="max-width:380px">
|
||
<h3>¿Qué quieres hacer? <button class="mo-x" onclick="closeMo('mo-del-pedido')">×</button></h3>
|
||
<div id="del-pedido-info" style="font-size:12px;color:var(--t2);margin-bottom:14px;padding:8px 10px;background:var(--s2);border-radius:6px"></div>
|
||
<div style="display:flex;flex-direction:column;gap:8px">
|
||
<button class="btn" style="border-color:var(--yl,#eab308);background:#fef9c3;color:#854d0e;padding:12px;text-align:left" onclick="confirmCancelarPedido()">
|
||
<div style="font-weight:700;font-size:13px">📦 Cancelar pedido</div>
|
||
<div style="font-size:10px;font-weight:400;margin-top:2px;opacity:.8">Marca el pedido como Cancelado. Queda en el registro pero fuera del flujo activo.</div>
|
||
</button>
|
||
<button class="btn" style="border-color:var(--rd);background:var(--rdd);color:var(--rd);padding:12px;text-align:left" onclick="confirmBorrarPedido()">
|
||
<div style="font-weight:700;font-size:13px">🗑 Borrar definitivo</div>
|
||
<div style="font-size:10px;font-weight:400;margin-top:2px;opacity:.8">Elimina el pedido por completo. No se puede deshacer. Para errores y duplicados.</div>
|
||
</button>
|
||
<button class="btn" onclick="closeMo('mo-del-pedido')" style="margin-top:4px">Volver</button>
|
||
</div>
|
||
</div></div>
|
||
|
||
<!-- ══ Entrega Parcial ══ -->
|
||
<div class="mo-bg" id="mo-parcial"><div class="mo" style="max-width:520px">
|
||
<h3>Entrega parcial <button class="mo-x" onclick="closeMo('mo-parcial')">×</button></h3>
|
||
<div style="font-size:11px;color:var(--t2);margin-bottom:10px" id="ep-intro"></div>
|
||
<div class="fg"><label>Pedidos a entregar (selecciona)</label>
|
||
<div id="ep-pedidos-list" style="background:var(--s2);border-radius:6px;padding:6px;max-height:200px;overflow-y:auto"></div>
|
||
</div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Folio nueva Orden hermana</label><input id="ep-oc-id"></div>
|
||
<div class="fg"><label>Fecha entrega</label><input type="date" id="ep-fecha"></div>
|
||
</div>
|
||
<div class="fg"><label>Recibió</label><input id="ep-recibio" placeholder="Quien recibe"></div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Subtotal factura ($)</label><input id="ep-sub" type="number" step="0.01" value="0"></div>
|
||
<div class="fg"><label>IVA %</label><input id="ep-iva" type="number" step="0.01" value="16"></div>
|
||
</div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Logística ($)</label><input id="ep-log" type="number" step="0.01" value="0"></div>
|
||
<div class="fg"><label>Otros gastos ($)</label><input id="ep-otros" type="number" step="0.01" value="0"></div>
|
||
</div>
|
||
<div class="fg-row">
|
||
<div class="fg"><label>Nº factura</label><input id="ep-factura-num"></div>
|
||
<div class="fg"><label>Condiciones pago</label><select id="ep-pago">
|
||
<option>Por definir</option><option>A la entrega</option><option>Crédito 30 días</option><option>Consignación</option><option>Efectivo</option><option>Anticipo 50%</option>
|
||
</select></div>
|
||
</div>
|
||
<div class="fg"><label>Notas</label><textarea id="ep-notas" placeholder="Comentarios de esta entrega..."></textarea></div>
|
||
<button class="mo-sub" onclick="confirmarEntregaParcial()">✓ Confirmar entrega parcial</button>
|
||
</div></div>
|
||
|
||
<!-- Edit (reusable) -->
|
||
<div class="mo-bg" id="mo-edit"><div class="mo">
|
||
<h3 id="edit-h">Editar <button class="mo-x" onclick="closeMo('mo-edit')">×</button></h3>
|
||
<div id="edit-fields"></div>
|
||
<button class="mo-sub" id="edit-go">Guardar</button>
|
||
</div></div>
|
||
|
||
<!-- Recoger (pickup from production) -->
|
||
<div class="mo-bg" id="mo-recoger"><div class="mo" style="max-width:420px">
|
||
<h3>Recoger Orden <button class="mo-x" onclick="closeMo('mo-recoger')">×</button></h3>
|
||
<div id="recoger-info" style="padding:8px 10px;background:var(--s2);border-radius:6px;margin-bottom:10px;font-size:11px"></div>
|
||
<div class="fg"><label>Piezas buenas recibidas</label><input id="rec-piezas" type="number" style="font-size:14px;padding:10px" oninput="recCheckDiff()"></div>
|
||
<div id="rec-diff-section" style="display:none;margin-bottom:8px">
|
||
<div id="rec-diff-msg" style="font-size:11px;color:var(--or);font-weight:600;margin-bottom:6px"></div>
|
||
<div class="fg"><label>¿Que paso con las piezas restantes?</label>
|
||
<select id="rec-motivo" style="font-size:14px;padding:10px" onchange="recMotivoChange()">
|
||
<option value="pendiente">Pendientes de recoger (siguen en produccion)</option>
|
||
<option value="danadas">Dañadas (crear orden de reposicion)</option>
|
||
<option value="conteo">Error de conteo (ajustar cantidad real)</option>
|
||
</select>
|
||
</div>
|
||
<div id="rec-stage-pick" style="display:none" class="fg">
|
||
<label>¿En que stage quedan las pendientes?</label>
|
||
<select id="rec-stage-dest" style="font-size:14px;padding:10px">
|
||
<option>En 2 Mares</option>
|
||
<option>En Taller Sofia</option>
|
||
<option>Nuevo</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="fg"><label>Notas (opcional)</label><textarea id="rec-notas" placeholder="Observaciones..." style="font-size:13px;padding:10px"></textarea></div>
|
||
<button class="mo-sub" onclick="confirmarRecogida()">✓ Confirmar Recogida</button>
|
||
</div></div>
|
||
|
||
<!-- Confirmar Entrega -->
|
||
<div class="mo-bg" id="mo-entrega"><div class="mo" style="max-width:420px">
|
||
<h3>Confirmar Entrega <button class="mo-x" onclick="closeMo('mo-entrega')">×</button></h3>
|
||
<div id="entrega-info" style="padding:8px 10px;background:var(--s2);border-radius:6px;margin-bottom:10px;font-size:11px"></div>
|
||
<div class="fg"><label>Fecha de entrega</label><input id="ent-fecha" type="date" style="font-size:14px;padding:10px"></div>
|
||
<div class="fg"><label>Recibio (nombre de quien recibe)</label><input id="ent-recibio" placeholder="Nombre de la persona" style="font-size:14px;padding:10px"></div>
|
||
<div class="fg"><label>Notas de entrega (opcional)</label><textarea id="ent-notas" placeholder="Observaciones, incidencias..." style="font-size:13px;padding:10px"></textarea></div>
|
||
<div style="border:1px solid var(--bd);border-radius:7px;padding:10px;margin-bottom:10px">
|
||
<div style="font-size:9px;font-weight:600;color:var(--t2);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">Subir soporte (opcional)</div>
|
||
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:6px">
|
||
<button class="doc-btn" onclick="entregaUploadTipo('recibo_entrega')" id="ent-btn-recibo">📋 Recibo</button>
|
||
<button class="doc-btn" onclick="entregaUploadTipo('foto_producto')" id="ent-btn-foto">📷 Fotos</button>
|
||
<button class="doc-btn" onclick="entregaUploadTipo('factura')" id="ent-btn-factura">📄 Factura</button>
|
||
</div>
|
||
<input type="file" id="ent-file-input" multiple accept="image/*,.pdf,.doc,.docx,.xlsx" style="display:none" onchange="entregaFileSelected()">
|
||
<div id="ent-files-preview" style="display:flex;gap:4px;flex-wrap:wrap"></div>
|
||
</div>
|
||
<button class="mo-sub" onclick="confirmarEntrega()">✓ Confirmar Entrega</button>
|
||
</div></div>
|
||
|
||
<!-- Catalogo Add -->
|
||
<div class="mo-bg" id="mo-cat"><div class="mo">
|
||
<h3 id="cat-mo-h">Agregar <button class="mo-x" onclick="closeMo('mo-cat')">×</button></h3>
|
||
<div id="cat-mo-fields"></div>
|
||
<button class="mo-sub" id="cat-mo-go">Guardar</button>
|
||
</div></div>
|
||
|
||
<!-- Files Modal -->
|
||
<div class="mo-bg" id="mo-files"><div class="mo" style="max-width:480px">
|
||
<h3 id="files-h">Archivos <button class="mo-x" onclick="closeMo('mo-files')">×</button></h3>
|
||
<div id="files-list"></div>
|
||
<div class="upload-zone" id="upload-zone" onclick="document.getElementById('file-input').click()">
|
||
<input type="file" id="file-input" multiple accept="image/*,.pdf,.doc,.docx,.xlsx">
|
||
<div style="font-size:18px;margin-bottom:3px">+</div>
|
||
<div class="upload-label">Click o arrastra archivos</div>
|
||
<div style="font-size:8px;color:var(--t3);margin-top:3px">JPG, PNG, PDF — max 20MB</div>
|
||
</div>
|
||
<div style="margin-top:6px">
|
||
<label class="fg" style="margin-bottom:0"><label style="font-size:8px">Tipo</label>
|
||
<select id="upload-tipo" onchange="suggestFileLabel()" style="padding:5px 8px;background:var(--bg);border:1px solid var(--bd);border-radius:5px;color:var(--tx);font-size:10px;width:100%">
|
||
<optgroup label="Venta / Propuesta">
|
||
<option value="propuesta_venta">Propuesta de venta</option>
|
||
<option value="contrato">Contrato / Acuerdo</option>
|
||
<option value="logo_diseno">Logo / Diseño cliente</option>
|
||
</optgroup>
|
||
<optgroup label="Producción">
|
||
<option value="soporte_trabajo">Soporte de trabajo</option>
|
||
<option value="foto_producto">Foto del producto</option>
|
||
</optgroup>
|
||
<optgroup label="Entrega / Cobro">
|
||
<option value="factura">Factura</option>
|
||
<option value="recibo_entrega">Recibo de entrega</option>
|
||
<option value="comprobante_pago">Comprobante de pago</option>
|
||
</optgroup>
|
||
<option value="otro">Otro</option>
|
||
</select></label>
|
||
<label class="fg" style="margin-bottom:0;margin-top:6px"><label style="font-size:8px">Nombre / etiqueta (opcional)</label>
|
||
<input id="upload-label" placeholder="se sugiere según el tipo" style="padding:5px 8px;background:var(--bg);border:1px solid var(--bd);border-radius:5px;color:var(--tx);font-size:10px;width:100%"></label>
|
||
</div>
|
||
</div></div>
|
||
|
||
<!-- Lightbox -->
|
||
<div class="lightbox" id="lightbox" onclick="closeLightbox(event)">
|
||
<img id="lb-img" src="">
|
||
<div class="lightbox-name" id="lb-name"></div>
|
||
<div class="lightbox-bar">
|
||
<a class="lightbox-btn lb-download" id="lb-dl" href="" download>Descargar</a>
|
||
<button class="lightbox-btn lb-close" onclick="closeLightbox()">Cerrar</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toast"></div>
|
||
|
||
<script>
|
||
const C={
|
||
'Nuevo':['yl','yld'],'En Tránsito':['cy','cyd'],'En 2 Mares':['or','ord'],'En Taller Sofia':['pr','prd'],'En Almacen':['bl','bld'],'En Vehiculo':['cy','cyd'],'Entregado':['gn','gnd'],'Cancelado':['rd','rdd'],
|
||
'pendiente':['or','ord'],'en_progreso':['bl','bld'],'en_revision':['yl','yld'],'completada':['gn','gnd'],'backlog':['pr','prd'],
|
||
'entrega':['gn','gnd'],'produccion':['or','ord'],'hito':['ac','acd'],'nota':['t2',''],'problema':['rd','rdd'],'decision':['pr','prd'],'movimiento':['bl','bld'],
|
||
'Bordado':['pr','prd'],'Serigrafia':['bl','bld'],'Grabado laser':['or','ord'],'Impresion':['gn','gnd'],'Costura':['yl','yld'],
|
||
'OC':['bl','bld'],'Resurtido':['gn','gnd'],'Muestra':['pr','prd'],'Defecto':['rd','rdd'],'Faltante':['or','ord'],
|
||
};
|
||
const cc=(k)=>{const v=C[k];return v?v:['t2','']};
|
||
|
||
let S={ordenes:[],inventario:[],tareas:[],bitacora:[],dash:{},clientes:[],modelos:[],materiales:[],trabajos:[],ocs:[]};
|
||
let showEntregados=false;
|
||
|
||
const api=async(m,p,b)=>{const o={method:m,headers:{'Content-Type':'application/json'}};if(b)o.body=JSON.stringify(b);const r=await fetch(p,o);if(r.status===401){location.reload();throw new Error('no auth');}return r.json()};
|
||
async function logoutHub(){try{await fetch('/api/logout',{method:'POST'});}catch(e){}location.href='/';}
|
||
const esc=(s)=>{const d=document.createElement('div');d.textContent=s||'';return d.innerHTML};
|
||
const toast=(m)=>{const t=document.getElementById('toast');t.textContent=m;t.style.display='block';setTimeout(()=>t.style.display='none',2e3)};
|
||
const $=id=>document.getElementById(id);
|
||
const fmt$=(n)=>'$'+Number(n||0).toLocaleString('es-MX',{minimumFractionDigits:0,maximumFractionDigits:0});
|
||
|
||
// NAV
|
||
document.getElementById('nav-tabs').onclick=e=>{
|
||
const t=e.target.closest('.tab');if(!t)return;
|
||
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('on'));
|
||
document.querySelectorAll('.pg').forEach(x=>x.classList.remove('on'));
|
||
t.classList.add('on');
|
||
document.getElementById('pg-'+t.dataset.pg).classList.add('on');
|
||
// Actualiza label del dropdown móvil y cierra
|
||
const navCur=document.getElementById('nav-current');
|
||
if(navCur) navCur.textContent=t.textContent.replace(/❓\s*/,'');
|
||
document.getElementById('nav-tabs').classList.remove('open');
|
||
document.querySelector('.nav-tab-toggle')?.classList.remove('open');
|
||
load(t.dataset.pg);
|
||
};
|
||
|
||
// Nav dropdown móvil
|
||
function toggleNavMenu(e){
|
||
if(e) e.stopPropagation();
|
||
document.getElementById('nav-tabs').classList.toggle('open');
|
||
document.querySelector('.nav-tab-toggle').classList.toggle('open');
|
||
}
|
||
// Cerrar nav dropdown al click afuera
|
||
document.addEventListener('click',e=>{
|
||
const tabs=document.getElementById('nav-tabs');
|
||
if(!tabs?.classList.contains('open')) return;
|
||
if(e.target.closest('.tabs')||e.target.closest('.nav-tab-toggle')) return;
|
||
tabs.classList.remove('open');
|
||
document.querySelector('.nav-tab-toggle')?.classList.remove('open');
|
||
});
|
||
|
||
// Manual TOC mobile toggle
|
||
function toggleManualToc(){
|
||
document.querySelector('.manual-toc')?.classList.toggle('open');
|
||
document.querySelector('.manual-toc-toggle')?.classList.toggle('open');
|
||
}
|
||
// Cerrar TOC del manual al click en una sección
|
||
document.addEventListener('click',e=>{
|
||
if(window.innerWidth>900) return;
|
||
const a=e.target.closest('.manual-toc a');
|
||
if(!a) return;
|
||
document.querySelector('.manual-toc')?.classList.remove('open');
|
||
document.querySelector('.manual-toc-toggle')?.classList.remove('open');
|
||
});
|
||
const load=(pg)=>({dashboard:loadDash,ordenes:loadOrdenes,compras:()=>setVentasView(ventasView),clientes:loadClientesCrm,propuestas:loadPropuestas,inventario:loadInv,catalogo:loadProductosTab,tareas:loadTareas,bitacora:loadBita})[pg]?.();
|
||
|
||
// Sub-view inside Productos tab
|
||
let prodView='catalogo';
|
||
let prodData={modelos:[],materiales:[],trabajos:[],clientes:[],productos:[]};
|
||
|
||
// Toggle de thumbnails en tabla de productos (OFF por default para carga rápida)
|
||
let prodThumbs=false;
|
||
function toggleProdThumbs(){prodThumbs=!prodThumbs;loadProductosTab();}
|
||
|
||
// Marca/desmarca un producto para publicarse en el sitio web público
|
||
async function toggleProductoWeb(id,val){
|
||
await api('PUT',`/api/productos/${id}`,{mostrar_en_web:val});
|
||
toast(val?'✓ Publicado en web — corre el sync para actualizar el sitio':'Oculto del sitio web');
|
||
loadProductosTab();
|
||
}
|
||
|
||
// Categorías canónicas del catálogo
|
||
const PRODUCTO_CATEGORIAS=['Bolsas','Accesorios','Sombreros','Cerámica','Textil','Mandiles','Llaveros','Otro'];
|
||
|
||
function getProd(id){return (S.productos||[]).find(p=>p.id===id);}
|
||
|
||
// Ejemplos de personalización de un producto base = pedidos con foto cuyo producto coincide
|
||
function getEjemplosDeProducto(nombre){
|
||
const n=(nombre||'').toLowerCase().trim();
|
||
if(!n) return [];
|
||
return (S.ordenes||[]).filter(o=>{
|
||
if((o.producto||'').toLowerCase().trim()!==n) return false;
|
||
const fi=fileIndex[o.orden_id];
|
||
return !!(fi && fi.first_image);
|
||
}).map(o=>({
|
||
id:o.id, orden_id:o.orden_id,
|
||
foto:fileIndex[o.orden_id].first_image,
|
||
trabajo:o.tipo_trabajo||'', cliente:o.cliente||'',
|
||
web:o.web_ejemplo==1, etiqueta:o.web_etiqueta||'',
|
||
}));
|
||
}
|
||
|
||
function renderEjemplosGaleria(p){
|
||
const ej=getEjemplosDeProducto(p.nombre);
|
||
if(!ej.length){
|
||
return `<div class="pv-ejemplos">
|
||
<span class="pv-ejemplos-label">Galería de ejemplos</span>
|
||
<div class="pv-ejemplos-empty">Aún no hay pedidos con foto de este producto. Sube fotos de avance a los pedidos y aparecerán aquí.</div>
|
||
</div>`;
|
||
}
|
||
const enWeb=ej.filter(e=>e.web).length;
|
||
return `<div class="pv-ejemplos">
|
||
<span class="pv-ejemplos-label">Galería de ejemplos · ${ej.length}${enWeb?` · ${enWeb} en web`:''}</span>
|
||
<div class="pv-ejemplos-sub">🌐 marca cuáles van a la web · escribe una etiqueta pública (zona o "muestra"), <b>no el cliente real</b></div>
|
||
<div class="pv-ejemplos-grid">
|
||
${ej.map(e=>`<div class="pv-ejemplo ${e.web?'is-web':''}">
|
||
<div class="pv-ejemplo-webdot ${e.web?'on':''}" onclick="toggleEjemploWeb(${e.id},${e.web?0:1})" title="${e.web?'Visible en web — click para ocultar':'Oculto — click para mostrar en web'}">${e.web?'🌐':'○'}</div>
|
||
<img src="${e.foto}" loading="lazy" onclick="openFile('${e.foto}',true)">
|
||
<div class="pv-ejemplo-info">
|
||
${e.trabajo?`<div class="pv-ejemplo-trab">${esc(e.trabajo)}</div>`:''}
|
||
<div class="pv-ejemplo-cli-real">cliente: ${esc(e.cliente||'—')}</div>
|
||
${e.web?`<input class="pv-ejemplo-etiqueta" value="${esc(e.etiqueta)}" placeholder="Etiqueta web (ej. Los Barriles)" onblur="setEjemploEtiqueta(${e.id},this.value)" onkeydown="if(event.key==='Enter')this.blur()">`:''}
|
||
</div>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
async function toggleEjemploWeb(id,val){
|
||
await api('PUT',`/api/ordenes/${id}`,{web_ejemplo:val});
|
||
// refrescar S.ordenes y re-render del quickview
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
const prod=getProd(prodViewId);
|
||
if(prod) openProductoView(prodViewId);
|
||
toast(val?'✓ Ejemplo añadido a web':'Ejemplo oculto de web');
|
||
}
|
||
async function setEjemploEtiqueta(id,txt){
|
||
await api('PUT',`/api/ordenes/${id}`,{web_etiqueta:(txt||'').trim()});
|
||
const o=(S.ordenes||[]).find(x=>x.id===id); if(o)o.web_etiqueta=(txt||'').trim();
|
||
toast('Etiqueta guardada');
|
||
}
|
||
|
||
// ── Quick View visual del producto ──
|
||
let prodViewId=null;
|
||
function openProductoView(id){
|
||
prodViewId=id;
|
||
const p=getProd(id); if(!p) return;
|
||
const key=productEntityKey(p);
|
||
const photo=(productFiles[key]||[]).find(f=>f.is_image);
|
||
const pers=(p.tipos_trabajo_disponibles||'').split(/[,;|]/).map(x=>x.trim()).filter(Boolean);
|
||
const enWeb=p.mostrar_en_web==1;
|
||
const specs=[];
|
||
if(p.material) specs.push({lbl:'Materiales',val:esc(p.material)});
|
||
if(p.medidas) specs.push({lbl:'Medidas',val:esc(p.medidas)});
|
||
if(p.talla) specs.push({lbl:'Talla',val:esc(p.talla)});
|
||
if(p.costo_base) specs.push({lbl:'Costo base',val:fmt$(p.costo_base)+'/pza'});
|
||
|
||
$('pv-prod-body').innerHTML=`
|
||
<div class="pv-prod-hero">
|
||
<button class="pv-prod-x" onclick="closeMo('mo-producto-view')">×</button>
|
||
${enWeb?'<span class="pv-prod-webbadge">🌐 En web</span>':''}
|
||
${photo?`<img src="${photo.url}" onclick="openFile('${photo.url}',true)" alt="${esc(p.nombre)}">`:'<div class="pv-noimg">📦</div>'}
|
||
</div>
|
||
<div class="pv-prod-info">
|
||
${p.categoria?`<div class="pv-prod-cat">${esc(p.categoria)}</div>`:''}
|
||
<div class="pv-prod-name">${esc(p.nombre)}</div>
|
||
${p.descripcion_web?`<div class="pv-prod-desc">${esc(p.descripcion_web)}</div>`:''}
|
||
${specs.length?`<div class="pv-prod-specs">${specs.map(s=>`<div class="pv-prod-spec"><span class="lbl">${s.lbl}</span><span class="val">${s.val}</span></div>`).join('')}</div>`:''}
|
||
<div class="pv-prod-pers-block">
|
||
<span class="pv-prod-pers-label">Personalizaciones disponibles</span>
|
||
${pers.length?`<div class="pv-prod-pers-tags">${pers.map(t=>`<span class="pv-prod-pers-tag">${esc(t)}</span>`).join('')}</div>`:'<span class="pv-prod-pers-empty">Sin definir — agrégalas en Editar</span>'}
|
||
</div>
|
||
<div class="pv-prod-actions">
|
||
<button class="btn-edit-main" onclick="closeMo('mo-producto-view');openProductoEdit(${p.id})">✎ Editar producto</button>
|
||
<button class="btn-sec" onclick="openProductFiles('${esc(key)}','${esc(p.nombre)}')" title="Fotos / archivos">📷</button>
|
||
<button class="btn-sec danger" onclick="closeMo('mo-producto-view');delItem('productos',${p.id})" title="Eliminar">🗑</button>
|
||
</div>
|
||
${renderEjemplosGaleria(p)}
|
||
</div>`;
|
||
openMo('mo-producto-view');
|
||
}
|
||
|
||
// ── Editor enfocado del producto ──
|
||
let prodEdit=null;
|
||
function openProductoEdit(id){
|
||
const p=getProd(id); if(!p) return;
|
||
prodEdit={...p};
|
||
const selected=new Set((p.tipos_trabajo_disponibles||'').split(/[,;|]/).map(x=>x.trim()).filter(Boolean));
|
||
const trabajos=trabajoOpts().filter(Boolean); // lista canónica sin el vacío
|
||
// Categorías: canónicas + las que ya se usan en el catálogo (sin duplicar)
|
||
const usadas=[...new Set((S.productos||[]).map(x=>x.categoria).filter(Boolean))];
|
||
const catSugeridas=[...new Set([...PRODUCTO_CATEGORIAS,...usadas])].sort();
|
||
|
||
$('pe-prod-header').innerHTML=`<div class="qv-head">
|
||
<div class="qv-head-main"><div class="qv-head-row">
|
||
<span class="qv-badge" style="background:var(--olive)">PRODUCTO</span>
|
||
<h2>${esc(p.nombre)}</h2>
|
||
</div></div>
|
||
<button class="mo-x" onclick="closeMo('mo-producto-edit')">×</button>
|
||
</div>`;
|
||
|
||
const tallaSug=['CH','MD','GR','XGR','Única'];
|
||
const tallasUsadas=[...new Set((S.productos||[]).map(x=>x.talla).filter(Boolean))];
|
||
const tallaOpts=[...new Set([...tallaSug,...tallasUsadas])];
|
||
|
||
$('pe-prod-body').innerHTML=`
|
||
<!-- IDENTIDAD -->
|
||
<div class="od-section">
|
||
<div class="pe-sec-label">📋 Identidad <span>catálogo · web · cotizador</span></div>
|
||
<div class="od-config-grid">
|
||
<div class="fg" style="grid-column:1/-1"><label>Nombre del producto</label>
|
||
<input id="pe-nombre" value="${esc(p.nombre||'')}" oninput="prodEdit.nombre=this.value">
|
||
</div>
|
||
<div class="fg"><label>Categoría</label>
|
||
<input id="pe-categoria" list="pe-cat-list" value="${esc(p.categoria||'')}" placeholder="Escribe o elige una" oninput="prodEdit.categoria=this.value" autocomplete="off">
|
||
<datalist id="pe-cat-list">${catSugeridas.map(c=>`<option value="${esc(c)}"></option>`).join('')}</datalist>
|
||
</div>
|
||
<div class="fg"><label>Talla / Tamaño</label>
|
||
<input id="pe-talla" list="pe-talla-list" value="${esc(p.talla||'')}" placeholder="CH / MD / GR / Única" oninput="prodEdit.talla=this.value" autocomplete="off">
|
||
<datalist id="pe-talla-list">${tallaOpts.map(t=>`<option value="${esc(t)}"></option>`).join('')}</datalist>
|
||
</div>
|
||
<div class="fg"><label>Medidas</label>
|
||
<input id="pe-medidas" value="${esc(p.medidas||'')}" placeholder="ej: 40 x 35 x 12 cm" oninput="prodEdit.medidas=this.value">
|
||
</div>
|
||
<div class="fg"><label>Materiales</label>
|
||
<input id="pe-material" value="${esc(p.material||'')}" placeholder="ej: Manta + base de yute" oninput="prodEdit.material=this.value">
|
||
</div>
|
||
<div class="fg" style="grid-column:1/-1"><label>Descripción</label>
|
||
<textarea id="pe-desc" rows="3" placeholder="Descripción del producto (se usa en cotizador y sitio web)" oninput="prodEdit.descripcion_web=this.value">${esc(p.descripcion_web||'')}</textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PERSONALIZACIÓN -->
|
||
<div class="od-section">
|
||
<div class="pe-sec-label">🎨 Personalización <span>cotizador · web</span></div>
|
||
<div style="font-size:11px;color:var(--t2);margin-bottom:8px">¿Qué trabajos se pueden hacer en este producto? (al cotizar solo aparecerán estos)</div>
|
||
<div class="pe-pers-grid" id="pe-pers-grid">
|
||
${trabajos.map(t=>{const on=selected.has(t);return`<div class="pe-pers-opt ${on?'on':''}" data-trabajo="${esc(t)}" onclick="this.classList.toggle('on')">
|
||
<span class="chk">${on?'✓':''}</span>${esc(t)}
|
||
</div>`;}).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- OPERACIÓN -->
|
||
<div class="od-section">
|
||
<div class="pe-sec-label">⚙️ Operación <span>producción · inventario</span></div>
|
||
<div class="od-config-grid">
|
||
<div class="fg"><label>SKU <span style="font-weight:400;color:var(--t3);font-size:9px">(no editable)</span></label>
|
||
<input value="${esc(p.sku||'')}" disabled style="opacity:.6;background:var(--s2)">
|
||
</div>
|
||
<div class="fg"><label>Costo base /pza</label>
|
||
<input id="pe-costo" type="number" step="0.01" min="0" value="${p.costo_base||''}" placeholder="0" oninput="prodEdit.costo_base=this.value">
|
||
</div>
|
||
<div class="fg"><label>Proveedor</label>
|
||
<input id="pe-proveedor" value="${esc(p.proveedor||'')}" placeholder="ej: Sandra" oninput="prodEdit.proveedor=this.value">
|
||
</div>
|
||
<div class="fg"><label>Stock actual</label>
|
||
<input id="pe-stock" type="number" min="0" value="${p.stock_actual||''}" placeholder="0" oninput="prodEdit.stock_actual=this.value">
|
||
</div>
|
||
<div class="fg"><label>Punto de reorden</label>
|
||
<input id="pe-reorden" type="number" min="0" value="${p.punto_reorden||''}" placeholder="0" oninput="prodEdit.punto_reorden=this.value">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="od-actions">
|
||
<button class="btn btn-ac" onclick="saveProductoEdit()">✓ Guardar</button>
|
||
<button class="btn" onclick="openProductFiles('${esc(productEntityKey(p))}','${esc(p.nombre)}')" style="border-color:var(--olive);color:var(--olive)">📷 Foto</button>
|
||
<label class="cat-toggle" style="margin-left:auto;font-size:12px"><input type="checkbox" id="pe-web" ${p.mostrar_en_web==1?'checked':''}> 🌐 Mostrar en web</label>
|
||
</div>`;
|
||
|
||
// Sincronizar el check visual al hacer toggle
|
||
document.querySelectorAll('#pe-pers-grid .pe-pers-opt').forEach(el=>{
|
||
el.addEventListener('click',()=>{el.querySelector('.chk').textContent=el.classList.contains('on')?'✓':'';});
|
||
});
|
||
openMo('mo-producto-edit');
|
||
}
|
||
|
||
async function saveProductoEdit(){
|
||
if(!prodEdit) return;
|
||
const pers=[...document.querySelectorAll('#pe-pers-grid .pe-pers-opt.on')].map(el=>el.dataset.trabajo);
|
||
const body={
|
||
nombre:($('pe-nombre').value||'').trim(),
|
||
categoria:$('pe-categoria').value,
|
||
talla:($('pe-talla').value||'').trim(),
|
||
medidas:($('pe-medidas').value||'').trim(),
|
||
material:($('pe-material').value||'').trim(),
|
||
descripcion_web:($('pe-desc').value||'').trim(),
|
||
tipos_trabajo_disponibles:pers.join(', '),
|
||
costo_base:+($('pe-costo').value)||0,
|
||
proveedor:($('pe-proveedor').value||'').trim(),
|
||
stock_actual:+($('pe-stock').value)||0,
|
||
punto_reorden:+($('pe-reorden').value)||0,
|
||
mostrar_en_web:$('pe-web').checked?1:0,
|
||
};
|
||
if(!body.nombre){toast('Falta el nombre');return;}
|
||
await api('PUT',`/api/productos/${prodEdit.id}`,body);
|
||
toast('✓ Producto actualizado');
|
||
closeMo('mo-producto-edit');
|
||
await loadProductosTab();
|
||
}
|
||
|
||
async function loadProductosTab(){
|
||
// Parallel load. File thumbnails come from fileIndex (already loaded at startup).
|
||
const[modelos,materiales,trabajos,clientes,productos,proyectos]=await Promise.all([
|
||
api('GET','/api/modelos'),api('GET','/api/materiales'),
|
||
api('GET','/api/trabajos'),api('GET','/api/clientes'),
|
||
api('GET','/api/productos'),api('GET','/api/proyectos')
|
||
]);
|
||
S.modelos=modelos;S.materiales=materiales;S.trabajos=trabajos;S.clientes=clientes;S.productos=productos;S.proyectos=proyectos;
|
||
proyectosData=proyectos;
|
||
prodData={modelos,materiales,trabajos,clientes,productos};
|
||
if(!S.ordenes.length) S.ordenes=await api('GET','/api/ordenes');
|
||
// Refresh file index in background (don't block render)
|
||
if(!Object.keys(fileIndex).length) loadFileCounts();
|
||
renderProdView();
|
||
}
|
||
|
||
function setProdView(view){
|
||
prodView=view;
|
||
const sel=$('prod-view-select');
|
||
if(sel) sel.value=view;
|
||
renderProdView();
|
||
}
|
||
|
||
function renderProdView(){
|
||
// Reset head actions and content per view
|
||
const actions=$('prod-head-actions');
|
||
const content=$('prod-content');
|
||
if(prodView==='catalogo'){
|
||
actions.innerHTML='';
|
||
// Re-use existing catalogo renderer (will fill cat-content if exists, else fall back)
|
||
content.innerHTML='<div id="cat-content"></div>';
|
||
renderCatalogoSection();
|
||
} else if(prodView==='proyectos'){
|
||
actions.innerHTML=`<input class="search-box" placeholder="Buscar..." id="search-proyectos" oninput="renderProyectosList()">
|
||
<button class="btn btn-ac" onclick="newProyecto()">+ Nuevo Proyecto</button>`;
|
||
content.innerHTML=`<div style="font-size:11px;color:var(--t2);margin-bottom:10px;padding:8px 12px;background:var(--s2);border-radius:6px">
|
||
Recetas guardadas <b>cliente + modelo + trabajo + logo</b>. Cuando un cliente repite un pedido, lo agregas con un click sin reconfigurar costos ni detalles.
|
||
</div>
|
||
<div id="proyectos-content"></div>`;
|
||
renderProyectosList();
|
||
} else if(prodView==='inventario'){
|
||
actions.innerHTML=`<input class="search-box" placeholder="Buscar..." id="search-inv-prods" oninput="renderInventarioView()">`;
|
||
content.innerHTML='<div id="inv-prod-content"></div>';
|
||
renderInventarioView();
|
||
}
|
||
}
|
||
|
||
// Refresh whatever view the user is currently on (without losing scroll position)
|
||
async function refreshActiveView(){
|
||
const tab=document.querySelector('.tab.on')?.dataset.pg;
|
||
if(!tab)return;
|
||
if(tab==='ordenes'){
|
||
await loadOrdenes();
|
||
if($('ordenes-ocs')?.style.display!=='none') renderOcsView();
|
||
} else if(tab==='compras'){
|
||
await setVentasView(ventasView);
|
||
} else if(tab==='clientes'){
|
||
await loadClientesCrm();
|
||
} else if(tab==='propuestas'){
|
||
await loadPropuestas();
|
||
} else if(tab==='inventario'){
|
||
loadInv();
|
||
} else if(tab==='catalogo'){
|
||
loadCatalogo();
|
||
} else if(tab==='tareas'){
|
||
loadTareas();
|
||
} else if(tab==='bitacora'){
|
||
loadBita();
|
||
} else if(tab==='dashboard'){
|
||
loadDash();
|
||
}
|
||
}
|
||
|
||
async function goToCliente(nombre){
|
||
// Close any open modal
|
||
document.querySelectorAll('.mo-bg').forEach(m=>{if(m.classList.contains('show'))closeMo(m.id)});
|
||
// Switch to Clientes tab
|
||
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('on'));
|
||
document.querySelectorAll('.pg').forEach(x=>x.classList.remove('on'));
|
||
const tab=document.querySelector('.tab[data-pg="clientes"]');
|
||
if(tab){tab.classList.add('on');const nc=document.getElementById('nav-current');if(nc)nc.textContent='Clientes';}
|
||
document.getElementById('pg-clientes').classList.add('on');
|
||
// Load CRM data and select the client by name
|
||
await loadClientesCrm();
|
||
const c=(S.clientes||[]).find(x=>(x.nombre||'').toLowerCase()===(nombre||'').toLowerCase());
|
||
if(c){
|
||
crmSelected=c.id;
|
||
renderClientesCrm();
|
||
}
|
||
}
|
||
|
||
// Clock
|
||
const tick=()=>{const d=new Date();$('clk').textContent=d.toLocaleDateString('es-MX',{weekday:'short',month:'short',day:'numeric'})+' '+d.toLocaleTimeString('es-MX',{hour:'2-digit',minute:'2-digit'})};
|
||
tick();setInterval(tick,3e4);
|
||
|
||
// Modal
|
||
const openMo=id=>{$(id).classList.add('show');document.body.style.overflow='hidden';};
|
||
const closeMo=id=>{$(id).classList.remove('show');document.body.style.overflow='';};
|
||
// Backdrop click closes modal EXCEPT wizard (prevent accidental close)
|
||
// Track mousedown origin to prevent close when dragging/selecting text
|
||
let moDownTarget=null;
|
||
document.querySelectorAll('.mo-bg').forEach(m=>{
|
||
m.addEventListener('mousedown',e=>{moDownTarget=e.target;});
|
||
m.addEventListener('mouseup',e=>{
|
||
// Only close if BOTH mousedown and mouseup were on the backdrop itself
|
||
if(moDownTarget===m && e.target===m){
|
||
if(m.id==='mo-wizard') return;
|
||
m.classList.remove('show');
|
||
document.body.style.overflow='';
|
||
}
|
||
moDownTarget=null;
|
||
});
|
||
m.onclick=e=>e.stopPropagation(); // prevent legacy click handler
|
||
});
|
||
|
||
// ══════ DASHBOARD ══════
|
||
async function loadDash(){
|
||
const[d,bl]=await Promise.all([api('GET','/api/dashboard'),api('GET','/api/bitacora')]);
|
||
S.dash=d;
|
||
|
||
$('kpis').innerHTML=`
|
||
<div class="kpi" onclick="goTab('ordenes')"><b style="color:var(--ac)">${d.ordenes_activas}</b><small>Ordenes Activas</small><em>de ${d.total_ordenes} total</em></div>
|
||
<div class="kpi"><b style="color:var(--bl)">${d.piezas_activas.toLocaleString()}</b><small>Piezas en Proceso</small></div>
|
||
<div class="kpi"><b style="color:var(--gn)">${d.piezas_entregadas.toLocaleString()}</b><small>Piezas Entregadas</small></div>
|
||
<div class="kpi" onclick="goTab('ordenes')"><b style="color:var(--cy)">${(d.en_almacen||0)+(d.en_vehiculo||0)}</b><small>Listas / En Carro</small><em>${d.en_almacen||0} almacen + ${d.en_vehiculo||0} vehiculo</em></div>
|
||
<div class="kpi" onclick="goTab('tareas')"><b style="color:var(--or)">${d.tareas_pendientes}</b><small>Tareas Pendientes</small></div>
|
||
<div class="kpi"><b style="color:${d.alertas_stock.length?'var(--rd)':'var(--gn)'}">${d.alertas_stock.length}</b><small>Alertas Stock</small></div>
|
||
`;
|
||
|
||
const stageOrder=['Nuevo','En 2 Mares','En Taller Sofia','En Almacen','En Vehiculo','Entregado'];
|
||
const maxS=Math.max(...stageOrder.map(s=>(d.stages[s]||{}).piezas||0),1);
|
||
$('ch-stages').innerHTML=stageOrder.map(s=>{
|
||
const v=d.stages[s]||{ordenes:0,piezas:0};const[c]=cc(s);
|
||
return`<div class="bar"><div class="bar-l">${s}</div><div class="bar-t"><div class="bar-f" style="width:${(v.piezas/maxS*100).toFixed(0)}%;background:var(--${c})">${v.piezas}</div></div><div class="bar-v">${v.ordenes} ord</div></div>`;
|
||
}).join('');
|
||
|
||
const clis=Object.entries(d.clientes_activos).slice(0,8);
|
||
const maxC=Math.max(...clis.map(([,v])=>v.piezas),1);
|
||
$('ch-clientes').innerHTML=clis.length?clis.map(([n,v])=>
|
||
`<div class="bar"><div class="bar-l" title="${n}">${n}</div><div class="bar-t"><div class="bar-f" style="width:${(v.piezas/maxC*100).toFixed(0)}%;background:var(--ac)">${v.piezas}</div></div><div class="bar-v">${v.ordenes} ord</div></div>`
|
||
).join(''):'<div class="empty">Sin ordenes activas</div>';
|
||
|
||
$('ch-alertas').innerHTML=d.alertas_stock.length?d.alertas_stock.map(a=>
|
||
`<div class="alert-row"><span class="alert-sku">${a.sku}</span> ${a.nombre} — stock: <b>${a.stock_disponible}</b> (reorden: ${a.punto_reorden})</div>`
|
||
).join(''):'<div style="padding:16px;text-align:center;color:var(--gn);font-size:11px">Sin alertas</div>';
|
||
|
||
$('ch-timeline').innerHTML=bl.slice(0,6).map(t=>{
|
||
const[c]=cc(t.tipo);
|
||
return`<div style="display:flex;gap:6px;padding:4px 0"><div style="width:6px;height:6px;border-radius:50%;background:var(--${c});margin-top:4px;flex-shrink:0"></div><div><div style="font-size:10px;font-weight:600">${esc(t.titulo)}</div><div style="font-size:8px;color:var(--t3)">${t.fecha}</div></div></div>`;
|
||
}).join('')||'<div class="empty">Sin actividad</div>';
|
||
}
|
||
|
||
function goTab(pg){
|
||
document.querySelectorAll('.tab').forEach(x=>{x.classList.toggle('on',x.dataset.pg===pg)});
|
||
document.querySelectorAll('.pg').forEach(x=>{x.classList.toggle('on',x.id==='pg-'+pg)});
|
||
load(pg);
|
||
}
|
||
|
||
// ══════ VENTAS ══════
|
||
async function loadVentas(){
|
||
const v=await api('GET','/api/ventas');
|
||
|
||
// ── Comparativo: mes actual vs mes pasado vs año pasado ──
|
||
const c=v.comparativo;
|
||
const fmtMes=(m)=>{
|
||
if(!m) return '-';
|
||
const[y,mm]=m.split('-');
|
||
const names=['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
||
return `${names[+mm-1]} ${y}`;
|
||
};
|
||
const trend=(a,b)=>{
|
||
if(!b) return {pct:0,cls:'flat',arrow:'—',label:'sin base'};
|
||
const pct=Math.round(((a-b)/b)*100);
|
||
if(pct>5) return {pct,cls:'up',arrow:'▲',label:`+${pct}%`};
|
||
if(pct<-5) return {pct,cls:'down',arrow:'▼',label:`${pct}%`};
|
||
return {pct,cls:'flat',arrow:'≈',label:`${pct}%`};
|
||
};
|
||
const tMP=trend(c.actual.facturado,c.mes_pasado.facturado);
|
||
const tAP=trend(c.actual.facturado,c.anio_pasado.facturado);
|
||
$('v-comparativo').innerHTML=`<div class="cmp-row">
|
||
<div class="cmp-card principal">
|
||
<div class="cmp-card-label">Mes actual</div>
|
||
<div class="cmp-card-mes">${fmtMes(c.actual.mes)}</div>
|
||
<div class="cmp-card-val">${fmt$(c.actual.facturado)}</div>
|
||
<div class="cmp-card-sub">${c.actual.ordenes} pedidos · ${c.actual.piezas} pzas · ${c.actual.margen}% margen</div>
|
||
</div>
|
||
<div class="cmp-card">
|
||
<div class="cmp-trend ${tMP.cls}">${tMP.arrow} ${tMP.label}</div>
|
||
<div class="cmp-card-label">Mes pasado</div>
|
||
<div class="cmp-card-mes">${fmtMes(c.mes_pasado.mes)}</div>
|
||
<div class="cmp-card-val">${fmt$(c.mes_pasado.facturado)}</div>
|
||
<div class="cmp-card-sub">${c.mes_pasado.ordenes} pedidos · ${c.mes_pasado.piezas} pzas</div>
|
||
</div>
|
||
<div class="cmp-card">
|
||
<div class="cmp-trend ${tAP.cls}">${tAP.arrow} ${tAP.label}</div>
|
||
<div class="cmp-card-label">Mismo mes año pasado</div>
|
||
<div class="cmp-card-mes">${fmtMes(c.anio_pasado.mes)}</div>
|
||
<div class="cmp-card-val">${fmt$(c.anio_pasado.facturado)}</div>
|
||
<div class="cmp-card-sub">${c.anio_pasado.ordenes} pedidos · ${c.anio_pasado.piezas} pzas</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// ── Tiempos de ciclo ──
|
||
const cy=v.cycle.summary;
|
||
let cycleByTrabajoHtml='';
|
||
const byTrab=Object.entries(v.cycle.by_trabajo||{}).sort((a,b)=>b[1].avg-a[1].avg);
|
||
if(byTrab.length){
|
||
cycleByTrabajoHtml=`<div style="margin-top:8px;font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;font-weight:600;margin-bottom:4px">Por tipo de trabajo (promedio)</div>`
|
||
+byTrab.map(([t,d])=>{
|
||
const[col]=cc(t);
|
||
return`<div class="bar"><div class="bar-l">${esc(t)}</div><div class="bar-t"><div class="bar-f" style="width:${Math.min(100,d.avg/Math.max(...byTrab.map(([,x])=>x.avg))*100).toFixed(0)}%;background:var(--${col})">${d.avg}d</div></div><div class="bar-v">${d.n} ped</div></div>`;
|
||
}).join('');
|
||
}
|
||
let topLentosHtml='';
|
||
if(v.cycle.top_lentos?.length){
|
||
topLentosHtml=`<div style="margin-top:8px;font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;font-weight:600;margin-bottom:4px">Más lentos · oportunidades de mejora</div>`
|
||
+`<div style="font-size:10px">`+v.cycle.top_lentos.map(c=>`<div style="padding:3px 0;border-bottom:1px solid var(--sand-light)"><b>${esc(c.orden_id)}</b> · ${esc(c.cliente)} · ${esc(c.producto)} <span style="float:right;color:var(--rd);font-weight:700">${c.dias} días</span></div>`).join('')+`</div>`;
|
||
}
|
||
$('v-cycle').innerHTML=cy.n?`<div class="cycle-summary">
|
||
<div class="cycle-stat"><b>${cy.avg}</b><small>promedio (días)</small></div>
|
||
<div class="cycle-stat"><b>${cy.median}</b><small>mediana</small></div>
|
||
<div class="cycle-stat"><b>${cy.p90}</b><small>p90</small></div>
|
||
<div class="cycle-stat"><b>${cy.n}</b><small>pedidos</small></div>
|
||
</div>${cycleByTrabajoHtml}${topLentosHtml}`:'<div class="empty">Sin pedidos entregados con fechas completas</div>';
|
||
|
||
// ── Tipo de trabajo (entregados) ──
|
||
const trabs=Object.entries(v.trabajo_stats);
|
||
const maxT=Math.max(...trabs.map(([,d])=>d.piezas||0),1);
|
||
$('v-trabajos').innerHTML=trabs.length?trabs.map(([n,d])=>{
|
||
const[col]=cc(n);
|
||
return`<div class="bar"><div class="bar-l">${esc(n)}</div><div class="bar-t"><div class="bar-f" style="width:${((d.piezas||0)/maxT*100).toFixed(0)}%;background:var(--${col})">${d.piezas} pzas</div></div><div class="bar-v">${d.ordenes} ord</div></div>`;
|
||
}).join(''):'<div class="empty">Sin datos</div>';
|
||
|
||
// ── Top clientes con comparativo ──
|
||
const fmtTrend=(a,b)=>{
|
||
if(!b) return a?'<span class="tc-trend" style="background:var(--gnd);color:var(--gn)">nuevo</span>':'';
|
||
const pct=Math.round(((a-b)/b)*100);
|
||
if(pct>10) return `<span class="tc-trend" style="background:var(--gnd);color:var(--gn)">▲${pct}%</span>`;
|
||
if(pct<-10) return `<span class="tc-trend" style="background:var(--rdd);color:var(--rd)">▼${pct}%</span>`;
|
||
return `<span class="tc-trend" style="background:var(--s2);color:var(--t3)">${pct>0?'+':''}${pct}%</span>`;
|
||
};
|
||
$('v-top-clientes').innerHTML=v.top_clientes?.length?`<div style="overflow-x:auto"><table class="tc-tbl">
|
||
<thead><tr>
|
||
<th>Cliente</th>
|
||
<th>${fmtMes(c.actual.mes)}</th>
|
||
<th>vs mes pasado</th>
|
||
<th>${fmtMes(c.mes_pasado.mes)}</th>
|
||
<th>vs año pasado</th>
|
||
<th>${fmtMes(c.anio_pasado.mes)}</th>
|
||
<th>Histórico total</th>
|
||
</tr></thead>
|
||
<tbody>${v.top_clientes.map(t=>`<tr>
|
||
<td><b class="cli-link" onclick="goToCliente('${esc(t.cliente).replace(/'/g,"\\\\'")}')">${esc(t.cliente)}</b></td>
|
||
<td><b>${fmt$(t.actual.fact)}</b><br><small style="color:var(--t3);font-size:9px">${t.actual.ord} ord</small></td>
|
||
<td>${fmtTrend(t.actual.fact,t.mes_pasado.fact)}</td>
|
||
<td>${fmt$(t.mes_pasado.fact)}<br><small style="color:var(--t3);font-size:9px">${t.mes_pasado.ord} ord</small></td>
|
||
<td>${fmtTrend(t.actual.fact,t.anio_pasado.fact)}</td>
|
||
<td>${fmt$(t.anio_pasado.fact)}<br><small style="color:var(--t3);font-size:9px">${t.anio_pasado.ord} ord</small></td>
|
||
<td style="color:var(--olive);font-weight:700">${fmt$(t.total_facturado)}<br><small style="color:var(--t3);font-size:9px">${t.n_ordenes} ord</small></td>
|
||
</tr>`).join('')}</tbody></table></div>`:'<div class="empty">Sin clientes con histórico</div>';
|
||
|
||
// ── Productos: precio + volumen ──
|
||
$('v-productos-pricing').innerHTML=v.productos_pricing?.length?`<div style="overflow-x:auto"><table class="tc-tbl">
|
||
<thead><tr>
|
||
<th>Producto</th>
|
||
<th>Pedidos</th>
|
||
<th>Pzas vendidas</th>
|
||
<th>Precio prom/pza</th>
|
||
<th>Min</th>
|
||
<th>Max</th>
|
||
<th>Costo base prom</th>
|
||
<th>Spread</th>
|
||
</tr></thead>
|
||
<tbody>${v.productos_pricing.map(p=>{
|
||
const spread=p.precio_unit_max-p.precio_unit_min;
|
||
const spreadPct=p.precio_unit_avg>0?Math.round(spread/p.precio_unit_avg*100):0;
|
||
return`<tr>
|
||
<td><b>${esc(p.producto)}</b></td>
|
||
<td>${p.n_pedidos}</td>
|
||
<td><b>${p.total_pzas}</b></td>
|
||
<td><b style="color:var(--olive)">${fmt$(p.precio_unit_avg)}</b></td>
|
||
<td style="color:var(--t3)">${fmt$(p.precio_unit_min)}</td>
|
||
<td style="color:var(--t3)">${fmt$(p.precio_unit_max)}</td>
|
||
<td style="color:var(--t2)">${p.costo_base_avg?fmt$(p.costo_base_avg):'-'}</td>
|
||
<td>${spreadPct>30?`<span class="tc-trend" style="background:#fff3cd;color:#d97706">${spreadPct}%</span>`:`<span style="color:var(--t3);font-size:9px">${spreadPct}%</span>`}</td>
|
||
</tr>`;
|
||
}).join('')}</tbody></table></div>`:'<div class="empty">Sin productos entregados</div>';
|
||
}
|
||
|
||
// ══════ ENTREGAS ══════
|
||
let entregasData=[];
|
||
let entregasMesActual=new Date().toISOString().slice(0,7);
|
||
let entregasView='fecha'; // 'fecha' or 'cliente'
|
||
|
||
function setEntregaView(view,btn){
|
||
entregasView=view;
|
||
btn.parentElement.querySelectorAll('.vt-btn').forEach(b=>b.classList.remove('on'));
|
||
btn.classList.add('on');
|
||
renderEntregasNav();
|
||
applyEntregasFilter();
|
||
}
|
||
|
||
let entregasYearShown=new Date().getFullYear();
|
||
let entregasClienteSel='';
|
||
|
||
function renderEntregasNav(){
|
||
const w=$('entregas-wrapper');
|
||
if(w){w.classList.toggle('cli-split',entregasView==='cliente');}
|
||
if(entregasView==='fecha') renderEntregasNavFecha();
|
||
else if(entregasView==='cliente') renderEntregasNavCliente();
|
||
else if(entregasView==='ordenes') renderEntregasNavOrdenes();
|
||
}
|
||
function renderEntregasNavOrdenes(){
|
||
// No nav needed — list-only view
|
||
$('entregas-nav-panel').innerHTML='';
|
||
}
|
||
|
||
// Location classifier — map zona_entrega to broader location
|
||
const ZONA_ESTANDAR=['Cabo San Lucas','San Jose del Cabo','Cabo del Este','Todos Santos / Pescadero','La Paz','Nacional','Internacional'];
|
||
function getCliLocation(cliente){
|
||
const cli=S.clientes.find(c=>c.nombre===cliente);
|
||
if(!cli)return'Nacional';
|
||
const z=cli.zona_entrega||'';
|
||
// Exact match against standard zones first
|
||
if(ZONA_ESTANDAR.includes(z)) return z;
|
||
// Legacy: fuzzy match strings for backwards compat
|
||
const zl=z.toLowerCase();
|
||
if(zl.includes('paz'))return'La Paz';
|
||
if(zl.includes('todos santos')||zl.includes('pescadero'))return'Todos Santos / Pescadero';
|
||
if(zl.includes('barriles')||zl.includes('east cape')||zl.includes('cabo del este')||zl.includes('ventana'))return'Cabo del Este';
|
||
if(zl.includes('san jose')||zl.includes('san josé')||zl.includes('san_jose'))return'San Jose del Cabo';
|
||
if(zl.includes('cabo')||zl.includes('corredor')||zl.includes('san lucas'))return'Cabo San Lucas';
|
||
return zl?'Nacional':'';
|
||
}
|
||
|
||
let entregasLocFilter='';
|
||
let entregasCatFilter='';
|
||
|
||
function renderEntregasNavFecha(){
|
||
const meses=['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
||
// Months with data — use OC's fecha_entrega when available; sum facturado
|
||
const monthData={}; // ym → {count, facturado}
|
||
const seenOcMonth=new Set(); // dedupe OCs per month for facturado
|
||
entregasData.forEach(o=>{
|
||
const ym=(o.oc_fecha_entrega||o.fecha_entrega||'').slice(0,7);
|
||
if(!ym) return;
|
||
if(!monthData[ym]) monthData[ym]={count:0,facturado:0};
|
||
monthData[ym].count++;
|
||
// Sum factura: per-OC once, otherwise per-pedido
|
||
if(o.oc_id && o.oc_folio){
|
||
const key=ym+':'+o.oc_id;
|
||
if(!seenOcMonth.has(key)){
|
||
seenOcMonth.add(key);
|
||
const sub=o.oc_factura||0;
|
||
const pct=o.oc_iva_pct!=null?o.oc_iva_pct:16;
|
||
monthData[ym].facturado+=sub*(1+pct/100);
|
||
}
|
||
} else {
|
||
monthData[ym].facturado+=(o.precio_factura||0);
|
||
}
|
||
});
|
||
// Sort months descending (past only)
|
||
const today=new Date().toISOString().slice(0,7);
|
||
const monthsAvailable=Object.keys(monthData).filter(ym=>ym<=today).sort((a,b)=>b.localeCompare(a));
|
||
if(monthsAvailable.length&&!monthData[entregasMesActual]) entregasMesActual=monthsAvailable[0];
|
||
|
||
// Find current index
|
||
const curIdx=monthsAvailable.indexOf(entregasMesActual);
|
||
const prevYm=curIdx>=0&&curIdx<monthsAvailable.length-1?monthsAvailable[curIdx+1]:null;
|
||
const nextYm=curIdx>0?monthsAvailable[curIdx-1]:null;
|
||
const formatYm=ym=>{const[y,m]=ym.split('-');return`${meses[+m-1]} ${y}`};
|
||
const fmtK=v=>v>=1000?`$${(v/1000).toFixed(v>=10000?0:1)}K`:`$${Math.round(v)}`;
|
||
|
||
// Build linear nav + dropdown showing $ facturado
|
||
const dropdown=monthsAvailable.map(ym=>{
|
||
const d=monthData[ym];
|
||
return`<option value="${ym}"${ym===entregasMesActual?' selected':''}>${formatYm(ym)} · ${fmtK(d.facturado)}</option>`;
|
||
}).join('');
|
||
|
||
$('entregas-nav-panel').innerHTML=`<div class="ent-linear-nav">
|
||
<button class="ent-arrow" onclick="${prevYm?`selectEntregaMes('${prevYm}')`:''}" ${!prevYm?'disabled':''} title="Mes anterior">◀</button>
|
||
<select class="ent-month-select" onchange="selectEntregaMes(this.value)">${dropdown||'<option>Sin entregas</option>'}</select>
|
||
<button class="ent-arrow" onclick="${nextYm?`selectEntregaMes('${nextYm}')`:''}" ${!nextYm?'disabled':''} title="Mes posterior">▶</button>
|
||
</div>`;
|
||
}
|
||
|
||
function renderEntregasNavCliente(){
|
||
// Compute stats per client
|
||
const cliStats={};
|
||
entregasData.forEach(o=>{
|
||
const c=o.cliente||'Sin cliente';
|
||
if(!cliStats[c]) cliStats[c]={count:0,fact:0,pzas:0,loc:getCliLocation(c)};
|
||
cliStats[c].count++;
|
||
const cli=S.clientes.find(x=>x.nombre===c);
|
||
if(o.oc_id && o.oc_folio){
|
||
cliStats[c].fact+=((o.oc_factura||0)/Math.max(1,(entregasData.filter(x=>x.oc_id===o.oc_id).length)));
|
||
} else {
|
||
cliStats[c].fact+=(o.precio_factura||0);
|
||
}
|
||
cliStats[c].pzas+=o.cantidad;
|
||
cliStats[c].tipo=cli?cli.tipo:'';
|
||
});
|
||
|
||
// Available locations and categories
|
||
const allLocs=[...new Set(Object.values(cliStats).map(s=>s.loc))].filter(x=>x).sort();
|
||
const allCats=[...new Set(Object.values(cliStats).map(s=>s.tipo).filter(t=>t))].sort();
|
||
|
||
// Apply filters
|
||
let entries=Object.entries(cliStats);
|
||
if(entregasLocFilter) entries=entries.filter(([,s])=>s.loc===entregasLocFilter);
|
||
if(entregasCatFilter) entries=entries.filter(([,s])=>s.tipo===entregasCatFilter);
|
||
// Sort alphabetically
|
||
entries.sort((a,b)=>a[0].localeCompare(b[0]));
|
||
|
||
const locChips=`<button class="ent-filter-chip${!entregasLocFilter?' on':''}" onclick="setEntLoc('')">Todas</button>`+
|
||
allLocs.map(l=>`<button class="ent-filter-chip${entregasLocFilter===l?' on':''}" onclick="setEntLoc('${esc(l).replace(/'/g,"\\\\'")}')">${esc(l)}</button>`).join('');
|
||
const catChips=`<button class="ent-filter-chip${!entregasCatFilter?' on':''}" onclick="setEntCat('')">Todos</button>`+
|
||
allCats.map(c=>`<button class="ent-filter-chip${entregasCatFilter===c?' on':''}" onclick="setEntCat('${esc(c)}')">${esc(c.charAt(0).toUpperCase()+c.slice(1))}</button>`).join('');
|
||
|
||
const clientList=entries.length?entries.map(([name,s])=>{
|
||
const isOn=entregasClienteSel===name;
|
||
const cli=S.clientes.find(c=>c.nombre===name);
|
||
const editBtn=cli?`<span class="ent-cli-edit" onclick="event.stopPropagation();editItem('clientes',${cli.id})" title="Editar cliente">✎</span>`:'';
|
||
return`<div class="ent-cli-item${isOn?' on':''}" onclick="selectEntregaCliente('${esc(name).replace(/'/g,"\\\\'")}')">
|
||
<div style="display:flex;flex-direction:column;align-items:flex-start;min-width:0;flex:1">
|
||
<span style="font-weight:600;font-size:12px;line-height:1.2">${esc(name)}</span>
|
||
<span style="font-size:9px;color:var(--t3);margin-top:1px">${s.loc||'—'}${s.tipo?' · '+esc(s.tipo):''}</span>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;align-items:flex-end;font-size:9px">
|
||
<span style="font-weight:600">${s.count}</span>
|
||
${s.fact?`<span style="color:var(--olive)">${fmt$(s.fact)}</span>`:''}
|
||
</div>
|
||
${editBtn}
|
||
</div>`;
|
||
}).join(''):'<div style="color:var(--t3);font-size:11px;padding:14px;text-align:center">Sin clientes con los filtros</div>';
|
||
|
||
$('entregas-nav-panel').innerHTML=`<div class="ent-nav-cli split">
|
||
<div class="ent-filter-row">
|
||
<span class="ent-filter-label">Ubicación:</span>
|
||
${locChips}
|
||
</div>
|
||
<div class="ent-filter-row">
|
||
<span class="ent-filter-label">Categoría:</span>
|
||
${catChips}
|
||
${entregasClienteSel?`<button class="ent-filter-chip" onclick="selectEntregaCliente('${esc(entregasClienteSel).replace(/'/g,"\\\\'")}')" style="margin-left:auto;color:var(--rd);border-color:var(--rd)">✕ Limpiar</button>`:''}
|
||
</div>
|
||
<div class="ent-cli-listbox">${clientList}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function setEntLoc(loc){entregasLocFilter=loc;renderEntregasNavCliente();}
|
||
function setEntCat(cat){entregasCatFilter=cat;renderEntregasNavCliente();}
|
||
|
||
function selectEntregaMes(ym){
|
||
entregasMesActual=ym;
|
||
renderEntregasNav();
|
||
applyEntregasFilter();
|
||
}
|
||
function entregaYear(dir){
|
||
entregasYearShown+=dir;
|
||
renderEntregasNav();
|
||
}
|
||
function selectEntregaCliente(name){
|
||
entregasClienteSel=entregasClienteSel===name?'':name;
|
||
renderEntregasNav();
|
||
applyEntregasFilter();
|
||
}
|
||
|
||
function applyEntregasFilter(){
|
||
if(entregasView==='ordenes'){
|
||
renderListaOrdenes();
|
||
$('entregas-summary').innerHTML='';
|
||
return;
|
||
}
|
||
|
||
let filtered=entregasData;
|
||
|
||
if(entregasView==='fecha'){
|
||
filtered=filtered.filter(o=>((o.oc_fecha_entrega||o.fecha_entrega||'')).startsWith(entregasMesActual));
|
||
} else {
|
||
if(entregasClienteSel) filtered=filtered.filter(o=>(o.cliente||'Sin cliente')===entregasClienteSel);
|
||
}
|
||
|
||
const q=($('search-entregas').value||'').toLowerCase();
|
||
if(q) filtered=filtered.filter(o=>
|
||
(o.orden_id||'').toLowerCase().includes(q)||
|
||
(o.cliente||'').toLowerCase().includes(q)||
|
||
(o.producto||'').toLowerCase().includes(q)
|
||
);
|
||
|
||
$('entregas-summary').innerHTML='';
|
||
renderEntregas(filtered);
|
||
}
|
||
|
||
function renderListaOrdenes(){
|
||
const q=($('search-entregas').value||'').toLowerCase();
|
||
let ocs=[...(S.ocs||[])];
|
||
if(q) ocs=ocs.filter(o=>
|
||
(o.oc_id||'').toLowerCase().includes(q)||
|
||
(o.cliente||'').toLowerCase().includes(q)||
|
||
(o.factura_num||'').toLowerCase().includes(q)
|
||
);
|
||
|
||
// ── TO-DO categorization (only actionable items, considering progress) ──
|
||
const todos={
|
||
cobrar:{label:'💵 Por cobrar',color:'#3b82f6',bg:'#dbeafe',items:[]},
|
||
facturar:{label:'📄 Por facturar',color:'#eab308',bg:'#fef9c3',items:[]},
|
||
info:{label:'📋 Falta info entrega',color:'#f97316',bg:'#fed7aa',items:[]},
|
||
vacias:{label:'📦 Sin pedidos',color:'#9ca3af',bg:'#f3f4f6',items:[]},
|
||
};
|
||
ocs.forEach(oc=>{
|
||
if(oc.status==='Cancelada') return;
|
||
if(!oc.lineas?.length){todos.vacias.items.push(oc);return;}
|
||
if(oc.progress==='Entregado'){
|
||
if(!oc.factura_num) todos.facturar.items.push(oc);
|
||
else if(!oc.pagado) todos.cobrar.items.push(oc);
|
||
else if(!oc.fecha_entrega||!oc.precio_factura) todos.info.items.push(oc);
|
||
} else if(oc.progress==='Parcial'){
|
||
if(!oc.fecha_entrega||!oc.precio_factura) todos.info.items.push(oc);
|
||
}
|
||
});
|
||
|
||
// ── Main list: sort by most recent activity (delivery date if exists, else fecha_oc, else id) ──
|
||
const activityKey=o=>o.fecha_entrega||o.fecha_oc||'';
|
||
ocs.sort((a,b)=>{
|
||
const ka=activityKey(a), kb=activityKey(b);
|
||
if(ka!==kb) return kb.localeCompare(ka);
|
||
return b.id-a.id;
|
||
});
|
||
|
||
if(!ocs.length){
|
||
$('entregas-list').innerHTML='<div class="empty">No hay Ordenes registradas</div>';
|
||
$('entregas-footer').innerHTML='';
|
||
return;
|
||
}
|
||
|
||
// Render compact row per OC — small dot indicator if it has to-dos
|
||
const todoMap=new Map(); // oc.id → todo category color
|
||
Object.entries(todos).forEach(([k,t])=>t.items.forEach(o=>todoMap.set(o.id,t.color)));
|
||
|
||
const rows=ocs.map(oc=>{
|
||
const sub=oc.precio_factura||0;
|
||
const ivaPct=oc.iva_pct!=null?oc.iva_pct:16;
|
||
const total=sub*(1+ivaPct/100);
|
||
const progressColors={Entregado:'var(--gn)','En proceso':'var(--bl)',Parcial:'var(--yl)'};
|
||
const pColor=progressColors[oc.progress]||'var(--t2)';
|
||
const dot=todoMap.get(oc.id);
|
||
return`<div class="lista-oc-row" onclick="openOrdenDetail(${oc.id})" style="${dot?`border-left-color:${dot}`:''}">
|
||
<div class="lista-oc-main">
|
||
<div class="lista-oc-head">
|
||
${dot?`<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${dot}" title="Requiere acción"></span>`:''}
|
||
<span style="font-weight:700">${esc(oc.oc_id)}</span>
|
||
<span style="font-size:10px;color:var(--t2)">${esc(oc.cliente)}</span>
|
||
<span style="font-size:9px;padding:1px 6px;border-radius:3px;background:${pColor}20;color:${pColor};font-weight:600">${oc.progress}</span>
|
||
${oc.pagado?'<span style="font-size:9px;padding:1px 6px;border-radius:3px;background:#dcfce7;color:#16a34a;font-weight:600">💵 Cobrada</span>':''}
|
||
</div>
|
||
<div class="lista-oc-meta">
|
||
${oc.fecha_entrega?`<span>📦 ${oc.fecha_entrega}</span>`:oc.fecha_oc?`<span>📝 ${oc.fecha_oc}</span>`:''}
|
||
<span>${oc.n_lineas} pedido${oc.n_lineas!==1?'s':''}</span>
|
||
<span>${oc.total_piezas} pzas</span>
|
||
${oc.factura_num?`<span>Fact. ${esc(oc.factura_num)}</span>`:''}
|
||
</div>
|
||
</div>
|
||
<div class="lista-oc-right">
|
||
${sub?`<div class="lista-oc-total">${fmt$(total)}</div>`:''}
|
||
<div class="lista-oc-arrow">›</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// ── TO-DO panel (right side) ──
|
||
const todoCardHtml=(k,t)=>{
|
||
if(!t.items.length) return '';
|
||
const totalAmount=k==='cobrar'?t.items.reduce((s,o)=>s+(o.precio_factura||0)*(1+(o.iva_pct!=null?o.iva_pct:16)/100),0):0;
|
||
const itemsHtml=t.items.slice(0,8).map(o=>{
|
||
const amt=k==='cobrar'?fmt$((o.precio_factura||0)*(1+(o.iva_pct!=null?o.iva_pct:16)/100)):'';
|
||
return`<div class="todo-item" onclick="event.stopPropagation();openOrdenDetail(${o.id})">
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-weight:600;font-size:11px">${esc(o.oc_id)}</div>
|
||
<div style="font-size:9px;color:var(--t2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(o.cliente)}</div>
|
||
</div>
|
||
${amt?`<div style="font-size:11px;font-weight:700;color:${t.color}">${amt}</div>`:''}
|
||
</div>`;
|
||
}).join('');
|
||
const more=t.items.length>8?`<div style="font-size:9px;color:var(--t3);padding:4px 8px;text-align:center">+${t.items.length-8} más</div>`:'';
|
||
return`<div class="todo-card">
|
||
<div class="todo-card-h" style="background:${t.bg};color:${t.color}">
|
||
<span>${t.label}</span>
|
||
<span class="todo-cnt" style="background:${t.color};color:#fff">${t.items.length}</span>
|
||
</div>
|
||
${k==='cobrar'&&totalAmount?`<div style="padding:6px 10px;font-size:10px;background:${t.bg}80;color:${t.color};font-weight:700">Total: ${fmt$(totalAmount)}</div>`:''}
|
||
<div class="todo-card-body">${itemsHtml}${more}</div>
|
||
</div>`;
|
||
};
|
||
const todoAnyItems=Object.values(todos).some(t=>t.items.length);
|
||
const todoHtml=todoAnyItems?Object.entries(todos).map(([k,t])=>todoCardHtml(k,t)).join(''):
|
||
`<div class="todo-empty">✓ Todo al día.<br>Sin acciones pendientes.</div>`;
|
||
|
||
$('entregas-list').innerHTML=`<div class="ordenes-layout">
|
||
<div class="ordenes-list-col">
|
||
<div class="ordenes-list-h">📋 Todas las Órdenes <span style="font-weight:400;font-size:10px;color:var(--t2)">· ${ocs.length}${q?' filtradas':''} · más reciente primero</span></div>
|
||
<div class="lista-oc-wrap">${rows}</div>
|
||
</div>
|
||
<div class="ordenes-todo-col">
|
||
<div class="ordenes-list-h">🎯 Por hacer</div>
|
||
${todoHtml}
|
||
</div>
|
||
</div>`;
|
||
|
||
// Footer totals
|
||
const sumSub=ocs.reduce((s,o)=>s+(o.precio_factura||0),0);
|
||
const sumIva=ocs.reduce((s,o)=>s+(o.precio_factura||0)*((o.iva_pct!=null?o.iva_pct:16)/100),0);
|
||
const total=sumSub+sumIva;
|
||
$('entregas-footer').innerHTML=`<div class="ent-footer-totals">
|
||
<div class="ftl"><span class="ftl-label">Ordenes</span><span class="ftl-val">${ocs.length}</span></div>
|
||
<div class="ftl"><span class="ftl-label">Subtotal</span><span class="ftl-val">${fmt$(sumSub)}</span></div>
|
||
<div class="ftl"><span class="ftl-label">IVA</span><span class="ftl-val">${fmt$(sumIva)}</span></div>
|
||
<div class="ftl"><span class="ftl-label">Total facturado</span><span class="ftl-val" style="color:var(--olive)">${fmt$(total)}</span></div>
|
||
</div>`;
|
||
}
|
||
|
||
async function loadEntregas(){
|
||
entregasData=await api('GET','/api/entregas');
|
||
S.ocs=await api('GET','/api/oc');
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
if(!S.clientes.length) S.clientes=await api('GET','/api/clientes');
|
||
// Auto-navigate to most recent month with data
|
||
if(entregasData.length&&!entregasData.some(o=>(o.fecha_entrega||'').startsWith(entregasMesActual))){
|
||
const withDate=entregasData.filter(o=>o.fecha_entrega);
|
||
if(withDate.length){
|
||
entregasMesActual=withDate[0].fecha_entrega.slice(0,7);
|
||
entregasYearShown=+entregasMesActual.split('-')[0];
|
||
}
|
||
}
|
||
renderVehiculoPanel();
|
||
renderEntregasNav();
|
||
applyEntregasFilter();
|
||
}
|
||
|
||
function renderVehiculoPanel(){
|
||
const el=$('entregas-vehiculo-panel');
|
||
if(!el) return;
|
||
const enVeh=(S.ordenes||[]).filter(o=>o.stage==='En Vehiculo');
|
||
if(!enVeh.length){el.innerHTML='';return;}
|
||
const totalPzas=enVeh.reduce((s,o)=>s+(+o.cantidad||0),0);
|
||
el.innerHTML=`<div class="veh-panel">
|
||
<div class="veh-panel-head">
|
||
<div class="veh-panel-title">🚚 En Vehículo — listos para entregar <span class="veh-panel-count">${enVeh.length} · ${totalPzas} pzas</span></div>
|
||
</div>
|
||
<div class="veh-list">${enVeh.map(o=>`
|
||
<div class="veh-card" onclick="openQuickView(${o.id})">
|
||
<div class="veh-card-info">
|
||
<div class="veh-card-id">${esc(o.orden_id)} <span class="veh-card-qty">· ${o.cantidad} pzas</span></div>
|
||
<div class="veh-card-cli">${esc(o.cliente||'')}</div>
|
||
<div class="veh-card-prod">${esc(o.producto||'')}</div>
|
||
</div>
|
||
<button class="veh-entregar" onclick="event.stopPropagation();openEntregaModal(${o.id})">✓ Entregar</button>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function filterEntregas(){
|
||
applyEntregasFilter();
|
||
}
|
||
|
||
let histSavedLines=[];
|
||
function populateHistDropdowns(){
|
||
const prodNames=new Set((S.productos||[]).map(p=>p.nombre));
|
||
S.ordenes.forEach(o=>{if(o.producto)prodNames.add(o.producto)});
|
||
$('h-producto').innerHTML='<option value="">-- Seleccionar --</option>'+[...prodNames].sort().map(n=>
|
||
`<option value="${esc(n)}">${esc(n)}</option>`).join('');
|
||
}
|
||
|
||
async function openRegistroHistorico(){
|
||
if(!S.clientes.length) S.clientes=await api('GET','/api/clientes');
|
||
if(!S.trabajos.length) S.trabajos=await api('GET','/api/trabajos');
|
||
if(!S.productos) S.productos=await api('GET','/api/productos');
|
||
S.ocs=await api('GET','/api/oc');
|
||
|
||
// Populate dropdowns
|
||
$('h-cliente').innerHTML='<option value="">-- Seleccionar --</option>'+S.clientes.map(c=>
|
||
`<option value="${esc(c.nombre)}" data-costo="${c.costo_entrega}">${esc(c.nombre)}</option>`).join('');
|
||
|
||
populateHistDropdowns();
|
||
|
||
$('h-trabajo').innerHTML='<option value="">--</option>'+S.trabajos.map(t=>
|
||
`<option value="${esc(t.nombre)}" data-costo="${t.costo_base}">${esc(t.nombre)}</option>`).join('');
|
||
|
||
// OC dropdown
|
||
const activeOcs=S.ocs.filter(o=>o.status==='Activa');
|
||
$('h-oc-sel').innerHTML='<option value="">-- Sin orden --</option>'+activeOcs.map(o=>
|
||
`<option value="${o.id}">${esc(o.oc_id)} — ${esc(o.cliente)}</option>`).join('');
|
||
|
||
// Auto-fill logistica on client change
|
||
$('h-cliente').onchange=()=>{
|
||
const sel=$('h-cliente').options[$('h-cliente').selectedIndex];
|
||
if(sel.dataset.costo) $('h-costo-log').value=sel.dataset.costo;
|
||
$('h-cli-edit').style.display=$('h-cliente').value?'inline-flex':'none';
|
||
};
|
||
$('h-trabajo').onchange=()=>{
|
||
const sel=$('h-trabajo').options[$('h-trabajo').selectedIndex];
|
||
if(sel.dataset.costo) $('h-costo-trab').value=sel.dataset.costo;
|
||
};
|
||
|
||
// Reset all
|
||
histSavedLines=[];
|
||
$('h-saved-lines').innerHTML='';
|
||
$('h-cantidad').value=50;
|
||
$('h-fecha-inicio').value='';
|
||
$('h-fecha-entrega').value='';
|
||
$('h-recibio').value='';
|
||
$('h-costo-prod').value=0;
|
||
$('h-costo-trab').value=0;
|
||
$('h-costo-log').value=0;
|
||
$('h-factura').value=0;
|
||
$('h-notas').value='';
|
||
$('h-producto').value='';
|
||
$('h-trabajo').value='';
|
||
openMo('mo-historico');
|
||
}
|
||
|
||
function openNewOCFromHist(){
|
||
// Pre-fill from historico client if selected
|
||
openNewOC();
|
||
const hCli=$('h-cliente').value;
|
||
if(hCli) setTimeout(()=>{$('noc-cliente').value=hCli;$('noc-cliente').onchange?.();},50);
|
||
}
|
||
|
||
async function guardarHistorico(addAnother=false){
|
||
const cliente=$('h-cliente').value;
|
||
const producto=$('h-producto').value;
|
||
if(!cliente){toast('Selecciona un cliente');return;}
|
||
if(!producto){toast('Selecciona un producto');return;}
|
||
|
||
const fechaInicio=$('h-fecha-inicio').value;
|
||
const fechaEntrega=$('h-fecha-entrega').value;
|
||
if(!fechaEntrega){toast('Selecciona la fecha de entrega');return;}
|
||
|
||
// Generate order ID
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
const yy=fechaEntrega.slice(0,4);
|
||
const ordenId=generatePedidoId(yy);
|
||
|
||
const body={
|
||
orden_id:ordenId,
|
||
tipo_orden:'OC',
|
||
cliente,
|
||
producto,
|
||
cantidad:+$('h-cantidad').value,
|
||
tipo_trabajo:$('h-trabajo').value,
|
||
stage:'Entregado',
|
||
fecha_inicio:fechaInicio||fechaEntrega,
|
||
fecha_entrega:fechaEntrega,
|
||
recibio:$('h-recibio').value,
|
||
costo_producto:+$('h-costo-prod').value,
|
||
costo_trabajo:+$('h-costo-trab').value,
|
||
costo_logistica:+$('h-costo-log').value,
|
||
precio_factura:+$('h-factura').value,
|
||
notas:$('h-notas').value,
|
||
oc_id:+$('h-oc-sel').value||0,
|
||
urgente:0
|
||
};
|
||
|
||
const histRes=await api('POST','/api/ordenes',body);
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
if(!body.proyecto_id && histRes?.id){
|
||
await autoLinkPedidoToProyecto(histRes.id);
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
}
|
||
|
||
if(addAnother){
|
||
// Track saved line
|
||
histSavedLines.push({ordenId,producto,cantidad:body.cantidad});
|
||
$('h-saved-lines').innerHTML=`<div style="padding:6px 8px;background:var(--gnd);border:1px solid var(--gn);border-radius:6px;margin-bottom:6px">
|
||
<div style="font-size:10px;color:var(--gn);font-weight:600;margin-bottom:4px">✓ ${histSavedLines.length} línea${histSavedLines.length>1?'s':''} registradas</div>
|
||
${histSavedLines.map(l=>`<div style="font-size:10px;color:var(--t2)">${esc(l.ordenId)} — ${esc(l.producto)} — ${l.cantidad} pzas</div>`).join('')}
|
||
</div>`;
|
||
|
||
// Clear only product-specific fields, keep shared ones
|
||
$('h-producto').value='';
|
||
$('h-cantidad').value=50;
|
||
$('h-costo-prod').value=0;
|
||
$('h-costo-trab').value=0;
|
||
$('h-factura').value=0;
|
||
$('h-notas').value='';
|
||
$('h-trabajo').value='';
|
||
populateHistDropdowns();
|
||
toast(`✓ ${ordenId} registrada — agrega otra`);
|
||
} else {
|
||
closeMo('mo-historico');
|
||
const total=histSavedLines.length+1;
|
||
toast(`✓ ${total} pedido${total>1?'s':''} registrado${total>1?'s':''}`);
|
||
entregasMesActual=fechaEntrega.slice(0,7);
|
||
refreshActiveView();
|
||
}
|
||
}
|
||
|
||
async function renderEntregas(items){
|
||
const visible=items.slice(0,60);
|
||
const fileData={};
|
||
const ocFoliosLoaded=new Set();
|
||
for(const o of visible){
|
||
const files=await api('GET',`/api/files/${encodeURIComponent(o.orden_id)}`);
|
||
fileData[o.orden_id]=files;
|
||
if(o.oc_folio && !ocFoliosLoaded.has(o.oc_folio)){
|
||
ocFoliosLoaded.add(o.oc_folio);
|
||
const ocFiles=await api('GET',`/api/files/${encodeURIComponent(o.oc_folio)}`);
|
||
fileData['__oc__'+o.oc_folio]=ocFiles;
|
||
}
|
||
}
|
||
|
||
if(!visible.length){$('entregas-list').innerHTML='<div class="empty">No hay ordenes entregadas en este periodo</div>';return;}
|
||
|
||
const formatDateHeader=(d)=>{
|
||
if(d==='Sin fecha') return'Sin fecha asignada';
|
||
const dt=new Date(d+'T12:00:00');
|
||
const today=new Date();today.setHours(12,0,0,0);
|
||
const diff=Math.round((today-dt)/(1000*60*60*24));
|
||
const fmt=dt.toLocaleDateString('es-MX',{weekday:'long',day:'numeric',month:'long'});
|
||
if(diff===0) return'Hoy · '+fmt;
|
||
if(diff===1) return'Ayer · '+fmt;
|
||
if(diff<7) return`Hace ${diff} días · ${fmt}`;
|
||
return fmt;
|
||
};
|
||
|
||
// Always group by date (newest first). Prefer OC's fecha_entrega over pedido's individual date.
|
||
const groups={};
|
||
visible.forEach(o=>{
|
||
const k=(o.oc_fecha_entrega||o.fecha_entrega||'Sin fecha');
|
||
if(!groups[k]) groups[k]=[];
|
||
groups[k].push(o);
|
||
});
|
||
const sortedKeys=Object.keys(groups).sort((a,b)=>{
|
||
if(a.startsWith('Sin')) return 1;
|
||
if(b.startsWith('Sin')) return -1;
|
||
return b.localeCompare(a);
|
||
});
|
||
|
||
$('entregas-list').innerHTML=sortedKeys.map(key=>{
|
||
const g=groups[key];
|
||
const totalPzas=g.reduce((s,o)=>s+o.cantidad,0);
|
||
// Sub-group by OC within this day
|
||
const ocGroups={};
|
||
const sueltas=[];
|
||
g.forEach(o=>{
|
||
if(o.oc_id && o.oc_folio){
|
||
if(!ocGroups[o.oc_id]) ocGroups[o.oc_id]={folio:o.oc_folio,factura:o.oc_factura,logistica:o.oc_logistica,factura_num:o.oc_factura_num,condiciones:o.oc_condiciones,iva_pct:o.oc_iva_pct,otros_gastos:o.oc_otros_gastos,otros_gastos_desc:o.oc_otros_gastos_desc,cliente:o.cliente,items:[]};
|
||
ocGroups[o.oc_id].items.push(o);
|
||
} else {
|
||
sueltas.push(o);
|
||
}
|
||
});
|
||
|
||
// Card pieces array — each piece is either an OC stack or a single pedido
|
||
const pieces=[];
|
||
for(const[ocId,oc] of Object.entries(ocGroups)){
|
||
const ocPzas=oc.items.reduce((s,o)=>s+o.cantidad,0);
|
||
const sub=oc.factura||0;
|
||
const ivaPct=oc.iva_pct!=null?oc.iva_pct:16;
|
||
const totalIva=sub+sub*ivaPct/100;
|
||
const ocFiles=fileData['__oc__'+oc.folio]||[];
|
||
const ocHasFiles=ocFiles.length;
|
||
// Cross-reference: total pedidos in the Orden vs entregados globally
|
||
const ocFull=(S.ocs||[]).find(x=>x.id==ocId);
|
||
const totalPedidosOrden=ocFull?ocFull.n_lineas:oc.items.length;
|
||
const totalPiezasOrden=ocFull?ocFull.total_piezas:ocPzas;
|
||
// Entregados globales (en TODAS las fechas, no solo este día)
|
||
const entregadosGlobal=(ocFull?.lineas||[]).filter(l=>l.stage==='Entregado').length;
|
||
const pzasEntregadasGlobal=(ocFull?.lineas||[]).filter(l=>l.stage==='Entregado').reduce((s,l)=>s+l.cantidad,0);
|
||
// Estados:
|
||
// - Pendiente: hay pedidos sin entregar (badge amarillo con conteo global)
|
||
// - Repartida en días: todos entregados pero este día tiene parcial (badge informativo gris)
|
||
// - Completa este día: todos los pedidos de la orden están en este día (sin badge)
|
||
const tieneIncompletos=entregadosGlobal<totalPedidosOrden;
|
||
const repartidaEnDias=!tieneIncompletos && oc.items.length<entregadosGlobal;
|
||
let progressLabel='';
|
||
if(tieneIncompletos){
|
||
const faltan=totalPedidosOrden-entregadosGlobal;
|
||
progressLabel=`<span title="Faltan ${faltan} pedido(s) por entregar" style="font-size:10px;padding:1px 6px;border-radius:3px;background:#fff3cd;color:#856404;font-weight:600">${entregadosGlobal}/${totalPedidosOrden} entregados</span>`;
|
||
} else if(repartidaEnDias){
|
||
progressLabel=`<span title="La Orden completa se entregó en varios días" style="font-size:10px;padding:1px 6px;border-radius:3px;background:var(--s2);color:var(--t2);font-weight:600">parte de ${oc.items.length}/${totalPedidosOrden} este día</span>`;
|
||
}
|
||
const pzasLabel=oc.items.length===totalPedidosOrden?`${ocPzas} pzas`:`${ocPzas} de ${totalPiezasOrden} pzas`;
|
||
pieces.push(`<div class="oc-stack">
|
||
<div class="oc-stack-head" onclick="openOrdenDetail(${ocId})">
|
||
<div class="oc-stack-folio">
|
||
<span style="font-size:9px;padding:1px 6px;border-radius:3px;background:var(--olive);color:#fff">ORDEN</span>
|
||
${esc(oc.folio)}
|
||
${progressLabel}
|
||
<span style="font-weight:400;color:var(--t2);font-size:10px">· ${pzasLabel}</span>
|
||
</div>
|
||
<div style="display:flex;gap:4px;align-items:center">
|
||
${sub?`<span style="color:var(--olive);font-weight:700">${fmt$(totalIva)}</span>`:''}
|
||
${ocHasFiles?`<span title="${ocHasFiles} archivos">📎</span>`:''}
|
||
</div>
|
||
</div>
|
||
${oc.items.map(o=>renderEntregaCard(o,fileData[o.orden_id]||[],true)).join('')}
|
||
</div>`);
|
||
}
|
||
sueltas.forEach(o=>{
|
||
pieces.push(renderEntregaCard(o,fileData[o.orden_id]||[],false));
|
||
});
|
||
|
||
// Sum facturado for the day (OC totals counted once + sueltos individual)
|
||
const seenOcDay=new Set();
|
||
let dayFact=0;
|
||
g.forEach(o=>{
|
||
if(o.oc_id&&o.oc_folio){
|
||
if(!seenOcDay.has(o.oc_id)){
|
||
seenOcDay.add(o.oc_id);
|
||
const sub=o.oc_factura||0;
|
||
const pct=o.oc_iva_pct!=null?o.oc_iva_pct:16;
|
||
dayFact+=sub*(1+pct/100);
|
||
}
|
||
} else {
|
||
dayFact+=(o.precio_factura||0);
|
||
}
|
||
});
|
||
return`<div class="day-group">
|
||
<div class="day-header">
|
||
<span>${formatDateHeader(key)}</span>
|
||
<small>${g.length} ${g.length===1?'pedido':'pedidos'} · ${totalPzas} pzas${dayFact?` · <b style="color:var(--olive-dark)">${fmt$(dayFact)}</b>`:''}</small>
|
||
</div>
|
||
<div class="entrega-grid">${pieces.join('')}</div>
|
||
</div>`;
|
||
}).join('')+
|
||
(items.length>60?`<div class="empty">Mostrando 60 de ${items.length} — usa el buscador</div>`:'');
|
||
|
||
// Render footer totals
|
||
renderEntregasFooter(items);
|
||
}
|
||
|
||
function renderEntregasFooter(items){
|
||
const seenOc=new Set();
|
||
let totalFactSub=0, totalIva=0, totalLog=0, totalOtros=0;
|
||
const totalCostProd=items.reduce((s,o)=>s+(o.costo_producto+o.costo_trabajo)*o.cantidad,0);
|
||
items.forEach(o=>{
|
||
if(o.oc_id && o.oc_folio){
|
||
if(!seenOc.has(o.oc_id)){
|
||
seenOc.add(o.oc_id);
|
||
const sub=o.oc_factura||0;
|
||
const pct=o.oc_iva_pct!=null?o.oc_iva_pct:16;
|
||
totalFactSub+=sub;
|
||
totalIva+=sub*pct/100;
|
||
totalLog+=o.oc_logistica||0;
|
||
totalOtros+=o.oc_otros_gastos||0;
|
||
}
|
||
} else {
|
||
totalFactSub+=o.precio_factura||0;
|
||
totalLog+=o.costo_logistica||0;
|
||
}
|
||
});
|
||
const totalConIva=totalFactSub+totalIva;
|
||
const totalCost=totalCostProd+totalLog+totalOtros;
|
||
const util=totalFactSub-totalCost;
|
||
const margen=totalFactSub>0?Math.round(util/totalFactSub*100):0;
|
||
const footer=document.getElementById('entregas-footer');
|
||
if(!footer){
|
||
const div=document.createElement('div');
|
||
div.id='entregas-footer';
|
||
$('entregas-list').parentElement.appendChild(div);
|
||
}
|
||
$('entregas-footer').innerHTML=items.length?`<div class="ent-footer-totals">
|
||
<div class="ftl"><span class="ftl-label">Pedidos</span><span class="ftl-val">${items.length}</span></div>
|
||
${seenOc.size?`<div class="ftl"><span class="ftl-label">Ordenes</span><span class="ftl-val">${seenOc.size}</span></div>`:''}
|
||
<div class="ftl"><span class="ftl-label">Subtotal</span><span class="ftl-val">${fmt$(totalFactSub)}</span></div>
|
||
<div class="ftl"><span class="ftl-label">IVA</span><span class="ftl-val">${fmt$(totalIva)}</span></div>
|
||
<div class="ftl"><span class="ftl-label">Total facturado</span><span class="ftl-val" style="color:var(--olive)">${fmt$(totalConIva)}</span></div>
|
||
${util?`<div class="ftl"><span class="ftl-label">Utilidad</span><span class="ftl-val" style="color:${util>=0?'var(--gn)':'var(--rd)'}">${fmt$(util)} (${margen}%)</span></div>`:''}
|
||
</div>`:'';
|
||
}
|
||
|
||
function toggleOcExpand(id){
|
||
const el=$(id);
|
||
el.style.display=el.style.display==='none'?'block':'none';
|
||
}
|
||
|
||
function renderEntregaCard(o,files,isOcLine=false){
|
||
const facturas=files.filter(f=>f.name.startsWith('factura_'));
|
||
const recibos=files.filter(f=>f.name.startsWith('recibo_entrega_'));
|
||
const fotos=files.filter(f=>f.name.startsWith('foto_producto_'));
|
||
|
||
const costoTotal=(o.costo_producto+o.costo_trabajo)*o.cantidad+o.costo_logistica;
|
||
const utilidad=o.precio_factura-costoTotal;
|
||
const margen=o.precio_factura>0?Math.round(utilidad/o.precio_factura*100):0;
|
||
const hasCostos=o.costo_producto||o.costo_trabajo||o.precio_factura;
|
||
// If line is part of an OC, the price/margen lives on the OC — don't show per-line
|
||
const showLinePrice=!isOcLine && o.precio_factura;
|
||
|
||
const cardId=`ec-${o.id}`;
|
||
return`<div class="entrega-card" style="cursor:pointer" onclick="openQuickView(${o.id})">
|
||
<div class="entrega-header">
|
||
<div style="display:flex;align-items:center;gap:6px;flex:1;min-width:0">
|
||
<span style="font-weight:700;font-size:13px">${esc(o.orden_id)}</span>
|
||
${o.tipo_orden&&o.tipo_orden!=='OC'?`<span class="tag" style="background:var(--${cc(o.tipo_orden)[1]});color:var(--${cc(o.tipo_orden)[0]})">${o.tipo_orden}</span>`:''}
|
||
${o.tipo_trabajo?`<span class="tag" style="background:var(--${cc(o.tipo_trabajo)[1]});color:var(--${cc(o.tipo_trabajo)[0]})">${o.tipo_trabajo}</span>`:''}
|
||
</div>
|
||
<div style="display:flex;gap:4px;align-items:center">
|
||
${showLinePrice?`<span style="font-size:11px;color:var(--olive);font-weight:700">${fmt$(o.precio_factura)}</span>`:''}
|
||
${showLinePrice&&hasCostos?`<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:${utilidad>=0?'var(--gnd)':'var(--rdd)'};color:${utilidad>=0?'var(--gn)':'var(--rd)'};font-weight:700">${margen}%</span>`:''}
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-top:2px">
|
||
<div style="flex:1;min-width:0">${isOcLine?'':`<b class="cli-link" onclick="event.stopPropagation();goToCliente('${esc(o.cliente)}')">${esc(o.cliente)}</b> — `}${esc(o.producto)} · <b>${o.cantidad}</b> pzas</div>
|
||
</div>
|
||
${o.recibio?`<div style="font-size:9px;color:var(--t3);margin-top:2px">Recibió: ${esc(o.recibio)}</div>`:''}
|
||
|
||
<!-- Action row: docs + expand costs -->
|
||
<div style="display:flex;gap:4px;flex-wrap:wrap;align-items:center;margin-top:6px" onclick="event.stopPropagation()">
|
||
${(!isOcLine||facturas.length)?`<button class="doc-btn ${facturas.length?'has-file':''}" onclick="openFilesWithTipo('${o.orden_id}','factura')">
|
||
${facturas.length?'✅':'📄'} Factura ${facturas.length?'('+facturas.length+')':''}
|
||
</button>`:''}
|
||
${(!isOcLine||recibos.length)?`<button class="doc-btn ${recibos.length?'has-file':''}" onclick="openFilesWithTipo('${o.orden_id}','recibo_entrega')">
|
||
${recibos.length?'✅':'📋'} Recibo ${recibos.length?'('+recibos.length+')':''}
|
||
</button>`:''}
|
||
<button class="doc-btn ${fotos.length?'has-file':''}" onclick="openFilesWithTipo('${o.orden_id}','foto_producto')">
|
||
${fotos.length?'✅':'📷'} Fotos ${fotos.length?'('+fotos.length+')':''}
|
||
</button>
|
||
<button class="doc-btn" onclick="toggleEntregaCostos('${cardId}')" style="margin-left:auto">✎ Costos</button>
|
||
</div>
|
||
|
||
<!-- Costos expandible -->
|
||
<div id="${cardId}" class="cost-row" style="display:none;gap:6px;flex-wrap:wrap;align-items:center;margin-top:6px;padding-top:6px;border-top:1px solid var(--bd)" onclick="event.stopPropagation()">
|
||
<div class="cost-field">
|
||
<label>C.Prod</label>
|
||
<input type="number" step="0.01" value="${o.costo_producto||''}" placeholder="0"
|
||
onchange="updateCosto(${o.id},'costo_producto',this.value)">
|
||
</div>
|
||
<div class="cost-field">
|
||
<label>C.Trab</label>
|
||
<input type="number" step="0.01" value="${o.costo_trabajo||''}" placeholder="0"
|
||
onchange="updateCosto(${o.id},'costo_trabajo',this.value)">
|
||
</div>
|
||
<div class="cost-field">
|
||
<label>Logist</label>
|
||
<input type="number" step="0.01" value="${o.costo_logistica||''}" placeholder="0"
|
||
onchange="updateCosto(${o.id},'costo_logistica',this.value)">
|
||
</div>
|
||
<div class="cost-field">
|
||
<label>Factura</label>
|
||
<input type="number" step="0.01" value="${o.precio_factura||''}" placeholder="0"
|
||
onchange="updateCosto(${o.id},'precio_factura',this.value)" style="border-color:var(--ac)">
|
||
</div>
|
||
<button class="kc-btn edit" onclick="editItem('ordenes',${o.id})" style="display:inline-flex;margin-left:auto" title="Editar todo">✎</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function toggleEntregaCostos(cardId){
|
||
const el=$(cardId);
|
||
el.style.display=el.style.display==='none'?'flex':'none';
|
||
}
|
||
|
||
async function updateCosto(id,field,value){
|
||
await api('PUT',`/api/ordenes/${id}`,{[field]:+value||0});
|
||
toast('Costo actualizado');
|
||
refreshActiveView();
|
||
}
|
||
|
||
function openFilesWithTipo(ordenId,tipo){
|
||
openFiles(ordenId);
|
||
setTimeout(()=>{
|
||
const sel=$('upload-tipo');
|
||
// Try setting the requested tipo; if it doesn't exist in this context, leave default
|
||
if([...sel.options].some(o=>o.value===tipo)) sel.value=tipo;
|
||
},100);
|
||
}
|
||
function openFilesWithTipoFor(entityId,tipo){
|
||
openFiles(entityId);
|
||
setTimeout(()=>{
|
||
const sel=$('upload-tipo');
|
||
if([...sel.options].some(o=>o.value===tipo)) sel.value=tipo;
|
||
},100);
|
||
}
|
||
function openProductFiles(key,nombre){
|
||
openFiles(key);
|
||
setTimeout(()=>{
|
||
// Override header with the friendly product name (still keep context label)
|
||
$('files-h').innerHTML=`<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%">
|
||
<div style="min-width:0;flex:1">
|
||
<div style="font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;font-weight:600">📦 Producto del catálogo</div>
|
||
<div style="font-size:14px;font-weight:700;color:var(--olive-dark);margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(nombre)}</div>
|
||
</div>
|
||
<button class="mo-x" onclick="closeMo('mo-files')">×</button>
|
||
</div>
|
||
<div style="font-size:10px;color:var(--t2);background:var(--s2);padding:6px 10px;border-radius:6px;margin-top:6px;line-height:1.4">${entityHint('producto')}</div>`;
|
||
const sel=$('upload-tipo');
|
||
if([...sel.options].some(o=>o.value==='foto_producto_base')) sel.value='foto_producto_base';
|
||
},100);
|
||
}
|
||
|
||
async function toggleCheck(id,field,val){
|
||
await api('PUT',`/api/ordenes/${id}`,{[field]:val?1:0});
|
||
toast(val?'Marcado':'Desmarcado');
|
||
}
|
||
|
||
// ══════ ORDENES KANBAN ══════
|
||
// Production columns (top kanban) — Almacen vive abajo en Bodega
|
||
const ORD_STAGES_PROD=['Nuevo','En 2 Mares','En Taller Sofia','En Vehiculo'];
|
||
// Lista canónica de tipos de personalización. Espejo de la tabla `trabajos` —
|
||
// si agregas/modificas, sincronizar también con la DB para que el wizard la vea.
|
||
const TRABAJO_OPTS=['','Bordado','Serigrafia','Grabado laser','Impresion DTF','Sublimación','DTF UV','Costura','Modificacion','Sin personalización','Otro'];
|
||
// Lista canónica de condiciones de pago (cuándo cobramos). 'Por definir' es default.
|
||
const CONDICIONES_PAGO_OPTS=['Por definir','A la entrega','Crédito 30 días','Consignación','Efectivo','Anticipo 50%'];
|
||
// Versión dinámica: usa S.trabajos cuando esté disponible, fallback a la constante
|
||
function trabajoOpts(){
|
||
if(S.trabajos&&S.trabajos.length){
|
||
return ['',...S.trabajos.filter(t=>t.activo!==0).map(t=>t.nombre)];
|
||
}
|
||
return TRABAJO_OPTS;
|
||
}
|
||
// Bodega panel: solo Almacen (cross-view por cliente)
|
||
const ORD_STAGES_BODEGA=['En Almacen'];
|
||
// En Tránsito (bottom, supplier tracking)
|
||
const ORD_STAGE_TRANSITO='En Tránsito';
|
||
// Full list still used by edit dropdowns and historic moves
|
||
const ORD_STAGES=['Nuevo','En 2 Mares','En Taller Sofia','En Almacen','En Vehiculo'];
|
||
const ORD_STAGES_ALL=['Nuevo','En Tránsito','En 2 Mares','En Taller Sofia','En Almacen','En Vehiculo','Entregado','Cancelado'];
|
||
|
||
function toggleEntregados(){
|
||
showEntregados=!showEntregados;
|
||
$('btn-entregados').textContent=showEntregados?'- Entregados':'+ Entregados';
|
||
renderOrdKanban();
|
||
}
|
||
|
||
async function loadOrdenes(){
|
||
// Parallel: pull main data in one round
|
||
const[ordenes,ocs,productos,proyectos]=await Promise.all([
|
||
api('GET','/api/ordenes'),
|
||
api('GET','/api/oc'),
|
||
S.productos?.length?Promise.resolve(S.productos):api('GET','/api/productos'),
|
||
S.proyectos?.length?Promise.resolve(S.proyectos):api('GET','/api/proyectos'),
|
||
]);
|
||
S.ordenes=ordenes;S.ocs=ocs;S.productos=productos;S.proyectos=proyectos;
|
||
renderOrdKanban();
|
||
renderGrid('ordenes',S.ordenes,ORD_GRID_COLS,'tbl-ordenes');
|
||
}
|
||
|
||
let showMuestras=false;
|
||
let showTransito=false;
|
||
let bodegaCliFilter='';
|
||
function toggleMuestras(){
|
||
showMuestras=!showMuestras;
|
||
$('btn-muestras').textContent=showMuestras?'- Muestras':'+ Muestras';
|
||
renderOrdKanban();
|
||
}
|
||
function toggleTransito(){
|
||
showTransito=!showTransito;
|
||
$('btn-transito').textContent=showTransito?'- Tránsito':'+ Tránsito';
|
||
renderOrdKanban();
|
||
}
|
||
function toggleFotos(){
|
||
const off=document.body.classList.toggle('no-thumbs');
|
||
const btn=$('btn-fotos');
|
||
if(btn){
|
||
btn.textContent=off?'🚫':'🖼';
|
||
btn.title=off?'Mostrar fotos':'Ocultar fotos para más velocidad';
|
||
}
|
||
try{localStorage.setItem('hub_no_thumbs',off?'1':'0');}catch(e){}
|
||
}
|
||
// Restore on load
|
||
try{if(localStorage.getItem('hub_no_thumbs')==='1'){
|
||
document.body.classList.add('no-thumbs');
|
||
setTimeout(()=>{const b=$('btn-fotos');if(b){b.textContent='🚫';b.title='Mostrar fotos';}},100);
|
||
}}catch(e){}
|
||
|
||
function renderOrdKanban(){
|
||
// Main kanban + bodega: NEVER include muestras (they live in their own panel)
|
||
let mainItems=S.ordenes.filter(o=>o.tipo_orden!=='Muestra');
|
||
mainItems=[...mainItems].sort((a,b)=>(b.cantidad||0)-(a.cantidad||0));
|
||
|
||
// ── Top: Production columns ──
|
||
let prodCols=[...ORD_STAGES_PROD];
|
||
if(showTransito) prodCols=['En Tránsito',...prodCols];
|
||
renderKB('kb-ordenes',mainItems,prodCols,renderOrdCard,'ordenes');
|
||
|
||
// ── Mid: Bodega panel (Almacen + Vehiculo) ──
|
||
renderBodegaPanel(mainItems);
|
||
|
||
// ── Muestras panel (only when toggled) ──
|
||
renderMuestrasPanel();
|
||
|
||
// ── Entregados panel (only when toggled) ──
|
||
renderEntregadosPanel();
|
||
}
|
||
|
||
let entregadosCliFilter='';
|
||
let entregadosShowCancelados=false;
|
||
function setEntregadosFilter(cli){
|
||
entregadosCliFilter=entregadosCliFilter===cli?'':cli;
|
||
renderEntregadosPanel();
|
||
}
|
||
function toggleCancelados(){
|
||
entregadosShowCancelados=!entregadosShowCancelados;
|
||
renderEntregadosPanel();
|
||
}
|
||
|
||
function renderEntregadosPanel(){
|
||
if(!showEntregados){$('kb-entregados').innerHTML='';return;}
|
||
const stagesIn=entregadosShowCancelados?['Entregado','Cancelado']:['Entregado'];
|
||
let items=S.ordenes.filter(o=>stagesIn.includes(o.stage)&&o.tipo_orden!=='Muestra');
|
||
// Sort: newest delivery first (by oc_fecha_entrega or fecha_entrega)
|
||
items.sort((a,b)=>{
|
||
const fa=a.fecha_entrega||a.fecha_inicio||'';
|
||
const fb=b.fecha_entrega||b.fecha_inicio||'';
|
||
if(fa!==fb) return fb.localeCompare(fa);
|
||
return b.id-a.id;
|
||
});
|
||
|
||
// Build client filter chips
|
||
const cliCounts={};
|
||
items.forEach(o=>{cliCounts[o.cliente||'Sin cliente']=(cliCounts[o.cliente||'Sin cliente']||0)+1});
|
||
const cliEntries=Object.entries(cliCounts).sort((a,b)=>b[1]-a[1]);
|
||
|
||
// Apply client filter
|
||
let filtered=items;
|
||
if(entregadosCliFilter) filtered=filtered.filter(o=>(o.cliente||'Sin cliente')===entregadosCliFilter);
|
||
|
||
// Limit to recent 60 to keep it fast
|
||
const VISIBLE_LIMIT=60;
|
||
const visible=filtered.slice(0,VISIBLE_LIMIT);
|
||
const totalPzas=filtered.reduce((s,o)=>s+o.cantidad,0);
|
||
|
||
const filterChips=`<button class="fchip${!entregadosCliFilter?' on':''}" onclick="setEntregadosFilter('')">Todos (${items.length})</button>`+
|
||
cliEntries.map(([n,c])=>`<button class="fchip${entregadosCliFilter===n?' on':''}" onclick="setEntregadosFilter('${esc(n).replace(/'/g,"\\\\'")}')">${esc(n)} <span style="opacity:.7">·${c}</span></button>`).join('');
|
||
|
||
// Group by month for navigation
|
||
const byMonth={};
|
||
visible.forEach(o=>{
|
||
const ym=(o.fecha_entrega||'').slice(0,7)||'Sin fecha';
|
||
if(!byMonth[ym]) byMonth[ym]=[];
|
||
byMonth[ym].push(o);
|
||
});
|
||
const months=Object.keys(byMonth).sort((a,b)=>b.localeCompare(a));
|
||
const meses=['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
||
const fmtMonth=ym=>{if(ym==='Sin fecha')return'Sin fecha';const[y,m]=ym.split('-');return`${meses[+m-1]} ${y}`};
|
||
|
||
const renderRow=(o)=>{
|
||
const isCancel=o.stage==='Cancelado';
|
||
const ocRef=o.oc_id?(S.ocs||[]).find(x=>x.id===o.oc_id):null;
|
||
return`<div class="entregado-row${isCancel?' cancel':''}" onclick="openQuickView(${o.id})">
|
||
<div class="er-date">${o.fecha_entrega||o.fecha_inicio||'—'}</div>
|
||
<div class="er-main">
|
||
<div class="er-head">
|
||
<span class="er-id">${esc(o.orden_id)}</span>
|
||
${ocRef?`<span class="er-oc" onclick="event.stopPropagation();openOrdenDetail(${ocRef.id})" title="Ver Orden">🔗 ${esc(ocRef.oc_id)}</span>`:''}
|
||
${isCancel?'<span class="er-cancel-badge">Cancelado</span>':''}
|
||
</div>
|
||
<div class="er-sub">
|
||
<b>${esc(o.cliente||'')}</b> · ${esc(o.producto||'')}
|
||
</div>
|
||
</div>
|
||
<div class="er-qty">${o.cantidad}<span style="font-size:9px;font-weight:400;color:var(--t3);margin-left:2px">pzas</span></div>
|
||
</div>`;
|
||
};
|
||
|
||
const groupsHtml=visible.length?months.map(ym=>`<div class="er-month-group">
|
||
<div class="er-month-h">${fmtMonth(ym)} <span style="font-weight:400;color:var(--t3)">· ${byMonth[ym].length}</span></div>
|
||
${byMonth[ym].map(renderRow).join('')}
|
||
</div>`).join(''):`<div class="bodega-empty">${entregadosCliFilter?'Sin entregas de este cliente':'Sin pedidos entregados'}</div>`;
|
||
|
||
$('kb-entregados').innerHTML=`
|
||
<div class="ord-panel-head" style="background:#dcfce7">
|
||
<h3 style="color:#15803d">✅ Entregados <span class="panel-meta" style="color:#15803d">${items.length} pedido${items.length!==1?'s':''} · ${totalPzas} pzas total</span></h3>
|
||
<div style="display:flex;gap:4px;align-items:center">
|
||
<label style="font-size:10px;color:var(--t2);cursor:pointer;display:flex;align-items:center;gap:4px">
|
||
<input type="checkbox" ${entregadosShowCancelados?'checked':''} onchange="toggleCancelados()" style="cursor:pointer"> Incluir cancelados
|
||
</label>
|
||
</div>
|
||
</div>
|
||
${cliEntries.length?`<div class="ord-panel-filter">${filterChips}</div>`:''}
|
||
<div style="padding:8px 12px">${groupsHtml}${filtered.length>VISIBLE_LIMIT?`<div style="text-align:center;padding:8px;font-size:11px;color:var(--t3)">Mostrando ${VISIBLE_LIMIT} de ${filtered.length}. Ve más en la pestaña <b>Entregas</b>.</div>`:''}</div>
|
||
`;
|
||
}
|
||
|
||
function renderMuestrasPanel(){
|
||
if(!showMuestras){$('kb-muestras').innerHTML='';return;}
|
||
const muestras=S.ordenes.filter(o=>o.tipo_orden==='Muestra'&&o.stage!=='Cancelado').sort((a,b)=>(b.cantidad||0)-(a.cantidad||0));
|
||
const total=muestras.length;
|
||
const totalPzas=muestras.reduce((s,o)=>s+o.cantidad,0);
|
||
|
||
// Group by stage
|
||
const stageOrder=['Nuevo','En Tránsito','En 2 Mares','En Taller Sofia','En Almacen','En Vehiculo','Entregado'];
|
||
const grouped={};
|
||
muestras.forEach(m=>{
|
||
const s=m.stage||'Sin stage';
|
||
if(!grouped[s]) grouped[s]=[];
|
||
grouped[s].push(m);
|
||
});
|
||
const stagesPresent=stageOrder.filter(s=>grouped[s]?.length);
|
||
|
||
const cardsHtml=total?stagesPresent.map(stage=>{
|
||
const items=grouped[stage];
|
||
const[sc,scd]=cc(stage);
|
||
return`<div style="margin-bottom:8px">
|
||
<div style="font-size:10px;font-weight:600;color:var(--${sc});padding:3px 8px;background:var(--${scd});border-radius:4px;display:inline-block;margin-bottom:4px">${stage} · ${items.length}</div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:6px">
|
||
${items.map(o=>renderBodegaCard(o)).join('')}
|
||
</div>
|
||
</div>`;
|
||
}).join(''):`<div class="bodega-empty">Sin muestras activas. Crea pedidos con tipo "Muestra" desde el wizard.</div>`;
|
||
|
||
$('kb-muestras').innerHTML=`
|
||
<div class="ord-panel-head" style="background:#fef3c7">
|
||
<h3 style="color:#854d0e">🧾 Muestras <span class="panel-meta" style="color:#854d0e">${total} muestra${total!==1?'s':''} · ${totalPzas} pzas</span></h3>
|
||
</div>
|
||
<div class="ord-panel-body${total?'':' empty-state'}" style="background:#fffbeb">
|
||
${cardsHtml}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderBodegaPanel(items){
|
||
const inBodega=items.filter(o=>ORD_STAGES_BODEGA.includes(o.stage));
|
||
const totalPzas=inBodega.reduce((s,o)=>s+o.cantidad,0);
|
||
|
||
// Build client filter chips from items in bodega
|
||
const cliCounts={};
|
||
inBodega.forEach(o=>{cliCounts[o.cliente||'Sin cliente']=(cliCounts[o.cliente||'Sin cliente']||0)+1});
|
||
const cliEntries=Object.entries(cliCounts).sort((a,b)=>b[1]-a[1]);
|
||
|
||
// Apply client filter
|
||
let filtered=inBodega;
|
||
if(bodegaCliFilter) filtered=filtered.filter(o=>(o.cliente||'Sin cliente')===bodegaCliFilter);
|
||
|
||
const filterChips=`<button class="fchip${!bodegaCliFilter?' on':''}" onclick="setBodegaFilter('')">Todos (${inBodega.length})</button>`+
|
||
cliEntries.map(([n,c])=>`<button class="fchip${bodegaCliFilter===n?' on':''}" onclick="setBodegaFilter('${esc(n).replace(/'/g,"\\\\'")}')">${esc(n)} <span style="opacity:.7">·${c}</span></button>`).join('');
|
||
|
||
const cardsHtml=filtered.length?filtered.map(o=>renderBodegaCard(o)).join(''):`<div class="bodega-empty">${bodegaCliFilter?'Este cliente no tiene piezas en bodega':'Sin piezas en bodega'}</div>`;
|
||
|
||
$('kb-bodega').innerHTML=`
|
||
<div class="ord-panel-head" style="cursor:pointer" onclick="openBodegaFull()" title="Click para ver vista completa con fotos">
|
||
<h3>📦 Bodega <span class="panel-meta">${inBodega.length} pedido${inBodega.length!==1?'s':''} · ${totalPzas} pzas listas para entregar</span></h3>
|
||
<button class="btn btn-sm" onclick="event.stopPropagation();openBodegaFull()" style="margin-left:auto">🔍 Vista completa</button>
|
||
</div>
|
||
${cliEntries.length?`<div class="ord-panel-filter">${filterChips}</div>`:''}
|
||
<div class="ord-panel-body${filtered.length?'':' empty-state'}" data-stage="En Almacen" data-table="ordenes" ondragover="dov(event)" ondragleave="dlv(event)" ondrop="drp(event)">
|
||
${cardsHtml}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function setBodegaFilter(cli){
|
||
bodegaCliFilter=bodegaCliFilter===cli?'':cli;
|
||
renderBodegaPanel(S.ordenes.filter(o=>(showMuestras||o.tipo_orden!=='Muestra')).sort((a,b)=>(b.cantidad||0)-(a.cantidad||0)));
|
||
}
|
||
|
||
// ── Bodega vista expandida (fullscreen modal con fotos) ──
|
||
let bfCliFilter='';
|
||
let bfSortBy='orden'; // orden | cliente | producto | cantidad | stage
|
||
|
||
function openBodegaFull(){
|
||
bfCliFilter='';bfSortBy='orden';
|
||
loadBodegaFiles().then(()=>renderBodegaFull());
|
||
renderBodegaFull();
|
||
openMo('mo-bodega-full');
|
||
}
|
||
|
||
async function loadBodegaFiles(){
|
||
// Make sure proyectoFiles is fresh — for products linked to proyectos
|
||
if(!Object.keys(proyectoFiles).length && S.proyectos?.length){
|
||
await loadProyectoFiles();
|
||
}
|
||
}
|
||
|
||
function renderBodegaFull(){
|
||
const items=S.ordenes.filter(o=>['En Almacen','En Vehiculo'].includes(o.stage)&&o.tipo_orden!=='Muestra');
|
||
const totalPzas=items.reduce((s,o)=>s+o.cantidad,0);
|
||
|
||
// Build client filter chips
|
||
const cliCounts={};
|
||
items.forEach(o=>{cliCounts[o.cliente||'Sin cliente']=(cliCounts[o.cliente||'Sin cliente']||0)+1});
|
||
const cliEntries=Object.entries(cliCounts).sort((a,b)=>b[1]-a[1]);
|
||
|
||
// Apply filter
|
||
let filtered=items;
|
||
if(bfCliFilter) filtered=filtered.filter(o=>(o.cliente||'Sin cliente')===bfCliFilter);
|
||
|
||
// Apply sort
|
||
const sorters={
|
||
cliente:(a,b)=>(a.cliente||'').localeCompare(b.cliente||'')||b.cantidad-a.cantidad,
|
||
producto:(a,b)=>(a.producto||'').localeCompare(b.producto||''),
|
||
cantidad:(a,b)=>b.cantidad-a.cantidad,
|
||
stage:(a,b)=>(a.stage||'').localeCompare(b.stage||'')
|
||
};
|
||
filtered=[...filtered].sort(sorters[bfSortBy]||sorters.cliente);
|
||
|
||
// Toolbar
|
||
const filterChips=`<button class="fchip${!bfCliFilter?' on':''}" onclick="setBfCli('')">Todos (${items.length})</button>`+
|
||
cliEntries.map(([n,c])=>`<button class="fchip${bfCliFilter===n?' on':''}" onclick="setBfCli('${esc(n).replace(/'/g,"\\\\'")}')">${esc(n)} <span style="opacity:.7">·${c}</span></button>`).join('');
|
||
|
||
// Pre-compute con/sin orden for the toolbar meta
|
||
const conOrden=items.filter(o=>o.oc_id).length;
|
||
const sinOrden=items.filter(o=>!o.oc_id).length;
|
||
|
||
$('bf-toolbar').innerHTML=`<div class="bf-toolbar">
|
||
<div class="bf-toolbar-left">
|
||
<h2>📦 Bodega <span class="bf-meta">${items.length} pedidos · ${totalPzas} pzas · ${conOrden} con orden · ${sinOrden} para resurtido</span></h2>
|
||
</div>
|
||
<div class="bf-toolbar-right">
|
||
<select class="bf-sort" onchange="setBfSort(this.value)">
|
||
<option value="orden"${bfSortBy==='orden'?' selected':''}>Agrupar: Por Orden de Compra</option>
|
||
<option value="cliente"${bfSortBy==='cliente'?' selected':''}>Agrupar: Por cliente</option>
|
||
<option value="producto"${bfSortBy==='producto'?' selected':''}>Ordenar: Por producto</option>
|
||
<option value="cantidad"${bfSortBy==='cantidad'?' selected':''}>Ordenar: Por cantidad</option>
|
||
<option value="stage"${bfSortBy==='stage'?' selected':''}>Ordenar: Por stage</option>
|
||
</select>
|
||
<button class="btn" onclick="closeMo('mo-bodega-full')">← Volver</button>
|
||
</div>
|
||
</div>
|
||
${cliEntries.length?`<div class="bf-filter-row">${filterChips}</div>`:''}`;
|
||
|
||
// Grouped output
|
||
if(bfSortBy==='orden'){
|
||
// Group by OC. Items WITHOUT oc_id go to special "Sin orden / resurtido" section
|
||
const byOc={};
|
||
const sueltos=[];
|
||
filtered.forEach(o=>{
|
||
if(o.oc_id){if(!byOc[o.oc_id])byOc[o.oc_id]=[];byOc[o.oc_id].push(o);}
|
||
else sueltos.push(o);
|
||
});
|
||
let html='';
|
||
// Render OCs first (the "exact" hotel-style orders)
|
||
for(const[ocId,items] of Object.entries(byOc)){
|
||
const oc=(S.ocs||[]).find(x=>x.id==ocId);
|
||
const pzas=items.reduce((s,o)=>s+o.cantidad,0);
|
||
const total_pzas=oc?.total_piezas||pzas;
|
||
const folio=oc?.oc_id||`OC#${ocId}`;
|
||
const cliente=oc?.cliente||items[0].cliente;
|
||
const fechaEnt=oc?.fecha_entrega;
|
||
html+=`<div class="bf-group bf-group-orden">
|
||
<div class="bf-group-head" style="background:linear-gradient(135deg,var(--sand-light),var(--s2));cursor:pointer" onclick="closeMo('mo-bodega-full');openOrdenDetail(${ocId})">
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<span style="font-size:9px;padding:2px 7px;border-radius:3px;background:var(--olive);color:#fff;font-weight:700">ORDEN</span>
|
||
<b>${esc(folio)}</b>
|
||
<span style="font-size:11px;color:var(--t2)">${esc(cliente)}</span>
|
||
</div>
|
||
<span>${items.length} de ${oc?.n_lineas||items.length} pedido${items.length!==1?'s':''} en bodega · ${pzas}/${total_pzas} pzas${fechaEnt?` · 📦 ${fechaEnt}`:''}</span>
|
||
</div>
|
||
<div class="bf-grid">${items.map(o=>renderBodegaCard(o,true)).join('')}</div>
|
||
</div>`;
|
||
}
|
||
// Then the loose pedidos (POS-style, free production for future restock)
|
||
if(sueltos.length){
|
||
const pzasSueltos=sueltos.reduce((s,o)=>s+o.cantidad,0);
|
||
html+=`<div class="bf-group bf-group-suelto">
|
||
<div class="bf-group-head" style="background:linear-gradient(135deg,#dbeafe,#eff6ff)">
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<span style="font-size:9px;padding:2px 7px;border-radius:3px;background:#3b82f6;color:#fff;font-weight:700">RESURTIDO</span>
|
||
<b style="color:#1e40af">Producción libre · sin orden vinculada</b>
|
||
</div>
|
||
<span>${sueltos.length} pedido${sueltos.length!==1?'s':''} · ${pzasSueltos} pzas · disponibles para POS / venta libre</span>
|
||
</div>
|
||
<div class="bf-grid">${sueltos.map(o=>renderBodegaCard(o,true)).join('')}</div>
|
||
</div>`;
|
||
}
|
||
$('bf-body').innerHTML=html||'<div class="bodega-empty">Sin piezas en bodega</div>';
|
||
} else if(bfSortBy==='cliente'){
|
||
const byCli={};
|
||
filtered.forEach(o=>{const c=o.cliente||'Sin cliente';if(!byCli[c])byCli[c]=[];byCli[c].push(o)});
|
||
const html=Object.entries(byCli).map(([cli,items])=>{
|
||
const pzas=items.reduce((s,o)=>s+o.cantidad,0);
|
||
return`<div class="bf-group">
|
||
<div class="bf-group-head"><b>${esc(cli)}</b> <span>${items.length} pedido${items.length!==1?'s':''} · ${pzas} pzas</span></div>
|
||
<div class="bf-grid">${items.map(o=>renderBodegaCard(o,true)).join('')}</div>
|
||
</div>`;
|
||
}).join('');
|
||
$('bf-body').innerHTML=html||'<div class="bodega-empty">Sin piezas en bodega</div>';
|
||
} else {
|
||
$('bf-body').innerHTML=`<div class="bf-grid">${filtered.length?filtered.map(o=>renderBodegaCard(o,true)).join(''):'<div class="bodega-empty">Sin piezas con esos filtros</div>'}</div>`;
|
||
}
|
||
}
|
||
|
||
function setBfCli(c){bfCliFilter=bfCliFilter===c?'':c;renderBodegaFull();}
|
||
function setBfSort(s){bfSortBy=s;renderBodegaFull();}
|
||
|
||
function renderBodegaCard(o,showPhoto){
|
||
const isVeh=o.stage==='En Vehiculo';
|
||
const isTran=o.stage==='En Tránsito';
|
||
const cls=isVeh?'vehiculo':isTran?'transito':'';
|
||
const[sc]=cc(o.stage);
|
||
const urgClass=o.urgente?' urgente':'';
|
||
const eta=isTran&&o.fecha_estimada?`<div class="bc-eta">📅 ETA: ${esc(o.fecha_estimada)}</div>`:'';
|
||
const notes=isTran&&o.notas?`<div class="bc-notes">${esc(o.notas.slice(0,80))}${o.notas.length>80?'...':''}</div>`:'';
|
||
const photo=showPhoto?getPedidoPhoto(o):null;
|
||
return`<div class="bodega-card ${cls}${urgClass}${showPhoto?' with-photo':''}" draggable="true" ondragstart="ds(event,${o.id},'ordenes')" ondragend="de(event)" onclick="openQuickView(${o.id})">
|
||
<div style="display:flex;gap:8px;align-items:flex-start">
|
||
${photo?`<img src="${photo.url}" class="bc-thumb" loading="lazy" decoding="async" onclick="event.stopPropagation();openFile('${photo.url}',true)" title="${esc(photo.name)}">`:''}
|
||
<div style="flex:1;min-width:0">
|
||
<div class="bc-head">
|
||
<span>${esc(o.orden_id)}</span>
|
||
<span class="bc-qty">${o.cantidad}</span>
|
||
</div>
|
||
<div class="bc-cli">${esc(o.cliente||'')}</div>
|
||
<div class="bc-prod">${esc(o.producto||'')}</div>
|
||
</div>
|
||
</div>
|
||
${eta}
|
||
${notes}
|
||
<div class="bc-tags">
|
||
${o.tipo_trabajo?`<span class="tag" style="background:var(--${cc(o.tipo_trabajo)[1]});color:var(--${cc(o.tipo_trabajo)[0]})">${o.tipo_trabajo}</span>`:''}
|
||
${isVeh?`<span class="bc-stage-badge" style="background:#cffafe;color:#0e7490">🚚 En vehículo</span>`:''}
|
||
${o.tipo_orden&&o.tipo_orden!=='OC'?`<span class="tag" style="background:var(--${cc(o.tipo_orden)[1]});color:var(--${cc(o.tipo_orden)[0]})">${o.tipo_orden}</span>`:''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Get product photo URL from catalog (matches by product name)
|
||
function getProductPhoto(productName){
|
||
if(!productName||!S.productos) return null;
|
||
const p=S.productos.find(x=>x.nombre===productName);
|
||
if(!p) return null;
|
||
const files=productFiles[productEntityKey(p)]||[];
|
||
return files.find(f=>f.is_image)||null;
|
||
}
|
||
|
||
// Files uploaded per proyecto (key: proy-{id})
|
||
let proyectoFiles={};
|
||
|
||
// Map: orden_id → cached file list (lazy loaded for photo fallback)
|
||
let pedidoFilesCache={};
|
||
|
||
// Smart photo lookup for a pedido — priority:
|
||
// 1. Proyecto vinculado (foto producto terminado, mockup, etc.)
|
||
// 2. Foto subida AL PROPIO PEDIDO (foto_producto_pedido, foto_avance_produccion)
|
||
// 3. Catálogo del producto base
|
||
function getPedidoPhoto(pedido){
|
||
if(!pedido) return null;
|
||
// 1. Project photo
|
||
if(pedido.proyecto_id){
|
||
const key='proy-'+pedido.proyecto_id;
|
||
const files=proyectoFiles[key]||[];
|
||
const fotoTerm=files.find(f=>f.is_image && (
|
||
f.name.startsWith('foto_producto_terminado')||
|
||
f.name.startsWith('foto_producto')||
|
||
f.name.startsWith('foto_terminado')||
|
||
f.name.startsWith('mockup')
|
||
));
|
||
if(fotoTerm) return fotoTerm;
|
||
const anyImg=files.find(f=>f.is_image);
|
||
if(anyImg) return anyImg;
|
||
}
|
||
// 2. Pedido's own uploaded files (useful for muestras and pedidos sueltos)
|
||
const ownFiles=pedidoFilesCache[pedido.orden_id]||[];
|
||
const ownFoto=ownFiles.find(f=>f.is_image && (
|
||
f.name.startsWith('foto_producto_pedido')||
|
||
f.name.startsWith('foto_avance_produccion')||
|
||
f.name.startsWith('foto_producto')
|
||
));
|
||
if(ownFoto) return ownFoto;
|
||
// 3. Base catalog product photo
|
||
return getProductPhoto(pedido.producto);
|
||
}
|
||
|
||
// Returns the logo_cliente file (preferred) or any non-photo file from the linked project
|
||
function getLogoClienteForPedido(pedido){
|
||
if(!pedido?.proyecto_id) return null;
|
||
const files=proyectoFiles['proy-'+pedido.proyecto_id]||[];
|
||
// Prefer explicit logo_cliente, then any image that's not the foto_producto_terminado
|
||
const logo=files.find(f=>f.name.startsWith('logo_cliente'));
|
||
if(logo) return logo;
|
||
// Fallback: instrucciones_diseno or mockup
|
||
return files.find(f=>f.name.startsWith('instrucciones_diseno')||f.name.startsWith('mockup'))||null;
|
||
}
|
||
|
||
// Find a matching project for a pedido — returns the project if exact match
|
||
// EXACT match = same cliente AND same producto AND same tipo_trabajo (case-insensitive)
|
||
function findExactMatchingProyecto(pedido){
|
||
if(!pedido||!pedido.cliente||!pedido.producto) return null;
|
||
return(S.proyectos||[]).find(p=>p.activo!==0&&
|
||
(p.cliente||'').toLowerCase()===(pedido.cliente||'').toLowerCase()&&
|
||
(p.producto_nombre||'').toLowerCase()===(pedido.producto||'').toLowerCase()&&
|
||
(p.tipo_trabajo||'').toLowerCase()===(pedido.tipo_trabajo||'').toLowerCase()
|
||
)||null;
|
||
}
|
||
|
||
// Auto-link a pedido to a matching project — silent, with toast notification
|
||
async function autoLinkPedidoToProyecto(pedidoId){
|
||
const o=S.ordenes.find(x=>x.id===pedidoId);
|
||
if(!o||o.proyecto_id) return false;
|
||
const match=findExactMatchingProyecto(o);
|
||
if(!match) return false;
|
||
await api('PUT',`/api/ordenes/${pedidoId}`,{proyecto_id:match.id});
|
||
await api('PUT',`/api/proyectos/${match.id}`,{
|
||
veces_usado:(match.veces_usado||0)+1,
|
||
ultimo_uso:new Date().toISOString().slice(0,10)
|
||
});
|
||
toast(`✓ Vinculado automáticamente al proyecto "${match.nombre}"`);
|
||
return true;
|
||
}
|
||
|
||
async function loadProyectoFiles(){
|
||
// Use the batch index when available, fallback to individual fetches
|
||
if(Object.keys(fileIndex).length){
|
||
proyectoFiles={};
|
||
for(const[k,v] of Object.entries(fileIndex)){
|
||
if(k.startsWith('proy-')&&v.first_image){
|
||
proyectoFiles[k]=[{name:v.first_image.split('/').pop(),url:v.first_image,is_image:true}];
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
if(!S.proyectos?.length) return;
|
||
proyectoFiles={};
|
||
await Promise.all(S.proyectos.map(async p=>{
|
||
const key='proy-'+p.id;
|
||
try{
|
||
const files=await api('GET',`/api/files/${encodeURIComponent(key)}`);
|
||
if(files.length) proyectoFiles[key]=files;
|
||
}catch(e){}
|
||
}));
|
||
}
|
||
|
||
function renderOrdCard(item){
|
||
const[c]=cc(item.tipo_trabajo||'');
|
||
const urg=item.urgente?'<span class="kc-urg" title="Urgente">🔴</span>':'';
|
||
const tipoTag=item.tipo_orden&&item.tipo_orden!=='OC'?`<span class="tag" style="background:var(--${cc(item.tipo_orden)[1]});color:var(--${cc(item.tipo_orden)[0]})">${item.tipo_orden}</span>`:'';
|
||
return`<div class="kc" draggable="true" ondragstart="ds(event,${item.id},'ordenes')" ondragend="de(event)" onclick="openQuickView(${item.id})">
|
||
<div class="kc-acts">
|
||
<button class="kc-btn edit" onclick="event.stopPropagation();editItem('ordenes',${item.id})">✎</button>
|
||
<button class="kc-btn" onclick="event.stopPropagation();delItem('ordenes',${item.id})">×</button>
|
||
</div>
|
||
${urg}
|
||
<div class="kc-t">${esc(item.orden_id)}</div>
|
||
<div class="kc-m">
|
||
<b>${esc(item.cliente)}</b><br>
|
||
${esc(item.producto)}<br>
|
||
<b>${item.cantidad}</b> pzas
|
||
${item.fecha_inicio?' · '+item.fecha_inicio:''}
|
||
</div>
|
||
<div class="kc-tags">
|
||
${item.tipo_trabajo?`<span class="tag" style="background:var(--${cc(item.tipo_trabajo)[1]});color:var(--${cc(item.tipo_trabajo)[0]})">${item.tipo_trabajo}</span>`:''}
|
||
${tipoTag}
|
||
${fileBadge(item.orden_id)}
|
||
</div>
|
||
${item.precio_factura?`<div style="font-size:9px;color:var(--ac);margin-top:3px">${fmt$(item.precio_factura)}</div>`:''}
|
||
${item.oc_id&&S.ocs.length?`<div style="font-size:8px;color:var(--olive);margin-top:2px;font-weight:600">🔗 ${esc((S.ocs.find(o=>o.id===item.oc_id)||{}).oc_id||'OC#'+item.oc_id)}</div>`:item.grupo_oc?`<div style="font-size:8px;color:var(--bl);margin-top:2px">🔗 ${esc(item.grupo_oc)}</div>`:''}
|
||
${item.notas?`<div class="kc-m" style="margin-top:3px;font-style:italic;opacity:.7">${esc(item.notas.substring(0,70))}${item.notas.length>70?'...':''}</div>`:''}
|
||
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:4px">
|
||
${item.stage==='En Vehiculo'?`<button class="kc-entrega" onclick="event.stopPropagation();openEntregaModal(${item.id})">✓ Entregar</button>`:''}
|
||
${item.stage==='En 2 Mares'||item.stage==='En Taller Sofia'?`<button class="kc-entrega" style="border-color:var(--bl);background:var(--bld);color:var(--bl)" onclick="event.stopPropagation();openRecogerModal(${item.id})">📦 Recoger</button>`:''}
|
||
${canConsolidate(item)?`<button class="kc-entrega" style="border-color:var(--pr);background:var(--prd);color:var(--pr)" onclick="event.stopPropagation();consolidarOrden(${item.id})">🔁 Consolidar</button>`:''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// ══════ INVENTARIO ══════
|
||
async function loadInv(){
|
||
S.inventario=await api('GET','/api/inventario');
|
||
const tb=$('inv-table');
|
||
tb.querySelector('thead').innerHTML=`<tr><th>SKU</th><th>Nombre</th><th>Tipo</th><th>Talla</th><th>Stock Ini</th><th>Ordenado</th><th>Disponible</th><th>Reorden</th><th>Costo</th><th>Proveedor</th><th></th></tr>`;
|
||
tb.querySelector('tbody').innerHTML=S.inventario.map(i=>{
|
||
const cls=i.stock_disponible<=0?'stock-crit':i.stock_disponible<=i.punto_reorden?'stock-low':'stock-ok';
|
||
return`<tr>
|
||
<td><b>${esc(i.sku)}</b></td><td>${esc(i.nombre)}</td>
|
||
<td>${esc(i.tipo)}</td><td>${i.talla}</td>
|
||
<td>${i.stock_inicial}</td><td>${i.total_ordenado}</td>
|
||
<td class="${cls}"><b>${i.stock_disponible}</b></td>
|
||
<td>${i.punto_reorden}</td>
|
||
<td>${fmt$(i.costo_unitario)}</td>
|
||
<td>${esc(i.proveedor)}</td>
|
||
<td><button class="kc-btn edit" onclick="editItem('inventario',${i.id})" style="display:inline-flex">✎</button></td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
renderInvDashboard();
|
||
}
|
||
|
||
function renderInvDashboard(){
|
||
const inv=S.inventario;
|
||
if(!inv.length){$('inv-dashboard').innerHTML='';return;}
|
||
|
||
// 1. Top movers (most ordered)
|
||
const movers=[...inv].filter(i=>i.total_ordenado>0).sort((a,b)=>b.total_ordenado-a.total_ordenado).slice(0,6);
|
||
const maxOrd=movers.length?movers[0].total_ordenado:1;
|
||
|
||
// 2. Need restock (at or below reorder point)
|
||
const restock=inv.filter(i=>i.stock_disponible<=i.punto_reorden).sort((a,b)=>a.stock_disponible-b.stock_disponible);
|
||
|
||
// 3. Sin movimiento (no orders, or lowest ordered relative to stock)
|
||
const sinMov=inv.filter(i=>i.total_ordenado===0&&i.stock_inicial>0).slice(0,6);
|
||
|
||
$('inv-dashboard').innerHTML=`
|
||
<div class="crd">
|
||
<div class="crd-h" style="color:var(--bl)">📈 Mas Movimiento</div>
|
||
<div class="crd-b">
|
||
${movers.length?movers.map(i=>`<div class="bar">
|
||
<div class="bar-l">${esc(i.nombre)}</div>
|
||
<div class="bar-t"><div class="bar-f" style="width:${(i.total_ordenado/maxOrd*100).toFixed(0)}%;background:var(--bl)">${i.total_ordenado}</div></div>
|
||
<div class="bar-v">${i.stock_disponible} disp</div>
|
||
</div>`).join(''):'<div class="empty">Sin datos aun</div>'}
|
||
</div>
|
||
</div>
|
||
<div class="crd">
|
||
<div class="crd-h" style="color:var(--rd)">⚠ Resurtir</div>
|
||
<div class="crd-b">
|
||
${restock.length?restock.map(i=>{
|
||
const pct=i.punto_reorden>0?Math.round(i.stock_disponible/i.punto_reorden*100):0;
|
||
const color=i.stock_disponible<=0?'var(--rd)':pct<=50?'var(--or)':'var(--yl)';
|
||
return`<div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid var(--sand-light)">
|
||
<div style="flex:1">
|
||
<div style="font-size:11px;font-weight:600">${esc(i.nombre)}</div>
|
||
<div style="font-size:9px;color:var(--t2)">${esc(i.sku)}</div>
|
||
</div>
|
||
<div style="text-align:right">
|
||
<div style="font-size:13px;font-weight:700;color:${color}">${i.stock_disponible}</div>
|
||
<div style="font-size:8px;color:var(--t3)">min: ${i.punto_reorden}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join(''):'<div class="empty" style="color:var(--gn)">✓ Todo bien</div>'}
|
||
</div>
|
||
</div>
|
||
<div class="crd">
|
||
<div class="crd-h" style="color:var(--t3)">😴 Sin Movimiento</div>
|
||
<div class="crd-b">
|
||
${sinMov.length?sinMov.map(i=>`<div style="display:flex;align-items:center;gap:8px;padding:4px 0;border-bottom:1px solid var(--sand-light)">
|
||
<div style="flex:1">
|
||
<div style="font-size:11px;font-weight:600">${esc(i.nombre)}</div>
|
||
<div style="font-size:9px;color:var(--t2)">${esc(i.sku)} — ${i.stock_disponible} pzas</div>
|
||
</div>
|
||
<div style="font-size:8px;color:var(--t3);background:var(--sand-light);padding:2px 6px;border-radius:3px">0 ordenes</div>
|
||
</div>`).join(''):'<div class="empty">Todo se mueve 🙌</div>'}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// ══════ CATALOGO ══════
|
||
function productEntityKey(p){return`prod-${p.sku||p.id}`}
|
||
|
||
let productFiles={}; // key → list of files
|
||
async function loadCatalogo(){
|
||
// Backward-compatible — defer to the tab loader
|
||
await loadProductosTab();
|
||
}
|
||
|
||
function renderCatalogoSection(){
|
||
const modelos=prodData.modelos||[],materiales=prodData.materiales||[],
|
||
trabajos=prodData.trabajos||[],clientes=prodData.clientes||[],
|
||
productos=prodData.productos||[];
|
||
const prodMap={};
|
||
S.ordenes.forEach(o=>{
|
||
if(!o.producto)return;
|
||
if(!prodMap[o.producto]) prodMap[o.producto]={nombre:o.producto,ordenes:0,pzas:0,clientes:new Set()};
|
||
prodMap[o.producto].ordenes++;
|
||
prodMap[o.producto].pzas+=o.cantidad;
|
||
prodMap[o.producto].clientes.add(o.cliente);
|
||
});
|
||
const prodList=Object.values(prodMap).sort((a,b)=>b.ordenes-a.ordenes);
|
||
|
||
// Compute active demand per product (from non-delivered orders)
|
||
const demand={};
|
||
S.ordenes.forEach(o=>{
|
||
if(o.stage==='Entregado'||o.stage==='Cancelado') return;
|
||
if(!o.producto) return;
|
||
demand[o.producto]=(demand[o.producto]||0)+o.cantidad;
|
||
});
|
||
|
||
// Products in orders but NOT in catalog (orphans)
|
||
const catalogNames=new Set(productos.map(p=>p.nombre));
|
||
const orphans=prodList.filter(p=>!catalogNames.has(p.nombre));
|
||
|
||
// Stock alerts (only for products with stock tracking enabled)
|
||
const alerts=productos.filter(p=>p.stock_actual<=p.punto_reorden && p.punto_reorden>0);
|
||
|
||
$('cat-content').innerHTML=`
|
||
${alerts.length?`<div style="background:var(--rdd);border:1px solid var(--rd);border-radius:8px;padding:8px 12px;margin-bottom:12px;font-size:11px">
|
||
<b style="color:var(--rd)">⚠ ${alerts.length} producto${alerts.length>1?'s':''} bajo punto de reorden:</b>
|
||
<span style="color:var(--t2)"> ${alerts.slice(0,5).map(p=>esc(p.nombre)+' ('+p.stock_actual+')').join(' · ')}${alerts.length>5?' +'+(alerts.length-5)+' más':''}</span>
|
||
</div>`:''}
|
||
|
||
<div class="cat-section">
|
||
<h4>Productos
|
||
<span style="font-weight:400;font-size:10px;color:var(--t2)">${productos.length} en catálogo · ${prodList.length} usados en pedidos${(()=>{const sinFoto=productos.filter(p=>!(productFiles[productEntityKey(p)]||[]).some(f=>f.is_image)).length;return sinFoto?` · <b style="color:#d97706">${sinFoto} sin foto</b>`:'';})()}</span>
|
||
<button class="btn btn-sm" onclick="toggleProdThumbs()" title="Mostrar/ocultar fotos">${prodThumbs?'🖼 Ocultar fotos':'📷 Mostrar fotos'}</button>
|
||
<button class="btn btn-sm" onclick="addCatItem('productos')">+ Agregar</button>
|
||
</h4>
|
||
<div style="overflow-x:auto"><table class="grid-tbl prod-tbl">
|
||
<thead><tr>
|
||
<th style="width:36px">Foto</th>
|
||
<th style="width:42px;text-align:center" title="Mostrar en el sitio web público art4hotel.com">🌐 Web</th>
|
||
<th>Producto</th><th>Categoría</th><th style="text-align:center" title="Tipos de personalización disponibles">Trabajos</th>
|
||
<th style="text-align:right">Stock</th>
|
||
<th style="text-align:right">Demanda</th>
|
||
<th style="text-align:right">Disp</th>
|
||
<th style="text-align:right">Reord</th>
|
||
<th style="text-align:right">Costo</th>
|
||
<th style="width:70px"></th>
|
||
</tr></thead>
|
||
<tbody>${productos.length?productos.map(p=>{
|
||
const ord=demand[p.nombre]||0;
|
||
const disp=(p.stock_actual||0)-ord;
|
||
const lowStock=p.punto_reorden>0 && disp<=p.punto_reorden;
|
||
const noStock=disp<=0;
|
||
const key=productEntityKey(p);
|
||
const files=productFiles[key]||[];
|
||
const photos=files.filter(f=>f.is_image);
|
||
const firstPhoto=photos[0];
|
||
const hasPhoto=!!firstPhoto;
|
||
let fotoCell;
|
||
if(prodThumbs){
|
||
fotoCell=hasPhoto
|
||
?`<img src="${firstPhoto.url}" class="prod-thumb" loading="lazy" decoding="async" onclick="event.stopPropagation();openProductFiles('${esc(key)}','${esc(p.nombre)}')" title="${esc(firstPhoto.name)}">`
|
||
:`<div class="prod-thumb-empty" onclick="openProductFiles('${esc(key)}','${esc(p.nombre)}')" title="Sin foto — click para subir" style="cursor:pointer">+</div>`;
|
||
} else {
|
||
fotoCell=hasPhoto
|
||
?`<span class="prod-foto-status has" onclick="openProductFiles('${esc(key)}','${esc(p.nombre)}')" title="Ver/editar fotos (${files.length})">✓</span>`
|
||
:`<span class="prod-foto-status missing" onclick="openProductFiles('${esc(key)}','${esc(p.nombre)}')" title="Sin foto — click para subir">📷</span>`;
|
||
}
|
||
const enWeb=p.mostrar_en_web==1;
|
||
const webCell=`<span class="prod-web-toggle ${enWeb?'on':''}" onclick="toggleProductoWeb(${p.id},${enWeb?0:1})" title="${enWeb?'Visible en art4hotel.com — click para ocultar':'Oculto del sitio — click para publicar'}${!hasPhoto?' (necesita foto para verse)':''}">${enWeb?'🌐':'○'}</span>`;
|
||
const nTrabajos=((p.tipos_trabajo_disponibles||'').split(/[,;|]/).filter(x=>x.trim())).length;
|
||
return`<tr style="${noStock?'background:var(--rdd)':lowStock?'background:var(--ylw,#fff8e1)':''}">
|
||
<td>${fotoCell}</td>
|
||
<td style="text-align:center">${webCell}</td>
|
||
<td><b class="cli-link" onclick="openProductoView(${p.id})" title="Ver detalle">${esc(p.nombre)}</b>${p.logo_diseno?`<div style="font-size:9px;color:var(--t3)">${esc(p.logo_diseno)}</div>`:''}</td>
|
||
<td><span class="tag" style="background:var(--s2);color:var(--t2);font-size:9px">${esc(p.categoria||'-')}</span></td>
|
||
<td style="text-align:center;font-size:10px;color:${nTrabajos?'var(--olive)':'var(--t3)'};font-weight:${nTrabajos?'600':'400'}">${nTrabajos||'-'}</td>
|
||
<td style="text-align:right;font-weight:600">${p.stock_actual||0}</td>
|
||
<td style="text-align:right;color:${ord?'var(--bl)':'var(--t3)'}">${ord||'-'}</td>
|
||
<td style="text-align:right;font-weight:700;color:${noStock?'var(--rd)':lowStock?'var(--or,#d97706)':'var(--gn)'}">${disp}</td>
|
||
<td style="text-align:right;font-size:10px;color:var(--t3)">${p.punto_reorden||'-'}</td>
|
||
<td style="text-align:right;font-size:10px">${p.costo_base?fmt$(p.costo_base):'-'}</td>
|
||
<td>
|
||
<div class="prod-actions">
|
||
<button class="kc-btn edit" onclick="openProductoEdit(${p.id})" title="Editar producto">✎</button>
|
||
<button class="kc-btn" onclick="delItem('productos',${p.id})" title="Eliminar">×</button>
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
}).join(''):'<tr><td colspan="11" style="text-align:center;color:var(--t3);padding:14px">Sin productos en catálogo aún</td></tr>'}</tbody>
|
||
</table></div>
|
||
</div>
|
||
|
||
${orphans.length?`<div class="cat-section" style="border:1px dashed var(--bd);background:var(--s2);padding:10px;border-radius:8px">
|
||
<h4 style="color:var(--olive-dark)">⚠ Productos en pedidos sin registrar en catálogo
|
||
<span style="font-weight:400;font-size:10px;color:var(--t2)">${orphans.length}</span>
|
||
<button class="btn btn-ac btn-sm" onclick="addAllOrphansToCatalog()" style="margin-left:auto">+ Agregar todos al catálogo</button>
|
||
</h4>
|
||
<div style="font-size:10px;color:var(--t2);margin-bottom:8px">Estos nombres aparecen en pedidos pero no están en el catálogo. <b>Agrégalos uno a uno</b> para configurar detalles (color, costo, stock), o <b>"Agregar todos"</b> para registrarlos rápido con valores por default (luego los editas).</div>
|
||
<div style="overflow-x:auto"><table class="grid-tbl">
|
||
<thead><tr><th>Nombre</th><th style="text-align:right">Pedidos</th><th style="text-align:right">Piezas</th><th>Clientes</th><th></th></tr></thead>
|
||
<tbody>${orphans.map(p=>`<tr>
|
||
<td><b>${esc(p.nombre)}</b></td>
|
||
<td style="text-align:right">${p.ordenes}</td>
|
||
<td style="text-align:right">${p.pzas}</td>
|
||
<td style="font-size:9px;color:var(--t2)">${[...p.clientes].slice(0,3).map(c=>esc(c)).join(', ')}${p.clientes.size>3?' +'+(p.clientes.size-3):''}</td>
|
||
<td style="white-space:nowrap;display:flex;gap:3px">
|
||
<button class="rbtn rbtn-edit" onclick="addProductFromOrphan('${esc(p.nombre).replace(/'/g,"\\\\'")}')" title="Registrar y abrir editor">+ Registrar</button>
|
||
<button class="rbtn" onclick="renombrarProducto('${esc(p.nombre).replace(/'/g,"\\\\'")}')" title="Unificar con otro nombre">Renombrar</button>
|
||
</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table></div>
|
||
</div>`:''}
|
||
|
||
<div class="cat-section">
|
||
<h4>Tipos de Trabajo <button class="btn btn-sm" onclick="addCatItem('trabajos')">+ Agregar</button></h4>
|
||
<div class="cat-grid">${trabajos.map(t=>
|
||
`<div class="cat-item"><div><div class="cat-item-name">${esc(t.nombre)}</div><div class="cat-item-meta">${esc(t.clave)} — ${fmt$(t.costo_base)}/pza${t.proveedor_default?' — '+esc(t.proveedor_default):''}</div></div>
|
||
<button class="kc-btn edit" onclick="editItem('trabajos',${t.id})" style="display:inline-flex">✎</button></div>`
|
||
).join('')}${!trabajos.length?'<div class="empty">Sin trabajos</div>':''}</div>
|
||
</div>
|
||
|
||
<div class="cat-section" style="opacity:.6;font-size:10px;color:var(--t3);text-align:center;padding:8px">
|
||
Los Clientes se gestionan desde la pestaña <b>Clientes</b>
|
||
</div>
|
||
|
||
<div class="cat-section">
|
||
<h4>🛡 Respaldos automáticos
|
||
<span style="font-weight:400;font-size:10px;color:var(--t2)">Cada noche a las 00:00 · últimos 30 días</span>
|
||
<button class="btn btn-sm" onclick="runBackupNow()">+ Backup ahora</button>
|
||
</h4>
|
||
<div id="backups-list" style="font-size:10px;color:var(--t3);padding:8px">Cargando...</div>
|
||
</div>`;
|
||
|
||
renderBackupsList();
|
||
}
|
||
|
||
async function renderBackupsList(){
|
||
try{
|
||
const backups=await api('GET','/api/backups');
|
||
if(!backups.length){$('backups-list').innerHTML='Sin respaldos todavía. El primero se hará esta noche.';return;}
|
||
const fmtSize=b=>b>=1048576?(b/1048576).toFixed(1)+' MB':(b/1024).toFixed(0)+' KB';
|
||
const fmtHora=h=>h&&h.length===4?h.slice(0,2)+':'+h.slice(2):'—';
|
||
$('backups-list').innerHTML=`<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:6px">
|
||
${backups.slice(0,30).map((b,i)=>`<div style="background:${i===0?'var(--gnd)':'var(--s2)'};border:1px solid var(--bd);border-radius:6px;padding:6px 8px">
|
||
<div style="font-weight:600;color:var(--olive-dark);font-size:11px">${b.fecha}${i===0?' <span style="font-size:8px;padding:1px 5px;background:var(--gn);color:#fff;border-radius:3px">+RECIENTE</span>':''}</div>
|
||
<div style="font-size:9px;color:var(--t2);margin-top:2px">${fmtHora(b.hora)} · DB ${fmtSize(b.db_size)}${b.uploads_size?' · Archivos '+fmtSize(b.uploads_size):''}</div>
|
||
</div>`).join('')}
|
||
</div>
|
||
<div style="font-size:10px;color:var(--t3);margin-top:8px">
|
||
📂 Los respaldos están en <code>/mnt/iclaude/art4hotel-hub/backups/</code> del servidor.<br>
|
||
⚙ Para restaurar uno, contacta al admin (es una operación manual por seguridad).
|
||
</div>`;
|
||
}catch(e){$('backups-list').innerHTML='Error al cargar respaldos';}
|
||
}
|
||
|
||
async function runBackupNow(){
|
||
if(!confirm('¿Crear un respaldo ahora? Tarda unos segundos.'))return;
|
||
toast('Ejecutando respaldo...');
|
||
try{
|
||
// Run backup via a fire-and-forget request to a special endpoint
|
||
await fetch('/api/backup-now',{method:'POST'});
|
||
setTimeout(async()=>{
|
||
await renderBackupsList();
|
||
toast('✓ Respaldo creado');
|
||
},4000);
|
||
}catch(e){toast('Error: '+e.message)}
|
||
}
|
||
|
||
// ── Vista Inventario (stock-centric) ──
|
||
function renderInventarioView(){
|
||
const q=($('search-inv-prods')?.value||'').toLowerCase();
|
||
const productos=(prodData.productos||S.productos||[]);
|
||
// Compute active demand per product
|
||
const demand={};
|
||
(S.ordenes||[]).forEach(o=>{
|
||
if(o.stage==='Entregado'||o.stage==='Cancelado') return;
|
||
if(!o.producto) return;
|
||
demand[o.producto]=(demand[o.producto]||0)+o.cantidad;
|
||
});
|
||
|
||
// Filter
|
||
let list=productos.filter(p=>!q || (p.nombre+' '+(p.categoria||'')+' '+(p.color||'')).toLowerCase().includes(q));
|
||
|
||
// Compute disponible
|
||
list=list.map(p=>{
|
||
const ord=demand[p.nombre]||0;
|
||
const disp=(p.stock_actual||0)-ord;
|
||
const lowStock=p.punto_reorden>0 && disp<=p.punto_reorden;
|
||
const noStock=disp<=0;
|
||
return{...p,_ord:ord,_disp:disp,_low:lowStock,_no:noStock};
|
||
});
|
||
|
||
// Sort: critical first (no stock), then low stock, then ordered by disponible asc
|
||
list.sort((a,b)=>{
|
||
if(a._no!==b._no) return a._no?-1:1;
|
||
if(a._low!==b._low) return a._low?-1:1;
|
||
return a._disp-b._disp;
|
||
});
|
||
|
||
// Summary
|
||
const total=productos.length;
|
||
const sinStock=list.filter(p=>p._no).length;
|
||
const bajos=list.filter(p=>p._low && !p._no).length;
|
||
const sumDisp=list.reduce((s,p)=>s+Math.max(0,p._disp),0);
|
||
|
||
const rows=list.length?list.map(p=>{
|
||
const key=productEntityKey(p);
|
||
const files=productFiles[key]||[];
|
||
const firstPhoto=files.find(f=>f.is_image);
|
||
const thumb=firstPhoto?`<img src="${firstPhoto.url}" class="inv-thumb" loading="lazy" decoding="async" onclick="event.stopPropagation();openFile('${firstPhoto.url}',true)">`:'<div class="inv-thumb-empty">📦</div>';
|
||
const dispColor=p._no?'#dc2626':p._low?'#d97706':'#16a34a';
|
||
const dispBg=p._no?'#fee2e2':p._low?'#fef3c7':'#dcfce7';
|
||
return`<div class="inv-row">
|
||
${thumb}
|
||
<div class="inv-row-main" onclick="editItem('productos',${p.id})" style="cursor:pointer">
|
||
<div class="inv-row-name">${esc(p.nombre)}</div>
|
||
<div class="inv-row-meta">${[p.categoria,p.color,p.logo_diseno].filter(x=>x).map(esc).join(' · ')||'<span style="opacity:.5">sin detalles</span>'}</div>
|
||
</div>
|
||
<div class="inv-num-block">
|
||
<div class="inv-num-lbl">Stock</div>
|
||
<input type="number" class="inv-stock-input" value="${p.stock_actual||0}" min="0" onchange="updateProdStock(${p.id},this.value)" onclick="event.stopPropagation()" title="Click para editar">
|
||
</div>
|
||
<div class="inv-num-block">
|
||
<div class="inv-num-lbl">En pedidos</div>
|
||
<div class="inv-num-val" style="color:${p._ord?'#3b82f6':'var(--t3)'}">${p._ord||'—'}</div>
|
||
</div>
|
||
<div class="inv-num-block" style="background:${dispBg};border-radius:6px;padding:6px 10px">
|
||
<div class="inv-num-lbl" style="color:${dispColor}">Disponible</div>
|
||
<div class="inv-num-val" style="color:${dispColor};font-weight:800">${p._disp}</div>
|
||
</div>
|
||
<div class="inv-num-block">
|
||
<div class="inv-num-lbl">Reorden</div>
|
||
<input type="number" class="inv-reorden-input" value="${p.punto_reorden||0}" min="0" onchange="updateProdReorden(${p.id},this.value)" onclick="event.stopPropagation()" title="Punto de reorden">
|
||
</div>
|
||
</div>`;
|
||
}).join(''):`<div class="empty">${q?'Sin coincidencias':'Sin productos en catálogo aún'}</div>`;
|
||
|
||
$('inv-prod-content').innerHTML=`
|
||
<div class="inv-summary">
|
||
<div class="inv-stat"><span class="lbl">Total productos</span><span class="val">${total}</span></div>
|
||
<div class="inv-stat"><span class="lbl">Disponibles</span><span class="val">${sumDisp}</span></div>
|
||
<div class="inv-stat${bajos?' warn':''}"><span class="lbl">Bajo reorden</span><span class="val">${bajos}</span></div>
|
||
<div class="inv-stat${sinStock?' crit':''}"><span class="lbl">Sin stock</span><span class="val">${sinStock}</span></div>
|
||
</div>
|
||
<div class="inv-list">${rows}</div>
|
||
`;
|
||
}
|
||
|
||
async function updateProdStock(id,val){
|
||
const v=Math.max(0,parseInt(val)||0);
|
||
await api('PUT',`/api/productos/${id}`,{stock_actual:v});
|
||
toast(`Stock actualizado: ${v}`);
|
||
// Update in-memory + re-render to refresh disponible calc
|
||
const p=(prodData.productos||S.productos||[]).find(x=>x.id===id);
|
||
if(p) p.stock_actual=v;
|
||
if(prodView==='inventario') renderInventarioView();
|
||
}
|
||
async function updateProdReorden(id,val){
|
||
const v=Math.max(0,parseInt(val)||0);
|
||
await api('PUT',`/api/productos/${id}`,{punto_reorden:v});
|
||
const p=(prodData.productos||S.productos||[]).find(x=>x.id===id);
|
||
if(p) p.punto_reorden=v;
|
||
if(prodView==='inventario') renderInventarioView();
|
||
}
|
||
|
||
async function addProductFromOrphan(nombre){
|
||
const sku=nombre.toLowerCase().replace(/[^a-z0-9]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'')+'-'+Date.now().toString(36);
|
||
const res=await api('POST','/api/productos',{sku,nombre,categoria:'bolsa'});
|
||
toast(`✓ "${nombre}" agregado al catálogo`);
|
||
S.productos=await api('GET','/api/productos');
|
||
// Open editor so user can fill in details (color, costo, stock)
|
||
editItem('productos',res.id);
|
||
refreshActiveView();
|
||
}
|
||
|
||
async function addAllOrphansToCatalog(){
|
||
const catalogNames=new Set((S.productos||[]).map(p=>p.nombre));
|
||
const orphanNames=[...new Set(S.ordenes.map(o=>o.producto).filter(n=>n && !catalogNames.has(n)))];
|
||
if(!orphanNames.length){toast('No hay productos sin registrar');return;}
|
||
if(!confirm(`Agregar ${orphanNames.length} producto${orphanNames.length>1?'s':''} al catálogo con valores por default?\n\nDespués podrás editar cada uno individualmente.`))return;
|
||
let added=0;
|
||
for(const nombre of orphanNames){
|
||
const sku=nombre.toLowerCase().replace(/[^a-z0-9]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'')+'-'+Date.now().toString(36)+'-'+added;
|
||
try{
|
||
await api('POST','/api/productos',{sku,nombre,categoria:'bolsa',costo_base:0,stock_actual:0,punto_reorden:0});
|
||
added++;
|
||
}catch(e){}
|
||
}
|
||
toast(`✓ ${added} productos agregados al catálogo`);
|
||
refreshActiveView();
|
||
}
|
||
|
||
async function renombrarProducto(oldName){
|
||
const newName=prompt('Nuevo nombre para: '+oldName,oldName);
|
||
if(!newName||newName.trim()===oldName)return;
|
||
const trimmed=newName.trim();
|
||
// Update all orders with this product name
|
||
const matching=S.ordenes.filter(o=>o.producto===oldName);
|
||
let updated=0;
|
||
for(const o of matching){
|
||
await api('PUT',`/api/ordenes/${o.id}`,{producto:trimmed});
|
||
updated++;
|
||
}
|
||
toast(`✓ ${updated} orden(es) actualizadas: "${trimmed}"`);
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
loadCatalogo();
|
||
}
|
||
|
||
function addCatItem(table){
|
||
const fields={
|
||
productos:[{k:'sku',l:'SKU',ph:'bolsa-cabo-bello'},{k:'nombre',l:'Nombre',ph:'Bolsa Cabo Bello'},{k:'categoria',l:'Categoria',type:'select',opts:['bolsa','accesorio','taza','textil','otro']},{k:'color',l:'Color / Características',ph:'Beige, NA, etc'},{k:'logo_diseno',l:'Logo / Diseño',ph:'Logo Flora Farm'},{k:'tipo_personalizacion',l:'Tipo personalizado',type:'select',opts:['','Bordado','Serigrafia','Grabado laser','Impresion','DTF','Costura']},{k:'costo_base',l:'Costo $/pza',ph:'0',type:'number'},{k:'stock_actual',l:'Stock inicial',ph:'0',type:'number'},{k:'punto_reorden',l:'Punto de reorden',ph:'10',type:'number'},{k:'proveedor',l:'Proveedor',ph:'2 Mares, etc'},{k:'notas',l:'Notas',type:'textarea'}],
|
||
modelos:[{k:'clave',l:'Clave',ph:'marina'},{k:'nombre',l:'Nombre',ph:'Marina'},{k:'descripcion',l:'Descripcion',ph:'Linea nautica',type:'textarea'}],
|
||
materiales:[{k:'clave',l:'Clave',ph:'yute'},{k:'nombre',l:'Nombre',ph:'Yute'},{k:'tipo',l:'Tipo',type:'select',opts:['base','secundario']},{k:'costo_unitario',l:'Costo',ph:'0',type:'number'}],
|
||
trabajos:[{k:'clave',l:'Clave',ph:'bordado'},{k:'nombre',l:'Nombre',ph:'Bordado'},{k:'costo_base',l:'Costo base/pza',ph:'35',type:'number'},{k:'variable_por',l:'Variable por',type:'select',opts:['fijo','complejidad','tamano']},{k:'proveedor_default',l:'Proveedor',ph:'2 Mares'}],
|
||
clientes:[{k:'nombre',l:'Nombre',ph:'Hotel X'},{k:'tipo',l:'Tipo',type:'select',opts:['hotel','tienda','restaurante','distribuidor','independiente','otro']},{k:'contacto',l:'Contacto',ph:'Nombre persona'},{k:'zona_entrega',l:'Zona entrega',type:'select',opts:['','Cabo San Lucas','San Jose del Cabo','Cabo del Este','Todos Santos / Pescadero','La Paz','Nacional','Internacional']},{k:'costo_entrega',l:'Costo entrega',ph:'200',type:'number'},{k:'condiciones_pago',l:'Condiciones pago',type:'select',opts:CONDICIONES_PAGO_OPTS},{k:'notas',l:'Notas',type:'textarea'}],
|
||
}[table]||[];
|
||
|
||
$('cat-mo-h').innerHTML=`Agregar ${table} <button class="mo-x" onclick="closeMo('mo-cat')">×</button>`;
|
||
$('cat-mo-fields').innerHTML=fields.map(f=>{
|
||
if(f.type==='select') return`<div class="fg"><label>${f.l}</label><select class="cf" data-k="${f.k}">${f.opts.map(o=>`<option>${o}</option>`).join('')}</select></div>`;
|
||
if(f.type==='textarea') return`<div class="fg"><label>${f.l}</label><textarea class="cf" data-k="${f.k}" placeholder="${f.ph||''}"></textarea></div>`;
|
||
if(f.type==='number') return`<div class="fg"><label>${f.l}</label><input class="cf" data-k="${f.k}" type="number" step="0.01" placeholder="${f.ph||'0'}"></div>`;
|
||
return`<div class="fg"><label>${f.l}</label><input class="cf" data-k="${f.k}" placeholder="${f.ph||''}"></div>`;
|
||
}).join('');
|
||
$('cat-mo-go').onclick=async()=>{
|
||
const body={};document.querySelectorAll('.cf').forEach(f=>{body[f.dataset.k]=f.value});
|
||
await api('POST',`/api/${table}`,body);
|
||
closeMo('mo-cat');toast('Agregado');loadCatalogo();
|
||
};
|
||
openMo('mo-cat');
|
||
}
|
||
|
||
// ══════ TAREAS KANBAN ══════
|
||
const TASK_STAGES=['backlog','pendiente','en_progreso','en_revision','completada'];
|
||
const TASK_LABELS={backlog:'Backlog',pendiente:'Pendiente',en_progreso:'En Progreso',en_revision:'En Revision',completada:'Completada'};
|
||
|
||
async function loadTareas(){
|
||
S.tareas=await api('GET','/api/tareas');
|
||
renderKB('kb-tareas',S.tareas,TASK_STAGES,renderTareaCard,'tareas');
|
||
renderGrid('tareas',S.tareas,TASK_GRID_COLS,'tbl-tareas');
|
||
}
|
||
|
||
function renderTareaCard(item){
|
||
const pc={alta:'rd',normal:'or',baja:'bl'}[item.prioridad]||'bl';
|
||
return`<div class="kc" draggable="true" ondragstart="ds(event,${item.id},'tareas')" ondragend="de(event)">
|
||
<div class="kc-acts">
|
||
<button class="kc-btn edit" onclick="editItem('tareas',${item.id})">✎</button>
|
||
<button class="kc-btn" onclick="delItem('tareas',${item.id})">×</button>
|
||
</div>
|
||
<div class="kc-t">${esc(item.titulo)}</div>
|
||
${item.descripcion?`<div class="kc-m">${esc(item.descripcion)}</div>`:''}
|
||
<div class="kc-tags">
|
||
<span class="tag" style="background:var(--${pc}d);color:var(--${pc})">${item.prioridad}</span>
|
||
${item.categoria?`<span class="tag" style="background:var(--acd);color:var(--ac)">${item.categoria}</span>`:''}
|
||
${item.asignado?`<span class="tag" style="background:var(--bld);color:var(--bl)">${item.asignado}</span>`:''}
|
||
</div>
|
||
${item.fecha_limite?`<div class="kc-m" style="margin-top:3px">Limite: ${item.fecha_limite}</div>`:''}
|
||
</div>`;
|
||
}
|
||
|
||
// ══════ BITACORA ══════
|
||
async function loadBita(){
|
||
S.bitacora=await api('GET','/api/bitacora');
|
||
renderGrid('bitacora',S.bitacora,BITA_GRID_COLS,'tbl-bitacora');
|
||
$('bita-list').innerHTML=S.bitacora.map(t=>{
|
||
const[c]=cc(t.tipo);
|
||
return`<div class="tl">
|
||
<div class="tl-d" style="border-color:var(--${c})"></div>
|
||
<div style="flex:1">
|
||
<div style="display:flex;align-items:center;gap:5px">
|
||
<div class="tl-t">${esc(t.titulo)}</div>
|
||
<span class="tag" style="background:var(--${cc(t.tipo)[1]});color:var(--${c})">${t.tipo}</span>
|
||
</div>
|
||
${t.descripcion?`<div class="tl-ds">${esc(t.descripcion)}</div>`:''}
|
||
<div class="tl-tm">${t.fecha}${t.referencia?' · '+esc(t.referencia):''}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('')||'<div class="empty">Sin registros</div>';
|
||
}
|
||
|
||
// ══════ KANBAN ENGINE ══════
|
||
function renderKB(boardId,items,cols,cardFn,table){
|
||
const b=$(boardId);
|
||
b.innerHTML=cols.map(col=>{
|
||
const label=TASK_LABELS[col]||col;
|
||
// Preserve incoming order (caller may have sorted); only filter by stage
|
||
const ci=items.filter(i=>i.stage===col);
|
||
const[c,cd]=cc(col);
|
||
return`<div class="kb-col" data-stage="${col}">
|
||
<div class="kb-ch" style="background:var(--${cd});color:var(--${c})">${label}<span class="kb-cnt" style="background:var(--${cd})">${ci.length}</span></div>
|
||
<div class="kb-cb" data-stage="${col}" data-table="${table}" ondragover="dov(event)" ondragleave="dlv(event)" ondrop="drp(event)">
|
||
${ci.map(i=>cardFn(i)).join('')}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// Drag & Drop
|
||
let DD=null;
|
||
function ds(e,id,tbl){DD={id,tbl};e.target.classList.add('drag');e.dataTransfer.effectAllowed='move';}
|
||
function de(e){e.target.classList.remove('drag');}
|
||
function dov(e){e.preventDefault();e.currentTarget.classList.add('over');}
|
||
function dlv(e){e.currentTarget.classList.remove('over');}
|
||
async function drp(e){
|
||
e.preventDefault();e.currentTarget.classList.remove('over');
|
||
if(!DD)return;
|
||
const stage=e.currentTarget.dataset.stage;
|
||
// Intercept: if dropping order to Entregado, show confirmation modal
|
||
if(stage==='Entregado'&&DD.tbl==='ordenes'){
|
||
openEntregaModal(DD.id);
|
||
DD=null;
|
||
return;
|
||
}
|
||
await api('PUT',`/api/${DD.tbl}/${DD.id}`,{stage});
|
||
toast('Movido a '+stage);
|
||
refreshActiveView();
|
||
DD=null;
|
||
}
|
||
|
||
// ══════ ENTREGA FLOW ══════
|
||
let entregaPending={id:null,ordenId:'',files:[]};
|
||
let entregaUploadType='recibo_entrega';
|
||
|
||
function openEntregaModal(ordId){
|
||
const ord=S.ordenes.find(o=>o.id===ordId);
|
||
if(!ord)return;
|
||
// Check for linked orders (same grupo_oc)
|
||
let linkedOrds=[];
|
||
if(ord.grupo_oc){
|
||
linkedOrds=S.ordenes.filter(o=>o.grupo_oc===ord.grupo_oc&&o.id!==ordId&&o.stage!=='Entregado'&&o.stage!=='Cancelado');
|
||
}
|
||
entregaPending={id:ordId,ordenId:ord.orden_id,files:[],linked:linkedOrds.map(o=>o.id)};
|
||
|
||
let infoHtml=`<b>${esc(ord.orden_id)}</b> — ${esc(ord.cliente)}<br>${esc(ord.producto)} — <b>${ord.cantidad}</b> pzas`;
|
||
if(linkedOrds.length){
|
||
infoHtml+=`<div style="margin-top:6px;padding:5px 8px;background:var(--bld);border-radius:4px;font-size:10px;color:var(--bl)">
|
||
<b>🔗 Grupo: ${esc(ord.grupo_oc)}</b> — ${linkedOrds.length} orden(es) vinculada(s):<br>
|
||
${linkedOrds.map(o=>`• ${esc(o.orden_id)} — ${esc(o.producto)} (${o.cantidad} pzas)`).join('<br>')}
|
||
<label style="display:flex;align-items:center;gap:4px;margin-top:4px;cursor:pointer;font-weight:700">
|
||
<input type="checkbox" id="ent-grupo-check" checked style="accent-color:var(--bl)"> Entregar todas juntas
|
||
</label>
|
||
</div>`;
|
||
}
|
||
$('entrega-info').innerHTML=infoHtml;
|
||
$('ent-fecha').value=new Date().toISOString().slice(0,10);
|
||
$('ent-recibio').value='';
|
||
$('ent-notas').value='';
|
||
$('ent-files-preview').innerHTML='';
|
||
// Reset doc btns
|
||
['ent-btn-recibo','ent-btn-foto','ent-btn-factura'].forEach(id=>{$(id).classList.remove('has-file')});
|
||
openMo('mo-entrega');
|
||
}
|
||
|
||
function entregaUploadTipo(tipo){
|
||
entregaUploadType=tipo;
|
||
$('ent-file-input').click();
|
||
}
|
||
|
||
function entregaFileSelected(){
|
||
const input=$('ent-file-input');
|
||
const newFiles=Array.from(input.files).map(f=>({file:f,tipo:entregaUploadType}));
|
||
entregaPending.files.push(...newFiles);
|
||
input.value='';
|
||
// Mark btn as has-file
|
||
const btnMap={'recibo_entrega':'ent-btn-recibo','foto_producto':'ent-btn-foto','factura':'ent-btn-factura'};
|
||
if(btnMap[entregaUploadType]) $(btnMap[entregaUploadType]).classList.add('has-file');
|
||
// Show preview
|
||
$('ent-files-preview').innerHTML=entregaPending.files.map((f,i)=>
|
||
`<div style="padding:2px 6px;border-radius:4px;background:var(--gnd);color:var(--gn);font-size:8px;font-weight:600;display:flex;align-items:center;gap:3px">
|
||
${f.file.name.substring(0,18)}${f.file.name.length>18?'...':''}
|
||
<span style="cursor:pointer;opacity:.6" onclick="removeEntregaFile(${i})">×</span>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
function removeEntregaFile(idx){
|
||
entregaPending.files.splice(idx,1);
|
||
$('ent-files-preview').innerHTML=entregaPending.files.map((f,i)=>
|
||
`<div style="padding:2px 6px;border-radius:4px;background:var(--gnd);color:var(--gn);font-size:8px;font-weight:600;display:flex;align-items:center;gap:3px">
|
||
${f.file.name.substring(0,18)}${f.file.name.length>18?'...':''}
|
||
<span style="cursor:pointer;opacity:.6" onclick="removeEntregaFile(${i})">×</span>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
async function confirmarEntrega(){
|
||
const fecha=$('ent-fecha').value;
|
||
const recibio=$('ent-recibio').value;
|
||
const notas=$('ent-notas').value;
|
||
if(!fecha){toast('Selecciona fecha de entrega');return;}
|
||
|
||
// Collect all order IDs to deliver
|
||
let idsToDeliver=[entregaPending.id];
|
||
const grupoCheck=document.getElementById('ent-grupo-check');
|
||
if(grupoCheck&&grupoCheck.checked&&entregaPending.linked.length){
|
||
idsToDeliver=idsToDeliver.concat(entregaPending.linked);
|
||
}
|
||
|
||
// 1. Update all orders
|
||
for(const id of idsToDeliver){
|
||
const ord=S.ordenes.find(o=>o.id===id);
|
||
const body={stage:'Entregado',fecha_entrega:fecha};
|
||
if(recibio) body.recibio=recibio;
|
||
if(notas&&id===entregaPending.id){
|
||
body.notas=ord&&ord.notas?ord.notas+' | Entrega: '+notas:notas;
|
||
}
|
||
await api('PUT',`/api/ordenes/${id}`,body);
|
||
}
|
||
|
||
// 2. Upload files if any (attach to main order)
|
||
for(const f of entregaPending.files){
|
||
const fd=new FormData();
|
||
fd.append('files',f.file);
|
||
try{
|
||
await fetch(`/api/upload/${encodeURIComponent(entregaPending.ordenId)}?tipo=${f.tipo}`,{method:'POST',body:fd});
|
||
}catch(e){}
|
||
}
|
||
|
||
closeMo('mo-entrega');
|
||
toast(`✓ ${idsToDeliver.length} pedido(s) entregado(s)`);
|
||
if(entregaPending.files.length) loadFileCounts();
|
||
refreshActiveView();
|
||
}
|
||
|
||
// ══════ RECOGER (pickup from production) ══════
|
||
let recogerPending=null;
|
||
|
||
function openRecogerModal(ordId){
|
||
const ord=S.ordenes.find(o=>o.id===ordId);
|
||
if(!ord)return;
|
||
recogerPending=ord;
|
||
$('recoger-info').innerHTML=`<b>${esc(ord.orden_id)}</b> — ${esc(ord.cliente)}<br>${esc(ord.producto)} — <b>${ord.cantidad}</b> pzas<br>Ubicacion: ${ord.stage}`;
|
||
$('rec-piezas').value=ord.cantidad;
|
||
$('rec-notas').value='';
|
||
$('rec-diff-section').style.display='none';
|
||
$('rec-motivo').value='pendiente';
|
||
$('rec-stage-pick').style.display='none';
|
||
// Pre-select current stage as default for pendientes
|
||
$('rec-stage-dest').value=ord.stage;
|
||
openMo('mo-recoger');
|
||
}
|
||
|
||
function recCheckDiff(){
|
||
if(!recogerPending)return;
|
||
const pzas=+$('rec-piezas').value||0;
|
||
const diff=recogerPending.cantidad-pzas;
|
||
if(diff>0){
|
||
$('rec-diff-section').style.display='block';
|
||
$('rec-diff-msg').textContent=`${diff} pieza(s) de diferencia (esperabamos ${recogerPending.cantidad})`;
|
||
} else {
|
||
$('rec-diff-section').style.display='none';
|
||
}
|
||
}
|
||
|
||
function recMotivoChange(){
|
||
const m=$('rec-motivo').value;
|
||
$('rec-stage-pick').style.display=m==='pendiente'?'block':'none';
|
||
}
|
||
|
||
async function confirmarRecogida(){
|
||
if(!recogerPending)return;
|
||
const pzas=+$('rec-piezas').value;
|
||
const notas=$('rec-notas').value;
|
||
if(!pzas||pzas<0){toast('Indica piezas recibidas');return;}
|
||
const hoy=new Date().toISOString().slice(0,10);
|
||
const parentId=recogerPending.orden_id;
|
||
const grupo=recogerPending.grupo_oc||parentId;
|
||
const diff=recogerPending.cantidad-pzas;
|
||
const motivo=diff>0?$('rec-motivo').value:'full';
|
||
|
||
if(diff>0 && motivo==='conteo'){
|
||
// ERROR DE CONTEO: adjust quantity, move all to Almacen
|
||
await api('PUT',`/api/ordenes/${recogerPending.id}`,{
|
||
stage:'En Almacen',cantidad:pzas,
|
||
piezas_recibidas:pzas,fecha_recepcion:hoy,
|
||
nota_recepcion:notas||`Ajuste conteo: ${recogerPending.cantidad} → ${pzas}`
|
||
});
|
||
closeMo('mo-recoger');
|
||
toast(`✓ Ajustado a ${pzas} pzas → Almacen`);
|
||
|
||
} else if(diff>0 && motivo==='danadas'){
|
||
// DAÑADAS: move good ones to Almacen, create Defecto order for replacement
|
||
await api('PUT',`/api/ordenes/${recogerPending.id}`,{
|
||
stage:'En Almacen',cantidad:pzas,
|
||
piezas_recibidas:pzas,piezas_danadas:diff,
|
||
fecha_recepcion:hoy,nota_recepcion:notas,grupo_oc:grupo
|
||
});
|
||
const defId=`${parentId}-DEF`;
|
||
await api('POST','/api/ordenes',{
|
||
orden_id:defId,tipo_orden:'Defecto',
|
||
cliente:recogerPending.cliente,producto:recogerPending.producto,
|
||
sku:recogerPending.sku||'',cantidad:diff,
|
||
tipo_trabajo:recogerPending.tipo_trabajo,stage:'Nuevo',
|
||
fecha_inicio:hoy,grupo_oc:grupo,
|
||
costo_producto:recogerPending.costo_producto,
|
||
costo_trabajo:recogerPending.costo_trabajo,
|
||
costo_logistica:0,precio_factura:0,
|
||
logo_instrucciones:recogerPending.logo_instrucciones||'',
|
||
notas:`Reposicion: ${diff} dañada(s) de ${parentId}`+(notas?' — '+notas:''),
|
||
urgente:recogerPending.urgente
|
||
});
|
||
await api('POST','/api/bitacora',{
|
||
tipo:'problema',
|
||
titulo:`${diff} pza(s) dañada(s) — ${parentId}`,
|
||
descripcion:`Cliente: ${recogerPending.cliente}\nProducto: ${recogerPending.producto}\n${notas?'Nota: '+notas:''}\nOrden reposicion: ${defId}`
|
||
});
|
||
closeMo('mo-recoger');
|
||
toast(`✓ ${pzas} → Almacen, ${diff} dañada(s) → orden Defecto`);
|
||
|
||
} else if(diff>0 && motivo==='pendiente'){
|
||
// PENDIENTES: split — partial to Almacen, rest stays in chosen stage
|
||
const destStage=$('rec-stage-dest').value;
|
||
const existP=S.ordenes.filter(o=>o.orden_id.startsWith(parentId+'-P')).length;
|
||
const childId=`${parentId}-P${existP+1}`;
|
||
await api('POST','/api/ordenes',{
|
||
orden_id:childId,tipo_orden:recogerPending.tipo_orden,
|
||
cliente:recogerPending.cliente,producto:recogerPending.producto,
|
||
sku:recogerPending.sku||'',cantidad:pzas,
|
||
tipo_trabajo:recogerPending.tipo_trabajo,stage:'En Almacen',
|
||
fecha_inicio:recogerPending.fecha_inicio,fecha_recepcion:hoy,
|
||
grupo_oc:grupo,piezas_recibidas:pzas,
|
||
nota_recepcion:notas||'Parcial de '+parentId,
|
||
costo_producto:recogerPending.costo_producto,
|
||
costo_trabajo:recogerPending.costo_trabajo,
|
||
costo_logistica:recogerPending.costo_logistica,precio_factura:0,
|
||
logo_instrucciones:recogerPending.logo_instrucciones||'',
|
||
notas:'Parcial de '+parentId,urgente:recogerPending.urgente
|
||
});
|
||
await api('PUT',`/api/ordenes/${recogerPending.id}`,{
|
||
cantidad:diff,stage:destStage,grupo_oc:grupo
|
||
});
|
||
closeMo('mo-recoger');
|
||
toast(`✓ ${pzas} → Almacen, ${diff} quedan en ${destStage}`);
|
||
|
||
} else {
|
||
// FULL PICKUP: all pieces received
|
||
await api('PUT',`/api/ordenes/${recogerPending.id}`,{
|
||
stage:'En Almacen',piezas_recibidas:pzas,
|
||
fecha_recepcion:hoy,nota_recepcion:notas
|
||
});
|
||
closeMo('mo-recoger');
|
||
toast(`✓ Recogido: ${pzas} pzas → Almacen`);
|
||
}
|
||
|
||
refreshActiveView();
|
||
recogerPending=null;
|
||
}
|
||
|
||
// ══════ QUICK VIEW ══════
|
||
async function openQuickView(ordId){
|
||
const o=S.ordenes.find(x=>x.id===ordId);
|
||
if(!o)return;
|
||
|
||
// Refresh files
|
||
try{
|
||
const files=await api('GET',`/api/files/${encodeURIComponent(o.orden_id)}`);
|
||
pedidoFilesCache[o.orden_id]=files;
|
||
}catch(e){}
|
||
|
||
const[stgC,stgCD]=cc(o.stage);
|
||
const ocRef=o.oc_id&&S.ocs.length?S.ocs.find(x=>x.id===o.oc_id):null;
|
||
const proyRef=o.proyecto_id?(S.proyectos||[]).find(x=>x.id===o.proyecto_id):null;
|
||
const photo=getPedidoPhoto(o);
|
||
|
||
// ── Header — propuesta-style ──
|
||
$('qv-header').innerHTML=`<div class="qv-head">
|
||
<div class="qv-head-main">
|
||
<div class="qv-head-row">
|
||
<span class="qv-badge">PEDIDO</span>
|
||
<h2>${esc(o.orden_id)}</h2>
|
||
${o.urgente?'<span class="qv-urg">🔴 URGENTE</span>':''}
|
||
<span class="qv-stage-pill" style="background:var(--${stgCD});color:var(--${stgC})">${o.stage}</span>
|
||
</div>
|
||
<div class="qv-head-cli">
|
||
<span class="cli-link" onclick="goToCliente('${esc(o.cliente).replace(/'/g,"\\\\'")}')">${esc(o.cliente||'Sin cliente')}</span>
|
||
${o.tipo_orden&&o.tipo_orden!=='OC'?`<span class="qv-tag">${esc(o.tipo_orden)}</span>`:''}
|
||
</div>
|
||
</div>
|
||
<button class="mo-x" onclick="closeMo('mo-quickview')">×</button>
|
||
</div>`;
|
||
|
||
// ── Body: photo (big) + info grid ──
|
||
const photoBlock=`<div class="qv-photo-block">
|
||
${photo?`<img src="${photo.url}" onclick="openFile('${photo.url}',true)" loading="lazy" decoding="async">`:`<div class="qv-photo-empty">Sin foto de avance</div>`}
|
||
<div><button class="qv-photo-upload" onclick="closeMo('mo-quickview');openFilesWithTipo('${o.orden_id}','foto_avance_produccion')">📷 ${photo?'Cambiar foto de avance':'Subir foto de avance'}</button></div>
|
||
</div>`;
|
||
|
||
// Product + qty as the hero info
|
||
const productHero=`<div class="qv-product-hero">
|
||
<div class="qv-product-name">${esc(o.producto||'Sin producto')}</div>
|
||
${o.tipo_trabajo||o.logo_instrucciones?`<div class="qv-product-meta">${[o.tipo_trabajo,o.logo_instrucciones].filter(x=>x).map(esc).join(' · ')}</div>`:''}
|
||
<div class="qv-qty"><b>${o.cantidad}</b> <span>pzas</span></div>
|
||
</div>`;
|
||
|
||
// Linked entities
|
||
const links=[];
|
||
if(ocRef){
|
||
links.push(`<div class="qv-link-card" onclick="closeMo('mo-quickview');openOrdenDetail(${ocRef.id})">
|
||
<span class="qv-link-icon">🔗</span>
|
||
<div>
|
||
<div class="qv-link-label">Orden de Compra</div>
|
||
<div class="qv-link-val">${esc(ocRef.oc_id)} · ${esc(ocRef.cliente)}</div>
|
||
</div>
|
||
<span class="qv-link-arrow">›</span>
|
||
</div>`);
|
||
}
|
||
if(proyRef){
|
||
links.push(`<div class="qv-link-card" onclick="closeMo('mo-quickview');viewProyecto(${proyRef.id})">
|
||
<span class="qv-link-icon">📐</span>
|
||
<div>
|
||
<div class="qv-link-label">Proyecto recurrente</div>
|
||
<div class="qv-link-val">${esc(proyRef.nombre)}</div>
|
||
</div>
|
||
<span class="qv-link-arrow">›</span>
|
||
</div>`);
|
||
}
|
||
const linksBlock=links.length?`<div class="qv-links">${links.join('')}</div>`:'';
|
||
|
||
// Dates compact grid (only those with values)
|
||
const dates=[];
|
||
if(o.fecha_inicio) dates.push({lbl:'Inicio',val:o.fecha_inicio});
|
||
if(o.fecha_recepcion) dates.push({lbl:'Recepción',val:o.fecha_recepcion});
|
||
if(o.fecha_entrega) dates.push({lbl:'Entrega',val:o.fecha_entrega});
|
||
if(o.recibio) dates.push({lbl:'Recibió',val:esc(o.recibio)});
|
||
const datesBlock=dates.length?`<div class="qv-section">
|
||
<div class="qv-section-h">Fechas</div>
|
||
<div class="qv-info-grid">${dates.map(d=>`<div class="qv-info-cell"><span class="lbl">${d.lbl}</span><span class="val">${d.val}</span></div>`).join('')}</div>
|
||
</div>`:'';
|
||
|
||
// Costs (only if any are filled)
|
||
const costoTotal=(+o.costo_producto+ +o.costo_trabajo)*+o.cantidad+ +o.costo_logistica;
|
||
const costFields=[];
|
||
if(o.costo_producto) costFields.push({lbl:'C. Producto',val:`${fmt$(o.costo_producto)}/pza`});
|
||
if(o.costo_trabajo) costFields.push({lbl:'C. Trabajo',val:`${fmt$(o.costo_trabajo)}/pza`});
|
||
if(o.costo_logistica) costFields.push({lbl:'Logística',val:fmt$(o.costo_logistica)});
|
||
if(costoTotal) costFields.push({lbl:'Total interno',val:`<b>${fmt$(costoTotal)}</b>`});
|
||
const costsBlock=costFields.length?`<div class="qv-section">
|
||
<div class="qv-section-h">Costos internos</div>
|
||
<div class="qv-info-grid">${costFields.map(d=>`<div class="qv-info-cell"><span class="lbl">${d.lbl}</span><span class="val">${d.val}</span></div>`).join('')}</div>
|
||
</div>`:'';
|
||
|
||
// Notes
|
||
const notesBlock=o.notas?`<div class="qv-section">
|
||
<div class="qv-section-h">Notas internas</div>
|
||
<div class="qv-notes">${esc(o.notas)}</div>
|
||
</div>`:'';
|
||
|
||
$('qv-body').innerHTML=photoBlock+productHero+linksBlock+datesBlock+costsBlock+notesBlock;
|
||
|
||
// ── Actions — agrupadas por categoría, las primarias primero ──
|
||
let primaryActions='';
|
||
let secondaryActions='';
|
||
let dangerActions='';
|
||
|
||
// Primarias: acciones del stage actual
|
||
if(o.stage==='En 2 Mares'||o.stage==='En Taller Sofia'){
|
||
primaryActions+=`<button class="btn btn-ac" style="background:var(--bl);border-color:var(--bl)" onclick="closeMo('mo-quickview');openRecogerModal(${o.id})">📦 Recoger</button>`;
|
||
}
|
||
if(o.stage==='En Vehiculo'){
|
||
primaryActions+=`<button class="btn btn-ac" onclick="closeMo('mo-quickview');openEntregaModal(${o.id})">✓ Entregar</button>`;
|
||
}
|
||
primaryActions+=`<button class="btn btn-ac" onclick="closeMo('mo-quickview');editItem('ordenes',${o.id})">✎ Editar</button>`;
|
||
|
||
// Secundarias: archivos, logo, duplicar, vínculos
|
||
secondaryActions+=`<button class="btn" onclick="closeMo('mo-quickview');openFilesWithTipo('${o.orden_id}','soporte_trabajo')">📁 Soporte</button>`;
|
||
if(o.proyecto_id){
|
||
const logo=getLogoClienteForPedido(o);
|
||
if(logo){
|
||
secondaryActions+=`<button class="btn" onclick="openFile('${logo.url}',${logo.is_image})" title="Logo del cliente">🎨 Logo cliente</button>`;
|
||
}
|
||
}
|
||
secondaryActions+=`<button class="btn" onclick="closeMo('mo-quickview');duplicarOrden(${o.id})">📋 Duplicar</button>`;
|
||
|
||
// Vínculos (solo si NO está vinculado ya)
|
||
if(!o.proyecto_id){
|
||
secondaryActions+=`<button class="btn" onclick="closeMo('mo-quickview');saveAsProyecto(${o.id})">💾 Guardar como proyecto</button>`;
|
||
const matchingProys=(S.proyectos||[]).filter(pr=>pr.cliente===o.cliente&&pr.activo!==0);
|
||
if(matchingProys.length){
|
||
secondaryActions+=`<button class="btn" onclick="qvLinkProyecto(${o.id})">🔗 Vincular a proyecto</button>`;
|
||
}
|
||
}
|
||
if(!o.oc_id){
|
||
const activeOcs=(S.ocs||[]).filter(x=>x.status==='Activa');
|
||
if(activeOcs.length){
|
||
secondaryActions+=`<div style="display:flex;gap:4px;align-items:center;width:100%;margin-top:2px">
|
||
<select id="qv-oc-sel" style="font-size:11px;padding:6px 8px;border:1px solid var(--olive);border-radius:6px;flex:1;background:var(--s1);font-family:inherit">
|
||
<option value="">🔗 Vincular a Orden de Compra...</option>
|
||
${activeOcs.map(x=>`<option value="${x.id}">${esc(x.oc_id)} — ${esc(x.cliente)}</option>`).join('')}
|
||
</select>
|
||
<button class="btn btn-ac" style="font-size:11px" onclick="qvLinkOC(${o.id})">Vincular</button>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
// Peligrosas
|
||
if(o.proyecto_id){
|
||
dangerActions+=`<button class="btn" onclick="qvUnlinkProyecto(${o.id})" style="border-color:var(--rd);color:var(--rd)">✕ Desvincular proyecto</button>`;
|
||
}
|
||
if(o.oc_id){
|
||
dangerActions+=`<button class="btn" onclick="qvUnlinkOC(${o.id})" style="border-color:var(--rd);color:var(--rd)">✕ Desvincular OC</button>`;
|
||
}
|
||
dangerActions+=`<button class="btn" onclick="openDelPedido(${o.id})" style="border-color:var(--rd);color:var(--rd);margin-left:auto">🗑 Eliminar</button>`;
|
||
|
||
$('qv-actions').innerHTML=primaryActions+secondaryActions+dangerActions;
|
||
|
||
openMo('mo-quickview');
|
||
}
|
||
|
||
// ══════ Quick View OC link ══════
|
||
async function qvLinkOC(ordId){
|
||
const sel=$('qv-oc-sel');
|
||
if(!sel||!sel.value){toast('Selecciona una Orden');return;}
|
||
await api('PUT',`/api/ordenes/${ordId}`,{oc_id:+sel.value});
|
||
toast('Pedido vinculado a Orden');
|
||
closeMo('mo-quickview');
|
||
refreshActiveView();
|
||
}
|
||
async function qvUnlinkOC(ordId){
|
||
await api('PUT',`/api/ordenes/${ordId}`,{oc_id:0});
|
||
toast('Pedido desvinculado');
|
||
closeMo('mo-quickview');
|
||
refreshActiveView();
|
||
}
|
||
|
||
// ══════ VINCULAR PEDIDO ↔ PROYECTO ══════
|
||
let _linkPedidoId=null;
|
||
async function qvLinkProyecto(pedidoId){
|
||
const o=S.ordenes.find(x=>x.id===pedidoId);
|
||
if(!o)return;
|
||
_linkPedidoId=pedidoId;
|
||
if(!S.proyectos) S.proyectos=await api('GET','/api/proyectos');
|
||
|
||
// Match priority: same cliente AND same producto AND same tipo_trabajo → exact match (highlighted)
|
||
// same cliente AND same producto → strong match
|
||
// same cliente → weak match
|
||
const proys=S.proyectos.filter(p=>p.activo!==0&&p.cliente===o.cliente);
|
||
if(!proys.length){toast('No hay proyectos del cliente '+o.cliente);return;}
|
||
|
||
const scoreMatch=(p)=>{
|
||
let s=0;
|
||
if((p.producto_nombre||'').toLowerCase()===(o.producto||'').toLowerCase()) s+=10;
|
||
if((p.tipo_trabajo||'').toLowerCase()===(o.tipo_trabajo||'').toLowerCase()) s+=3;
|
||
return s;
|
||
};
|
||
proys.sort((a,b)=>scoreMatch(b)-scoreMatch(a));
|
||
|
||
$('lp-pedido-info').innerHTML=`<b>${esc(o.orden_id)}</b> · ${esc(o.cliente)}<br>${esc(o.producto)}${o.tipo_trabajo?' · '+esc(o.tipo_trabajo):''} · ${o.cantidad} pzas`;
|
||
|
||
closeMo('mo-quickview');
|
||
|
||
$('lp-proy-list').innerHTML=proys.map(p=>{
|
||
const score=scoreMatch(p);
|
||
const matchLabel=score>=13?'<span style="font-size:9px;padding:1px 6px;background:var(--gn);color:#fff;border-radius:3px;font-weight:600">MATCH EXACTO</span>':score>=10?'<span style="font-size:9px;padding:1px 6px;background:var(--bl);color:#fff;border-radius:3px;font-weight:600">MISMO PRODUCTO</span>':'';
|
||
const photo=getProyectoPhoto(p);
|
||
return`<div class="lp-proy-row" onclick="confirmLinkProyecto(${p.id})">
|
||
${photo?`<img src="${photo}" class="lp-proy-photo" loading="lazy">`:'<div class="lp-proy-photo-empty">📦</div>'}
|
||
<div style="flex:1;min-width:0">
|
||
<div style="display:flex;align-items:center;gap:6px"><b style="font-size:12px">${esc(p.nombre)}</b>${matchLabel}</div>
|
||
<div style="font-size:10px;color:var(--t2);margin-top:1px">
|
||
${p.tipo_trabajo?`<span class="proy-tag" style="background:var(--${cc(p.tipo_trabajo)[1]});color:var(--${cc(p.tipo_trabajo)[0]})">${esc(p.tipo_trabajo)}</span>`:''}
|
||
${p.producto_nombre?`<span>${esc(p.producto_nombre)}</span>`:''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
openMo('mo-link-proy');
|
||
}
|
||
|
||
async function confirmLinkProyecto(proyId){
|
||
if(!_linkPedidoId)return;
|
||
const p=S.proyectos.find(x=>x.id===proyId);
|
||
await api('PUT',`/api/ordenes/${_linkPedidoId}`,{proyecto_id:proyId});
|
||
// Bump usage counter
|
||
if(p){
|
||
await api('PUT',`/api/proyectos/${proyId}`,{
|
||
veces_usado:(p.veces_usado||0)+1,
|
||
ultimo_uso:new Date().toISOString().slice(0,10)
|
||
});
|
||
}
|
||
toast(`Pedido vinculado a "${p?.nombre||proyId}"`);
|
||
closeMo('mo-link-proy');
|
||
_linkPedidoId=null;
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
S.proyectos=await api('GET','/api/proyectos');
|
||
refreshActiveView();
|
||
}
|
||
|
||
async function qvUnlinkProyecto(pedidoId){
|
||
if(!confirm('¿Desvincular este pedido del proyecto?'))return;
|
||
await api('PUT',`/api/ordenes/${pedidoId}`,{proyecto_id:0});
|
||
toast('Desvinculado');
|
||
closeMo('mo-quickview');
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
refreshActiveView();
|
||
}
|
||
|
||
// ══════ GUARDAR COMO PROYECTO RECURRENTE ══════
|
||
async function saveAsProyecto(ordId){
|
||
const o=S.ordenes.find(x=>x.id===ordId);
|
||
if(!o)return;
|
||
if(!S.proyectos) S.proyectos=await api('GET','/api/proyectos');
|
||
if(!S.productos?.length) S.productos=await api('GET','/api/productos');
|
||
// Find matching product in catalog
|
||
const prod=(S.productos||[]).find(p=>p.nombre===o.producto);
|
||
proyEdit={
|
||
id:null,
|
||
nombre:`${o.producto||'Pedido'} · ${o.cliente||''}${o.tipo_trabajo?' · '+o.tipo_trabajo:''}`,
|
||
producto_id:prod?prod.id:null,
|
||
producto_nombre:o.producto||'',
|
||
cliente:o.cliente||'',
|
||
tipo_trabajo:o.tipo_trabajo||'',
|
||
costo_unitario:o.costo_producto||0,
|
||
costo_trabajo:o.costo_trabajo||0,
|
||
logo_descripcion:o.logo_instrucciones||'',
|
||
logo_archivo:'',
|
||
foto_terminado:'',
|
||
notas:`Creado desde pedido ${o.orden_id}`,
|
||
activo:1,veces_usado:0,ultimo_uso:''
|
||
};
|
||
renderProyEditor();
|
||
openMo('mo-proyecto');
|
||
toast('Revisa los datos y guarda');
|
||
}
|
||
|
||
// ══════ DUPLICAR ORDEN ══════
|
||
async function duplicarOrden(ordId){
|
||
const o=S.ordenes.find(x=>x.id===ordId);
|
||
if(!o)return;
|
||
|
||
// Generate new order ID
|
||
const yr=new Date().getFullYear();
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
const newId=generatePedidoId(yr);
|
||
|
||
const body={
|
||
orden_id:newId,
|
||
tipo_orden:o.tipo_orden,
|
||
cliente:o.cliente,
|
||
producto:o.producto,
|
||
sku:o.sku,
|
||
cantidad:o.cantidad,
|
||
tipo_trabajo:o.tipo_trabajo,
|
||
stage:o.stage,
|
||
fecha_oc:o.fecha_oc,
|
||
fecha_inicio:o.fecha_inicio||new Date().toISOString().slice(0,10),
|
||
fecha_estimada:o.fecha_estimada,
|
||
logo_instrucciones:o.logo_instrucciones,
|
||
notas:'',
|
||
costo_producto:o.costo_producto,
|
||
costo_trabajo:o.costo_trabajo,
|
||
costo_logistica:o.costo_logistica,
|
||
precio_factura:0,
|
||
oc_id:o.oc_id||0,
|
||
urgente:o.urgente
|
||
};
|
||
|
||
const res=await api('POST','/api/ordenes',body);
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
toast(`Duplicada: ${newId}`);
|
||
editItem('ordenes',res.id);
|
||
}
|
||
|
||
// ══════ CONSOLIDAR ══════
|
||
function canConsolidate(item){
|
||
if(!item.grupo_oc)return false;
|
||
return S.ordenes.some(o=>o.id!==item.id&&o.grupo_oc===item.grupo_oc&&o.stage===item.stage);
|
||
}
|
||
|
||
async function consolidarOrden(ordId){
|
||
const ord=S.ordenes.find(o=>o.id===ordId);
|
||
if(!ord||!ord.grupo_oc)return;
|
||
const siblings=S.ordenes.filter(o=>o.grupo_oc===ord.grupo_oc&&o.stage===ord.stage&&o.id!==ordId);
|
||
if(!siblings.length){toast('No hay ordenes para consolidar');return;}
|
||
|
||
const totalPzas=ord.cantidad+siblings.reduce((s,o)=>s+o.cantidad,0);
|
||
const names=siblings.map(o=>o.orden_id).join(', ');
|
||
|
||
if(!confirm(`Consolidar ${siblings.length+1} ordenes en una?\n\n${ord.orden_id} (${ord.cantidad}) + ${names}\n= ${totalPzas} piezas total\n\nLas ordenes parciales se eliminaran.`))return;
|
||
|
||
// Keep the base order (shortest ID) as survivor
|
||
const allOrds=[ord,...siblings].sort((a,b)=>a.orden_id.length-b.orden_id.length);
|
||
const survivor=allOrds[0];
|
||
const toDelete=allOrds.slice(1);
|
||
|
||
const totalFactura=allOrds.reduce((s,o)=>s+(o.precio_factura||0),0);
|
||
|
||
await api('PUT',`/api/ordenes/${survivor.id}`,{
|
||
cantidad:totalPzas,
|
||
precio_factura:totalFactura,
|
||
notas:(survivor.notas?survivor.notas+' | ':'')+'Consolidado: '+allOrds.map(o=>o.orden_id+'('+o.cantidad+')').join('+')
|
||
});
|
||
|
||
for(const o of toDelete){
|
||
await api('DELETE',`/api/ordenes/${o.id}`);
|
||
}
|
||
|
||
toast(`✓ Consolidado: ${totalPzas} pzas en ${survivor.orden_id}`);
|
||
refreshActiveView();
|
||
}
|
||
|
||
// ══════ WIZARD (Nueva Orden) ══════
|
||
let wizCur=1;
|
||
const WIZ_STEPS=4;
|
||
|
||
let addingCliente=false;
|
||
function addClienteInline(){
|
||
if(addingCliente)return;
|
||
addingCliente=true;
|
||
const container=$('w-cliente').parentElement;
|
||
const inp=document.createElement('div');
|
||
inp.id='inline-new-cli';
|
||
inp.style.cssText='display:flex;gap:4px;margin-top:4px';
|
||
inp.innerHTML=`<input id="new-cli-name" placeholder="Nombre del cliente" style="flex:1;font-size:14px;padding:8px 10px;border:1px solid var(--olive);border-radius:6px;outline:none;font-family:inherit">
|
||
<button class="btn btn-ac" onclick="saveClienteInline()" style="font-size:11px">OK</button>
|
||
<button class="btn" onclick="cancelClienteInline()" style="font-size:11px">X</button>`;
|
||
container.appendChild(inp);
|
||
document.getElementById('new-cli-name').focus();
|
||
// Enter to save
|
||
document.getElementById('new-cli-name').onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();saveClienteInline();}};
|
||
}
|
||
async function saveClienteInline(){
|
||
const inp=document.getElementById('new-cli-name');
|
||
const nombre=(inp?.value||'').trim();
|
||
if(!nombre){toast('Escribe un nombre');return;}
|
||
await api('POST','/api/clientes',{nombre,tipo:'hotel',contacto:'',zona_entrega:'',costo_entrega:0,condiciones_pago:'Por definir',notas:''});
|
||
S.clientes=await api('GET','/api/clientes');
|
||
const wCli=$('w-cliente');
|
||
wCli.innerHTML='<option value="">-- Seleccionar --</option>'+S.clientes.map(c=>`<option value="${esc(c.nombre)}" data-costo="${c.costo_entrega}">${esc(c.nombre)}</option>`).join('');
|
||
wCli.value=nombre;
|
||
cancelClienteInline();
|
||
toast('Cliente agregado');
|
||
}
|
||
function cancelClienteInline(){
|
||
const el=document.getElementById('inline-new-cli');
|
||
if(el)el.remove();
|
||
addingCliente=false;
|
||
}
|
||
|
||
// ── Inline creation for Historico modal ──
|
||
let addingCliH=false, addingProdH=false;
|
||
function addClienteInlineH(){
|
||
if(addingCliH)return;
|
||
addingCliH=true;
|
||
const container=$('h-cliente').parentElement;
|
||
const inp=document.createElement('div');
|
||
inp.id='inline-new-cli-h';
|
||
inp.style.cssText='display:flex;gap:4px;margin-top:4px';
|
||
inp.innerHTML=`<input id="new-cli-name-h" placeholder="Nombre del cliente" style="flex:1;font-size:14px;padding:8px 10px;border:1px solid var(--olive);border-radius:6px;outline:none;font-family:inherit">
|
||
<button class="btn btn-ac" onclick="saveClienteInlineH()" style="font-size:11px">OK</button>
|
||
<button class="btn" onclick="cancelCliH()" style="font-size:11px">X</button>`;
|
||
container.appendChild(inp);
|
||
document.getElementById('new-cli-name-h').focus();
|
||
document.getElementById('new-cli-name-h').onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();saveClienteInlineH();}};
|
||
}
|
||
async function saveClienteInlineH(){
|
||
const inp=document.getElementById('new-cli-name-h');
|
||
const nombre=(inp?.value||'').trim();
|
||
if(!nombre){toast('Escribe un nombre');return;}
|
||
await api('POST','/api/clientes',{nombre,tipo:'hotel',contacto:'',zona_entrega:'',costo_entrega:0,condiciones_pago:'Por definir',notas:''});
|
||
S.clientes=await api('GET','/api/clientes');
|
||
const sel=$('h-cliente');
|
||
sel.innerHTML='<option value="">-- Seleccionar --</option>'+S.clientes.map(c=>`<option value="${esc(c.nombre)}" data-costo="${c.costo_entrega}">${esc(c.nombre)}</option>`).join('');
|
||
sel.value=nombre;
|
||
cancelCliH();
|
||
toast('Cliente agregado');
|
||
}
|
||
function cancelCliH(){
|
||
const el=document.getElementById('inline-new-cli-h');
|
||
if(el)el.remove();
|
||
addingCliH=false;
|
||
}
|
||
|
||
function editClienteFromH(){
|
||
const nombre=$('h-cliente').value;
|
||
if(!nombre)return;
|
||
const cli=S.clientes.find(c=>c.nombre===nombre);
|
||
if(cli) editItem('clientes',cli.id);
|
||
}
|
||
|
||
function addProductoInlineH(){
|
||
if(addingProdH)return;
|
||
addingProdH=true;
|
||
const container=$('h-producto').parentElement;
|
||
const inp=document.createElement('div');
|
||
inp.id='inline-new-prod-h';
|
||
inp.style.cssText='display:flex;gap:4px;margin-top:4px';
|
||
inp.innerHTML=`<input id="new-prod-name-h" placeholder="Nombre del producto" style="flex:1;font-size:14px;padding:8px 10px;border:1px solid var(--olive);border-radius:6px;outline:none;font-family:inherit">
|
||
<button class="btn btn-ac" onclick="saveProdInlineH()" style="font-size:11px">OK</button>
|
||
<button class="btn" onclick="cancelProdH()" style="font-size:11px">X</button>`;
|
||
container.appendChild(inp);
|
||
document.getElementById('new-prod-name-h').focus();
|
||
document.getElementById('new-prod-name-h').onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();saveProdInlineH();}};
|
||
}
|
||
async function saveProdInlineH(){
|
||
const inp=document.getElementById('new-prod-name-h');
|
||
const nombre=(inp?.value||'').trim();
|
||
if(!nombre){toast('Escribe un nombre');return;}
|
||
await api('POST','/api/productos',{nombre,descripcion:'',unidad:'pza',costo_base:0});
|
||
S.productos=await api('GET','/api/productos');
|
||
const prodNames=new Set(S.productos.map(p=>p.nombre));
|
||
S.ordenes.forEach(o=>{if(o.producto)prodNames.add(o.producto)});
|
||
const sel=$('h-producto');
|
||
sel.innerHTML='<option value="">-- Seleccionar --</option>'+[...prodNames].sort().map(n=>`<option value="${esc(n)}">${esc(n)}</option>`).join('');
|
||
sel.value=nombre;
|
||
cancelProdH();
|
||
toast('Producto agregado');
|
||
}
|
||
function cancelProdH(){
|
||
const el=document.getElementById('inline-new-prod-h');
|
||
if(el)el.remove();
|
||
addingProdH=false;
|
||
}
|
||
|
||
function wizProductoChange(){
|
||
const sel=$('w-producto-sel');
|
||
const opt=sel.options[sel.selectedIndex];
|
||
if(sel.value){
|
||
$('w-producto').value=sel.value;
|
||
$('w-new-prod-fields').style.display='none';
|
||
// Auto-fill trabajo from product's tipo_personalizacion
|
||
if(opt.dataset.tipo){
|
||
$('w-trabajo').value=opt.dataset.tipo;
|
||
const tSel=$('w-trabajo').options[$('w-trabajo').selectedIndex];
|
||
if(tSel&&tSel.dataset.costo) $('w-costo-trab').value=tSel.dataset.costo;
|
||
calcWizPrice();
|
||
}
|
||
// Auto-fill logo in step 4
|
||
if(opt.dataset.logo) $('w-logo').value=opt.dataset.logo;
|
||
} else {
|
||
$('w-producto').value='';
|
||
}
|
||
}
|
||
|
||
let newProdMode=false;
|
||
function toggleNewProducto(){
|
||
newProdMode=!newProdMode;
|
||
$('w-new-prod-fields').style.display=newProdMode?'block':'none';
|
||
$('w-prod-toggle').textContent=newProdMode?'Cancelar':'+ Nuevo';
|
||
if(newProdMode){
|
||
$('w-producto-sel').value='';
|
||
$('w-prod-nombre').value='';
|
||
$('w-prod-color').value='';
|
||
$('w-prod-logo').value='';
|
||
$('w-prod-tipo-pers').value='';
|
||
}
|
||
}
|
||
|
||
async function saveNewProductoIfNeeded(){
|
||
if(!newProdMode||!$('w-prod-nombre').value.trim())return;
|
||
const nombre=$('w-prod-nombre').value.trim();
|
||
const color=$('w-prod-color').value.trim();
|
||
// Reusa los datos del pedido para no preguntar dos veces
|
||
const tipoP=$('w-trabajo').value||'';
|
||
// Create product (limpio: sin logo del cliente — eso va en el proyecto)
|
||
const sku=nombre.toLowerCase().replace(/\s+/g,'-').replace(/[^a-z0-9-]/g,'').substring(0,30);
|
||
await api('POST','/api/productos',{
|
||
sku:sku+'-'+Date.now().toString(36),
|
||
nombre,color,logo_diseno:'',tipo_personalizacion:tipoP,
|
||
categoria:'bolsa'
|
||
});
|
||
// Set hidden field
|
||
$('w-producto').value=nombre;
|
||
if(logo) $('w-logo').value=logo;
|
||
if(tipoP){
|
||
$('w-trabajo').value=tipoP;
|
||
const tSel=$('w-trabajo').options[$('w-trabajo').selectedIndex];
|
||
if(tSel&&tSel.dataset.costo){$('w-costo-trab').value=tSel.dataset.costo;calcWizPrice();}
|
||
}
|
||
toast('Producto guardado en catalogo');
|
||
}
|
||
|
||
function refreshWizProyectos(){
|
||
const cli=$('w-cliente')?.value;
|
||
const row=$('w-proyecto-row');
|
||
const sel=$('w-proyecto-sel');
|
||
if(!row||!sel)return;
|
||
// Only show when a cliente is selected AND has proyectos
|
||
if(!cli){row.style.display='none';return;}
|
||
const proys=(S.proyectos||[]).filter(p=>p.activo!==0&&p.cliente===cli);
|
||
if(!proys.length){row.style.display='none';return;}
|
||
// Populate hidden select (used by wizApplyProyecto)
|
||
sel.innerHTML=`<option value="">— Manual —</option>`+proys.map(p=>`<option value="${p.id}">${esc(p.nombre)}</option>`).join('');
|
||
sel.value='';
|
||
// Render visual cards
|
||
const cards=proys.map(p=>{
|
||
const photo=getProyectoPhoto(p);
|
||
return`<div class="wiz-proy-card" onclick="wizPickProyecto(${p.id})" data-proy-id="${p.id}">
|
||
${photo?`<img src="${photo}" loading="lazy" decoding="async">`:'<div class="wiz-proy-empty">📦</div>'}
|
||
<div class="wiz-proy-info">
|
||
<div class="wiz-proy-name">${esc(p.nombre)}</div>
|
||
<div class="wiz-proy-meta">
|
||
${p.tipo_trabajo?`<span class="proy-tag" style="background:var(--${cc(p.tipo_trabajo)[1]});color:var(--${cc(p.tipo_trabajo)[0]})">${esc(p.tipo_trabajo)}</span>`:''}
|
||
${p.costo_unitario?`<span style="font-size:10px;color:var(--olive);font-weight:600">${fmt$(p.costo_unitario)}/pza</span>`:''}
|
||
${p.veces_usado?`<span style="font-size:9px;color:var(--t3)">🔁 ${p.veces_usado}×</span>`:''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
row.style.display='block';
|
||
row.innerHTML=`<div class="wiz-quick-banner">
|
||
<div class="wiz-quick-title">
|
||
<span style="font-size:14px">✨</span>
|
||
<span>¿Es un trabajo recurrente?</span>
|
||
<span class="wiz-quick-sub">${proys.length} proyecto${proys.length!==1?'s':''} guardado${proys.length!==1?'s':''} de ${esc(cli)}</span>
|
||
</div>
|
||
<div class="wiz-quick-cards">${cards}</div>
|
||
<div class="wiz-quick-hint">O <button type="button" class="wiz-manual-btn" onclick="wizClearProyecto()">capturar manual ↓</button> para configurar producto desde cero.</div>
|
||
</div>`;
|
||
}
|
||
|
||
function wizPickProyecto(id){
|
||
$('w-proyecto-sel').value=id;
|
||
wizApplyProyecto();
|
||
// Visual feedback: highlight selected card
|
||
document.querySelectorAll('.wiz-proy-card').forEach(c=>c.classList.toggle('selected',+c.dataset.proyId===id));
|
||
// Show "selected" banner
|
||
const proy=(S.proyectos||[]).find(p=>p.id===id);
|
||
const row=$('w-proyecto-row');
|
||
if(proy){
|
||
const photo=getProyectoPhoto(proy);
|
||
row.innerHTML=`<div class="wiz-quick-banner selected">
|
||
<div class="wiz-selected-row">
|
||
${photo?`<img src="${photo}" class="wiz-selected-photo" loading="lazy">`:'<div class="wiz-selected-photo-empty">📦</div>'}
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-size:10px;color:var(--olive-dark);text-transform:uppercase;letter-spacing:1px;font-weight:600">✓ Basado en proyecto</div>
|
||
<div style="font-weight:700;font-size:13px;color:var(--olive-dark);margin-top:1px">${esc(proy.nombre)}</div>
|
||
<div style="font-size:10px;color:var(--t2);margin-top:2px">Los datos se llenaron automáticamente. Puedes editar abajo si necesitas.</div>
|
||
</div>
|
||
<button type="button" class="wiz-unlink" onclick="wizClearProyecto()" title="Quitar y capturar manual">✕</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
function wizClearProyecto(){
|
||
$('w-proyecto-sel').value='';
|
||
// Remove hidden proyecto_id field
|
||
const hidden=document.getElementById('w-proyecto-id');
|
||
if(hidden) hidden.value='';
|
||
// Re-render the cards (back to selection mode)
|
||
refreshWizProyectos();
|
||
}
|
||
|
||
function wizApplyProyecto(){
|
||
const id=+$('w-proyecto-sel').value;
|
||
if(!id) return;
|
||
const p=(S.proyectos||[]).find(x=>x.id===id);
|
||
if(!p) return;
|
||
// Pre-fill from project
|
||
if(p.cliente && !$('w-cliente').value) $('w-cliente').value=p.cliente;
|
||
if(p.producto_nombre){
|
||
$('w-producto-sel').value=p.producto_nombre;
|
||
$('w-producto').value=p.producto_nombre;
|
||
wizProductoChange();
|
||
}
|
||
if(p.tipo_trabajo) $('w-trabajo').value=p.tipo_trabajo;
|
||
if(p.costo_unitario) $('w-costo-prod').value=p.costo_unitario;
|
||
if(p.costo_trabajo) $('w-costo-trab').value=p.costo_trabajo;
|
||
if(p.logo_descripcion) $('w-logo').value=p.logo_descripcion;
|
||
// Save proyecto_id in a hidden field
|
||
let hidden=document.getElementById('w-proyecto-id');
|
||
if(!hidden){
|
||
hidden=document.createElement('input');
|
||
hidden.type='hidden';hidden.id='w-proyecto-id';
|
||
$('w-proyecto-row').appendChild(hidden);
|
||
}
|
||
hidden.value=id;
|
||
calcWizPrice();
|
||
toast(`Cargado: ${p.nombre}`);
|
||
}
|
||
|
||
function closeWizard(){
|
||
// Check if user has entered any data
|
||
const hasData=$('w-cliente').value||$('w-producto').value||$('w-orden-id').value.includes('ORD-');
|
||
if(hasData && wizCur>1){
|
||
if(!confirm('Tienes datos sin guardar. ¿Cerrar de todos modos?'))return;
|
||
}
|
||
closeMo('mo-wizard');
|
||
}
|
||
|
||
async function openWizard(){
|
||
// Load catalog data for dropdowns
|
||
if(!S.clientes.length) S.clientes=await api('GET','/api/clientes');
|
||
if(!S.trabajos.length) S.trabajos=await api('GET','/api/trabajos');
|
||
S.productos=await api('GET','/api/productos');
|
||
S.proyectos=await api('GET','/api/proyectos');
|
||
|
||
// Populate client dropdown
|
||
const wCli=$('w-cliente');
|
||
wCli.innerHTML='<option value="">-- Seleccionar --</option>'+S.clientes.map(c=>`<option value="${esc(c.nombre)}" data-costo="${c.costo_entrega}">${esc(c.nombre)}</option>`).join('');
|
||
|
||
// Populate producto dropdown — SOLO nombres limpios del catálogo (sin personalizaciones heredadas)
|
||
const wProd=$('w-producto-sel');
|
||
const prodNames=new Map();
|
||
S.productos.forEach(p=>prodNames.set(p.nombre,{logo:'',tipo:''}));
|
||
// Include unique names from past orders that aren't in catalog (orphans), but show ONLY the name
|
||
S.ordenes.forEach(o=>{if(o.producto && !prodNames.has(o.producto)) prodNames.set(o.producto,{logo:'',tipo:''})});
|
||
wProd.innerHTML='<option value="">-- Seleccionar producto --</option>'+[...prodNames.entries()].sort((a,b)=>a[0].localeCompare(b[0])).map(([name])=>
|
||
`<option value="${esc(name)}">${esc(name)}</option>`
|
||
).join('');
|
||
$('w-producto').value='';
|
||
$('w-new-prod-fields').style.display='none';
|
||
$('w-prod-toggle').textContent='+ Nuevo';
|
||
|
||
// Populate tipo personalizacion from trabajos
|
||
$('w-prod-tipo-pers').innerHTML='<option value="">-- Seleccionar --</option>'+S.trabajos.map(t=>`<option value="${esc(t.nombre)}">${esc(t.nombre)}</option>`).join('');
|
||
|
||
// Populate trabajo dropdown
|
||
const wTrab=$('w-trabajo');
|
||
wTrab.innerHTML='<option value="">-- Seleccionar --</option>'+S.trabajos.map(t=>`<option value="${esc(t.nombre)}" data-costo="${t.costo_base}">${esc(t.nombre)} (${fmt$(t.costo_base)}/pza)</option>`).join('');
|
||
|
||
// Populate OC dropdown
|
||
S.ocs=await api('GET','/api/oc');
|
||
const wOcSel=$('w-oc-sel');
|
||
const activeOcs=S.ocs.filter(o=>o.status==='Activa');
|
||
wOcSel.innerHTML='<option value="">-- Sin orden (pedido suelto) --</option>'+activeOcs.map(o=>`<option value="${o.id}">${esc(o.oc_id)} — ${esc(o.cliente)} (${o.n_lineas} pedidos)</option>`).join('');
|
||
|
||
// Auto-fill order ID
|
||
$('w-orden-id').value=generatePedidoId(2026);
|
||
|
||
// Client change -> auto-fill logistica + refresh proyectos list
|
||
wCli.onchange=()=>{
|
||
const sel=wCli.options[wCli.selectedIndex];
|
||
if(sel.dataset.costo) $('w-costo-log').value=sel.dataset.costo;
|
||
calcWizPrice();
|
||
refreshWizProyectos();
|
||
};
|
||
refreshWizProyectos();
|
||
// Trabajo change -> auto-fill costo trabajo
|
||
wTrab.onchange=()=>{
|
||
const sel=wTrab.options[wTrab.selectedIndex];
|
||
if(sel.dataset.costo) $('w-costo-trab').value=sel.dataset.costo;
|
||
calcWizPrice();
|
||
};
|
||
|
||
wizCur=1;
|
||
updateWizard();
|
||
openMo('mo-wizard');
|
||
}
|
||
|
||
function wizStep(dir){
|
||
if(dir===1 && wizCur===WIZ_STEPS){
|
||
// Submit
|
||
submitWizard();
|
||
return;
|
||
}
|
||
wizCur=Math.max(1,Math.min(WIZ_STEPS,wizCur+dir));
|
||
updateWizard();
|
||
}
|
||
|
||
function updateWizard(){
|
||
document.querySelectorAll('.wizard-step').forEach(s=>{
|
||
s.classList.toggle('active',+s.dataset.step===wizCur);
|
||
});
|
||
$('wiz-prev').style.visibility=wizCur===1?'hidden':'visible';
|
||
$('wiz-next').textContent=wizCur===WIZ_STEPS?'Crear Orden':'Siguiente';
|
||
$('wiz-next').className=wizCur===WIZ_STEPS?'btn btn-ac':'btn btn-ac';
|
||
|
||
// Dots
|
||
$('wiz-dots').innerHTML=Array.from({length:WIZ_STEPS},(_,i)=>`<div class="wizard-dot ${i+1===wizCur?'on':''}"></div>`).join('');
|
||
}
|
||
|
||
function calcWizPrice(){
|
||
const cp=+($('w-costo-prod').value||0);
|
||
const ct=+($('w-costo-trab').value||0);
|
||
const cl=+($('w-costo-log').value||0);
|
||
const cant=+($('w-cantidad').value||0);
|
||
const fac=+($('w-factura').value||0);
|
||
const total=(cp+ct)*cant+cl;
|
||
const util=fac-total;
|
||
const margen=fac>0?Math.round(util/fac*100):0;
|
||
$('wpc-total').textContent=fmt$(total);
|
||
$('wpc-factura').textContent=fmt$(fac);
|
||
$('wpc-util').textContent=`${fmt$(util)} (${margen}%)`;
|
||
$('wpc-util').style.color=util>=0?'var(--gn)':'var(--rd)';
|
||
}
|
||
|
||
async function submitWizard(){
|
||
// Save new product to catalog if creating one
|
||
await saveNewProductoIfNeeded();
|
||
const b={
|
||
orden_id:$('w-orden-id').value,
|
||
tipo_orden:$('w-tipo').value,
|
||
cliente:$('w-cliente').value,
|
||
producto:$('w-producto').value,
|
||
cantidad:+$('w-cantidad').value,
|
||
tipo_trabajo:$('w-trabajo').value,
|
||
stage:$('w-stage').value,
|
||
costo_producto:+$('w-costo-prod').value,
|
||
costo_trabajo:+$('w-costo-trab').value,
|
||
costo_logistica:+$('w-costo-log').value,
|
||
precio_factura:+$('w-factura').value,
|
||
logo_instrucciones:$('w-logo').value,
|
||
notas:$('w-notas').value,
|
||
urgente:+$('w-urgente').value,
|
||
oc_id:+$('w-oc-sel').value||0,
|
||
proyecto_id:+$('w-proyecto-id')?.value||0,
|
||
fecha_inicio:new Date().toISOString().split('T')[0]
|
||
};
|
||
if(!b.orden_id){toast('Orden ID requerido');return;}
|
||
if(!b.cliente){toast('Selecciona un cliente');wizCur=1;updateWizard();return;}
|
||
if(!b.producto){toast('Selecciona o crea un producto');wizCur=2;updateWizard();return;}
|
||
const res=await api('POST','/api/ordenes',b);
|
||
// Bump proyecto usage if explicit project selected
|
||
if(b.proyecto_id){
|
||
const p=(S.proyectos||[]).find(x=>x.id===b.proyecto_id);
|
||
if(p){
|
||
await api('PUT',`/api/proyectos/${b.proyecto_id}`,{
|
||
veces_usado:(p.veces_usado||0)+1,
|
||
ultimo_uso:new Date().toISOString().slice(0,10)
|
||
});
|
||
}
|
||
} else if(res?.id){
|
||
// No explicit project — try auto-linking based on exact match
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
await autoLinkPedidoToProyecto(res.id);
|
||
}
|
||
closeMo('mo-wizard');toast('Pedido creado');
|
||
refreshActiveView();
|
||
// If we came from "Add Pedido to Orden" context, return to the Orden detail
|
||
if(window._pendingOcContext){
|
||
const ctx=window._pendingOcContext;
|
||
window._pendingOcContext=null;
|
||
setTimeout(()=>openOrdenDetail(ctx.id),200);
|
||
}
|
||
}
|
||
|
||
// ══════ Orden Detail (visualizador) ══════
|
||
async function openOrdenDetail(ocId){
|
||
// Refresh data
|
||
S.ocs=await api('GET','/api/oc');
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
const oc=S.ocs.find(o=>o.id===ocId);
|
||
if(!oc){toast('Orden no encontrada');return;}
|
||
|
||
const progressColors={Entregado:'var(--gn)','En proceso':'var(--bl)',Parcial:'var(--yl)'};
|
||
const pColor=progressColors[oc.progress]||'var(--t2)';
|
||
|
||
// Sister/origin info
|
||
const origenOc=oc.oc_origen_id?(S.ocs||[]).find(x=>x.id==oc.oc_origen_id):null;
|
||
const hermanas=(S.ocs||[]).filter(x=>x.oc_origen_id==oc.id || (oc.oc_origen_id && x.oc_origen_id==oc.oc_origen_id && x.id!==oc.id) || (oc.oc_origen_id && x.id==oc.oc_origen_id));
|
||
const sisterHtml=origenOc||hermanas.length?`<div style="font-size:10px;color:var(--t2);margin-top:3px">
|
||
${origenOc?`<span class="cli-link" onclick="openOrdenDetail(${origenOc.id})" style="color:var(--olive)">🔗 Entrega parcial de ${esc(origenOc.oc_id)}</span>`:''}
|
||
${hermanas.length?` ${origenOc?'· ':''}Hermanas: ${hermanas.map(h=>`<span class="cli-link" onclick="openOrdenDetail(${h.id})" style="color:var(--olive)">${esc(h.oc_id)}</span>`).join(', ')}`:''}
|
||
</div>`:'';
|
||
|
||
// Header
|
||
$('od-header').innerHTML=`<div class="od-head">
|
||
<div class="od-head-left">
|
||
<h2>
|
||
<span style="font-size:10px;padding:3px 8px;border-radius:4px;background:var(--olive);color:#fff;font-weight:700;letter-spacing:.5px">ORDEN</span>
|
||
${esc(oc.oc_id)}
|
||
<span class="od-status-pill" style="background:${pColor}20;color:${pColor}">${oc.progress}</span>
|
||
</h2>
|
||
<div class="od-cli">${esc(oc.cliente)}${oc.fecha_oc?' · '+oc.fecha_oc:''}</div>
|
||
${sisterHtml}
|
||
</div>
|
||
<button class="mo-x" onclick="closeMo('mo-orden')">×</button>
|
||
</div>`;
|
||
|
||
// Render full body — pedidos + configurar + totales + acciones
|
||
renderOrdenDetailBody(oc);
|
||
openMo('mo-orden');
|
||
}
|
||
|
||
function renderOrdenDetailBody(oc){
|
||
// ── Pedidos enriquecidos (foto, producto, trabajo, costos) ──
|
||
const pedidos=oc.lineas||[];
|
||
let pedidosHtml='';
|
||
if(pedidos.length){
|
||
pedidosHtml=pedidos.map(p=>{
|
||
const[sc]=cc(p.stage);
|
||
const lineCost=(p.costo_producto+p.costo_trabajo)*p.cantidad;
|
||
const photo=getPedidoPhoto(p);
|
||
const meta=[
|
||
p.tipo_trabajo,
|
||
p.logo_instrucciones?`logo: ${p.logo_instrucciones}`:''
|
||
].filter(x=>x).join(' · ');
|
||
return`<div class="od-pedido-card" onclick="closeMo('mo-orden');openQuickView(${p.id})">
|
||
${photo?`<img src="${photo.url}" class="odp-photo" loading="lazy" decoding="async" onclick="event.stopPropagation();openFile('${photo.url}',true)">`:'<div class="odp-photo-empty">📦</div>'}
|
||
<div class="odp-info">
|
||
<div class="odp-head">
|
||
<span class="stage-dot" style="background:var(--${sc})"></span>
|
||
<b>${esc(p.orden_id)}</b>
|
||
<span class="pr-stage" style="background:var(--${cc(p.stage)[1]});color:var(--${sc})">${p.stage}</span>
|
||
</div>
|
||
<div class="odp-prod">${esc(p.producto)}</div>
|
||
${meta?`<div class="odp-meta">${esc(meta)}</div>`:''}
|
||
<div class="odp-bottom">
|
||
<span class="odp-qty"><b>${p.cantidad}</b> pzas</span>
|
||
${lineCost?`<span class="odp-cost">${fmt$(lineCost)}</span>`:''}
|
||
</div>
|
||
</div>
|
||
<button class="kc-btn odp-del" onclick="event.stopPropagation();openDelPedido(${p.id})" title="Eliminar pedido">🗑</button>
|
||
</div>`;
|
||
}).join('');
|
||
} else {
|
||
pedidosHtml='<div style="font-size:11px;color:var(--t3);text-align:center;padding:12px">Sin pedidos vinculados</div>';
|
||
}
|
||
|
||
// Compute totals
|
||
const sub=oc.precio_factura||0;
|
||
const ivaPct=oc.iva_pct!=null?oc.iva_pct:16;
|
||
const iva=sub*ivaPct/100;
|
||
const totalIva=sub+iva;
|
||
const costoProd=oc.costo_produccion||0;
|
||
const otrosG=oc.otros_gastos||0;
|
||
const logG=oc.costo_logistica||0;
|
||
const fullCost=costoProd+logG+otrosG;
|
||
const util=sub-fullCost;
|
||
const margen=sub>0?Math.round(util/sub*100):0;
|
||
|
||
$('od-body').innerHTML=`
|
||
<!-- Pedidos -->
|
||
<div class="od-section">
|
||
<div class="od-section-title">
|
||
<span>Pedidos (${pedidos.length} · ${oc.total_piezas||0} pzas)</span>
|
||
${oc.status==='Activa'?`<div style="display:flex;gap:4px">
|
||
<button class="oc-link-btn" onclick="addPedidoToOrden(${oc.id})">+ Nuevo Pedido</button>
|
||
<button class="oc-link-btn" onclick="showLinkExistingPedido(${oc.id})" style="background:var(--s2);color:var(--t2);border-color:var(--bd)">Vincular existente</button>
|
||
</div>`:''}
|
||
</div>
|
||
<div class="od-pedidos-list">${pedidosHtml}</div>
|
||
<div id="od-link-existing" style="display:none;margin-top:6px;padding:8px;background:var(--s2);border-radius:6px"></div>
|
||
</div>
|
||
|
||
<!-- Subtotal (always visible, primary action) -->
|
||
<div class="od-section">
|
||
<div class="od-quick-edit">
|
||
<div class="qe-row">
|
||
<div class="qe-field"><label>Subtotal factura</label><input type="number" step="0.01" id="od-sub" value="${sub||''}" oninput="recalcOd()" placeholder="0.00"></div>
|
||
<div class="qe-field qe-narrow"><label>IVA %</label><input type="number" step="0.01" id="od-iva" value="${ivaPct}" oninput="recalcOd()"></div>
|
||
<div class="qe-field"><label>Nº factura</label><input id="od-factura-num" value="${esc(oc.factura_num||'')}" placeholder="A123..."></div>
|
||
</div>
|
||
<div class="qe-totals" id="od-totales">
|
||
<div class="qe-tot-row"><span>Subtotal</span><span id="od-t-sub">${fmt$(sub)}</span></div>
|
||
<div class="qe-tot-row"><span>IVA <span id="od-t-ivapct">${ivaPct}</span>%</span><span id="od-t-iva">${fmt$(iva)}</span></div>
|
||
<div class="qe-tot-row big"><span>Total con IVA</span><span id="od-t-total">${fmt$(totalIva)}</span></div>
|
||
<div class="qe-tot-row util" style="color:${util>=0?'var(--gn)':'var(--rd)'}" id="od-t-utilrow"><span>Utilidad neta · costo prod <span id="od-t-prod">${fmt$(costoProd)}</span></span><span><b id="od-t-util">${fmt$(util)}</b> <span id="od-t-margen">(${margen}%)</span></span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Detalles colapsable -->
|
||
<button type="button" class="od-details-toggle" onclick="toggleOdDetails()" id="od-det-btn">
|
||
<span>▾ Más detalles</span>
|
||
</button>
|
||
<div class="od-section" id="od-details" style="display:none">
|
||
<div class="od-config-grid">
|
||
<div class="fg"><label>Fecha Orden</label><input type="date" id="od-fecha" value="${esc(oc.fecha_oc||'')}"></div>
|
||
<div class="fg"><label>Fecha entrega</label><input type="date" id="od-fecha-entrega" value="${esc(oc.fecha_entrega||'')}"></div>
|
||
<div class="fg"><label>Recibió</label><input id="od-recibio" value="${esc(oc.recibio||'')}" placeholder="Nombre de quien recibió"></div>
|
||
<div class="fg"><label>Condiciones de pago</label><select id="od-pago">
|
||
${CONDICIONES_PAGO_OPTS.map(p=>`<option${p===oc.condiciones_pago?' selected':''}>${p}</option>`).join('')}
|
||
</select></div>
|
||
<div class="fg"><label>Logística ($)</label><input type="number" step="0.01" id="od-log" value="${logG||''}" oninput="recalcOd()"></div>
|
||
<div class="fg"><label>Otros gastos ($)</label><input type="number" step="0.01" id="od-otros" value="${otrosG||''}" oninput="recalcOd()"></div>
|
||
<div class="fg" style="grid-column:1/-1"><label>Concepto otros gastos</label><input id="od-otros-desc" value="${esc(oc.otros_gastos_desc||'')}" placeholder="Comisión, flete, etc."></div>
|
||
<div class="fg"><label>Estado</label><select id="od-status">
|
||
${['Activa','Cerrada','Cancelada'].map(s=>`<option${s===oc.status?' selected':''}>${s}</option>`).join('')}
|
||
</select></div>
|
||
<div class="fg"><label>Pagado</label><select id="od-pagado">
|
||
<option value="0"${!oc.pagado?' selected':''}>No</option>
|
||
<option value="1"${oc.pagado?' selected':''}>Sí</option>
|
||
</select></div>
|
||
<div class="fg"><label>Fecha pago</label><input type="date" id="od-fecha-pago" value="${esc(oc.fecha_pago||'')}"></div>
|
||
<div class="fg"><label>Método de pago</label><select id="od-metodo">
|
||
${['','Efectivo','Transferencia','Cheque','Depósito','Tarjeta'].map(m=>`<option${m===(oc.metodo_pago||'')?' selected':''}>${m}</option>`).join('')}
|
||
</select></div>
|
||
<div class="fg" style="grid-column:1/-1"><label>Notas</label><textarea id="od-notas" rows="2">${esc(oc.notas||'')}</textarea></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="od-actions">
|
||
<button class="btn btn-ac" onclick="saveOrdenDetail(${oc.id})">✓ Guardar</button>
|
||
<button class="btn" onclick="openFilesWithTipo('${esc(oc.oc_id)}','factura')">📁 Soporte</button>
|
||
${pedidos.length>1 && oc.status==='Activa'?`<button class="btn" style="border-color:var(--ac);color:var(--ac)" onclick="openEntregaParcial(${oc.id})" title="Crear Orden hermana con los pedidos que se entregan hoy">✊ Parcial</button>`:''}
|
||
<button class="btn" style="border-color:var(--rd);color:var(--rd);margin-left:auto" onclick="deleteOrdenDetail(${oc.id})">🗑</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function toggleOdDetails(){
|
||
const el=$('od-details');
|
||
const btn=$('od-det-btn');
|
||
const open=el.style.display!=='none';
|
||
el.style.display=open?'none':'block';
|
||
btn.querySelector('span').textContent=open?'▾ Más detalles':'▴ Ocultar detalles';
|
||
}
|
||
|
||
// ── Eliminar Pedido (borrar / cancelar) ──
|
||
let _delPedidoId=null;
|
||
function openDelPedido(pedidoId){
|
||
const p=S.ordenes.find(x=>x.id===pedidoId);
|
||
if(!p)return;
|
||
_delPedidoId=pedidoId;
|
||
$('del-pedido-info').innerHTML=`<b>${esc(p.orden_id)}</b> — ${esc(p.cliente)} · ${esc(p.producto)} · ${p.cantidad} pzas<br><span style="font-size:10px">Stage actual: ${esc(p.stage)}</span>`;
|
||
// Close any source modal
|
||
closeMo('mo-quickview');
|
||
openMo('mo-del-pedido');
|
||
}
|
||
async function confirmCancelarPedido(){
|
||
if(!_delPedidoId)return;
|
||
await api('PUT',`/api/ordenes/${_delPedidoId}`,{stage:'Cancelado'});
|
||
toast('Pedido marcado como Cancelado');
|
||
closeMo('mo-del-pedido');
|
||
_delPedidoId=null;
|
||
refreshActiveView();
|
||
// If we were viewing an Orden detail, refresh it
|
||
if($('mo-orden').classList.contains('show')){/* already closed; reopen if was open */}
|
||
}
|
||
async function confirmBorrarPedido(){
|
||
if(!_delPedidoId)return;
|
||
const p=S.ordenes.find(x=>x.id===_delPedidoId);
|
||
if(!confirm(`¿Borrar definitivamente ${p?.orden_id||'el pedido'}? Esta acción NO se puede deshacer.`))return;
|
||
await api('DELETE',`/api/ordenes/${_delPedidoId}`);
|
||
toast('Pedido eliminado');
|
||
closeMo('mo-del-pedido');
|
||
_delPedidoId=null;
|
||
refreshActiveView();
|
||
}
|
||
|
||
let _parcialOcId=null;
|
||
async function openEntregaParcial(ocId){
|
||
const oc=S.ocs.find(o=>o.id===ocId);
|
||
if(!oc)return;
|
||
_parcialOcId=ocId;
|
||
const pedidosActivos=(oc.lineas||[]).filter(p=>p.stage!=='Entregado'&&p.stage!=='Cancelado');
|
||
if(!pedidosActivos.length){toast('No hay pedidos pendientes que entregar');return;}
|
||
|
||
$('ep-intro').innerHTML=`Selecciona los pedidos que se entregan hoy. Quedará una <b>Orden hermana</b> con ellos (facturada/cobrada aparte). Los no seleccionados se quedan en <b>${esc(oc.oc_id)}</b> para entregar después.`;
|
||
|
||
$('ep-pedidos-list').innerHTML=pedidosActivos.map(p=>{
|
||
const[sc]=cc(p.stage);
|
||
return`<label class="oc-line-row" style="background:var(--s1);cursor:pointer">
|
||
<input type="checkbox" class="ep-cb" value="${p.id}" data-cant="${p.cantidad}" onchange="recalcEpHint()" checked style="margin-right:6px">
|
||
<span class="stage-dot" style="background:var(--${sc});width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:5px"></span>
|
||
<b style="margin-right:6px">${esc(p.orden_id)}</b>
|
||
<span style="color:var(--t2);flex:1">${esc(p.producto)}</span>
|
||
<span><b>${p.cantidad}</b> pzas</span>
|
||
<span class="tag" style="font-size:9px;background:var(--${cc(p.stage)[1]});color:var(--${sc});margin-left:6px">${p.stage}</span>
|
||
</label>`;
|
||
}).join('');
|
||
|
||
// Suggest new folio (append a letter/number)
|
||
const base=oc.oc_id;
|
||
// Find existing sisters' suffixes
|
||
const hermanas=S.ocs.filter(x=>x.oc_origen_id===oc.id||x.oc_id.startsWith(base+'-'));
|
||
const next=String.fromCharCode(65+hermanas.length); // A, B, C...
|
||
$('ep-oc-id').value=`${base}-${next}`;
|
||
$('ep-fecha').value=new Date().toISOString().slice(0,10);
|
||
$('ep-recibio').value=oc.recibio||'';
|
||
$('ep-sub').value=0;
|
||
$('ep-iva').value=16;
|
||
$('ep-log').value=0;
|
||
$('ep-otros').value=0;
|
||
$('ep-factura-num').value='';
|
||
$('ep-pago').value=oc.condiciones_pago||'Por definir';
|
||
$('ep-notas').value=`Entrega parcial de ${base}`;
|
||
|
||
closeMo('mo-orden');
|
||
openMo('mo-parcial');
|
||
}
|
||
function recalcEpHint(){
|
||
// (puede usarse para mostrar contadores)
|
||
}
|
||
async function confirmarEntregaParcial(){
|
||
const selected=[...document.querySelectorAll('.ep-cb:checked')].map(cb=>+cb.value);
|
||
if(!selected.length){toast('Selecciona al menos un pedido');return;}
|
||
const newOcId=$('ep-oc-id').value.trim();
|
||
const fecha=$('ep-fecha').value;
|
||
if(!newOcId){toast('Folio requerido');return;}
|
||
if(!fecha){toast('Fecha requerida');return;}
|
||
const body={
|
||
pedidos_ids:selected,
|
||
oc_id:newOcId,
|
||
fecha_entrega:fecha,
|
||
recibio:$('ep-recibio').value,
|
||
precio_factura:+$('ep-sub').value||0,
|
||
iva_pct:+$('ep-iva').value||0,
|
||
costo_logistica:+$('ep-log').value||0,
|
||
otros_gastos:+$('ep-otros').value||0,
|
||
factura_num:$('ep-factura-num').value,
|
||
condiciones_pago:$('ep-pago').value,
|
||
notas:$('ep-notas').value
|
||
};
|
||
const res=await fetch(`/api/oc-split/${_parcialOcId}`,{
|
||
method:'POST',headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify(body)
|
||
});
|
||
const data=await res.json();
|
||
if(data.error){toast(data.error);return;}
|
||
toast(`✓ Entrega parcial creada: ${newOcId}`);
|
||
closeMo('mo-parcial');
|
||
refreshActiveView();
|
||
setTimeout(()=>openOrdenDetail(data.id),200);
|
||
}
|
||
|
||
async function igualarFechaPedidos(ocId){
|
||
const oc=S.ocs.find(o=>o.id===ocId);
|
||
if(!oc)return;
|
||
// Suggest most common date or today
|
||
const fechas=(oc.lineas||[]).map(l=>l.fecha_entrega).filter(f=>f);
|
||
const conteo={};
|
||
fechas.forEach(f=>{conteo[f]=(conteo[f]||0)+1});
|
||
const sugerida=Object.entries(conteo).sort((a,b)=>b[1]-a[1])[0]?.[0]||new Date().toISOString().slice(0,10);
|
||
const fecha=prompt(`Fecha de entrega a aplicar a los ${oc.lineas.length} pedidos:`,sugerida);
|
||
if(!fecha)return;
|
||
if(!/^\d{4}-\d{2}-\d{2}$/.test(fecha)){toast('Formato YYYY-MM-DD');return;}
|
||
let updated=0;
|
||
for(const l of oc.lineas){
|
||
if(l.fecha_entrega!==fecha){
|
||
await api('PUT',`/api/ordenes/${l.id}`,{fecha_entrega:fecha});
|
||
updated++;
|
||
}
|
||
}
|
||
toast(`✓ ${updated} pedido(s) actualizado(s) a ${fecha}`);
|
||
openOrdenDetail(ocId);
|
||
}
|
||
|
||
function recalcOd(){
|
||
const sub=+($('od-sub').value||0);
|
||
const ivaPct=+($('od-iva').value||0);
|
||
const log=+($('od-log').value||0);
|
||
const otros=+($('od-otros').value||0);
|
||
const iva=sub*ivaPct/100;
|
||
const total=sub+iva;
|
||
// Costo producción doesn't change in this edit — read from totales
|
||
const costoProd=+($('od-t-prod').dataset.val||$('od-t-prod').textContent.replace(/[$,]/g,''))||0;
|
||
const fullCost=costoProd+log+otros;
|
||
const util=sub-fullCost;
|
||
const margen=sub>0?Math.round(util/sub*100):0;
|
||
$('od-t-log').textContent=fmt$(log);
|
||
$('od-t-otros').textContent=fmt$(otros);
|
||
$('od-t-sub').textContent=fmt$(sub);
|
||
$('od-t-iva').textContent=fmt$(iva);
|
||
$('od-t-ivapct').textContent=ivaPct;
|
||
$('od-t-total').textContent=fmt$(total);
|
||
$('od-t-util').textContent=fmt$(util);
|
||
$('od-t-margen').textContent=`(${margen}%)`;
|
||
$('od-t-utilrow').style.color=util>=0?'var(--gn)':'var(--rd)';
|
||
}
|
||
|
||
async function saveOrdenDetail(ocId){
|
||
const body={
|
||
fecha_oc:$('od-fecha').value,
|
||
fecha_entrega:$('od-fecha-entrega').value,
|
||
recibio:$('od-recibio').value,
|
||
condiciones_pago:$('od-pago').value,
|
||
precio_factura:+$('od-sub').value||0,
|
||
iva_pct:+$('od-iva').value||0,
|
||
costo_logistica:+$('od-log').value||0,
|
||
otros_gastos:+$('od-otros').value||0,
|
||
otros_gastos_desc:$('od-otros-desc').value,
|
||
factura_num:$('od-factura-num').value,
|
||
status:$('od-status').value,
|
||
pagado:+$('od-pagado').value||0,
|
||
fecha_pago:$('od-fecha-pago').value,
|
||
metodo_pago:$('od-metodo').value,
|
||
notas:$('od-notas').value
|
||
};
|
||
await api('PUT',`/api/oc/${ocId}`,body);
|
||
toast('Orden actualizada');
|
||
S.ocs=await api('GET','/api/oc');
|
||
closeMo('mo-orden');
|
||
refreshActiveView();
|
||
}
|
||
|
||
async function addPedidoToOrden(ocId){
|
||
const oc=S.ocs.find(o=>o.id===ocId);
|
||
if(!oc)return;
|
||
// Stash for the wizard to pre-fill
|
||
window._pendingOcContext={id:ocId,cliente:oc.cliente};
|
||
closeMo('mo-orden');
|
||
await openWizard();
|
||
// Pre-fill client and OC
|
||
setTimeout(()=>{
|
||
const cliSel=$('w-cliente');
|
||
if(cliSel) cliSel.value=oc.cliente;
|
||
cliSel?.onchange?.();
|
||
const ocSel=$('w-oc-sel');
|
||
if(ocSel) ocSel.value=ocId;
|
||
// Jump to step 2 (producto) since cliente/OC ready
|
||
if(typeof wizCur!=='undefined'){wizCur=2;updateWizard();}
|
||
},120);
|
||
}
|
||
|
||
function showLinkExistingPedido(ocId){
|
||
const oc=S.ocs.find(o=>o.id===ocId);
|
||
if(!oc)return;
|
||
const el=$('od-link-existing');
|
||
if(el.style.display!=='none'){el.style.display='none';return;}
|
||
const unlinked=S.ordenes.filter(o=>!o.oc_id && o.stage!=='Cancelado');
|
||
if(!unlinked.length){el.innerHTML='<div style="font-size:11px;color:var(--t3);text-align:center">No hay pedidos sueltos</div>';el.style.display='block';return;}
|
||
const same=unlinked.filter(o=>o.cliente===oc.cliente);
|
||
const otros=unlinked.filter(o=>o.cliente!==oc.cliente);
|
||
const row=(o)=>`<div class="oc-line-row" style="background:var(--s1)">
|
||
<div style="flex:1;font-size:11px"><b>${esc(o.orden_id)}</b> — <span style="color:var(--t2)">${esc(o.cliente)}</span> — ${esc(o.producto)} — ${o.cantidad} pzas <span class="tag" style="font-size:9px;background:var(--${cc(o.stage)[1]});color:var(--${cc(o.stage)[0]})">${o.stage}</span></div>
|
||
<button class="oc-link-btn" onclick="linkAndRefreshDetail(${o.id},${oc.id})">Vincular</button>
|
||
</div>`;
|
||
el.innerHTML=same.map(row).join('')+(otros.length?`<div style="font-size:9px;color:var(--t3);padding:4px 0;border-top:1px solid var(--bd);margin-top:4px">Otros clientes</div>`+otros.map(row).join(''):'');
|
||
el.style.display='block';
|
||
}
|
||
|
||
async function linkAndRefreshDetail(ordenId,ocId){
|
||
await api('PUT',`/api/ordenes/${ordenId}`,{oc_id:ocId});
|
||
toast('Pedido vinculado');
|
||
openOrdenDetail(ocId);
|
||
}
|
||
|
||
async function deleteOrdenDetail(ocId){
|
||
if(!confirm('¿Eliminar esta Orden? Los pedidos quedarán sin vincular pero no se borran.'))return;
|
||
await api('DELETE',`/api/oc/${ocId}`);
|
||
toast('Orden eliminada');
|
||
S.ocs=await api('GET','/api/oc');
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
closeMo('mo-orden');
|
||
refreshActiveView();
|
||
}
|
||
|
||
// ══════ OC (Orden de Compra) ══════
|
||
function calcNocIva(){
|
||
const sub=+($('noc-factura').value||0);
|
||
const ivaPct=+($('noc-iva-pct').value||0);
|
||
const iva=sub*ivaPct/100;
|
||
const total=sub+iva;
|
||
const el=$('noc-iva-calc');
|
||
if(!el)return;
|
||
if(sub>0){
|
||
el.style.display='block';
|
||
el.innerHTML=`Subtotal: <b>${fmt$(sub)}</b> · IVA ${ivaPct}%: <b>${fmt$(iva)}</b> · <b style="color:var(--olive)">Total: ${fmt$(total)}</b>`;
|
||
}else{
|
||
el.style.display='none';
|
||
}
|
||
}
|
||
|
||
function cliInitials(name){
|
||
if(!name)return'XX';
|
||
const words=name.replace(/[()]/g,'').split(/\s+/).filter(w=>w&&/[A-Za-zÁÉÍÓÚÑáéíóúñ]/.test(w[0]));
|
||
// Skip common short words
|
||
const skip=new Set(['de','del','la','las','el','los','y','en']);
|
||
const meaningful=words.filter(w=>!skip.has(w.toLowerCase()));
|
||
return(meaningful.length?meaningful:words).slice(0,3).map(w=>w[0].toUpperCase()).join('').slice(0,3)||'XX';
|
||
}
|
||
function generateOcFolio(cliente){
|
||
const d=new Date();
|
||
const ym=`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`;
|
||
const inits=cliInitials(cliente||'XX');
|
||
const prefix=`${ym}-${inits}`;
|
||
const n=(S.ocs||[]).filter(o=>o.oc_id.startsWith(prefix)).length+1;
|
||
return`${prefix}-${String(n).padStart(2,'0')}`;
|
||
}
|
||
|
||
async function openNewOC(){
|
||
if(!S.clientes.length) S.clientes=await api('GET','/api/clientes');
|
||
$('noc-cliente').innerHTML='<option value="">-- Seleccionar --</option>'+S.clientes.map(c=>
|
||
`<option value="${esc(c.nombre)}" data-costo="${c.costo_entrega}" data-pago="${esc(c.condiciones_pago)}">${esc(c.nombre)}</option>`).join('');
|
||
|
||
// Auto-generate OC ID with cliente initials (will refine on cliente change)
|
||
$('noc-id').value=generateOcFolio('');
|
||
$('noc-fecha').value=new Date().toISOString().slice(0,10);
|
||
$('noc-logistica').value=0;
|
||
$('noc-otros').value=0;
|
||
$('noc-otros-desc').value='';
|
||
$('noc-factura').value=0;
|
||
$('noc-iva-pct').value=16;
|
||
$('noc-factura-num').value='';
|
||
$('noc-notas').value='';
|
||
calcNocIva();
|
||
|
||
// Auto-fill from wizard client if set
|
||
const wizCli=$('w-cliente')?.value;
|
||
if(wizCli){
|
||
$('noc-cliente').value=wizCli;
|
||
const sel=$('noc-cliente').options[$('noc-cliente').selectedIndex];
|
||
if(sel?.dataset.costo) $('noc-logistica').value=sel.dataset.costo;
|
||
if(sel?.dataset.pago) $('noc-pago').value=sel.dataset.pago;
|
||
$('noc-id').value=generateOcFolio(wizCli);
|
||
}
|
||
|
||
$('noc-cliente').onchange=()=>{
|
||
const sel=$('noc-cliente').options[$('noc-cliente').selectedIndex];
|
||
if(sel?.dataset.costo) $('noc-logistica').value=sel.dataset.costo;
|
||
if(sel?.dataset.pago) $('noc-pago').value=sel.dataset.pago;
|
||
$('noc-id').value=generateOcFolio($('noc-cliente').value);
|
||
};
|
||
|
||
openMo('mo-new-oc');
|
||
}
|
||
|
||
async function saveNewOC(){
|
||
const ocId=$('noc-id').value.trim();
|
||
const cliente=$('noc-cliente').value;
|
||
if(!ocId){toast('Escribe un folio de OC');return;}
|
||
if(!cliente){toast('Selecciona un cliente');return;}
|
||
|
||
const body={
|
||
oc_id:ocId,
|
||
cliente,
|
||
fecha_oc:$('noc-fecha').value,
|
||
costo_logistica:+$('noc-logistica').value||0,
|
||
otros_gastos:+$('noc-otros').value||0,
|
||
otros_gastos_desc:$('noc-otros-desc').value,
|
||
precio_factura:+$('noc-factura').value||0,
|
||
iva_pct:+$('noc-iva-pct').value||0,
|
||
factura_num:$('noc-factura-num').value,
|
||
condiciones_pago:$('noc-pago').value,
|
||
notas:$('noc-notas').value
|
||
};
|
||
|
||
const res=await api('POST','/api/oc',body);
|
||
closeMo('mo-new-oc');
|
||
toast(`Orden creada: ${ocId}`);
|
||
|
||
// Refresh OC list and select the new one
|
||
S.ocs=await api('GET','/api/oc');
|
||
const wOcSel=$('w-oc-sel');
|
||
const wizardOpen=$('mo-wizard').classList.contains('show');
|
||
if(wOcSel){
|
||
const activeOcs=S.ocs.filter(o=>o.status==='Activa');
|
||
wOcSel.innerHTML='<option value="">-- Sin orden (pedido suelto) --</option>'+activeOcs.map(o=>`<option value="${o.id}">${esc(o.oc_id)} — ${esc(o.cliente)} (${o.n_lineas} pedidos)</option>`).join('');
|
||
wOcSel.value=res.id;
|
||
}
|
||
// Refresh historico OC dropdown if open
|
||
const hOcSel=$('h-oc-sel');
|
||
const histOpen=$('mo-historico').classList.contains('show');
|
||
if(hOcSel){
|
||
const activeOcs=S.ocs.filter(o=>o.status==='Activa');
|
||
hOcSel.innerHTML='<option value="">-- Sin orden --</option>'+activeOcs.map(o=>`<option value="${o.id}">${esc(o.oc_id)} — ${esc(o.cliente)}</option>`).join('');
|
||
hOcSel.value=res.id;
|
||
}
|
||
// If wizard or historico modal isn't open, the user is creating from scratch — open the Orden detail to add pedidos
|
||
if(!wizardOpen && !histOpen){
|
||
openOrdenDetail(res.id);
|
||
} else if($('ordenes-ocs').style.display!=='none'){
|
||
renderOcsView();
|
||
}
|
||
}
|
||
|
||
// ══════ VENTAS UNIFICADA (Dashboard + OC + Entregas) ══════
|
||
let ventasView='dashboard';
|
||
async function setVentasView(view,btn){
|
||
ventasView=view;
|
||
if(btn){
|
||
btn.parentElement.querySelectorAll('.vt-btn').forEach(b=>b.classList.remove('on'));
|
||
btn.classList.add('on');
|
||
} else {
|
||
// Sync visual state cuando se llama programáticamente
|
||
const tg=document.querySelector('#pg-compras > .kb-head .view-toggle');
|
||
if(tg){
|
||
const idx={dashboard:0,oc:1,entregas:2}[view]??0;
|
||
tg.querySelectorAll('.vt-btn').forEach((b,i)=>b.classList.toggle('on',i===idx));
|
||
}
|
||
}
|
||
// Mostrar/ocultar sub-vistas
|
||
document.getElementById('ventas-sub-dashboard').style.display=view==='dashboard'?'':'none';
|
||
document.getElementById('ventas-sub-oc').style.display=view==='oc'?'':'none';
|
||
document.getElementById('ventas-sub-entregas').style.display=view==='entregas'?'':'none';
|
||
// Toolbar contextual
|
||
const tb=$('ventas-toolbar');
|
||
if(view==='dashboard'){
|
||
tb.innerHTML='';
|
||
} else if(view==='oc'){
|
||
tb.innerHTML=`<input class="search-box" placeholder="Buscar..." id="search-compras" oninput="renderCompras()">
|
||
<button class="btn btn-ac" onclick="openNewOCFromView()">+ Nueva Orden</button>`;
|
||
} else if(view==='entregas'){
|
||
tb.innerHTML=`<input class="search-box" placeholder="Buscar..." id="search-entregas" oninput="filterEntregas()">
|
||
<button class="btn btn-ac" onclick="openRegistroHistorico()">+ Registrar</button>`;
|
||
}
|
||
// Cargar data de la sub-vista activa
|
||
if(view==='dashboard') await loadVentas();
|
||
else if(view==='oc') await loadCompras();
|
||
else if(view==='entregas') await loadEntregas();
|
||
}
|
||
|
||
// ══════ COMPRAS (Kanban de OCs) ══════
|
||
let comprasData=[];
|
||
async function loadCompras(){
|
||
comprasData=await api('GET','/api/oc');
|
||
S.ocs=comprasData;
|
||
if(!S.clientes.length) S.clientes=await api('GET','/api/clientes');
|
||
renderCompras();
|
||
}
|
||
|
||
function ocDeliveryStage(oc){
|
||
if(oc.status==='Cancelada') return 'cancelada';
|
||
if(oc.progress==='Entregado') return 'entregadas';
|
||
// Partial OR has any pedido in Almacen/Vehiculo → ready/in delivery flow
|
||
if(oc.progress==='Parcial' || (oc.lineas||[]).some(l=>['En Almacen','En Vehiculo'].includes(l.stage))) return 'porentregar';
|
||
return 'activas';
|
||
}
|
||
// Legacy alias for any references
|
||
function ocWorkflowStage(oc){return ocDeliveryStage(oc)}
|
||
|
||
function renderCompras(){
|
||
const q=($('search-compras')?.value||'').toLowerCase();
|
||
const cols={
|
||
activas:{label:'Activas',sub:'En producción',items:[],cls:'col-prod'},
|
||
porentregar:{label:'Por entregar',sub:'Listas o parciales',items:[],cls:'col-fact'},
|
||
entregadas:{label:'Entregadas',sub:'Completadas',items:[],cls:'col-cobrado'},
|
||
};
|
||
comprasData.forEach(oc=>{
|
||
if(q && !(oc.oc_id+' '+oc.cliente+' '+(oc.factura_num||'')).toLowerCase().includes(q)) return;
|
||
const s=ocDeliveryStage(oc);
|
||
if(cols[s]) cols[s].items.push(oc);
|
||
});
|
||
// Sort each column: incomplete-fields first inside Entregadas (facturar/cobrar), else newest first
|
||
Object.values(cols).forEach(c=>{
|
||
c.items.sort((a,b)=>{
|
||
const fa=a.fecha_oc||'', fb=b.fecha_oc||'';
|
||
return fb.localeCompare(fa)||b.id-a.id;
|
||
});
|
||
});
|
||
const renderCard=(oc)=>{
|
||
const sub=oc.precio_factura||0;
|
||
const ivaPct=oc.iva_pct!=null?oc.iva_pct:16;
|
||
const totalIva=sub+sub*ivaPct/100;
|
||
const stage=ocDeliveryStage(oc);
|
||
// Workflow status badges (inside the card)
|
||
const badges=[];
|
||
if(stage==='entregadas'){
|
||
if(!oc.factura_num) badges.push('<span class="cmp-badge fact">📄 Falta factura</span>');
|
||
else if(!oc.pagado) badges.push('<span class="cmp-badge cobr">💵 Por cobrar</span>');
|
||
else badges.push('<span class="cmp-badge cobrada">✓ Cobrada</span>');
|
||
}
|
||
// Action button per state
|
||
let action='';
|
||
if(stage==='entregadas'){
|
||
if(!oc.factura_num) action=`<button onclick="event.stopPropagation();openMarkFactura(${oc.id})">Registrar factura</button>`;
|
||
else if(!oc.pagado) action=`<button onclick="event.stopPropagation();markPagado(${oc.id})">Marcar cobrada</button>`;
|
||
}
|
||
return`<div class="compras-card ${cols[stage].cls}" onclick="openOrdenDetail(${oc.id})">
|
||
<div class="cc-head">
|
||
<span>${esc(oc.oc_id)}</span>
|
||
${sub?`<span style="color:var(--olive)">${fmt$(totalIva)}</span>`:''}
|
||
</div>
|
||
<div class="cc-cli">${esc(oc.cliente)}</div>
|
||
<div class="cc-meta">
|
||
${oc.fecha_oc?`<span>📝 ${oc.fecha_oc}</span>`:''}
|
||
${oc.fecha_entrega?`<span>📦 ${oc.fecha_entrega}</span>`:''}
|
||
<span>${oc.n_lineas} pedido${oc.n_lineas!==1?'s':''} · ${oc.total_piezas} pzas</span>
|
||
${oc.factura_num?`<span>📄 ${esc(oc.factura_num)}</span>`:''}
|
||
</div>
|
||
${badges.length?`<div class="cc-badges">${badges.join('')}</div>`:''}
|
||
${action?`<div class="cc-actions">${action}</div>`:''}
|
||
</div>`;
|
||
};
|
||
$('compras-body').innerHTML=`<div class="compras-kb-3">${Object.entries(cols).map(([k,c])=>`
|
||
<div class="compras-col">
|
||
<div class="compras-col-h">
|
||
<span>${c.label}<span class="col-sub">${c.sub}</span></span>
|
||
<span class="cnt">${c.items.length}</span>
|
||
</div>
|
||
${c.items.length?c.items.map(renderCard).join(''):'<div style="font-size:10px;color:var(--t3);text-align:center;padding:20px">Sin Ordenes</div>'}
|
||
</div>
|
||
`).join('')}</div>`;
|
||
}
|
||
|
||
async function openMarkFactura(ocId){
|
||
const oc=comprasData.find(o=>o.id===ocId);
|
||
if(!oc)return;
|
||
const num=prompt(`Nº de factura para ${oc.oc_id}:`,oc.factura_num||'');
|
||
if(num===null)return;
|
||
await api('PUT',`/api/oc/${ocId}`,{factura_num:num});
|
||
toast('Factura registrada');
|
||
refreshActiveView();
|
||
}
|
||
|
||
async function markPagado(ocId){
|
||
const hoy=new Date().toISOString().slice(0,10);
|
||
await api('PUT',`/api/oc/${ocId}`,{pagado:1,fecha_pago:hoy,status:'Cerrada'});
|
||
toast('✓ Marcada como cobrada');
|
||
refreshActiveView();
|
||
}
|
||
|
||
async function unmarkPagado(ocId){
|
||
await api('PUT',`/api/oc/${ocId}`,{pagado:0,fecha_pago:'',status:'Activa'});
|
||
toast('Revertida a Por cobrar');
|
||
refreshActiveView();
|
||
}
|
||
|
||
// ══════ CLIENTES CRM ══════
|
||
let crmSelected=null;
|
||
|
||
// ══════ EDITOR DE PEDIDO (propuesta-style) ══════
|
||
let peEdit=null;
|
||
|
||
async function openPedidoEdit(ordId){
|
||
const o=S.ordenes.find(x=>x.id===ordId);
|
||
if(!o)return;
|
||
if(!S.clientes.length) S.clientes=await api('GET','/api/clientes');
|
||
if(!S.productos?.length) S.productos=await api('GET','/api/productos');
|
||
peEdit={...o};
|
||
renderPedidoEdit();
|
||
openMo('mo-pedido-edit');
|
||
}
|
||
|
||
function renderPedidoEdit(){
|
||
const p=peEdit;
|
||
const[stgC,stgCD]=cc(p.stage);
|
||
const ocRef=p.oc_id&&S.ocs.length?S.ocs.find(x=>x.id===p.oc_id):null;
|
||
const proyRef=p.proyecto_id?(S.proyectos||[]).find(x=>x.id===p.proyecto_id):null;
|
||
const photo=getPedidoPhoto(p);
|
||
|
||
// Header (matches quick view style)
|
||
$('pe-header').innerHTML=`<div class="qv-head">
|
||
<div class="qv-head-main">
|
||
<div class="qv-head-row">
|
||
<span class="qv-badge" style="background:var(--ac)">EDITANDO</span>
|
||
<h2>${esc(p.orden_id)}</h2>
|
||
<span class="qv-stage-pill" style="background:var(--${stgCD});color:var(--${stgC})">${p.stage}</span>
|
||
</div>
|
||
<div class="qv-head-cli">${esc(p.cliente||'')}</div>
|
||
</div>
|
||
<button class="mo-x" onclick="closeMo('mo-pedido-edit')">×</button>
|
||
</div>`;
|
||
|
||
const trabajoOpts=window.trabajoOpts();
|
||
const stageOpts=['Nuevo','En Tránsito','En 2 Mares','En Taller Sofia','En Almacen','En Vehiculo','Entregado','Cancelado'];
|
||
const cliList=(S.clientes||[]).map(c=>c.nombre).sort();
|
||
const cliOpts=cliList.includes(p.cliente)?cliList:[p.cliente,...cliList];
|
||
const prodSet=new Set((S.productos||[]).map(x=>x.nombre));
|
||
S.ordenes.forEach(o=>{if(o.producto)prodSet.add(o.producto)});
|
||
const prodList=[...prodSet].sort();
|
||
|
||
const photoBlock=photo?`<div class="qv-photo-block" style="margin-bottom:14px">
|
||
<img src="${photo.url}" onclick="openFile('${photo.url}',true)" loading="lazy" decoding="async">
|
||
</div>`:'';
|
||
|
||
// Stage pills
|
||
const stagePills=stageOpts.map(s=>`<button type="button" class="stage-pill${s===p.stage?' on':''}" onclick="peSet('stage','${esc(s)}');renderPedidoEdit()">${s}</button>`).join('');
|
||
|
||
$('pe-body').innerHTML=`
|
||
${photoBlock}
|
||
|
||
<!-- Esenciales -->
|
||
<div class="od-section">
|
||
<div class="od-section-title">Esenciales</div>
|
||
<div class="od-config-grid">
|
||
<div class="fg" style="grid-column:1/-1"><label>Cliente</label>
|
||
<select onchange="peSet('cliente',this.value)">
|
||
${cliOpts.map(c=>`<option value="${esc(c)}"${c===p.cliente?' selected':''}>${esc(c)}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="fg" style="grid-column:1/2"><label>Producto</label>
|
||
<select onchange="peSet('producto',this.value)">
|
||
${prodList.map(n=>`<option value="${esc(n)}"${n===p.producto?' selected':''}>${esc(n)}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="fg"><label>Cantidad</label><input type="number" value="${p.cantidad||0}" oninput="peSet('cantidad',+this.value||0)"></div>
|
||
<div class="fg"><label>Tipo de trabajo</label>
|
||
<select onchange="peSet('tipo_trabajo',this.value)">
|
||
${trabajoOpts.map(t=>`<option value="${esc(t)}"${t===(p.tipo_trabajo||'')?' selected':''}>${t||'— Sin definir —'}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="fg"><label>Urgente</label>
|
||
<button type="button" class="urgente-toggle${p.urgente?' on':''}" onclick="peSet('urgente',peEdit.urgente?0:1);renderPedidoEdit()" style="margin-top:4px">
|
||
${p.urgente?'🔴 Urgente':'Normal'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="fg" style="margin-top:8px"><label>Stage</label>
|
||
<div class="stage-pills">${stagePills}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Logo / Instrucciones -->
|
||
<div class="od-section">
|
||
<div class="od-section-title">Diseño / Personalización</div>
|
||
<div class="fg"><label>Logo / Instrucciones</label>
|
||
<textarea rows="2" oninput="peSet('logo_instrucciones',this.value)" placeholder="ej: Logo cliente bordado en pecho izquierdo, color crudo">${esc(p.logo_instrucciones||'')}</textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Vínculos (read-only con acciones) -->
|
||
${ocRef||proyRef?`<div class="od-section">
|
||
<div class="od-section-title">Vínculos</div>
|
||
${ocRef?`<div class="qv-link-card" onclick="closeMo('mo-pedido-edit');openOrdenDetail(${ocRef.id})" style="margin-bottom:6px">
|
||
<span class="qv-link-icon">🔗</span>
|
||
<div><div class="qv-link-label">Orden de Compra</div><div class="qv-link-val">${esc(ocRef.oc_id)} · ${esc(ocRef.cliente)}</div></div>
|
||
<span class="qv-link-arrow">›</span>
|
||
</div>`:''}
|
||
${proyRef?`<div class="qv-link-card" onclick="closeMo('mo-pedido-edit');viewProyecto(${proyRef.id})">
|
||
<span class="qv-link-icon">📐</span>
|
||
<div><div class="qv-link-label">Proyecto recurrente</div><div class="qv-link-val">${esc(proyRef.nombre)}</div></div>
|
||
<span class="qv-link-arrow">›</span>
|
||
</div>`:''}
|
||
</div>`:''}
|
||
|
||
<!-- Costos -->
|
||
<button type="button" class="od-details-toggle" onclick="toggleSection('pe-costos')" id="pe-costos-btn">
|
||
<span>▾ Costos internos</span>
|
||
</button>
|
||
<div class="od-section" id="pe-costos" style="display:none">
|
||
<div class="od-config-grid">
|
||
<div class="fg"><label>C. Producto $/pza</label><input type="number" step="0.01" value="${p.costo_producto||0}" oninput="peSet('costo_producto',+this.value||0)"></div>
|
||
<div class="fg"><label>C. Trabajo $/pza</label><input type="number" step="0.01" value="${p.costo_trabajo||0}" oninput="peSet('costo_trabajo',+this.value||0)"></div>
|
||
<div class="fg"><label>Logística $</label><input type="number" step="0.01" value="${p.costo_logistica||0}" oninput="peSet('costo_logistica',+this.value||0)"></div>
|
||
<div class="fg"><label>Precio factura $</label><input type="number" step="0.01" value="${p.precio_factura||0}" oninput="peSet('precio_factura',+this.value||0)"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Fechas + Recepción -->
|
||
<button type="button" class="od-details-toggle" onclick="toggleSection('pe-fechas')" id="pe-fechas-btn">
|
||
<span>▾ Fechas y recepción</span>
|
||
</button>
|
||
<div class="od-section" id="pe-fechas" style="display:none">
|
||
<div class="od-config-grid">
|
||
<div class="fg"><label>Fecha inicio</label><input type="date" value="${esc(p.fecha_inicio||'')}" onchange="peSet('fecha_inicio',this.value)"></div>
|
||
<div class="fg"><label>Fecha estimada</label><input type="date" value="${esc(p.fecha_estimada||'')}" onchange="peSet('fecha_estimada',this.value)"></div>
|
||
<div class="fg"><label>Fecha recepción</label><input type="date" value="${esc(p.fecha_recepcion||'')}" onchange="peSet('fecha_recepcion',this.value)"></div>
|
||
<div class="fg"><label>Fecha entrega</label><input type="date" value="${esc(p.fecha_entrega||'')}" onchange="peSet('fecha_entrega',this.value)"></div>
|
||
<div class="fg" style="grid-column:1/-1"><label>Recibió</label><input value="${esc(p.recibio||'')}" placeholder="Nombre de quien recibió" onchange="peSet('recibio',this.value)"></div>
|
||
<div class="fg"><label>Piezas recibidas</label><input type="number" value="${p.piezas_recibidas||0}" onchange="peSet('piezas_recibidas',+this.value||0)"></div>
|
||
<div class="fg"><label>Piezas dañadas</label><input type="number" value="${p.piezas_danadas||0}" onchange="peSet('piezas_danadas',+this.value||0)"></div>
|
||
<div class="fg" style="grid-column:1/-1"><label>Nota recepción</label><textarea rows="2" oninput="peSet('nota_recepcion',this.value)">${esc(p.nota_recepcion||'')}</textarea></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Notas internas -->
|
||
<button type="button" class="od-details-toggle" onclick="toggleSection('pe-notas')" id="pe-notas-btn">
|
||
<span>▾ Notas internas</span>
|
||
</button>
|
||
<div class="od-section" id="pe-notas" style="display:none">
|
||
<div class="fg"><textarea rows="3" oninput="peSet('notas',this.value)" placeholder="Notas para el equipo (no visibles al cliente)">${esc(p.notas||'')}</textarea></div>
|
||
</div>
|
||
|
||
<div class="od-actions">
|
||
<button class="btn btn-ac" onclick="savePedidoEdit()">✓ Guardar cambios</button>
|
||
<button class="btn" onclick="closeMo('mo-pedido-edit')">Cancelar</button>
|
||
<button class="btn" onclick="closeMo('mo-pedido-edit');openDelPedido(${p.id})" style="border-color:var(--rd);color:var(--rd);margin-left:auto">🗑 Eliminar</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function peSet(field,val){if(peEdit)peEdit[field]=val;}
|
||
|
||
function toggleSection(id){
|
||
const el=$(id);
|
||
const btn=$(id+'-btn');
|
||
const open=el.style.display!=='none';
|
||
el.style.display=open?'none':'block';
|
||
if(btn) btn.querySelector('span').textContent=open?btn.querySelector('span').textContent.replace('▴','▾'):btn.querySelector('span').textContent.replace('▾','▴');
|
||
}
|
||
|
||
async function savePedidoEdit(){
|
||
const p=peEdit;
|
||
if(!p.cliente){toast('Selecciona un cliente');return;}
|
||
if(!p.producto){toast('Selecciona un producto');return;}
|
||
const body={};
|
||
// Send all editable fields
|
||
['cliente','producto','cantidad','tipo_trabajo','stage','urgente','logo_instrucciones',
|
||
'costo_producto','costo_trabajo','costo_logistica','precio_factura',
|
||
'fecha_inicio','fecha_estimada','fecha_recepcion','fecha_entrega','recibio',
|
||
'piezas_recibidas','piezas_danadas','nota_recepcion','notas'
|
||
].forEach(k=>{body[k]=p[k]});
|
||
await api('PUT',`/api/ordenes/${p.id}`,body);
|
||
toast('Pedido actualizado');
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
closeMo('mo-pedido-edit');
|
||
refreshActiveView();
|
||
}
|
||
|
||
// ══════ NUEVA ORDEN + PEDIDOS (estilo propuesta) ══════
|
||
let ocCreate=null;
|
||
|
||
function generatePedidoId(yr){
|
||
yr=yr||new Date().getFullYear();
|
||
// Use MAX(num)+1 instead of count+1 to avoid collisions when orders are deleted
|
||
const prefix=`ORD-${yr}-`;
|
||
const re=new RegExp(`^ORD-${yr}-(\\d{3})$`);
|
||
let maxN=0;
|
||
for(const o of (S.ordenes||[])){
|
||
const m=(o.orden_id||'').match(re);
|
||
if(m){const n=+m[1]; if(n>maxN) maxN=n;}
|
||
}
|
||
return`${prefix}${(maxN+1).toString().padStart(3,'0')}`;
|
||
}
|
||
|
||
async function openNewOrdenCreate(){
|
||
// Ensure data is loaded
|
||
if(!S.clientes.length) S.clientes=await api('GET','/api/clientes');
|
||
if(!S.trabajos.length) S.trabajos=await api('GET','/api/trabajos');
|
||
if(!S.productos?.length) S.productos=await api('GET','/api/productos');
|
||
if(!S.proyectos) S.proyectos=await api('GET','/api/proyectos');
|
||
if(!S.ocs) S.ocs=await api('GET','/api/oc');
|
||
|
||
ocCreate={
|
||
// OC fields (will be created if at least one is set)
|
||
oc_id:'', cliente:'', fecha_oc:new Date().toISOString().slice(0,10),
|
||
condiciones_pago:'Por definir', notas_oc:'',
|
||
crear_orden:true, // si false, los pedidos quedan sueltos
|
||
// Lineas (pedidos)
|
||
lineas:[]
|
||
};
|
||
renderOcCreate();
|
||
openMo('mo-orden-create');
|
||
}
|
||
|
||
function renderOcCreate(){
|
||
const oc=ocCreate;
|
||
// Header
|
||
$('oc-create-header').innerHTML=`<div class="od-head">
|
||
<div class="od-head-left">
|
||
<h2>
|
||
<span style="font-size:10px;padding:3px 8px;border-radius:4px;background:var(--olive);color:#fff;font-weight:700;letter-spacing:.5px">${oc.crear_orden?'NUEVA ORDEN':'PEDIDOS SUELTOS'}</span>
|
||
${oc.oc_id?esc(oc.oc_id):''}
|
||
</h2>
|
||
<div class="od-cli">${oc.lineas.length} pedido${oc.lineas.length!==1?'s':''} · ${oc.lineas.reduce((s,l)=>s+(+l.cantidad||0),0)} pzas</div>
|
||
</div>
|
||
<button class="mo-x" onclick="closeMo('mo-orden-create')">×</button>
|
||
</div>`;
|
||
|
||
// Quick start proyectos (when cliente set)
|
||
let quickStart='';
|
||
if(oc.cliente){
|
||
const proys=(S.proyectos||[]).filter(p=>p.activo!==0&&p.cliente===oc.cliente);
|
||
if(proys.length){
|
||
quickStart=`<div class="wiz-quick-banner" style="margin-bottom:14px">
|
||
<div class="wiz-quick-title">
|
||
<span style="font-size:14px">✨</span>
|
||
<span>Trabajos recurrentes de ${esc(oc.cliente)}</span>
|
||
<span class="wiz-quick-sub">click para agregar como pedido</span>
|
||
</div>
|
||
<div class="wiz-quick-cards">
|
||
${proys.map(p=>{
|
||
const photo=getProyectoPhoto(p);
|
||
return`<div class="wiz-proy-card" onclick="ocAddLineaFromProyecto(${p.id})">
|
||
${photo?`<img src="${photo}" loading="lazy" decoding="async">`:'<div class="wiz-proy-empty">📦</div>'}
|
||
<div class="wiz-proy-info">
|
||
<div class="wiz-proy-name">${esc(p.nombre)}</div>
|
||
<div class="wiz-proy-meta">
|
||
${p.tipo_trabajo?`<span class="proy-tag" style="background:var(--${cc(p.tipo_trabajo)[1]});color:var(--${cc(p.tipo_trabajo)[0]})">${esc(p.tipo_trabajo)}</span>`:''}
|
||
${p.costo_unitario?`<span style="font-size:10px;color:var(--olive);font-weight:600">${fmt$(p.costo_unitario)}/pza</span>`:''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
// Items section
|
||
const trabajoOpts=window.trabajoOpts();
|
||
const stageOpts=['Nuevo','En Tránsito','En 2 Mares','En Taller Sofia','En Almacen','En Vehiculo'];
|
||
const itemsHtml=oc.lineas.length?oc.lineas.map((l,i)=>{
|
||
const subtotal=(+l.cantidad||0)*((+l.costo_producto||0)+(+l.costo_trabajo||0));
|
||
const photo=l.foto_url||(getProductPhoto(l.producto)?.url);
|
||
return`<div class="pp-item">
|
||
${photo?`<img src="${photo}" class="pp-item-photo" loading="lazy" decoding="async">`:'<div class="pp-item-photo-empty">📦</div>'}
|
||
<div class="pp-item-main">
|
||
<div class="pp-item-name">${esc(l.producto)}</div>
|
||
<div class="pp-item-fields">
|
||
<label class="pp-item-field">
|
||
<span>Tipo de trabajo</span>
|
||
<select onchange="ocSetLinea(${i},'tipo_trabajo',this.value)">
|
||
${trabajoOpts.map(o=>`<option value="${esc(o)}"${o===(l.tipo_trabajo||'')?' selected':''}>${o||'— Sin definir —'}</option>`).join('')}
|
||
</select>
|
||
</label>
|
||
<label class="pp-item-field">
|
||
<span>Logo / Instrucciones</span>
|
||
<input type="text" value="${esc(l.logo_instrucciones||'')}" placeholder="ej: Logo bordado pecho izquierdo" onchange="ocSetLinea(${i},'logo_instrucciones',this.value)">
|
||
</label>
|
||
</div>
|
||
<div class="pp-item-fields">
|
||
<label class="pp-item-field">
|
||
<span>Stage inicial</span>
|
||
<select onchange="ocSetLinea(${i},'stage',this.value)">
|
||
${stageOpts.map(s=>`<option value="${esc(s)}"${s===(l.stage||'Nuevo')?' selected':''}>${s}</option>`).join('')}
|
||
</select>
|
||
</label>
|
||
<label class="pp-item-field">
|
||
<span>Urgente</span>
|
||
<select onchange="ocSetLinea(${i},'urgente',+this.value)">
|
||
<option value="0"${!l.urgente?' selected':''}>No</option>
|
||
<option value="1"${l.urgente?' selected':''}>Sí</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div class="pp-item-controls">
|
||
<label>Cant <input type="number" min="0" value="${l.cantidad||0}" oninput="ocLiveItem(${i},'cantidad',this.value)" onblur="renderOcCreate()"></label>
|
||
<label>$ Prod <input type="number" step="0.01" value="${l.costo_producto||0}" oninput="ocLiveItem(${i},'costo_producto',this.value)" onblur="renderOcCreate()"></label>
|
||
<label>$ Trab <input type="number" step="0.01" value="${l.costo_trabajo||0}" oninput="ocLiveItem(${i},'costo_trabajo',this.value)" onblur="renderOcCreate()"></label>
|
||
<div class="pp-item-sub" id="oc-sub-${i}">${fmt$(subtotal)}</div>
|
||
</div>
|
||
</div>
|
||
<button class="kc-btn" onclick="ocRemoveLinea(${i})" style="color:var(--rd);align-self:flex-start">×</button>
|
||
</div>`;
|
||
}).join(''):'<div style="font-size:11px;color:var(--t3);text-align:center;padding:14px;background:var(--s2);border-radius:6px">Sin pedidos. Agrega uno abajo (o desde un proyecto recurrente arriba si el cliente tiene).</div>';
|
||
|
||
const totalPzas=oc.lineas.reduce((s,l)=>s+(+l.cantidad||0),0);
|
||
const totalCosto=oc.lineas.reduce((s,l)=>s+((+l.cantidad||0)*((+l.costo_producto||0)+(+l.costo_trabajo||0))),0);
|
||
|
||
const cliOpts=(S.clientes||[]).map(c=>`<option value="${esc(c.nombre)}"${c.nombre===oc.cliente?' selected':''}>${esc(c.nombre)}</option>`).join('');
|
||
|
||
$('oc-create-body').innerHTML=`
|
||
<!-- Cliente + Orden info -->
|
||
<div class="od-section">
|
||
<div class="od-section-title">
|
||
<span>Datos del cliente y Orden</span>
|
||
<label style="font-size:10px;color:var(--t2);cursor:pointer;display:flex;align-items:center;gap:4px">
|
||
<input type="checkbox" ${oc.crear_orden?'checked':''} onchange="ocCreate.crear_orden=this.checked;renderOcCreate()" style="cursor:pointer">
|
||
Crear como Orden de Compra agrupadora
|
||
</label>
|
||
</div>
|
||
<div class="od-config-grid">
|
||
<div class="fg" style="grid-column:1/-1"><label>Cliente</label>
|
||
<div style="display:flex;gap:4px">
|
||
<select id="oc-cliente" onchange="ocCreate.cliente=this.value;renderOcCreate()" style="flex:1">
|
||
<option value="">-- Seleccionar --</option>${cliOpts}
|
||
</select>
|
||
<button class="btn" onclick="addClienteFromOcCreate()" style="white-space:nowrap;font-size:12px">+ Nuevo</button>
|
||
</div>
|
||
</div>
|
||
${oc.crear_orden?`
|
||
<div class="fg"><label>Folio Orden</label><input id="oc-folio" value="${esc(oc.oc_id)}" placeholder="auto" onchange="ocCreate.oc_id=this.value"></div>
|
||
<div class="fg"><label>Fecha Orden</label><input type="date" id="oc-fecha" value="${esc(oc.fecha_oc)}" onchange="ocCreate.fecha_oc=this.value"></div>
|
||
<div class="fg"><label>Condiciones de pago</label><select id="oc-pago" onchange="ocCreate.condiciones_pago=this.value">
|
||
${CONDICIONES_PAGO_OPTS.map(p=>`<option${p===oc.condiciones_pago?' selected':''}>${p}</option>`).join('')}
|
||
</select></div>
|
||
<div class="fg" style="grid-column:1/-1"><label>Notas de la Orden (opcional)</label><textarea rows="2" onchange="ocCreate.notas_oc=this.value">${esc(oc.notas_oc||'')}</textarea></div>
|
||
`:`<div class="fg" style="grid-column:1/-1;font-size:10px;color:var(--t3);background:var(--s2);padding:8px 10px;border-radius:6px">Los pedidos se crearán <b>sueltos</b> (sin Orden agrupadora) — útil para producción libre y resurtido para POS.</div>`}
|
||
</div>
|
||
</div>
|
||
|
||
${quickStart}
|
||
|
||
<!-- Pedidos / items -->
|
||
<div class="od-section">
|
||
<div class="od-section-title">
|
||
<span>Pedidos (${oc.lineas.length} · ${totalPzas} pzas)</span>
|
||
<button class="oc-link-btn" onclick="openOcProds()">+ Agregar producto</button>
|
||
</div>
|
||
<div class="pp-items-list">${itemsHtml}</div>
|
||
</div>
|
||
|
||
<!-- Totales -->
|
||
<div class="od-section">
|
||
<div class="od-quick-edit">
|
||
<div class="qe-totals">
|
||
<div class="qe-tot-row"><span>Pedidos</span><span>${oc.lineas.length}</span></div>
|
||
<div class="qe-tot-row"><span>Piezas totales</span><span>${totalPzas.toLocaleString()}</span></div>
|
||
<div class="qe-tot-row big"><span>Costo producción total</span><span>${fmt$(totalCosto)}</span></div>
|
||
</div>
|
||
<div style="font-size:10px;color:var(--t3);margin-top:6px;font-style:italic">El precio de factura se define a nivel de Orden de Compra (después de guardar).</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="od-actions">
|
||
<button class="btn btn-ac" onclick="saveOcCreate()">✓ Crear ${oc.crear_orden?'Orden + Pedidos':'pedidos sueltos'}</button>
|
||
<button class="btn" onclick="closeMo('mo-orden-create')">Cancelar</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function ocSetLinea(i,field,val){
|
||
if(!ocCreate.lineas[i])return;
|
||
ocCreate.lineas[i][field]=val;
|
||
}
|
||
function ocLiveItem(i,field,val){
|
||
if(!ocCreate.lineas[i])return;
|
||
ocCreate.lineas[i][field]=field==='cantidad'?parseInt(val)||0:parseFloat(val)||0;
|
||
const it=ocCreate.lineas[i];
|
||
const sub=(+it.cantidad||0)*((+it.costo_producto||0)+(+it.costo_trabajo||0));
|
||
const cell=document.getElementById(`oc-sub-${i}`);
|
||
if(cell) cell.textContent=fmt$(sub);
|
||
}
|
||
function ocRemoveLinea(i){
|
||
ocCreate.lineas.splice(i,1);
|
||
renderOcCreate();
|
||
}
|
||
|
||
function ocAddLineaFromProyecto(proyId){
|
||
const p=(S.proyectos||[]).find(x=>x.id===proyId);
|
||
if(!p)return;
|
||
const prod=p.producto_id?(S.productos||[]).find(x=>x.id===p.producto_id):null;
|
||
ocCreate.lineas.push({
|
||
producto:p.producto_nombre||(prod?prod.nombre:''),
|
||
sku:prod?prod.sku:'',
|
||
cantidad:0,
|
||
tipo_trabajo:p.tipo_trabajo||'',
|
||
logo_instrucciones:p.logo_descripcion||'',
|
||
costo_producto:+p.costo_unitario||0,
|
||
costo_trabajo:+p.costo_trabajo||0,
|
||
stage:'Nuevo',
|
||
urgente:0,
|
||
proyecto_id:p.id,
|
||
foto_url:getProyectoPhoto(p)
|
||
});
|
||
renderOcCreate();
|
||
}
|
||
|
||
function openOcProds(){
|
||
renderOcProds();
|
||
openMo('mo-oc-prods');
|
||
}
|
||
function renderOcProds(){
|
||
const qRaw=($('oc-prods-search')?.value||'').trim();
|
||
const q=qRaw.toLowerCase();
|
||
let prods=[...(S.productos||[])];
|
||
if(q) prods=prods.filter(p=>p.nombre.toLowerCase().includes(q));
|
||
prods.sort((a,b)=>a.nombre.localeCompare(b.nombre));
|
||
const list=prods.length?prods.map(p=>{
|
||
const key=productEntityKey(p);
|
||
const photo=(productFiles[key]||[]).find(f=>f.is_image);
|
||
return`<div class="pp-prod-row" onclick="ocAddLineaFromProducto('${esc(p.sku||'')}')">
|
||
${photo?`<img src="${photo.url}" class="pp-prod-photo" loading="lazy" decoding="async">`:'<div class="pp-prod-photo-empty">📦</div>'}
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-weight:600;font-size:12px">${esc(p.nombre)}</div>
|
||
<div style="font-size:10px;color:var(--t2)">${p.categoria||'—'}</div>
|
||
</div>
|
||
${p.costo_base?`<div style="font-size:11px;color:var(--olive);font-weight:600">${fmt$(p.costo_base)}</div>`:''}
|
||
</div>`;
|
||
}).join(''):'';
|
||
// If search yielded nothing, suggest creating it with that name
|
||
const hint=qRaw && !prods.length?`<div style="font-size:11px;color:var(--t3);text-align:center;padding:14px;background:var(--s2);border-radius:6px">
|
||
Sin resultados para "<b>${esc(qRaw)}</b>".<br>
|
||
<button class="btn btn-ac" style="margin-top:8px" onclick="toggleNewOcProdForm('${esc(qRaw).replace(/'/g,"\\\\'")}')">+ Crear "${esc(qRaw)}" como nuevo producto</button>
|
||
</div>`:'';
|
||
$('oc-prods-list').innerHTML=list+hint||'<div style="font-size:11px;color:var(--t3);text-align:center;padding:14px">Sin productos. Crea uno con el botón "+ Nuevo" arriba.</div>';
|
||
}
|
||
|
||
function toggleNewOcProdForm(prefillName){
|
||
const form=$('oc-new-prod-form');
|
||
const open=form.style.display!=='none';
|
||
if(open){form.style.display='none';return;}
|
||
// Pre-fill name from search if available
|
||
$('oc-np-nombre').value=prefillName||($('oc-prods-search')?.value||'').trim();
|
||
$('oc-np-cat').value='bolsa';
|
||
$('oc-np-costo').value=0;
|
||
$('oc-np-color').value='';
|
||
form.style.display='block';
|
||
$('oc-np-nombre').focus();
|
||
}
|
||
|
||
async function saveNewOcProducto(){
|
||
const nombre=$('oc-np-nombre').value.trim();
|
||
if(!nombre){toast('Escribe un nombre');return;}
|
||
const categoria=$('oc-np-cat').value;
|
||
const costo=+$('oc-np-costo').value||0;
|
||
const color=$('oc-np-color').value.trim();
|
||
// Generate sku
|
||
const sku=nombre.toLowerCase().replace(/[^a-z0-9]/g,'-').replace(/-+/g,'-').replace(/^-|-$/g,'').slice(0,30)+'-'+Date.now().toString(36);
|
||
try{
|
||
await api('POST','/api/productos',{
|
||
sku,nombre,categoria,color,costo_base:costo,activo:1
|
||
});
|
||
}catch(e){toast('Error creando producto: '+e.message);return;}
|
||
// Refresh local cache
|
||
S.productos=await api('GET','/api/productos');
|
||
// Add it to the order as a line
|
||
const p=S.productos.find(x=>x.sku===sku);
|
||
if(p){
|
||
ocCreate.lineas.push({
|
||
producto:p.nombre,sku:p.sku,
|
||
cantidad:0,
|
||
tipo_trabajo:p.tipo_personalizacion||'',
|
||
logo_instrucciones:'',
|
||
costo_producto:+p.costo_base||0,
|
||
costo_trabajo:0,
|
||
stage:'Nuevo',
|
||
urgente:0,
|
||
proyecto_id:null,
|
||
foto_url:null
|
||
});
|
||
}
|
||
toast(`✓ "${nombre}" creado y agregado`);
|
||
closeMo('mo-oc-prods');
|
||
renderOcCreate();
|
||
}
|
||
function ocAddLineaFromProducto(sku){
|
||
const p=(S.productos||[]).find(x=>x.sku===sku);
|
||
if(!p)return;
|
||
const key=productEntityKey(p);
|
||
const photo=(productFiles[key]||[]).find(f=>f.is_image);
|
||
ocCreate.lineas.push({
|
||
producto:p.nombre,sku:p.sku,
|
||
cantidad:0,
|
||
tipo_trabajo:p.tipo_personalizacion||'',
|
||
logo_instrucciones:'',
|
||
costo_producto:+p.costo_base||0,
|
||
costo_trabajo:0,
|
||
stage:'Nuevo',
|
||
urgente:0,
|
||
proyecto_id:null,
|
||
foto_url:photo?photo.url:null
|
||
});
|
||
closeMo('mo-oc-prods');
|
||
renderOcCreate();
|
||
}
|
||
|
||
async function addClienteFromOcCreate(){
|
||
const nombre=prompt('Nombre del cliente nuevo:');
|
||
if(!nombre)return;
|
||
await api('POST','/api/clientes',{nombre:nombre.trim(),tipo:'hotel',contacto:'',zona_entrega:'',costo_entrega:0,condiciones_pago:'Por definir',notas:''});
|
||
S.clientes=await api('GET','/api/clientes');
|
||
ocCreate.cliente=nombre.trim();
|
||
renderOcCreate();
|
||
toast('Cliente agregado');
|
||
}
|
||
|
||
async function saveOcCreate(){
|
||
const oc=ocCreate;
|
||
if(!oc.cliente){toast('Selecciona un cliente');return;}
|
||
if(!oc.lineas.length){toast('Agrega al menos un pedido');return;}
|
||
// Validate
|
||
for(const l of oc.lineas){
|
||
if(!l.producto){toast('Hay un pedido sin producto');return;}
|
||
if(!l.cantidad){toast(`Falta cantidad en: ${l.producto}`);return;}
|
||
}
|
||
|
||
let ocId=null;
|
||
if(oc.crear_orden){
|
||
// Create OC first
|
||
const folio=oc.oc_id||generateOcFolio(oc.cliente);
|
||
const ocBody={
|
||
oc_id:folio,
|
||
cliente:oc.cliente,
|
||
fecha_oc:oc.fecha_oc||new Date().toISOString().slice(0,10),
|
||
condiciones_pago:oc.condiciones_pago,
|
||
notas:oc.notas_oc||'',
|
||
status:'Activa'
|
||
};
|
||
try{
|
||
const res=await api('POST','/api/oc',ocBody);
|
||
ocId=res.id;
|
||
}catch(e){toast('Error creando Orden: '+e.message);return;}
|
||
}
|
||
|
||
// Create each pedido
|
||
let created=0;
|
||
for(const l of oc.lineas){
|
||
const pedidoBody={
|
||
orden_id:generatePedidoId(),
|
||
tipo_orden:'OC',
|
||
cliente:oc.cliente,
|
||
producto:l.producto,
|
||
sku:l.sku||'',
|
||
cantidad:+l.cantidad||0,
|
||
tipo_trabajo:l.tipo_trabajo||'',
|
||
stage:l.stage||'Nuevo',
|
||
fecha_inicio:new Date().toISOString().slice(0,10),
|
||
urgente:+l.urgente||0,
|
||
logo_instrucciones:l.logo_instrucciones||'',
|
||
notas:'',
|
||
costo_producto:+l.costo_producto||0,
|
||
costo_trabajo:+l.costo_trabajo||0,
|
||
costo_logistica:0,
|
||
precio_factura:0,
|
||
oc_id:ocId||0,
|
||
proyecto_id:l.proyecto_id||0
|
||
};
|
||
const res=await api('POST','/api/ordenes',pedidoBody);
|
||
// Bump proyecto counter
|
||
if(l.proyecto_id){
|
||
const p=(S.proyectos||[]).find(x=>x.id===l.proyecto_id);
|
||
if(p){
|
||
api('PUT',`/api/proyectos/${l.proyecto_id}`,{
|
||
veces_usado:(p.veces_usado||0)+1,
|
||
ultimo_uso:new Date().toISOString().slice(0,10)
|
||
});
|
||
}
|
||
} else {
|
||
// Try auto-link
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
await autoLinkPedidoToProyecto(res.id);
|
||
}
|
||
created++;
|
||
// Wait a tiny bit between requests to ensure unique orden_ids
|
||
await new Promise(r=>setTimeout(r,30));
|
||
}
|
||
|
||
toast(`✓ ${oc.crear_orden?'Orden creada con ':''}${created} pedido${created!==1?'s':''}`);
|
||
closeMo('mo-orden-create');
|
||
if(ocId) setTimeout(()=>openOrdenDetail(ocId),200);
|
||
refreshActiveView();
|
||
}
|
||
|
||
// ══════ PROYECTOS RECURRENTES ══════
|
||
let proyectosData=[];
|
||
let proyEdit=null;
|
||
|
||
async function loadProyectos(){
|
||
proyectosData=await api('GET','/api/proyectos');
|
||
if(!S.clientes.length) S.clientes=await api('GET','/api/clientes');
|
||
if(!S.trabajos.length) S.trabajos=await api('GET','/api/trabajos');
|
||
if(!S.productos?.length){
|
||
S.productos=await api('GET','/api/productos');
|
||
productFiles={};
|
||
await Promise.all(S.productos.map(async p=>{
|
||
const key=productEntityKey(p);
|
||
try{const files=await api('GET',`/api/files/${encodeURIComponent(key)}`);if(files.length) productFiles[key]=files;}catch(e){}
|
||
}));
|
||
}
|
||
renderProyectosList();
|
||
}
|
||
|
||
function renderProyectosList(){
|
||
const q=($('search-proyectos')?.value||'').toLowerCase();
|
||
let items=proyectosData;
|
||
if(q) items=items.filter(p=>(p.nombre+' '+p.cliente+' '+(p.producto_nombre||'')+' '+(p.tipo_trabajo||'')).toLowerCase().includes(q));
|
||
|
||
// Build suggestions: unique cliente+producto+trabajo combos from pedidos without proyecto_id,
|
||
// not already covered by existing projects
|
||
const existingKeys=new Set(proyectosData.map(p=>`${(p.cliente||'').toLowerCase()}|${(p.producto_nombre||'').toLowerCase()}|${(p.tipo_trabajo||'').toLowerCase()}`));
|
||
const sugerencias={};
|
||
(S.ordenes||[]).forEach(o=>{
|
||
if(o.proyecto_id) return;
|
||
if(!o.cliente||!o.producto) return;
|
||
if(o.tipo_orden==='Muestra') return; // skip samples
|
||
const key=`${o.cliente.toLowerCase()}|${o.producto.toLowerCase()}|${(o.tipo_trabajo||'').toLowerCase()}`;
|
||
if(existingKeys.has(key)) return;
|
||
if(!sugerencias[key]) sugerencias[key]={
|
||
cliente:o.cliente,producto:o.producto,tipo_trabajo:o.tipo_trabajo||'',
|
||
count:0,total_pzas:0,last_pedido:o,last_date:'',
|
||
costo_producto:o.costo_producto||0,costo_trabajo:o.costo_trabajo||0,
|
||
logo:o.logo_instrucciones||''
|
||
};
|
||
const s=sugerencias[key];
|
||
s.count++;s.total_pzas+=o.cantidad;
|
||
const d=o.fecha_entrega||o.fecha_inicio||'';
|
||
if(d>s.last_date){s.last_date=d;s.last_pedido=o;}
|
||
// Use latest non-zero costs
|
||
if(o.costo_producto) s.costo_producto=o.costo_producto;
|
||
if(o.costo_trabajo) s.costo_trabajo=o.costo_trabajo;
|
||
if(o.logo_instrucciones) s.logo=o.logo_instrucciones;
|
||
});
|
||
const sugerenciasList=Object.values(sugerencias).filter(s=>s.count>=1).sort((a,b)=>b.count-a.count||b.total_pzas-a.total_pzas);
|
||
const filteredSug=q?sugerenciasList.filter(s=>(s.cliente+' '+s.producto+' '+s.tipo_trabajo).toLowerCase().includes(q)):sugerenciasList;
|
||
|
||
// Suggestions panel HTML
|
||
const sugHtml=filteredSug.length?`
|
||
<div class="proy-sug-panel">
|
||
<div class="proy-sug-header">
|
||
<div>
|
||
<b style="color:var(--olive-dark)">💡 Sugerencias desde tu historial</b>
|
||
<span style="font-size:10px;color:var(--t3);margin-left:6px">${filteredSug.length} combinacion${filteredSug.length!==1?'es':''} cliente + producto sin proyecto</span>
|
||
</div>
|
||
<button class="btn btn-sm" onclick="toggleSugerencias()" id="btn-toggle-sug">${sugerenciasOpen?'▴ Ocultar':'▾ Mostrar'}</button>
|
||
</div>
|
||
<div id="proy-sug-list" style="display:${sugerenciasOpen?'block':'none'}">
|
||
<div style="font-size:11px;color:var(--t2);margin-bottom:8px">Estos pedidos pasados se podrían convertir en proyectos recurrentes. Click en uno y te pre-llenamos el formulario con sus datos.</div>
|
||
<div class="proy-sug-grid">
|
||
${filteredSug.slice(0,40).map((s,i)=>`<div class="proy-sug-card" onclick="createProyectoFromSugerencia(${i})">
|
||
<div class="psg-head">
|
||
<b>${esc(s.cliente)}</b>
|
||
<span class="psg-count">${s.count}×</span>
|
||
</div>
|
||
<div class="psg-prod">${esc(s.producto)}</div>
|
||
<div class="psg-meta">
|
||
${s.tipo_trabajo?`<span class="proy-tag" style="background:var(--${cc(s.tipo_trabajo)[1]});color:var(--${cc(s.tipo_trabajo)[0]})">${esc(s.tipo_trabajo)}</span>`:'<span style="font-size:9px;color:var(--t3)">sin trabajo</span>'}
|
||
<span style="font-size:9px;color:var(--t3)">${s.total_pzas} pzas total</span>
|
||
${s.costo_producto||s.costo_trabajo?`<span style="font-size:9px;color:var(--olive);font-weight:600">${fmt$((s.costo_producto||0)+(s.costo_trabajo||0))}/pza</span>`:''}
|
||
</div>
|
||
</div>`).join('')}
|
||
</div>
|
||
${filteredSug.length>40?`<div style="font-size:10px;color:var(--t3);text-align:center;padding:6px">Mostrando 40 de ${filteredSug.length}</div>`:''}
|
||
</div>
|
||
</div>
|
||
`:'';
|
||
// Save sugerencias for click handler
|
||
window._sugerenciasCache=filteredSug;
|
||
|
||
if(!items.length){
|
||
$('proyectos-content').innerHTML=`${sugHtml}<div class="empty">
|
||
<div style="font-size:36px;opacity:.3;margin-bottom:8px">📐</div>
|
||
<div>${q?'Sin coincidencias':'Sin proyectos recurrentes todavía'}</div>
|
||
${!q?`<div style="font-size:11px;color:var(--t3);margin-top:6px;max-width:380px;text-align:center;line-height:1.5">Usa las sugerencias arriba para crear proyectos desde tu historial, o haz uno nuevo desde cero.</div>
|
||
<button class="btn btn-ac" style="margin-top:12px" onclick="newProyecto()">+ Crear nuevo</button>`:''}
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
// Group by cliente
|
||
const byCli={};
|
||
items.forEach(p=>{
|
||
const c=p.cliente||'Sin cliente';
|
||
if(!byCli[c]) byCli[c]=[];
|
||
byCli[c].push(p);
|
||
});
|
||
const sortedClis=Object.keys(byCli).sort((a,b)=>byCli[b].length-byCli[a].length||a.localeCompare(b));
|
||
|
||
const cardHtml=(p)=>{
|
||
const photo=getProyectoPhoto(p);
|
||
return`<div class="proy-card" onclick="viewProyecto(${p.id})">
|
||
${photo?`<img src="${photo}" class="proy-photo" loading="lazy" decoding="async">`:'<div class="proy-photo-empty">📦</div>'}
|
||
<div class="proy-info">
|
||
<div class="proy-nombre">${esc(p.nombre)}</div>
|
||
<div class="proy-meta">
|
||
${p.tipo_trabajo?`<span class="proy-tag" style="background:var(--${cc(p.tipo_trabajo)[1]});color:var(--${cc(p.tipo_trabajo)[0]})">${esc(p.tipo_trabajo)}</span>`:''}
|
||
${p.producto_nombre?`<span style="font-size:10px;color:var(--t2)">${esc(p.producto_nombre)}</span>`:''}
|
||
</div>
|
||
${p.costo_unitario?`<div class="proy-cost">${fmt$(p.costo_unitario)}/pza</div>`:''}
|
||
<div class="proy-stats">
|
||
<span title="Veces usado">🔁 ${p.veces_usado||0}</span>
|
||
<span title="Pedidos vinculados">📦 ${p.pedidos_count||0}</span>
|
||
${p.ultimo_uso?`<span style="font-size:9px;color:var(--t3);margin-left:auto">${p.ultimo_uso.slice(0,10)}</span>`:''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
};
|
||
|
||
$('proyectos-content').innerHTML=sugHtml+sortedClis.map(cli=>`
|
||
<div class="proy-cli-group">
|
||
<div class="proy-cli-header">
|
||
<b>${esc(cli)}</b>
|
||
<span style="font-weight:400;color:var(--t3);font-size:10px">${byCli[cli].length} proyecto${byCli[cli].length!==1?'s':''}</span>
|
||
</div>
|
||
<div class="proy-grid">${byCli[cli].map(cardHtml).join('')}</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
let sugerenciasOpen=true;
|
||
function toggleSugerencias(){
|
||
sugerenciasOpen=!sugerenciasOpen;
|
||
const list=$('proy-sug-list');
|
||
const btn=$('btn-toggle-sug');
|
||
if(list){list.style.display=sugerenciasOpen?'block':'none';}
|
||
if(btn){btn.textContent=sugerenciasOpen?'▴ Ocultar':'▾ Mostrar';}
|
||
}
|
||
|
||
function createProyectoFromSugerencia(idx){
|
||
const s=(window._sugerenciasCache||[])[idx];
|
||
if(!s) return;
|
||
// Find matching base product in catalog
|
||
const prod=(S.productos||[]).find(p=>p.nombre===s.producto);
|
||
const desc=`${s.producto} · ${s.cliente}${s.tipo_trabajo?' · '+s.tipo_trabajo:''}`;
|
||
proyEdit={
|
||
id:null,
|
||
nombre:desc,
|
||
producto_id:prod?prod.id:null,
|
||
producto_nombre:s.producto,
|
||
cliente:s.cliente,
|
||
tipo_trabajo:s.tipo_trabajo,
|
||
costo_unitario:s.costo_producto||0,
|
||
costo_trabajo:s.costo_trabajo||0,
|
||
logo_descripcion:s.logo||'',
|
||
logo_archivo:'',foto_terminado:'',
|
||
notas:`Creado desde historial · ${s.count} pedidos previos · ${s.total_pzas} pzas totales`,
|
||
activo:1,veces_usado:0,ultimo_uso:''
|
||
};
|
||
renderProyEditor();
|
||
openMo('mo-proyecto');
|
||
}
|
||
|
||
function getProyectoPhoto(p){
|
||
if(!p) return null;
|
||
// 1. Project's own uploaded files (prefer foto_terminado / mockup / any image)
|
||
const key='proy-'+p.id;
|
||
const files=proyectoFiles[key]||[];
|
||
const preferred=files.find(f=>f.is_image&&(
|
||
f.name.startsWith('foto_producto_terminado')||
|
||
f.name.startsWith('mockup')||
|
||
f.name.startsWith('foto_terminado')||
|
||
f.name.startsWith('foto_producto')
|
||
));
|
||
if(preferred) return preferred.url;
|
||
const anyImg=files.find(f=>f.is_image);
|
||
if(anyImg) return anyImg.url;
|
||
// 2. Inline foto_terminado URL field if set
|
||
if(p.foto_terminado) return p.foto_terminado;
|
||
if(p.logo_archivo) return p.logo_archivo;
|
||
// 3. Fall back to base catalog product photo
|
||
if(p.producto_id&&S.productos){
|
||
const prod=S.productos.find(x=>x.id===p.producto_id);
|
||
if(prod){
|
||
const ph=(productFiles[productEntityKey(prod)]||[]).find(f=>f.is_image);
|
||
if(ph) return ph.url;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function viewProyecto(id){
|
||
const p=proyectosData.find(x=>x.id===id);
|
||
if(!p) return;
|
||
// Always refresh this project's files to ensure latest photo shows
|
||
try{
|
||
const files=await api('GET',`/api/files/${encodeURIComponent('proy-'+id)}`);
|
||
proyectoFiles['proy-'+id]=files;
|
||
}catch(e){}
|
||
renderProyView(p);
|
||
openMo('mo-proyecto-view');
|
||
}
|
||
|
||
function renderProyView(p){
|
||
const photo=getProyectoPhoto(p);
|
||
// Find pedidos that used this proyecto
|
||
const pedidos=(S.ordenes||[]).filter(o=>o.proyecto_id===p.id).sort((a,b)=>(b.fecha_entrega||b.fecha_inicio||'').localeCompare(a.fecha_entrega||a.fecha_inicio||''));
|
||
const totalPzas=pedidos.reduce((s,o)=>s+o.cantidad,0);
|
||
|
||
const pedidosHtml=pedidos.slice(0,8).map(o=>{
|
||
const[sc]=cc(o.stage);
|
||
return`<div class="pv-pedido-row" onclick="closeMo('mo-proyecto-view');openQuickView(${o.id})">
|
||
<span class="stage-dot" style="background:var(--${sc});width:8px;height:8px;border-radius:50%;display:inline-block"></span>
|
||
<span style="font-weight:600">${esc(o.orden_id)}</span>
|
||
<span style="font-size:10px;color:var(--t2);flex:1">${o.cantidad} pzas · ${o.stage}</span>
|
||
<span style="font-size:10px;color:var(--t3)">${o.fecha_entrega||o.fecha_inicio||''}</span>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
$('proy-view-body').innerHTML=`
|
||
${photo?`<div style="margin:-4px -16px 14px;background:var(--s2);padding:8px 0;text-align:center"><img src="${photo}" style="max-width:100%;max-height:300px;cursor:zoom-in;border-radius:6px;box-shadow:0 2px 8px rgba(0,0,0,.1)" onclick="openFile('${photo}',true)"></div>`:''}
|
||
|
||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:12px">
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:1px;font-weight:600">📐 Proyecto Recurrente</div>
|
||
<h2 style="margin:2px 0 0;font-size:17px;color:var(--olive-dark);font-weight:600">${esc(p.nombre)}</h2>
|
||
<div style="font-size:11px;color:var(--t2);margin-top:3px"><b class="cli-link" onclick="goToCliente('${esc(p.cliente).replace(/'/g,"\\\\'")}')">${esc(p.cliente)}</b></div>
|
||
</div>
|
||
<button class="mo-x" onclick="closeMo('mo-proyecto-view')">×</button>
|
||
</div>
|
||
|
||
<!-- Stats -->
|
||
<div class="pv-stats">
|
||
<div class="pv-stat"><span class="lbl">Veces usado</span><span class="val">${p.veces_usado||0}</span></div>
|
||
<div class="pv-stat"><span class="lbl">Pedidos</span><span class="val">${pedidos.length}</span></div>
|
||
<div class="pv-stat"><span class="lbl">Piezas totales</span><span class="val">${totalPzas.toLocaleString()}</span></div>
|
||
${p.costo_unitario?`<div class="pv-stat"><span class="lbl">Costo/pza</span><span class="val" style="color:var(--olive)">${fmt$(p.costo_unitario)}</span></div>`:''}
|
||
</div>
|
||
|
||
<!-- Detalles -->
|
||
<div class="pv-details">
|
||
${p.tipo_trabajo?`<div class="pv-row"><span class="pv-lbl">Tipo de trabajo</span><span class="pv-val"><span class="tag" style="background:var(--${cc(p.tipo_trabajo)[1]});color:var(--${cc(p.tipo_trabajo)[0]})">${esc(p.tipo_trabajo)}</span></span></div>`:''}
|
||
${p.producto_nombre?`<div class="pv-row"><span class="pv-lbl">Modelo base</span><span class="pv-val">${esc(p.producto_nombre)}</span></div>`:''}
|
||
${p.logo_descripcion?`<div class="pv-row"><span class="pv-lbl">Logo / Acabado</span><span class="pv-val" style="text-align:left;max-width:65%">${esc(p.logo_descripcion)}</span></div>`:''}
|
||
${p.costo_trabajo?`<div class="pv-row"><span class="pv-lbl">Costo trabajo</span><span class="pv-val">${fmt$(p.costo_trabajo)}</span></div>`:''}
|
||
${p.ultimo_uso?`<div class="pv-row"><span class="pv-lbl">Último uso</span><span class="pv-val">${esc(p.ultimo_uso.slice(0,10))}</span></div>`:''}
|
||
${p.notas?`<div class="pv-row" style="flex-direction:column;align-items:stretch;gap:4px"><span class="pv-lbl">Notas internas</span><span class="pv-val" style="text-align:left;font-size:11px;color:var(--t2);font-style:italic;background:var(--s2);padding:6px 10px;border-radius:6px;white-space:pre-wrap">${esc(p.notas)}</span></div>`:''}
|
||
</div>
|
||
|
||
${pedidos.length?`<div style="margin-top:14px">
|
||
<div style="font-size:10px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;font-weight:600;margin-bottom:6px">Pedidos recientes (${pedidos.length}${pedidos.length>8?' · mostrando 8':''})</div>
|
||
${pedidosHtml}
|
||
</div>`:''}
|
||
|
||
<!-- Actions -->
|
||
<div style="display:flex;gap:6px;margin-top:14px;flex-wrap:wrap">
|
||
<button class="btn btn-ac" onclick="closeMo('mo-proyecto-view');openProyecto(${p.id})">✎ Editar</button>
|
||
<button class="btn" onclick="openFiles('proy-${p.id}')">📎 Archivos</button>
|
||
<button class="btn" onclick="closeMo('mo-proyecto-view');createPedidoFromProyecto(${p.id})" style="border-color:var(--olive);color:var(--olive)">+ Pedido con este proyecto</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function createPedidoFromProyecto(id){
|
||
const p=proyectosData.find(x=>x.id===id);
|
||
if(!p) return;
|
||
// Stash a hint for the wizard to auto-pick this proyecto after opening
|
||
window._pendingProyectoSelect=id;
|
||
await openWizard();
|
||
// After open, set cliente then apply proyecto
|
||
setTimeout(()=>{
|
||
if(p.cliente){$('w-cliente').value=p.cliente;$('w-cliente').onchange?.();}
|
||
setTimeout(()=>{
|
||
$('w-proyecto-sel').value=id;
|
||
wizApplyProyecto();
|
||
wizCur=2;updateWizard();
|
||
},150);
|
||
},120);
|
||
}
|
||
|
||
function newProyecto(){
|
||
proyEdit={
|
||
id:null,nombre:'',producto_id:null,producto_nombre:'',cliente:'',tipo_trabajo:'',
|
||
costo_unitario:0,costo_trabajo:0,logo_descripcion:'',logo_archivo:'',foto_terminado:'',
|
||
notas:'',activo:1,veces_usado:0,ultimo_uso:''
|
||
};
|
||
renderProyEditor();
|
||
openMo('mo-proyecto');
|
||
}
|
||
|
||
function openProyecto(id){
|
||
const p=proyectosData.find(x=>x.id===id);
|
||
if(!p)return;
|
||
proyEdit={...p};
|
||
renderProyEditor();
|
||
openMo('mo-proyecto');
|
||
}
|
||
|
||
function renderProyEditor(){
|
||
const p=proyEdit;
|
||
$('proy-header').innerHTML=`<div class="od-head">
|
||
<div class="od-head-left">
|
||
<h2>
|
||
<span style="font-size:10px;padding:3px 8px;border-radius:4px;background:var(--olive);color:#fff;font-weight:700;letter-spacing:.5px">PROYECTO</span>
|
||
${p.id?'Editar':'Nuevo'}
|
||
</h2>
|
||
${p.veces_usado?`<div class="od-cli">Usado ${p.veces_usado} ${p.veces_usado===1?'vez':'veces'}${p.ultimo_uso?' · último '+p.ultimo_uso.slice(0,10):''}</div>`:''}
|
||
</div>
|
||
<button class="mo-x" onclick="closeMo('mo-proyecto')">×</button>
|
||
</div>`;
|
||
|
||
const cliOpts=(S.clientes||[]).map(c=>`<option value="${esc(c.nombre)}"${c.nombre===p.cliente?' selected':''}>${esc(c.nombre)}</option>`).join('');
|
||
const prodOpts=(S.productos||[]).map(pr=>`<option value="${pr.id}" data-nombre="${esc(pr.nombre)}"${pr.id===p.producto_id?' selected':''}>${esc(pr.nombre)}</option>`).join('');
|
||
const trabOpts=trabajoOpts().map(t=>`<option value="${esc(t)}"${t===p.tipo_trabajo?' selected':''}>${t||'—'}</option>`).join('');
|
||
|
||
$('proy-body').innerHTML=`
|
||
<div class="od-section">
|
||
<div class="od-config-grid">
|
||
<div class="fg" style="grid-column:1/-1"><label>Nombre descriptivo</label>
|
||
<input id="proy-nombre" value="${esc(p.nombre||'')}" placeholder='ej: "Bolsa Cabo Bello + Logo Flora Farms DTF negro"' oninput="proyEdit.nombre=this.value">
|
||
</div>
|
||
<div class="fg"><label>Cliente</label>
|
||
<select id="proy-cliente" onchange="proyEdit.cliente=this.value">
|
||
<option value="">— Selecciona —</option>${cliOpts}
|
||
</select>
|
||
</div>
|
||
<div class="fg"><label>Modelo base</label>
|
||
<select id="proy-producto" onchange="proyEdit.producto_id=+this.value||null;proyEdit.producto_nombre=this.options[this.selectedIndex].dataset.nombre||''">
|
||
<option value="">— Selecciona —</option>${prodOpts}
|
||
</select>
|
||
</div>
|
||
<div class="fg"><label>Tipo de trabajo</label>
|
||
<select id="proy-trabajo" onchange="proyEdit.tipo_trabajo=this.value">${trabOpts}</select>
|
||
</div>
|
||
<div class="fg"><label>Costo unitario ($)</label>
|
||
<input type="number" step="0.01" value="${p.costo_unitario||0}" oninput="proyEdit.costo_unitario=+this.value||0">
|
||
</div>
|
||
<div class="fg" style="grid-column:1/-1"><label>Descripción del logo / acabado</label>
|
||
<input value="${esc(p.logo_descripcion||'')}" placeholder="ej: Logo Flora Farms en DTF negro, centrado" oninput="proyEdit.logo_descripcion=this.value">
|
||
</div>
|
||
<div class="fg" style="grid-column:1/-1"><label>Notas internas</label>
|
||
<textarea rows="2" oninput="proyEdit.notas=this.value">${esc(p.notas||'')}</textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${p.id?`<div class="od-section">
|
||
<div class="od-section-title">Archivos de referencia</div>
|
||
<div style="font-size:11px;color:var(--t2);margin-bottom:6px">Logo, fotos del producto terminado, instrucciones. Se reusarán en pedidos futuros.</div>
|
||
<button class="btn" onclick="openFiles('proy-${p.id}')">📎 Abrir archivos del proyecto</button>
|
||
</div>`:'<div style="font-size:10px;color:var(--t3);padding:8px;text-align:center">Guarda primero para subir archivos</div>'}
|
||
|
||
<div class="od-actions">
|
||
<button class="btn btn-ac" onclick="saveProyecto()">✓ Guardar</button>
|
||
${p.id?`<button class="btn" style="border-color:var(--rd);color:var(--rd);margin-left:auto" onclick="deleteProyecto()">🗑</button>`:''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function saveProyecto(){
|
||
const p=proyEdit;
|
||
if(!p.nombre){toast('Escribe un nombre');return;}
|
||
if(!p.cliente){toast('Selecciona un cliente');return;}
|
||
const body={
|
||
nombre:p.nombre,
|
||
producto_id:p.producto_id||0,
|
||
producto_nombre:p.producto_nombre||'',
|
||
cliente:p.cliente,
|
||
tipo_trabajo:p.tipo_trabajo||'',
|
||
costo_unitario:+p.costo_unitario||0,
|
||
costo_trabajo:+p.costo_trabajo||0,
|
||
logo_descripcion:p.logo_descripcion||'',
|
||
logo_archivo:p.logo_archivo||'',
|
||
foto_terminado:p.foto_terminado||'',
|
||
notas:p.notas||'',
|
||
activo:1,
|
||
veces_usado:p.veces_usado||0,
|
||
ultimo_uso:p.ultimo_uso||''
|
||
};
|
||
if(p.id){
|
||
await api('PUT',`/api/proyectos/${p.id}`,body);
|
||
} else {
|
||
const res=await api('POST','/api/proyectos',body);
|
||
proyEdit.id=res.id;
|
||
}
|
||
toast('Proyecto guardado');
|
||
proyectosData=await api('GET','/api/proyectos');
|
||
renderProyectosList();
|
||
closeMo('mo-proyecto');
|
||
}
|
||
|
||
async function deleteProyecto(){
|
||
if(!confirm('¿Eliminar este proyecto recurrente? Los pedidos pasados que lo usaron seguirán existiendo.'))return;
|
||
await api('DELETE',`/api/proyectos/${proyEdit.id}`);
|
||
toast('Eliminado');
|
||
proyectosData=await api('GET','/api/proyectos');
|
||
renderProyectosList();
|
||
closeMo('mo-proyecto');
|
||
}
|
||
|
||
// ══════ PROPUESTAS ══════
|
||
let propuestasData=[];
|
||
let ppEdit=null; // current propuesta being edited
|
||
|
||
async function loadPropuestas(){
|
||
propuestasData=await api('GET','/api/propuestas');
|
||
S.ocs=await api('GET','/api/oc');
|
||
if(!S.clientes.length) S.clientes=await api('GET','/api/clientes');
|
||
if(!S.productos?.length){
|
||
S.productos=await api('GET','/api/productos');
|
||
productFiles={};
|
||
await Promise.all(S.productos.map(async p=>{
|
||
const key=productEntityKey(p);
|
||
try{const files=await api('GET',`/api/files/${encodeURIComponent(key)}`);if(files.length) productFiles[key]=files;}catch(e){}
|
||
}));
|
||
}
|
||
setPropuestasView(propuestasView);
|
||
}
|
||
|
||
// ══════ Sub-vista de Propuestas (Cotizaciones / Catálogos) ══════
|
||
let propuestasView='cotizaciones';
|
||
async function setPropuestasView(view,btn){
|
||
propuestasView=view;
|
||
if(btn){
|
||
btn.parentElement.querySelectorAll('.vt-btn').forEach(b=>b.classList.remove('on'));
|
||
btn.classList.add('on');
|
||
}
|
||
document.getElementById('propuestas-sub-cotizaciones').style.display=view==='cotizaciones'?'':'none';
|
||
document.getElementById('propuestas-sub-catalogos').style.display=view==='catalogos'?'':'none';
|
||
const tb=$('propuestas-toolbar');
|
||
if(view==='cotizaciones'){
|
||
tb.innerHTML=`<input class="search-box" placeholder="Buscar..." id="search-propuestas" oninput="renderPropuestasList()">
|
||
<button class="btn btn-ac" onclick="newPropuesta()">+ Nueva Propuesta</button>`;
|
||
renderPropuestasList();
|
||
} else {
|
||
tb.innerHTML=`<input class="search-box" placeholder="Buscar..." id="search-catalogos" oninput="renderCatalogosList()">
|
||
<button class="btn btn-ac" onclick="newCatalogo()">+ Nuevo Catálogo</button>`;
|
||
await loadCatalogos();
|
||
}
|
||
}
|
||
|
||
function renderPropuestasList(){
|
||
const q=($('search-propuestas')?.value||'').toLowerCase();
|
||
let items=propuestasData;
|
||
if(q) items=items.filter(p=>(p.numero+' '+p.cliente_nombre+' '+p.empresa).toLowerCase().includes(q));
|
||
items.sort((a,b)=>b.id-a.id);
|
||
|
||
if(!items.length){
|
||
$('propuestas-content').innerHTML=`<div class="empty">
|
||
<div style="font-size:36px;opacity:.3;margin-bottom:8px">📄</div>
|
||
<div>Sin propuestas todavía</div>
|
||
<button class="btn btn-ac" style="margin-top:12px" onclick="newPropuesta()">+ Crear primera propuesta</button>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
const statusColor={borrador:'#9ca3af',enviada:'#3b82f6',aceptada:'#16a34a',rechazada:'#ef4444'};
|
||
const statusLabel={borrador:'Borrador',enviada:'Enviada',aceptada:'Aceptada',rechazada:'Rechazada'};
|
||
|
||
const rows=items.map(p=>{
|
||
const its=safeParseItems(p.items);
|
||
const totals=computeProposalTotals(its,p.iva_pct,p.descuento_pct);
|
||
const sc=statusColor[p.status]||'#9ca3af';
|
||
return`<div class="pp-row" onclick="openPropuesta(${p.id})">
|
||
<div class="pp-row-main">
|
||
<div class="pp-row-head">
|
||
<span class="pp-num">${esc(p.numero)}</span>
|
||
<span class="pp-status" style="background:${sc}20;color:${sc}">${statusLabel[p.status]||p.status}</span>
|
||
</div>
|
||
<div class="pp-row-cli"><b>${esc(p.empresa||p.cliente_nombre||'Sin cliente')}</b>${p.cliente_nombre&&p.empresa?` · ${esc(p.cliente_nombre)}`:''}${p.tipo_negocio?` · ${esc(p.tipo_negocio)}`:''}</div>
|
||
<div class="pp-row-meta">${p.fecha?`📅 ${p.fecha}`:''} ${its.length} producto${its.length!==1?'s':''}</div>
|
||
</div>
|
||
<div class="pp-row-right">
|
||
<div class="pp-row-total">${fmt$(totals.total)}</div>
|
||
<div style="font-size:9px;color:var(--t3)">Total con IVA</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
$('propuestas-content').innerHTML=`<div class="pp-list">${rows}</div>`;
|
||
}
|
||
|
||
function safeParseItems(s){try{return JSON.parse(s||'[]')}catch(e){return[]}}
|
||
|
||
function computeProposalTotals(items,ivaPct,descPct){
|
||
const subtotal=items.reduce((s,i)=>s+(i.cantidad||0)*(i.precio_unit||0),0);
|
||
const desc=subtotal*((descPct||0)/100);
|
||
const sub2=subtotal-desc;
|
||
const iva=sub2*((ivaPct||16)/100);
|
||
const total=sub2+iva;
|
||
return{subtotal,desc,sub2,iva,total};
|
||
}
|
||
|
||
function newPropuesta(){
|
||
const yr=new Date().getFullYear();
|
||
const month=String(new Date().getMonth()+1).padStart(2,'0');
|
||
const prefix=`PROP-${yr}-${month}`;
|
||
const n=propuestasData.filter(p=>p.numero.startsWith(prefix)).length+1;
|
||
const numero=`${prefix}-${String(n).padStart(2,'0')}`;
|
||
ppEdit={
|
||
id:null,
|
||
numero,
|
||
cliente_nombre:'',contacto:'',empresa:'',direccion:'',locacion:'',tipo_negocio:'hotel',
|
||
email:'',telefono:'',fecha:new Date().toISOString().slice(0,10),vigencia_dias:15,
|
||
items:[],iva_pct:16,descuento_pct:0,notas:'',status:'borrador'
|
||
};
|
||
renderPropuestaEditor();
|
||
openMo('mo-propuesta');
|
||
}
|
||
|
||
function openPropuesta(id){
|
||
const p=propuestasData.find(x=>x.id===id);
|
||
if(!p)return;
|
||
ppEdit={...p,items:safeParseItems(p.items)};
|
||
renderPropuestaEditor();
|
||
openMo('mo-propuesta');
|
||
}
|
||
|
||
function renderPropuestaEditor(){
|
||
const p=ppEdit;
|
||
const totals=computeProposalTotals(p.items,p.iva_pct,p.descuento_pct);
|
||
|
||
$('pp-header').innerHTML=`<div class="od-head">
|
||
<div class="od-head-left">
|
||
<h2>
|
||
<span style="font-size:10px;padding:3px 8px;border-radius:4px;background:var(--olive);color:#fff;font-weight:700;letter-spacing:.5px">PROPUESTA</span>
|
||
${esc(p.numero)}
|
||
</h2>
|
||
<div class="od-cli">${p.id?'Editando':'Nueva'}${p.fecha?' · '+p.fecha:''}</div>
|
||
</div>
|
||
<button class="mo-x" onclick="closeMo('mo-propuesta')">×</button>
|
||
</div>`;
|
||
|
||
// Items rows
|
||
const trabajoOpts=window.trabajoOpts();
|
||
const itemsHtml=p.items.length?p.items.map((it,i)=>{
|
||
const subtotal=(it.cantidad||0)*(it.precio_unit||0);
|
||
const photo=it.foto_url||(getProductPhoto(it.producto)?.url);
|
||
const baseDesc=[it.color,it.logo_diseno].filter(x=>x).map(esc).join(' · ');
|
||
return`<div class="pp-item">
|
||
${photo?`<img src="${photo}" class="pp-item-photo" onclick="openFile('${photo}',true)">`:'<div class="pp-item-photo-empty">📦</div>'}
|
||
<div class="pp-item-main">
|
||
<div class="pp-item-name">${esc(it.producto)}</div>
|
||
${baseDesc?`<div class="pp-item-desc">${baseDesc}</div>`:''}
|
||
<div class="pp-item-fields">
|
||
<label class="pp-item-field">
|
||
<span>Tipo de trabajo</span>
|
||
<select onchange="ppLiveItemRaw(${i},'tipo_trabajo',this.value)">
|
||
${trabajoOpts.map(o=>`<option value="${esc(o)}"${o===(it.tipo_trabajo||'')?' selected':''}>${o||'— Sin definir —'}</option>`).join('')}
|
||
</select>
|
||
</label>
|
||
<label class="pp-item-field">
|
||
<span>Detalles / descripción</span>
|
||
<input type="text" value="${esc(it.descripcion||'')}" placeholder="ej: Logo bordado a 3 colores" onchange="ppLiveItemRaw(${i},'descripcion',this.value)">
|
||
</label>
|
||
</div>
|
||
<div class="pp-item-controls">
|
||
<label>Cant <input type="number" min="0" value="${it.cantidad||0}" oninput="ppLiveItem(${i},'cantidad',this.value)" onblur="renderPropuestaEditor()"></label>
|
||
<label>$/pza <input type="number" step="0.01" value="${it.precio_unit||0}" oninput="ppLiveItem(${i},'precio_unit',this.value)" onblur="renderPropuestaEditor()"></label>
|
||
<div class="pp-item-sub" id="pp-sub-${i}">${fmt$(subtotal)}</div>
|
||
</div>
|
||
</div>
|
||
<button class="kc-btn" onclick="removePpItem(${i})" style="color:var(--rd);align-self:flex-start">×</button>
|
||
</div>`;
|
||
}).join(''):'<div style="font-size:11px;color:var(--t3);text-align:center;padding:14px;background:var(--s2);border-radius:6px">Sin productos. Agrega abajo.</div>';
|
||
|
||
$('pp-body').innerHTML=`
|
||
<!-- Cliente section -->
|
||
<div class="od-section">
|
||
<div class="od-section-title">Datos del cliente</div>
|
||
<div class="pp-cli-grid">
|
||
<div class="fg" style="grid-column:1/-1"><label>Empresa</label><input id="pp-empresa" value="${esc(p.empresa||'')}" placeholder="Hotel X / Restaurante Y / ..." oninput="ppEdit.empresa=this.value"></div>
|
||
<div class="fg"><label>Contacto (nombre)</label><input id="pp-contacto" value="${esc(p.contacto||'')}" placeholder="Persona contacto" oninput="ppEdit.contacto=this.value"></div>
|
||
<div class="fg"><label>Locación <span style="font-weight:400;color:var(--t3);font-size:9px">(visible en propuesta)</span></label><select id="pp-locacion" onchange="ppEdit.locacion=this.value">
|
||
${['','Cabo San Lucas','San Jose del Cabo','Cabo del Este','Todos Santos / Pescadero','La Paz','Nacional','Internacional'].map(l=>`<option value="${esc(l)}"${l===(p.locacion||'')?' selected':''}>${l||'—'}</option>`).join('')}
|
||
</select></div>
|
||
<div class="fg"><label>Tipo de negocio <span style="font-weight:400;color:var(--t3);font-size:9px">(interno)</span></label><select id="pp-tipo" onchange="ppEdit.tipo_negocio=this.value">
|
||
${['hotel','tienda','restaurante','distribuidor','independiente','otro'].map(t=>`<option${t===p.tipo_negocio?' selected':''}>${t}</option>`).join('')}
|
||
</select></div>
|
||
<div class="fg"><label>Email</label><input id="pp-email" type="email" value="${esc(p.email||'')}" oninput="ppEdit.email=this.value"></div>
|
||
<div class="fg"><label>Teléfono</label><input id="pp-telefono" value="${esc(p.telefono||'')}" oninput="ppEdit.telefono=this.value"></div>
|
||
<div class="fg" style="grid-column:1/-1"><label>Dirección</label><input id="pp-direccion" value="${esc(p.direccion||'')}" oninput="ppEdit.direccion=this.value"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Items section -->
|
||
<div class="od-section">
|
||
<div class="od-section-title">
|
||
<span>Productos (${p.items.length})</span>
|
||
<button class="oc-link-btn" onclick="openPpProds()">+ Agregar productos</button>
|
||
</div>
|
||
<div class="pp-items-list">${itemsHtml}</div>
|
||
</div>
|
||
|
||
<!-- Totals -->
|
||
<div class="od-section">
|
||
<div class="od-quick-edit">
|
||
<div class="qe-row">
|
||
<div class="qe-field qe-narrow"><label>Descuento %</label><input type="number" step="0.01" value="${p.descuento_pct||0}" oninput="ppEdit.descuento_pct=+this.value||0;ppRecalcTotals()" onblur="renderPropuestaEditor()"></div>
|
||
<div class="qe-field qe-narrow"><label>IVA %</label><input type="number" step="0.01" value="${p.iva_pct||16}" oninput="ppEdit.iva_pct=+this.value||0;ppRecalcTotals()" onblur="renderPropuestaEditor()"></div>
|
||
<div class="qe-field"><label>Status</label><select onchange="ppEdit.status=this.value">
|
||
${['borrador','enviada','aceptada','rechazada'].map(s=>`<option value="${s}"${s===p.status?' selected':''}>${s.charAt(0).toUpperCase()+s.slice(1)}</option>`).join('')}
|
||
</select></div>
|
||
</div>
|
||
<div class="qe-totals">
|
||
<div class="qe-tot-row"><span>Subtotal</span><span>${fmt$(totals.subtotal)}</span></div>
|
||
${p.descuento_pct>0?`<div class="qe-tot-row" style="color:var(--rd)"><span>Descuento ${p.descuento_pct}%</span><span>−${fmt$(totals.desc)}</span></div>`:''}
|
||
${p.descuento_pct>0?`<div class="qe-tot-row"><span>Subtotal con descuento</span><span>${fmt$(totals.sub2)}</span></div>`:''}
|
||
<div class="qe-tot-row"><span>IVA ${p.iva_pct||16}%</span><span>${fmt$(totals.iva)}</span></div>
|
||
<div class="qe-tot-row big"><span>Total</span><span>${fmt$(totals.total)}</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Detalles -->
|
||
<button type="button" class="od-details-toggle" onclick="togglePpDetails()" id="pp-det-btn">
|
||
<span>▾ Más detalles (fecha, vigencia, notas)</span>
|
||
</button>
|
||
<div class="od-section" id="pp-details" style="display:none">
|
||
<div class="od-config-grid">
|
||
<div class="fg"><label>Fecha</label><input type="date" id="pp-fecha" value="${esc(p.fecha||'')}" oninput="ppEdit.fecha=this.value"></div>
|
||
<div class="fg"><label>Vigencia (días)</label><input type="number" id="pp-vig" value="${p.vigencia_dias||15}" oninput="ppEdit.vigencia_dias=+this.value"></div>
|
||
<div class="fg" style="grid-column:1/-1"><label>Notas / condiciones</label><textarea id="pp-notas" rows="3" oninput="ppEdit.notas=this.value">${esc(p.notas||'')}</textarea></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="od-actions">
|
||
<button class="btn btn-ac" onclick="savePropuesta()">✓ Guardar</button>
|
||
<button class="btn" onclick="previewPropuesta()" style="border-color:var(--olive);color:var(--olive)">👁 Vista previa</button>
|
||
${p.id&&p.status==='aceptada'?(()=>{
|
||
const existingOc=(S.ocs||[]).find(x=>x.propuesta_id==p.id);
|
||
return existingOc
|
||
?`<button class="btn" style="background:var(--bld);border-color:var(--bl);color:var(--bl)" onclick="closeMo('mo-propuesta');openOrdenDetail(${existingOc.id})">🔗 Ver Orden ${esc(existingOc.oc_id)}</button>`
|
||
:`<button class="btn btn-ac" style="background:var(--bl);border-color:var(--bl)" onclick="convertirPropuestaAOrden()">➜ Convertir a Orden</button>`;
|
||
})():''}
|
||
${p.id?`<button class="btn" style="border-color:var(--rd);color:var(--rd);margin-left:auto" onclick="deletePropuesta()">🗑</button>`:''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
async function convertirPropuestaAOrden(){
|
||
const p=ppEdit;
|
||
if(!p.id){toast('Guarda la propuesta primero');return;}
|
||
if(p.status!=='aceptada'){toast('Solo propuestas aceptadas');return;}
|
||
if(!p.items||!p.items.length){toast('Sin items para convertir');return;}
|
||
const cliente=p.empresa||p.cliente_nombre||'Sin cliente';
|
||
if(!confirm(`Crear Orden de Compra para "${cliente}" con ${p.items.length} pedido(s)?`)) return;
|
||
|
||
// Refresh state
|
||
S.ocs=await api('GET','/api/oc');
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
|
||
// Generate OC ID: YYYY-MM-XX-NN (XX = inicial cliente, NN secuencial mes)
|
||
const dt=new Date();
|
||
const yr=dt.getFullYear();
|
||
const mm=String(dt.getMonth()+1).padStart(2,'0');
|
||
const cliCode=(cliente.replace(/[^A-Za-z]/g,'').slice(0,2).toUpperCase())||'XX';
|
||
const prefix=`${yr}-${mm}-${cliCode}-`;
|
||
let n=1;
|
||
while((S.ocs||[]).some(o=>o.oc_id===`${prefix}${String(n).padStart(2,'0')}`)) n++;
|
||
const ocId=`${prefix}${String(n).padStart(2,'0')}`;
|
||
|
||
// Totales
|
||
const subtotal=(p.items||[]).reduce((s,it)=>s+(+it.cantidad||0)*(+it.precio_unit||0),0);
|
||
const descPct=+p.descuento_pct||0;
|
||
const factura=subtotal*(1-descPct/100);
|
||
|
||
// Crear OC
|
||
let ocRes;
|
||
try{
|
||
ocRes=await api('POST','/api/oc',{
|
||
oc_id:ocId,
|
||
cliente:cliente,
|
||
fecha_oc:dt.toISOString().slice(0,10),
|
||
fecha_entrega:'',
|
||
precio_factura:factura,
|
||
iva_pct:p.iva_pct||16,
|
||
condiciones_pago:'Por definir',
|
||
status:'Activa',
|
||
notas:`Generada desde propuesta #${p.numero||p.id}`,
|
||
propuesta_id:p.id
|
||
});
|
||
}catch(e){toast('Error al crear Orden');return;}
|
||
|
||
// Crear pedidos secuencialmente
|
||
const yy=yr;
|
||
const firstId=generatePedidoId(yy);
|
||
const firstNum=parseInt(firstId.split('-').pop(),10);
|
||
let created=0;
|
||
for(let i=0;i<p.items.length;i++){
|
||
const it=p.items[i];
|
||
const ordenId=`ORD-${yy}-${String(firstNum+i).padStart(3,'0')}`;
|
||
try{
|
||
const r=await api('POST','/api/ordenes',{
|
||
orden_id:ordenId,
|
||
tipo_orden:'OC',
|
||
cliente:cliente,
|
||
producto:it.producto||'',
|
||
cantidad:+it.cantidad||0,
|
||
tipo_trabajo:it.tipo_trabajo||'',
|
||
stage:'Nuevo',
|
||
fecha_inicio:dt.toISOString().slice(0,10),
|
||
oc_id:ocRes.id,
|
||
costo_producto:+it.costo_producto||0,
|
||
costo_trabajo:+it.costo_trabajo||0,
|
||
precio_factura:(+it.cantidad||0)*(+it.precio_unit||0),
|
||
logo_instrucciones:[it.color,it.logo_diseno].filter(Boolean).join(' · ')
|
||
});
|
||
if(r&&r.id){
|
||
created++;
|
||
S.ordenes.push({orden_id:ordenId});
|
||
// Auto-link a proyecto si existe match
|
||
await autoLinkPedidoToProyecto(r.id);
|
||
}
|
||
}catch(e){}
|
||
}
|
||
|
||
toast(`✓ ${ocId} creada con ${created} pedido(s)`);
|
||
closeMo('mo-propuesta');
|
||
S.ocs=await api('GET','/api/oc');
|
||
setTimeout(()=>openOrdenDetail(ocRes.id),200);
|
||
}
|
||
|
||
function togglePpDetails(){
|
||
const el=$('pp-details');
|
||
const btn=$('pp-det-btn').querySelector('span');
|
||
const open=el.style.display!=='none';
|
||
el.style.display=open?'none':'block';
|
||
btn.textContent=open?'▾ Más detalles (fecha, vigencia, notas)':'▴ Ocultar detalles';
|
||
}
|
||
|
||
function updatePpItem(i,field,val){
|
||
if(!ppEdit.items[i])return;
|
||
ppEdit.items[i][field]=field==='cantidad'?parseInt(val)||0:parseFloat(val)||0;
|
||
renderPropuestaEditor();
|
||
}
|
||
// String field update (no re-render, no re-calc)
|
||
function ppLiveItemRaw(i,field,val){
|
||
if(!ppEdit.items[i])return;
|
||
ppEdit.items[i][field]=val;
|
||
}
|
||
|
||
// Live updates without re-rendering inputs (preserves focus while typing)
|
||
function ppLiveItem(i,field,val){
|
||
if(!ppEdit.items[i])return;
|
||
ppEdit.items[i][field]=field==='cantidad'?parseInt(val)||0:parseFloat(val)||0;
|
||
// Update only the subtotal cell + totals block
|
||
const it=ppEdit.items[i];
|
||
const sub=(it.cantidad||0)*(it.precio_unit||0);
|
||
const cell=document.getElementById(`pp-sub-${i}`);
|
||
if(cell) cell.textContent=fmt$(sub);
|
||
ppRecalcTotals();
|
||
}
|
||
function ppRecalcTotals(){
|
||
const t=computeProposalTotals(ppEdit.items,ppEdit.iva_pct,ppEdit.descuento_pct);
|
||
const totalsEl=document.querySelector('.qe-totals');
|
||
if(!totalsEl)return;
|
||
const fmt=fmt$;
|
||
const ivaPct=ppEdit.iva_pct||16;
|
||
const descPct=ppEdit.descuento_pct||0;
|
||
totalsEl.innerHTML=`
|
||
<div class="qe-tot-row"><span>Subtotal</span><span>${fmt(t.subtotal)}</span></div>
|
||
${descPct>0?`<div class="qe-tot-row" style="color:var(--rd)"><span>Descuento ${descPct}%</span><span>−${fmt(t.desc)}</span></div>`:''}
|
||
${descPct>0?`<div class="qe-tot-row"><span>Subtotal con descuento</span><span>${fmt(t.sub2)}</span></div>`:''}
|
||
<div class="qe-tot-row"><span>IVA ${ivaPct}%</span><span>${fmt(t.iva)}</span></div>
|
||
<div class="qe-tot-row big"><span>Total</span><span>${fmt(t.total)}</span></div>
|
||
`;
|
||
}
|
||
function removePpItem(i){
|
||
ppEdit.items.splice(i,1);
|
||
renderPropuestaEditor();
|
||
}
|
||
|
||
function openPpProds(){
|
||
renderPpProds();
|
||
openMo('mo-pp-prods');
|
||
}
|
||
function renderPpProds(){
|
||
const q=($('pp-prods-search')?.value||'').toLowerCase();
|
||
// Count how many times each product is already in the proposal (informational only)
|
||
const usedCount={};
|
||
ppEdit.items.forEach(it=>{usedCount[it.producto]=(usedCount[it.producto]||0)+1});
|
||
let prods=[...(S.productos||[])];
|
||
if(q) prods=prods.filter(p=>p.nombre.toLowerCase().includes(q));
|
||
prods.sort((a,b)=>a.nombre.localeCompare(b.nombre));
|
||
$('pp-prods-list').innerHTML=prods.length?prods.map(p=>{
|
||
const key=productEntityKey(p);
|
||
const photo=(productFiles[key]||[]).find(f=>f.is_image);
|
||
const used=usedCount[p.nombre]||0;
|
||
return`<div class="pp-prod-row" onclick="addPpItem('${esc(p.sku||'')}')">
|
||
${photo?`<img src="${photo.url}" class="pp-prod-photo" loading="lazy" decoding="async">`:'<div class="pp-prod-photo-empty">📦</div>'}
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-weight:600;font-size:12px;display:flex;align-items:center;gap:6px">
|
||
<span>${esc(p.nombre)}</span>
|
||
${used?`<span style="font-size:9px;background:var(--olive);color:#fff;padding:1px 6px;border-radius:10px;font-weight:600">ya × ${used}</span>`:''}
|
||
</div>
|
||
<div style="font-size:10px;color:var(--t2)">${[p.color,p.logo_diseno,p.categoria].filter(x=>x).map(esc).join(' · ')||'—'}</div>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--olive);font-weight:600">${p.costo_base?fmt$(p.costo_base):'—'}</div>
|
||
</div>`;
|
||
}).join(''):'<div style="font-size:11px;color:var(--t3);text-align:center;padding:14px">Sin productos disponibles. Agrega productos en el Catálogo.</div>';
|
||
}
|
||
function addPpItem(sku){
|
||
const p=(S.productos||[]).find(x=>x.sku===sku);
|
||
if(!p)return;
|
||
const key=productEntityKey(p);
|
||
const photo=(productFiles[key]||[]).find(f=>f.is_image);
|
||
ppEdit.items.push({
|
||
sku:p.sku,producto:p.nombre,color:p.color||'',logo_diseno:p.logo_diseno||'',
|
||
tipo_trabajo:p.tipo_personalizacion||'',
|
||
descripcion:'',
|
||
cantidad:0,precio_unit:p.costo_base||0,
|
||
foto_url:photo?photo.url:null
|
||
});
|
||
closeMo('mo-pp-prods');
|
||
renderPropuestaEditor();
|
||
}
|
||
|
||
async function savePropuesta(){
|
||
if(!ppEdit.empresa && !ppEdit.cliente_nombre){toast('Captura nombre/empresa del cliente');return;}
|
||
const body={
|
||
numero:ppEdit.numero,
|
||
cliente_nombre:ppEdit.cliente_nombre||'',
|
||
contacto:ppEdit.contacto||'',
|
||
empresa:ppEdit.empresa||'',
|
||
direccion:ppEdit.direccion||'',
|
||
locacion:ppEdit.locacion||'',
|
||
tipo_negocio:ppEdit.tipo_negocio||'',
|
||
email:ppEdit.email||'',
|
||
telefono:ppEdit.telefono||'',
|
||
fecha:ppEdit.fecha||'',
|
||
vigencia_dias:ppEdit.vigencia_dias||15,
|
||
items:JSON.stringify(ppEdit.items||[]),
|
||
iva_pct:ppEdit.iva_pct||16,
|
||
descuento_pct:ppEdit.descuento_pct||0,
|
||
notas:ppEdit.notas||'',
|
||
status:ppEdit.status||'borrador'
|
||
};
|
||
if(ppEdit.id){
|
||
await api('PUT',`/api/propuestas/${ppEdit.id}`,body);
|
||
} else {
|
||
const res=await api('POST','/api/propuestas',body);
|
||
ppEdit.id=res.id;
|
||
}
|
||
toast('Propuesta guardada');
|
||
propuestasData=await api('GET','/api/propuestas');
|
||
renderPropuestasList();
|
||
closeMo('mo-propuesta');
|
||
}
|
||
|
||
async function deletePropuesta(){
|
||
if(!confirm('¿Eliminar esta propuesta?'))return;
|
||
await api('DELETE',`/api/propuestas/${ppEdit.id}`);
|
||
toast('Eliminada');
|
||
propuestasData=await api('GET','/api/propuestas');
|
||
renderPropuestasList();
|
||
closeMo('mo-propuesta');
|
||
}
|
||
|
||
// Resize an image URL to a smaller JPEG data URL — runs in browser, no server needed
|
||
function imageToDataURL(url,maxW,quality){
|
||
quality=quality||0.85;
|
||
return new Promise(resolve=>{
|
||
const img=new Image();
|
||
img.onload=()=>{
|
||
const ratio=Math.min(maxW/img.width,1);
|
||
const w=Math.round(img.width*ratio);
|
||
const h=Math.round(img.height*ratio);
|
||
const c=document.createElement('canvas');
|
||
c.width=w;c.height=h;
|
||
c.getContext('2d').drawImage(img,0,0,w,h);
|
||
try{resolve(c.toDataURL('image/jpeg',quality));}catch(e){resolve(url);}
|
||
};
|
||
img.onerror=()=>resolve(url);
|
||
img.src=url;
|
||
});
|
||
}
|
||
|
||
async function previewPropuesta(){
|
||
// Show in-page fullscreen preview (works on mobile + desktop, no popup blocker)
|
||
$('pp-preview-body').innerHTML=`<div class="pp-pv-toolbar">
|
||
<h3>Propuesta ${esc(ppEdit.numero)}</h3>
|
||
<div style="display:flex;gap:6px">
|
||
<button onclick="closeMo('mo-pp-preview')">← Volver</button>
|
||
<button class="primary" onclick="window.print()">🖨 Imprimir / PDF</button>
|
||
</div>
|
||
</div>
|
||
<div style="padding:24px;text-align:center;color:#5C6B4F">
|
||
<div style="display:inline-block;width:30px;height:30px;border:3px solid #D4C5A9;border-top-color:#5C6B4F;border-radius:50%;animation:s 1s linear infinite"></div>
|
||
<div style="margin-top:10px;font-size:13px">Preparando propuesta...</div>
|
||
</div>
|
||
<style>@keyframes s{to{transform:rotate(360deg)}}</style>`;
|
||
openMo('mo-pp-preview');
|
||
toast('Preparando vista previa...');
|
||
const p=ppEdit;
|
||
const totals=computeProposalTotals(p.items,p.iva_pct,p.descuento_pct);
|
||
const vigenciaFecha=p.fecha&&p.vigencia_dias?(()=>{
|
||
const d=new Date(p.fecha+'T12:00:00');d.setDate(d.getDate()+p.vigencia_dias);
|
||
return d.toISOString().slice(0,10);
|
||
})():'';
|
||
const fechaFmt=p.fecha?(()=>{
|
||
const d=new Date(p.fecha+'T12:00:00');
|
||
return d.toLocaleDateString('es-MX',{day:'numeric',month:'long',year:'numeric'});
|
||
})():'';
|
||
const vigFmt=vigenciaFecha?(()=>{
|
||
const d=new Date(vigenciaFecha+'T12:00:00');
|
||
return d.toLocaleDateString('es-MX',{day:'numeric',month:'long',year:'numeric'});
|
||
})():'';
|
||
|
||
// Pre-resize all item images so the preview/PDF stays light but sharp
|
||
const optimized=await Promise.all(p.items.map(async it=>{
|
||
const photo=it.foto_url||(getProductPhoto(it.producto)?.url);
|
||
if(!photo) return null;
|
||
return await imageToDataURL(photo,640,0.88);
|
||
}));
|
||
|
||
const itemRows=p.items.map((it,idx)=>{
|
||
const sub=(it.cantidad||0)*(it.precio_unit||0);
|
||
const photo=optimized[idx];
|
||
// Build the description block: color · logo · tipo_trabajo
|
||
const descParts=[it.color,it.logo_diseno,it.tipo_trabajo].filter(x=>x).map(esc);
|
||
return`<div class="item-card">
|
||
<div class="item-img">${photo?`<img src="${photo}">`:'<div class="item-img-empty">📦</div>'}</div>
|
||
<div class="item-info">
|
||
<div class="prod">${esc(it.producto)}</div>
|
||
${descParts.length?`<div class="desc">${descParts.join(' · ')}</div>`:''}
|
||
${it.descripcion?`<div class="item-details">${esc(it.descripcion)}</div>`:''}
|
||
<div class="item-pricing">
|
||
<span class="pq"><span class="pq-label">Cantidad</span><span class="pq-val">${it.cantidad}</span></span>
|
||
<span class="pq"><span class="pq-label">Precio unitario</span><span class="pq-val">${fmt$(it.precio_unit||0)}</span></span>
|
||
<span class="pq pq-sub"><span class="pq-label">Subtotal</span><span class="pq-val">${fmt$(sub)}</span></span>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// In-page preview content (uses .pp-pv-doc scoped CSS already in main stylesheet)
|
||
const docHtml=`<div class="pp-pv-toolbar">
|
||
<h3>Propuesta ${esc(p.numero)} · ${esc(p.empresa||p.cliente_nombre||'')}</h3>
|
||
<div style="display:flex;gap:6px">
|
||
<button onclick="closeMo('mo-pp-preview')">← Volver</button>
|
||
<button class="primary" onclick="window.print()">🖨 Imprimir / PDF</button>
|
||
</div>
|
||
</div>
|
||
<div class="pp-pv-doc">
|
||
<div class="top">
|
||
<div>
|
||
<img src="/brand/logo.svg" alt="Art4Hotel" class="brand-logo">
|
||
<div class="brand-tagline">Personalización para hospitalidad · Los Cabos</div>
|
||
</div>
|
||
<div class="num-block">
|
||
<div class="label">Propuesta</div>
|
||
<div class="num">${esc(p.numero)}</div>
|
||
${fechaFmt?`<div class="date">${fechaFmt}</div>`:''}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="client">
|
||
<div class="label">Preparada para</div>
|
||
<h2>${esc(p.empresa||p.cliente_nombre||'')}</h2>
|
||
${p.locacion?`<div class="meta loc"><span class="loc-label">Locación:</span> ${esc(p.locacion)}</div>`:''}
|
||
${p.contacto?`<div class="contact">A la atención de <b>${esc(p.contacto)}</b></div>`:''}
|
||
${p.direccion?`<div class="meta">${esc(p.direccion)}</div>`:''}
|
||
${p.email||p.telefono?`<div class="meta">${[p.email,p.telefono].filter(x=>x).map(esc).join(' · ')}</div>`:''}
|
||
</div>
|
||
|
||
<div class="intro">Agradecemos su interés en Art4Hotel. A continuación encontrará la propuesta detallada con los productos solicitados, precios unitarios y total estimado. Quedamos atentos a sus comentarios.</div>
|
||
|
||
<div class="items-section">
|
||
<div class="items-label">Productos</div>
|
||
${itemRows||'<div style="text-align:center;color:#999;padding:30px">Sin productos</div>'}
|
||
</div>
|
||
|
||
<div class="totals">
|
||
<div class="tot-row"><span>Subtotal</span><span>${fmt$(totals.subtotal)}</span></div>
|
||
${p.descuento_pct>0?`<div class="tot-row disc"><span>Descuento ${p.descuento_pct}%</span><span>−${fmt$(totals.desc)}</span></div>`:''}
|
||
<div class="tot-row"><span>IVA ${p.iva_pct||16}%</span><span>${fmt$(totals.iva)}</span></div>
|
||
<div class="tot-row tot-big"><span>Total</span><span>${fmt$(totals.total)}</span></div>
|
||
</div>
|
||
|
||
${p.notas?`<div class="notes"><div class="label" style="margin-bottom:8px">Notas y condiciones</div>${esc(p.notas).replace(/\n/g,'<br>')}</div>`:''}
|
||
|
||
<div class="footer">
|
||
${vigFmt?`<div class="vigencia">Propuesta vigente hasta <b>${vigFmt}</b> (${p.vigencia_dias} días)</div>`:''}
|
||
<div class="thanks">Gracias por considerar a Art4Hotel.</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
$('pp-preview-body').innerHTML=docHtml;
|
||
}
|
||
|
||
async function loadClientesCrm(){
|
||
S.clientes=await api('GET','/api/clientes');
|
||
S.ocs=await api('GET','/api/oc');
|
||
S.ordenes=await api('GET','/api/ordenes');
|
||
renderClientesCrm();
|
||
}
|
||
|
||
function renderClientesCrm(){
|
||
const q=($('search-clientes')?.value||'').toLowerCase();
|
||
// Compute stats per client
|
||
const stats={};
|
||
S.clientes.forEach(c=>{stats[c.nombre]={ordenes:0,facturado:0,pendiente:0,activos:0}});
|
||
S.ocs.forEach(oc=>{
|
||
if(!stats[oc.cliente]) return;
|
||
stats[oc.cliente].ordenes++;
|
||
if(oc.precio_factura) stats[oc.cliente].facturado+=oc.precio_factura;
|
||
if(!oc.pagado && oc.precio_factura) stats[oc.cliente].pendiente+=oc.precio_factura;
|
||
});
|
||
S.ordenes.forEach(p=>{
|
||
if(p.stage!=='Entregado' && p.stage!=='Cancelado' && stats[p.cliente]) stats[p.cliente].activos++;
|
||
});
|
||
|
||
// Filter + sort
|
||
let list=S.clientes.filter(c=>!q || c.nombre.toLowerCase().includes(q) || (c.zona_entrega||'').toLowerCase().includes(q));
|
||
list.sort((a,b)=>(stats[b.nombre].facturado-stats[a.nombre].facturado)||a.nombre.localeCompare(b.nombre));
|
||
|
||
// Render list
|
||
const listHtml=`<div class="crm-list-wrap">
|
||
${list.length?list.map(c=>{
|
||
const s=stats[c.nombre]||{};
|
||
const isOn=crmSelected===c.id;
|
||
return`<div class="crm-cli-row${isOn?' on':''}" onclick="selectCrmClient(${c.id})">
|
||
<div style="flex:1;min-width:0">
|
||
<div class="crm-cli-name">${esc(c.nombre)}</div>
|
||
<div class="crm-cli-meta">${esc(c.tipo||'')}${c.zona_entrega?' · '+esc(c.zona_entrega):''}</div>
|
||
</div>
|
||
<div class="crm-cli-stat">
|
||
${s.ordenes?`${s.ordenes} orden${s.ordenes>1?'es':''}`:'<span style="opacity:.5">—</span>'}
|
||
${s.facturado?`<div style="font-size:9px">${fmt$(s.facturado)}</div>`:''}
|
||
</div>
|
||
</div>`;
|
||
}).join(''):'<div style="color:var(--t3);font-size:11px;padding:10px;text-align:center">Sin clientes</div>'}
|
||
</div>`;
|
||
$('crm-list').innerHTML=listHtml;
|
||
|
||
// Render detail
|
||
if(crmSelected){
|
||
const c=S.clientes.find(x=>x.id===crmSelected);
|
||
if(c) renderCrmDetail(c,stats[c.nombre]||{});
|
||
} else {
|
||
$('crm-detail').innerHTML=`<div class="crm-detail-empty">
|
||
<div style="font-size:36px;opacity:.3;margin-bottom:6px">👥</div>
|
||
<div>Selecciona un cliente para ver su detalle</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
function selectCrmClient(id){
|
||
crmSelected=id;
|
||
// Preserve scroll position of the list
|
||
const wrap=document.querySelector('.crm-list-wrap');
|
||
const prevScroll=wrap?wrap.scrollTop:0;
|
||
renderClientesCrm();
|
||
// After re-render, restore scroll and nudge selected row to be the 2nd visible item
|
||
requestAnimationFrame(()=>{
|
||
const w=document.querySelector('.crm-list-wrap');
|
||
if(!w) return;
|
||
w.scrollTop=prevScroll;
|
||
const sel=w.querySelector('.crm-cli-row.on');
|
||
if(!sel) return;
|
||
const rowH=sel.offsetHeight||30;
|
||
const wRect=w.getBoundingClientRect();
|
||
const sRect=sel.getBoundingClientRect();
|
||
const relTop=sRect.top-wRect.top+w.scrollTop;
|
||
// Target: selected row at position 2 (one row above visible)
|
||
const target=relTop-rowH-4;
|
||
// Only scroll if the selected row is out of view or not in the desired position
|
||
if(sRect.top<wRect.top||sRect.bottom>wRect.bottom){
|
||
w.scrollTop=Math.max(0,target);
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderCrmDetail(c,s){
|
||
const ocs=S.ocs.filter(o=>o.cliente===c.nombre).sort((a,b)=>(b.fecha_oc||'').localeCompare(a.fecha_oc||''));
|
||
const pedidosActivos=S.ordenes.filter(p=>p.cliente===c.nombre && p.stage!=='Entregado' && p.stage!=='Cancelado');
|
||
|
||
const infoCells=[
|
||
['Tipo',esc(c.tipo||'-')],
|
||
['Zona',esc(c.zona_entrega||'-')],
|
||
['Contacto',esc(c.contacto||'-')],
|
||
['Pago',esc(c.condiciones_pago||'-')],
|
||
['Costo entrega',c.costo_entrega?fmt$(c.costo_entrega):'-'],
|
||
];
|
||
|
||
const ocsHtml=ocs.length?ocs.slice(0,20).map(oc=>{
|
||
const sub=oc.precio_factura||0;
|
||
const ivaPct=oc.iva_pct!=null?oc.iva_pct:16;
|
||
const total=sub+sub*ivaPct/100;
|
||
const ws=ocWorkflowStage(oc);
|
||
const stagePill={produccion:'#6b7280',facturar:'#eab308',cobrar:'#3b82f6',cobrada:'#16a34a',cancelada:'#9ca3af'}[ws];
|
||
const stageLabel={produccion:'En producción',facturar:'Por facturar',cobrar:'Por cobrar',cobrada:'Cobrada',cancelada:'Cancelada'}[ws];
|
||
return`<div class="oc-line-row" style="background:var(--s2);cursor:pointer" onclick="openOrdenDetail(${oc.id})">
|
||
<div style="flex:1;min-width:0;font-size:11px">
|
||
<b>${esc(oc.oc_id)}</b>
|
||
<span style="font-size:9px;padding:1px 6px;border-radius:3px;background:${stagePill}20;color:${stagePill};font-weight:600;margin-left:4px">${stageLabel}</span>
|
||
<span style="color:var(--t3);font-size:9px;margin-left:4px">${oc.fecha_oc||''}</span>
|
||
<span style="color:var(--t3);font-size:9px;margin-left:4px">· ${oc.n_lineas} pedido${oc.n_lineas!==1?'s':''}</span>
|
||
</div>
|
||
${sub?`<span style="font-weight:700;color:var(--olive);font-size:11px">${fmt$(total)}</span>`:''}
|
||
</div>`;
|
||
}).join(''):'<div style="font-size:11px;color:var(--t3);padding:10px;text-align:center">Sin órdenes registradas</div>';
|
||
|
||
const pedidosHtml=pedidosActivos.length?pedidosActivos.map(p=>{
|
||
const[sc]=cc(p.stage);
|
||
return`<div class="oc-line-row" style="background:var(--s2);cursor:pointer" onclick="openQuickView(${p.id})">
|
||
<div style="flex:1;min-width:0;font-size:11px">
|
||
<span class="stage-dot" style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--${sc});margin-right:5px"></span>
|
||
<b>${esc(p.orden_id)}</b>
|
||
<span style="color:var(--t2)">${esc(p.producto)}</span>
|
||
<span class="tag" style="font-size:9px;background:var(--${cc(p.stage)[1]});color:var(--${sc})">${p.stage}</span>
|
||
</div>
|
||
<span style="font-weight:600;font-size:11px">${p.cantidad} pzas</span>
|
||
</div>`;
|
||
}).join(''):'<div style="font-size:11px;color:var(--t3);padding:10px;text-align:center">Sin pedidos activos</div>';
|
||
|
||
$('crm-detail').innerHTML=`
|
||
<div class="crm-detail-head">
|
||
<div>
|
||
<h2>${esc(c.nombre)}</h2>
|
||
<div style="font-size:11px;color:var(--t2);margin-top:2px">${esc(c.tipo||'')}${c.zona_entrega?' · '+esc(c.zona_entrega):''}</div>
|
||
</div>
|
||
<div style="display:flex;gap:4px">
|
||
<button class="btn" onclick="quickNewOCForClient('${esc(c.nombre)}')">+ Nueva Orden</button>
|
||
<button class="btn" onclick="editItem('clientes',${c.id})">✎ Editar</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="crm-stats-row">
|
||
<div class="crm-stat-card"><div class="stat-lbl">Órdenes totales</div><div class="stat-val">${s.ordenes||0}</div></div>
|
||
<div class="crm-stat-card"><div class="stat-lbl">Facturado</div><div class="stat-val" style="font-size:14px">${fmt$(s.facturado||0)}</div></div>
|
||
<div class="crm-stat-card"><div class="stat-lbl">Por cobrar</div><div class="stat-val" style="font-size:14px;color:${s.pendiente?'var(--rd)':'var(--gn)'}">${fmt$(s.pendiente||0)}</div></div>
|
||
<div class="crm-stat-card"><div class="stat-lbl">Pedidos activos</div><div class="stat-val">${s.activos||0}</div></div>
|
||
</div>
|
||
|
||
<div class="crm-info-grid">
|
||
${infoCells.map(([l,v])=>`<div class="crm-info-cell"><span class="lbl">${l}</span><span class="val">${v}</span></div>`).join('')}
|
||
</div>
|
||
|
||
${c.notas?`<div class="crm-info-cell" style="margin-bottom:14px"><span class="lbl">Notas</span><span class="val" style="font-weight:400;white-space:pre-wrap">${esc(c.notas)}</span></div>`:''}
|
||
|
||
<div class="crm-section-title"><span>Pedidos activos en producción</span><span style="font-weight:400;font-size:10px">${pedidosActivos.length}</span></div>
|
||
${pedidosHtml}
|
||
|
||
<div class="crm-section-title"><span>Historial de Órdenes</span><span style="font-weight:400;font-size:10px">${ocs.length}${ocs.length>20?' (mostrando 20)':''}</span></div>
|
||
${ocsHtml}
|
||
`;
|
||
}
|
||
|
||
async function quickNewOCForClient(cliente){
|
||
await openNewOC();
|
||
setTimeout(()=>{
|
||
const sel=$('noc-cliente');
|
||
if(sel){
|
||
sel.value=cliente;
|
||
sel.onchange?.();
|
||
}
|
||
},80);
|
||
}
|
||
|
||
// ══════ OC View ══════
|
||
async function renderOcsView(){
|
||
S.ocs=await api('GET','/api/oc');
|
||
const container=$('ordenes-ocs');
|
||
|
||
// Orders not linked to any OC
|
||
const unlinked=S.ordenes.filter(o=>!o.oc_id && o.stage!=='Cancelado');
|
||
|
||
if(!S.ocs.length && !unlinked.length){
|
||
container.innerHTML='<div class="empty">No hay Ordenes creadas. Usa "+ Pedido" y vincula a una nueva Orden.</div>';
|
||
return;
|
||
}
|
||
|
||
let html='<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px"><div style="font-size:12px;color:var(--t2)">' +
|
||
S.ocs.length+' Orden'+(S.ocs.length!==1?'es':'')+' · '+unlinked.length+' pedido'+(unlinked.length!==1?'s':'')+' sin orden</div>' +
|
||
'<button class="btn btn-ac" onclick="openNewOCFromView()" style="font-size:12px">+ Nueva Orden</button></div>';
|
||
|
||
// Render each OC
|
||
S.ocs.forEach(oc=>{
|
||
const isActive=oc.status==='Activa';
|
||
const progressColors={Entregado:'var(--gn)','En proceso':'var(--bl)',Parcial:'var(--yl)'};
|
||
const pColor=progressColors[oc.progress]||'var(--t2)';
|
||
const sub=oc.precio_factura||0;
|
||
const ivaPct=oc.iva_pct!=null?oc.iva_pct:16;
|
||
const iva=sub*ivaPct/100;
|
||
const totalConIva=sub+iva;
|
||
const otrosG=oc.otros_gastos||0;
|
||
const ocCostoTotal=oc.costo_produccion+(oc.costo_logistica||0)+otrosG;
|
||
const ocUtil=sub-ocCostoTotal;
|
||
const ocMargen=sub>0?Math.round(ocUtil/sub*100):0;
|
||
|
||
html+=`<div class="oc-mgmt-card${isActive?' active':' entregada'}" style="cursor:pointer" onclick="openOrdenDetail(${oc.id})">
|
||
<div class="oc-mgmt-header">
|
||
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
|
||
<span style="font-size:10px;padding:2px 8px;border-radius:4px;background:var(--olive);color:#fff;font-weight:700">Orden</span>
|
||
<span style="font-weight:700;font-size:14px">${esc(oc.oc_id)}</span>
|
||
<span style="font-size:11px;color:var(--t2)">— ${esc(oc.cliente)}</span>
|
||
<span style="font-size:9px;padding:2px 6px;border-radius:4px;background:${pColor}20;color:${pColor};font-weight:600">${oc.progress}</span>
|
||
</div>
|
||
<div style="display:flex;gap:4px;align-items:center">
|
||
${sub?`<span style="font-size:12px;color:var(--olive);font-weight:700">${fmt$(totalConIva)}</span>`:''}
|
||
${sub?`<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:${ocUtil>=0?'var(--gnd)':'var(--rdd)'};color:${ocUtil>=0?'var(--gn)':'var(--rd)'};font-weight:700">${ocMargen}%</span>`:''}
|
||
<button class="kc-btn edit" onclick="event.stopPropagation();openFiles('${esc(oc.oc_id)}')" title="Archivos">📎</button>
|
||
<button class="kc-btn edit" onclick="event.stopPropagation();openOrdenDetail(${oc.id})" title="Ver Orden">👁</button>
|
||
<button class="kc-btn" onclick="event.stopPropagation();delItem('oc',${oc.id})" title="Eliminar Orden">×</button>
|
||
</div>
|
||
</div>
|
||
<div style="font-size:10px;color:var(--t3);display:flex;gap:12px;flex-wrap:wrap">
|
||
${oc.fecha_oc?`<span>Fecha: ${oc.fecha_oc}</span>`:''}
|
||
<span>${oc.n_lineas} pedido${oc.n_lineas!==1?'s':''} · ${oc.total_piezas} pzas</span>
|
||
${oc.condiciones_pago?`<span>Pago: ${esc(oc.condiciones_pago)}</span>`:''}
|
||
${oc.factura_num?`<span>Factura: ${esc(oc.factura_num)}</span>`:''}
|
||
</div>
|
||
${sub?`<div class="oc-costs-summary">
|
||
<div class="oc-cost-row"><span>Costo producción (pedidos)</span><span>${fmt$(oc.costo_produccion)}</span></div>
|
||
${oc.costo_logistica?`<div class="oc-cost-row"><span>Logística</span><span>${fmt$(oc.costo_logistica)}</span></div>`:''}
|
||
${otrosG?`<div class="oc-cost-row"><span>Otros gastos${oc.otros_gastos_desc?' ('+esc(oc.otros_gastos_desc)+')':''}</span><span>${fmt$(otrosG)}</span></div>`:''}
|
||
<div class="oc-cost-row oc-cost-sep"><span>Subtotal factura</span><span>${fmt$(sub)}</span></div>
|
||
<div class="oc-cost-row"><span>IVA ${ivaPct}%</span><span>${fmt$(iva)}</span></div>
|
||
<div class="oc-cost-row oc-cost-total"><span>Total con IVA</span><span>${fmt$(totalConIva)}</span></div>
|
||
<div class="oc-cost-row" style="color:${ocUtil>=0?'var(--gn)':'var(--rd)'}"><span>Utilidad neta</span><span><b>${fmt$(ocUtil)}</b> (${ocMargen}%)</span></div>
|
||
</div>`:''}
|
||
${oc.notas?`<div style="font-size:10px;color:var(--t2);margin-top:4px;font-style:italic">${esc(oc.notas)}</div>`:''}`;
|
||
|
||
// Linked lines
|
||
if(oc.lineas.length){
|
||
html+=`<div class="oc-mgmt-lines" onclick="event.stopPropagation()">`;
|
||
oc.lineas.forEach(l=>{
|
||
const[sc]=cc(l.stage);
|
||
html+=`<div class="oc-line-row">
|
||
<div style="display:flex;align-items:center;gap:6px;flex:1;min-width:0">
|
||
<span class="stage-dot" style="background:var(--${sc})"></span>
|
||
<b>${esc(l.orden_id)}</b>
|
||
<span style="color:var(--t2)">${esc(l.producto)}</span>
|
||
<span><b>${l.cantidad}</b> pzas</span>
|
||
<span class="tag" style="font-size:9px;background:var(--${cc(l.stage)[1]});color:var(--${sc})">${l.stage}</span>
|
||
</div>
|
||
<div style="display:flex;gap:4px">
|
||
<button class="kc-btn edit" onclick="openQuickView(${l.id})" title="Ver pedido">👁</button>
|
||
<button class="oc-link-btn" onclick="unlinkFromOC(${l.id})" title="Desvincular" style="border-color:var(--rd);color:var(--rd);background:var(--rdd)">✕</button>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
html+=`</div>`;
|
||
}
|
||
|
||
// Add line button — show unlinked orders, same client first then others
|
||
if(unlinked.length){
|
||
const sameC=unlinked.filter(o=>o.cliente===oc.cliente);
|
||
const otherC=unlinked.filter(o=>o.cliente!==oc.cliente);
|
||
html+=`<div style="margin-top:6px" onclick="event.stopPropagation()"><button class="oc-link-btn" onclick="toggleLinkPanel(${oc.id})">+ Vincular pedido</button></div>`;
|
||
html+=`<div id="oc-link-panel-${oc.id}" style="display:none;margin-top:6px;padding:8px;background:var(--s2);border-radius:6px" onclick="event.stopPropagation()">`;
|
||
const renderLinkRow=(o)=>`<div class="oc-line-row" style="background:var(--s1)">
|
||
<div style="flex:1"><b>${esc(o.orden_id)}</b> — <span style="color:var(--t2)">${esc(o.cliente)}</span> — ${esc(o.producto)} — ${o.cantidad} pzas <span class="tag" style="font-size:9px;background:var(--${cc(o.stage)[1]});color:var(--${cc(o.stage)[0]})">${o.stage}</span></div>
|
||
<button class="oc-link-btn" onclick="linkToOC(${o.id},${oc.id})">Vincular</button>
|
||
</div>`;
|
||
sameC.forEach(o=>{html+=renderLinkRow(o);});
|
||
if(otherC.length){
|
||
html+=`<div style="font-size:9px;color:var(--t3);padding:4px 0;border-top:1px solid var(--bd);margin-top:4px">Otros clientes</div>`;
|
||
otherC.forEach(o=>{html+=renderLinkRow(o);});
|
||
}
|
||
html+=`</div>`;
|
||
}
|
||
|
||
html+=`</div>`;
|
||
});
|
||
|
||
// Unlinked orders section
|
||
const activeOcs=S.ocs.filter(o=>o.status==='Activa');
|
||
if(unlinked.length){
|
||
html+=`<div class="oc-unlinked">
|
||
<div class="oc-unlinked-title">Pedidos sin Orden (${unlinked.length})</div>`;
|
||
unlinked.forEach(o=>{
|
||
const[sc]=cc(o.stage);
|
||
// OCs that match this order's client first, then the rest
|
||
const sameClient=activeOcs.filter(x=>x.cliente===o.cliente);
|
||
const otherOcs=activeOcs.filter(x=>x.cliente!==o.cliente);
|
||
const ocOpts=sameClient.map(x=>`<option value="${x.id}">${esc(x.oc_id)} — ${esc(x.cliente)}</option>`).join('')
|
||
+(otherOcs.length?`<option disabled>──────</option>`+otherOcs.map(x=>`<option value="${x.id}">${esc(x.oc_id)} — ${esc(x.cliente)}</option>`).join(''):'');
|
||
html+=`<div class="oc-line-row" style="background:var(--s1);flex-wrap:wrap;gap:4px">
|
||
<div style="display:flex;align-items:center;gap:6px;flex:1;min-width:0">
|
||
<span class="stage-dot" style="background:var(--${sc})"></span>
|
||
<b>${esc(o.orden_id)}</b>
|
||
<span style="color:var(--t2)">${esc(o.cliente)} — ${esc(o.producto)}</span>
|
||
<span><b>${o.cantidad}</b> pzas</span>
|
||
<span class="tag" style="font-size:9px;background:var(--${cc(o.stage)[1]});color:var(--${sc})">${o.stage}</span>
|
||
</div>
|
||
<div style="display:flex;gap:3px;align-items:center">
|
||
${ocOpts?`<select id="oc-link-sel-${o.id}" style="font-size:10px;padding:3px 6px;border:1px solid var(--bd);border-radius:4px;max-width:160px">
|
||
<option value="">OC...</option>${ocOpts}
|
||
</select>
|
||
<button class="oc-link-btn" onclick="linkUnlinkedToOC(${o.id})">Vincular</button>`:''}
|
||
<button class="kc-btn edit" onclick="openQuickView(${o.id})" title="Ver">👁</button>
|
||
</div>
|
||
</div>`;
|
||
});
|
||
html+=`</div>`;
|
||
}
|
||
|
||
container.innerHTML=html;
|
||
}
|
||
|
||
async function linkUnlinkedToOC(ordId){
|
||
const sel=$('oc-link-sel-'+ordId);
|
||
if(!sel||!sel.value){toast('Selecciona una Orden');return;}
|
||
await api('PUT',`/api/ordenes/${ordId}`,{oc_id:+sel.value});
|
||
toast('Pedido vinculado');
|
||
refreshActiveView();
|
||
}
|
||
|
||
function toggleLinkPanel(ocId){
|
||
const el=$('oc-link-panel-'+ocId);
|
||
el.style.display=el.style.display==='none'?'block':'none';
|
||
}
|
||
|
||
async function linkToOC(ordenId,ocId){
|
||
await api('PUT',`/api/ordenes/${ordenId}`,{oc_id:ocId});
|
||
toast('Pedido vinculado a Orden');
|
||
refreshActiveView();
|
||
}
|
||
|
||
async function unlinkFromOC(ordenId){
|
||
await api('PUT',`/api/ordenes/${ordenId}`,{oc_id:0});
|
||
toast('Pedido desvinculado');
|
||
refreshActiveView();
|
||
}
|
||
|
||
async function openNewOCFromView(){
|
||
if(!S.clientes.length) S.clientes=await api('GET','/api/clientes');
|
||
$('noc-cliente').innerHTML='<option value="">-- Seleccionar --</option>'+S.clientes.map(c=>
|
||
`<option value="${esc(c.nombre)}" data-costo="${c.costo_entrega}" data-pago="${esc(c.condiciones_pago)}">${esc(c.nombre)}</option>`).join('');
|
||
$('noc-id').value=generateOcFolio('');
|
||
$('noc-fecha').value=new Date().toISOString().slice(0,10);
|
||
$('noc-logistica').value=0;$('noc-factura').value=0;$('noc-notas').value='';
|
||
$('noc-iva-pct').value=16;$('noc-factura-num').value='';
|
||
$('noc-otros').value=0;$('noc-otros-desc').value='';
|
||
calcNocIva();
|
||
$('noc-cliente').onchange=()=>{
|
||
const sel=$('noc-cliente').options[$('noc-cliente').selectedIndex];
|
||
if(sel?.dataset.costo) $('noc-logistica').value=sel.dataset.costo;
|
||
if(sel?.dataset.pago) $('noc-pago').value=sel.dataset.pago;
|
||
$('noc-id').value=generateOcFolio($('noc-cliente').value);
|
||
};
|
||
openMo('mo-new-oc');
|
||
}
|
||
|
||
// ══════ Inline creation in Edit modal ══════
|
||
let addingProdEdit=false, addingCliEdit=false;
|
||
|
||
function addProductoInlineEdit(){
|
||
if(addingProdEdit)return;
|
||
addingProdEdit=true;
|
||
const container=$('edit-producto-sel').parentElement;
|
||
const inp=document.createElement('div');
|
||
inp.id='inline-new-prod-edit';
|
||
inp.style.cssText='display:flex;gap:4px;margin-top:4px';
|
||
inp.innerHTML=`<input id="new-prod-name-edit" placeholder="Nombre del producto" style="flex:1;font-size:14px;padding:8px 10px;border:1px solid var(--olive);border-radius:6px;outline:none;font-family:inherit">
|
||
<button class="btn btn-ac" onclick="saveProdInlineEdit()" style="font-size:11px">OK</button>
|
||
<button class="btn" onclick="cancelProdEdit()" style="font-size:11px">X</button>`;
|
||
container.appendChild(inp);
|
||
document.getElementById('new-prod-name-edit').focus();
|
||
document.getElementById('new-prod-name-edit').onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();saveProdInlineEdit();}};
|
||
}
|
||
async function saveProdInlineEdit(){
|
||
const inp=document.getElementById('new-prod-name-edit');
|
||
const nombre=(inp?.value||'').trim();
|
||
if(!nombre){toast('Escribe un nombre');return;}
|
||
const sku=nombre.toLowerCase().replace(/\s+/g,'-').replace(/[^a-z0-9-]/g,'')+'-'+Date.now().toString(36);
|
||
await api('POST','/api/productos',{sku,nombre,categoria:'bolsa'});
|
||
S.productos=await api('GET','/api/productos');
|
||
// Rebuild dropdown and select new
|
||
const sel=$('edit-producto-sel');
|
||
const allP=new Set(S.productos.map(p=>p.nombre));
|
||
S.ordenes.forEach(o=>{if(o.producto)allP.add(o.producto)});
|
||
sel.innerHTML=[...allP].sort().map(n=>`<option${n===nombre?' selected':''}>${esc(n)}</option>`).join('');
|
||
cancelProdEdit();
|
||
toast('Producto agregado');
|
||
}
|
||
function cancelProdEdit(){
|
||
const el=document.getElementById('inline-new-prod-edit');
|
||
if(el)el.remove();
|
||
addingProdEdit=false;
|
||
}
|
||
|
||
function addClienteInlineEdit(){
|
||
if(addingCliEdit)return;
|
||
addingCliEdit=true;
|
||
const container=$('edit-cliente-sel').parentElement;
|
||
const inp=document.createElement('div');
|
||
inp.id='inline-new-cli-edit';
|
||
inp.style.cssText='display:flex;gap:4px;margin-top:4px';
|
||
inp.innerHTML=`<input id="new-cli-name-edit" placeholder="Nombre del cliente" style="flex:1;font-size:14px;padding:8px 10px;border:1px solid var(--olive);border-radius:6px;outline:none;font-family:inherit">
|
||
<button class="btn btn-ac" onclick="saveCliInlineEdit()" style="font-size:11px">OK</button>
|
||
<button class="btn" onclick="cancelCliEdit()" style="font-size:11px">X</button>`;
|
||
container.appendChild(inp);
|
||
document.getElementById('new-cli-name-edit').focus();
|
||
document.getElementById('new-cli-name-edit').onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();saveCliInlineEdit();}};
|
||
}
|
||
async function saveCliInlineEdit(){
|
||
const inp=document.getElementById('new-cli-name-edit');
|
||
const nombre=(inp?.value||'').trim();
|
||
if(!nombre){toast('Escribe un nombre');return;}
|
||
await api('POST','/api/clientes',{nombre,tipo:'hotel',contacto:'',zona_entrega:'',costo_entrega:0,condiciones_pago:'Por definir',notas:''});
|
||
S.clientes=await api('GET','/api/clientes');
|
||
const sel=$('edit-cliente-sel');
|
||
sel.innerHTML=S.clientes.map(c=>`<option${c.nombre===nombre?' selected':''}>${esc(c.nombre)}</option>`).join('');
|
||
cancelCliEdit();
|
||
toast('Cliente agregado');
|
||
}
|
||
function cancelCliEdit(){
|
||
const el=document.getElementById('inline-new-cli-edit');
|
||
if(el)el.remove();
|
||
addingCliEdit=false;
|
||
}
|
||
|
||
// ══════ CRUD (legacy forms) ══════
|
||
async function crearSKU(){
|
||
const b={sku:$('f-i-sku').value,nombre:$('f-i-nom').value,descripcion:$('f-i-desc').value,tipo:$('f-i-tipo').value,talla:$('f-i-talla').value,stock_inicial:+$('f-i-stock').value,punto_reorden:+$('f-i-reorden').value,costo_unitario:+$('f-i-costo').value,proveedor:$('f-i-prov').value};
|
||
if(!b.sku){toast('SKU requerido');return;}
|
||
await api('POST','/api/inventario',b);closeMo('mo-inv');toast('SKU creado');loadInv();
|
||
}
|
||
async function crearTarea(){
|
||
const b={titulo:$('f-t-tit').value,descripcion:$('f-t-desc').value,prioridad:$('f-t-prio').value,stage:'pendiente',categoria:$('f-t-cat').value,asignado:$('f-t-asig').value,fecha_limite:$('f-t-fecha').value};
|
||
if(!b.titulo){toast('Titulo requerido');return;}
|
||
await api('POST','/api/tareas',b);closeMo('mo-tarea');toast('Tarea creada');loadTareas();
|
||
}
|
||
async function crearBita(){
|
||
const b={tipo:$('f-b-tipo').value,titulo:$('f-b-tit').value,descripcion:$('f-b-desc').value};
|
||
if(!b.titulo){toast('Titulo requerido');return;}
|
||
await api('POST','/api/bitacora',b);closeMo('mo-bita');toast('Registrado');loadBita();
|
||
}
|
||
|
||
function toggleEditGroup(id){
|
||
const body=$(id);
|
||
const btn=body.previousElementSibling;
|
||
const isOpen=body.style.display!=='none';
|
||
body.style.display=isOpen?'none':'block';
|
||
btn.classList.toggle('open',!isOpen);
|
||
}
|
||
|
||
function setEditStage(btn,stage){
|
||
btn.parentElement.querySelectorAll('.stage-pill').forEach(b=>b.classList.remove('on'));
|
||
btn.classList.add('on');
|
||
$('edit-stage-input').value=stage;
|
||
}
|
||
|
||
function toggleUrgenteEdit(btn){
|
||
const inp=$('edit-urgente-input');
|
||
const newVal=inp.value==='1'?0:1;
|
||
inp.value=newVal;
|
||
btn.classList.toggle('on',newVal===1);
|
||
btn.firstChild.textContent=newVal?'🔴 Urgente ':'Normal ';
|
||
}
|
||
|
||
async function editItem(table,id){
|
||
// Fetch fresh data
|
||
let items=S[table];
|
||
if(!items||!items.length){
|
||
items=await api('GET',`/api/${table}`);
|
||
S[table]=items;
|
||
}
|
||
const item=items.find(i=>i.id===id);if(!item)return;
|
||
const skip=['id','created_at','updated_at','total_ordenado','stock_disponible'];
|
||
const stageOpts={
|
||
ordenes:['Nuevo','En Tránsito','En 2 Mares','En Taller Sofia','En Almacen','En Vehiculo','Entregado','Cancelado'],
|
||
tareas:['backlog','pendiente','en_progreso','en_revision','completada'],
|
||
};
|
||
const selectFields={
|
||
tipo_orden:['OC','Resurtido','Muestra','Defecto','Faltante'],
|
||
tipo_trabajo:trabajoOpts(),
|
||
prioridad:['alta','normal','baja'],
|
||
categoria:['operaciones','entregas','ventas','produccion','cobranza','config','general'],
|
||
asignado:['Clod','Tess','Andre','Sandra'],
|
||
tipo:['base','secundario','hotel','tienda','restaurante','distribuidor','independiente','otro','bolsa','cosmetiquera','kit_bano','accesorio','textil'],
|
||
variable_por:['fijo','complejidad','tamano'],
|
||
condiciones_pago:CONDICIONES_PAGO_OPTS,
|
||
zona_entrega:['','Cabo San Lucas','San Jose del Cabo','Cabo del Este','Todos Santos / Pescadero','La Paz','Nacional','Internacional'],
|
||
talla:['CH','MD','GR','XGR','Unica'],
|
||
};
|
||
|
||
// Build product and client lists for dropdowns
|
||
const allProductos=new Set((S.productos||[]).map(p=>p.nombre));
|
||
S.ordenes.forEach(o=>{if(o.producto)allProductos.add(o.producto)});
|
||
const prodList=[...allProductos].sort();
|
||
const cliList=(S.clientes||[]).map(c=>c.nombre).sort();
|
||
|
||
// Field renderer
|
||
const renderField=(k,v)=>{
|
||
const lbl=k.replace(/_/g,' ');
|
||
if(k==='stage'&&stageOpts[table]){
|
||
return`<div class="fg"><label>${lbl}</label><select class="ef" data-k="${k}">${stageOpts[table].map(o=>`<option${o===v?' selected':''}>${o}</option>`).join('')}</select></div>`;
|
||
}
|
||
if(k==='producto'&&table==='ordenes'){
|
||
const opts=prodList.includes(v)?prodList:[v,...prodList];
|
||
return`<div class="fg"><label>${lbl}</label><div style="display:flex;gap:4px">
|
||
<select class="ef" data-k="${k}" id="edit-producto-sel" style="flex:1">${opts.map(o=>`<option${o===v?' selected':''}>${esc(o)}</option>`).join('')}</select>
|
||
<button class="btn" onclick="addProductoInlineEdit()" style="white-space:nowrap;font-size:12px">+ Nuevo</button>
|
||
</div></div>`;
|
||
}
|
||
if(k==='cliente'&&(table==='ordenes'||table==='oc')){
|
||
const opts=cliList.includes(v)?cliList:[v,...cliList];
|
||
return`<div class="fg"><label>${lbl}</label><div style="display:flex;gap:4px">
|
||
<select class="ef" data-k="${k}" id="edit-cliente-sel" style="flex:1">${opts.map(o=>`<option${o===v?' selected':''}>${esc(o)}</option>`).join('')}</select>
|
||
<button class="btn" onclick="addClienteInlineEdit()" style="white-space:nowrap;font-size:12px">+ Nuevo</button>
|
||
</div></div>`;
|
||
}
|
||
if(selectFields[k]){
|
||
return`<div class="fg"><label>${lbl}</label><select class="ef" data-k="${k}">${selectFields[k].map(o=>`<option${String(o)===String(v)?' selected':''}>${o}</option>`).join('')}</select></div>`;
|
||
}
|
||
if(k==='notas'||k==='descripcion'||k==='logo_instrucciones'){
|
||
return`<div class="fg"><label>${lbl}</label><textarea class="ef" data-k="${k}">${esc(v||'')}</textarea></div>`;
|
||
}
|
||
if(k==='urgente'||k==='activo'||k.startsWith('check_')){
|
||
return`<div class="fg"><label>${lbl}</label><select class="ef" data-k="${k}"><option value="0"${!v?' selected':''}>No</option><option value="1"${v?' selected':''}>Si</option></select></div>`;
|
||
}
|
||
if(k.startsWith('fecha')){
|
||
return`<div class="fg"><label>${lbl}</label><input class="ef" data-k="${k}" type="date" value="${esc(String(v||''))}"></div>`;
|
||
}
|
||
return`<div class="fg"><label>${lbl}</label><input class="ef" data-k="${k}" value="${esc(String(v||''))}"></div>`;
|
||
};
|
||
|
||
let html='';
|
||
if(table==='ordenes'){
|
||
// Designed top section with proper hierarchy
|
||
const groups={
|
||
'Costos':['costo_producto','costo_trabajo','costo_logistica','precio_factura'],
|
||
'Fechas':['fecha_oc','fecha_inicio','fecha_estimada','fecha_recepcion','fecha_entrega','recibio'],
|
||
'Recepción':['piezas_recibidas','piezas_danadas','nota_recepcion'],
|
||
'Identificación':['orden_id','tipo_orden','sku','oc_id','grupo_oc'],
|
||
'Checklist entrega':['check_facturada','check_empacada','check_etiquetas','check_vehiculo'],
|
||
'Detalles':['logo_instrucciones','notas'],
|
||
};
|
||
const priorityKeys=new Set(['cliente','producto','cantidad','tipo_trabajo','stage','urgente']);
|
||
const allGroupedKeys=new Set([...priorityKeys,...Object.values(groups).flat()]);
|
||
|
||
// ── Priority block — custom layouts ──
|
||
// Cliente (full row)
|
||
if('cliente' in item){
|
||
const v=item.cliente;
|
||
const opts=cliList.includes(v)?cliList:[v,...cliList];
|
||
html+=`<div class="fg"><label>Cliente</label><div style="display:flex;gap:4px">
|
||
<select class="ef" data-k="cliente" id="edit-cliente-sel" style="flex:1">${opts.map(o=>`<option${o===v?' selected':''}>${esc(o)}</option>`).join('')}</select>
|
||
<button class="btn" onclick="addClienteInlineEdit()" style="white-space:nowrap;font-size:12px">+ Nuevo</button>
|
||
</div></div>`;
|
||
}
|
||
// Producto + Cantidad (same row)
|
||
if('producto' in item){
|
||
const v=item.producto;
|
||
const opts=prodList.includes(v)?prodList:[v,...prodList];
|
||
html+=`<div style="display:grid;grid-template-columns:1fr 100px;gap:8px">
|
||
<div class="fg"><label>Producto</label><div style="display:flex;gap:4px">
|
||
<select class="ef" data-k="producto" id="edit-producto-sel" style="flex:1">${opts.map(o=>`<option${o===v?' selected':''}>${esc(o)}</option>`).join('')}</select>
|
||
<button class="btn" onclick="addProductoInlineEdit()" style="white-space:nowrap;font-size:12px">+</button>
|
||
</div></div>
|
||
<div class="fg"><label>Cantidad</label><input class="ef" data-k="cantidad" type="number" value="${item.cantidad||0}" style="text-align:center"></div>
|
||
</div>`;
|
||
}
|
||
// Stage — pill selector
|
||
if('stage' in item && stageOpts.ordenes){
|
||
const cur=item.stage;
|
||
const allStages=['Nuevo',...stageOpts.ordenes];
|
||
html+=`<div class="fg"><label>Stage</label>
|
||
<div class="stage-pills">
|
||
${allStages.map(s=>`<button type="button" class="stage-pill${s===cur?' on':''}" onclick="setEditStage(this,'${esc(s)}')">${s}</button>`).join('')}
|
||
<input type="hidden" class="ef" data-k="stage" id="edit-stage-input" value="${esc(cur)}">
|
||
</div>
|
||
</div>`;
|
||
}
|
||
// Tipo trabajo + Urgente toggle (same row)
|
||
if('tipo_trabajo' in item || 'urgente' in item){
|
||
html+=`<div style="display:grid;grid-template-columns:1fr auto;gap:8px;align-items:end">`;
|
||
if('tipo_trabajo' in item){
|
||
const opts=selectFields.tipo_trabajo;
|
||
html+=`<div class="fg"><label>Tipo trabajo</label><select class="ef" data-k="tipo_trabajo">${opts.map(o=>`<option${String(o)===String(item.tipo_trabajo)?' selected':''}>${o}</option>`).join('')}</select></div>`;
|
||
} else html+=`<div></div>`;
|
||
if('urgente' in item){
|
||
const isU=!!item.urgente;
|
||
html+=`<div class="fg"><label> </label>
|
||
<button type="button" class="urgente-toggle${isU?' on':''}" onclick="toggleUrgenteEdit(this)">
|
||
${isU?'🔴 Urgente':'Normal'}
|
||
<input type="hidden" class="ef" data-k="urgente" id="edit-urgente-input" value="${isU?1:0}">
|
||
</button>
|
||
</div>`;
|
||
}
|
||
html+=`</div>`;
|
||
}
|
||
|
||
// Any field not in priority and not in groups (rare)
|
||
for(const[k,v] of Object.entries(item)){
|
||
if(skip.includes(k))continue;
|
||
if(!allGroupedKeys.has(k)) html+=renderField(k,v);
|
||
}
|
||
// Collapsible groups
|
||
for(const[gName,keys] of Object.entries(groups)){
|
||
const present=keys.filter(k=>k in item);
|
||
if(!present.length)continue;
|
||
const gId='editgrp-'+gName.toLowerCase().replace(/[^a-z]/g,'');
|
||
html+=`<div class="edit-group">
|
||
<button type="button" class="edit-group-toggle" onclick="toggleEditGroup('${gId}')">
|
||
<span>${gName} (${present.length})</span>
|
||
<span class="edit-group-arrow">▾</span>
|
||
</button>
|
||
<div id="${gId}" class="edit-group-body" style="display:none">
|
||
${present.map(k=>renderField(k,item[k])).join('')}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
} else {
|
||
for(const[k,v] of Object.entries(item)){
|
||
if(skip.includes(k))continue;
|
||
html+=renderField(k,v);
|
||
}
|
||
}
|
||
$('edit-fields').innerHTML=html;
|
||
$('edit-h').innerHTML=`Editar ${item.orden_id||item.sku||item.nombre||item.titulo||item.clave||''} <button class="mo-x" onclick="closeMo('mo-edit')">×</button>`;
|
||
$('edit-go').onclick=async()=>{
|
||
const body={};document.querySelectorAll('.ef').forEach(f=>{body[f.dataset.k]=f.value});
|
||
// Type coercion
|
||
['cantidad','urgente','stock_inicial','punto_reorden','stock_actual','activo','check_facturada','check_empacada','check_etiquetas','check_vehiculo'].forEach(k=>{if(body[k]!==undefined)body[k]=+body[k]});
|
||
['costo_unitario','costo_base','costo_entrega','costo_producto','costo_trabajo','costo_logistica','precio_factura'].forEach(k=>{if(body[k]!==undefined)body[k]=+body[k]});
|
||
await api('PUT',`/api/${table}/${id}`,body);
|
||
closeMo('mo-edit');toast('Actualizado');
|
||
// Refresh data and active view
|
||
if(table==='clientes') S.clientes=await api('GET','/api/clientes');
|
||
if(table==='oc') S.ocs=await api('GET','/api/oc');
|
||
if(table==='productos') S.productos=await api('GET','/api/productos');
|
||
if(table==='trabajos') S.trabajos=await api('GET','/api/trabajos');
|
||
refreshActiveView();
|
||
};
|
||
openMo('mo-edit');
|
||
}
|
||
|
||
async function delItem(table,id){
|
||
if(!confirm('Eliminar este registro?'))return;
|
||
await api('DELETE',`/api/${table}/${id}`);toast('Eliminado');
|
||
if(table==='oc') S.ocs=await api('GET','/api/oc');
|
||
if(table==='clientes') S.clientes=await api('GET','/api/clientes');
|
||
if(table==='productos') S.productos=await api('GET','/api/productos');
|
||
refreshActiveView();
|
||
return;
|
||
}
|
||
|
||
// ══════ VIEW TOGGLE ══════
|
||
let currentViews={ordenes:'kanban',tareas:'kanban',bitacora:'timeline'};
|
||
|
||
function setView(section,view,btn){
|
||
currentViews[section]=view;
|
||
btn.parentElement.querySelectorAll('.vt-btn').forEach(b=>b.classList.remove('on'));
|
||
btn.classList.add('on');
|
||
if(section==='ordenes'){
|
||
$('ordenes-kanban').style.display=view==='kanban'?'':'none';
|
||
$('ordenes-tabla').style.display=view==='tabla'?'':'none';
|
||
$('ordenes-ocs').style.display=view==='ocs'?'':'none';
|
||
if(view==='ocs') renderOcsView();
|
||
}else if(section==='tareas'){
|
||
$('tareas-kanban').style.display=view==='kanban'?'':'none';
|
||
$('tareas-tabla').style.display=view==='tabla'?'':'none';
|
||
}else if(section==='bitacora'){
|
||
$('bitacora-timeline').style.display=view==='timeline'?'':'none';
|
||
$('bitacora-tabla').style.display=view==='tabla'?'':'none';
|
||
}
|
||
}
|
||
|
||
// ══════ GRID COLUMNS CONFIG ══════
|
||
const ORD_GRID_COLS=[
|
||
{key:'orden_id',label:'Orden',w:'90px'},
|
||
{key:'tipo_orden',label:'Tipo',w:'70px',type:'select',opts:['OC','Resurtido','Muestra','Defecto','Faltante']},
|
||
{key:'stage',label:'Stage',w:'110px',type:'select',opts:['En 2 Mares','En Taller Sofia','En Almacen','En Vehiculo','Entregado','Cancelado']},
|
||
{key:'cliente',label:'Cliente',w:'110px'},
|
||
{key:'producto',label:'Producto',w:'170px',type:'text'},
|
||
{key:'cantidad',label:'Cant',w:'45px',type:'number'},
|
||
{key:'tipo_trabajo',label:'Trabajo',w:'80px',type:'select',opts:trabajoOpts()},
|
||
{key:'costo_producto',label:'C.Prod',w:'60px',type:'number'},
|
||
{key:'costo_trabajo',label:'C.Trab',w:'60px',type:'number'},
|
||
{key:'costo_logistica',label:'Logist',w:'55px',type:'number'},
|
||
{key:'precio_factura',label:'Factura',w:'65px',type:'number'},
|
||
{key:'fecha_inicio',label:'Inicio',w:'80px',type:'date'},
|
||
{key:'fecha_entrega',label:'Entrega',w:'80px',type:'date'},
|
||
{key:'urgente',label:'Urg',w:'35px',type:'bool'},
|
||
{key:'notas',label:'Notas',w:'200px',type:'textarea'},
|
||
];
|
||
|
||
const TASK_GRID_COLS=[
|
||
{key:'titulo',label:'Titulo',w:'180px'},
|
||
{key:'stage',label:'Stage',w:'90px',type:'select',opts:['backlog','pendiente','en_progreso','en_revision','completada']},
|
||
{key:'prioridad',label:'Prio',w:'65px',type:'select',opts:['alta','normal','baja']},
|
||
{key:'categoria',label:'Cat',w:'80px',type:'select',opts:['operaciones','entregas','ventas','produccion','cobranza','config','general']},
|
||
{key:'asignado',label:'Quien',w:'60px',type:'select',opts:['Clod','Tess','Andre','Sandra']},
|
||
{key:'fecha_limite',label:'Limite',w:'80px',type:'date'},
|
||
{key:'descripcion',label:'Descripcion',w:'180px',type:'textarea'},
|
||
{key:'notas',label:'Notas',w:'130px',type:'textarea'},
|
||
];
|
||
|
||
const BITA_GRID_COLS=[
|
||
{key:'fecha',label:'Fecha',w:'120px'},
|
||
{key:'tipo',label:'Tipo',w:'70px',type:'select',opts:['entrega','produccion','hito','nota','problema','decision','movimiento']},
|
||
{key:'titulo',label:'Titulo',w:'180px'},
|
||
{key:'descripcion',label:'Descripcion',w:'260px',type:'textarea'},
|
||
{key:'referencia',label:'Ref',w:'90px'},
|
||
];
|
||
|
||
// ══════ GRID RENDERER ══════
|
||
let gridSort={};
|
||
let editingRow={};
|
||
|
||
function renderGrid(table,items,cols,tblId){
|
||
const tbl=$(tblId);
|
||
const search=$('search-'+table);
|
||
const query=search?search.value.toLowerCase():'';
|
||
let filtered=items;
|
||
if(query){
|
||
filtered=items.filter(item=>cols.some(col=>{const v=String(item[col.key]||'').toLowerCase();return v.includes(query)}));
|
||
}
|
||
const sort=gridSort[table];
|
||
if(sort){
|
||
filtered=[...filtered].sort((a,b)=>{
|
||
let va=a[sort.key]||'',vb=b[sort.key]||'';
|
||
if(typeof va==='number')return sort.asc?va-vb:vb-va;
|
||
va=String(va).toLowerCase();vb=String(vb).toLowerCase();
|
||
return sort.asc?va.localeCompare(vb):vb.localeCompare(va);
|
||
});
|
||
}
|
||
|
||
const fileColHdr=table==='ordenes'?'<th style="width:40px">+</th>':'';
|
||
tbl.querySelector('thead').innerHTML=`<tr>${cols.map(c=>
|
||
`<th style="min-width:${c.w}" onclick="sortGrid('${table}','${c.key}','${tblId}')">${c.label}${sort&&sort.key===c.key?(sort.asc?' ↑':' ↓'):''}</th>`
|
||
).join('')}${fileColHdr}<th style="width:70px">Acc</th></tr>`;
|
||
|
||
const eRow=editingRow[table];
|
||
tbl.querySelector('tbody').innerHTML=filtered.map(item=>{
|
||
const isEditing=eRow&&eRow.id===item.id;
|
||
const fileCell=table==='ordenes'?`<td>${fileBadge(item.orden_id)}</td>`:'';
|
||
if(isEditing){
|
||
return`<tr class="editing">${cols.map(c=>{
|
||
const v=item[c.key]??'';
|
||
if(c.type==='select') return`<td><select class="cell-edit" data-k="${c.key}">${(c.opts||[]).map(o=>`<option${String(o)===String(v)?' selected':''}>${o}</option>`).join('')}</select></td>`;
|
||
if(c.type==='textarea') return`<td><textarea class="cell-edit-ta" data-k="${c.key}">${esc(v)}</textarea></td>`;
|
||
if(c.type==='bool') return`<td><select class="cell-edit" data-k="${c.key}"><option value="0"${!v?' selected':''}>No</option><option value="1"${v?' selected':''}>Si</option></select></td>`;
|
||
if(c.type==='number') return`<td><input class="cell-edit" data-k="${c.key}" type="number" step="0.01" value="${v}"></td>`;
|
||
if(c.type==='date') return`<td><input class="cell-edit" data-k="${c.key}" type="date" value="${v}"></td>`;
|
||
return`<td><input class="cell-edit" data-k="${c.key}" value="${esc(String(v))}"></td>`;
|
||
}).join('')}${fileCell}<td><div class="row-actions"><button class="rbtn rbtn-save" onclick="saveGridRow('${table}',${item.id})">OK</button><button class="rbtn rbtn-cancel" onclick="cancelGridEdit('${table}')">X</button></div></td></tr>`;
|
||
}
|
||
return`<tr>${cols.map(c=>{
|
||
const v=item[c.key]??'';
|
||
if(c.key==='stage'){const[sc,scd]=cc(v);return`<td><span class="stage-pill" style="background:var(--${scd});color:var(--${sc})">${v}</span></td>`}
|
||
if(c.key==='tipo_orden'&&v&&v!=='OC'){const[tc,tcd]=cc(v);return`<td><span class="tag" style="background:var(--${tcd});color:var(--${tc})">${v}</span></td>`}
|
||
if(c.key==='urgente')return`<td>${v?'🔴':''}</td>`;
|
||
if(c.key==='precio_factura'||c.key==='costo_producto'||c.key==='costo_trabajo'||c.key==='costo_logistica')return`<td>${v?fmt$(v):''}</td>`;
|
||
if(c.type==='textarea')return`<td title="${esc(String(v))}">${esc(String(v).substring(0,50))}${String(v).length>50?'...':''}</td>`;
|
||
return`<td>${esc(String(v))}</td>`;
|
||
}).join('')}${fileCell}<td><div class="row-actions"><button class="rbtn rbtn-edit" onclick="startGridEdit('${table}',${item.id})">Ed</button><button class="rbtn rbtn-del" onclick="delItem('${table}',${item.id})">X</button></div></td></tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function sortGrid(table,key,tblId){
|
||
const cur=gridSort[table];
|
||
if(cur&&cur.key===key){gridSort[table]={key,asc:!cur.asc};}
|
||
else{gridSort[table]={key,asc:true};}
|
||
const colsMap={ordenes:ORD_GRID_COLS,tareas:TASK_GRID_COLS,bitacora:BITA_GRID_COLS};
|
||
renderGrid(table,S[table],colsMap[table],tblId);
|
||
}
|
||
function startGridEdit(table,id){
|
||
editingRow[table]={id};
|
||
const colsMap={ordenes:ORD_GRID_COLS,tareas:TASK_GRID_COLS,bitacora:BITA_GRID_COLS};
|
||
const tblMap={ordenes:'tbl-ordenes',tareas:'tbl-tareas',bitacora:'tbl-bitacora'};
|
||
renderGrid(table,S[table],colsMap[table],tblMap[table]);
|
||
}
|
||
function cancelGridEdit(table){
|
||
editingRow[table]=null;
|
||
const colsMap={ordenes:ORD_GRID_COLS,tareas:TASK_GRID_COLS,bitacora:BITA_GRID_COLS};
|
||
const tblMap={ordenes:'tbl-ordenes',tareas:'tbl-tareas',bitacora:'tbl-bitacora'};
|
||
renderGrid(table,S[table],colsMap[table],tblMap[table]);
|
||
}
|
||
async function saveGridRow(table,id){
|
||
const body={};
|
||
document.querySelectorAll(`#tbl-${table} .editing .cell-edit, #tbl-${table} .editing .cell-edit-ta`).forEach(f=>{body[f.dataset.k]=f.value});
|
||
['cantidad','urgente','check_facturada','check_empacada','check_etiquetas','check_vehiculo'].forEach(k=>{if(body[k]!==undefined)body[k]=+body[k]});
|
||
['costo_producto','costo_trabajo','costo_logistica','precio_factura'].forEach(k=>{if(body[k]!==undefined)body[k]=+body[k]});
|
||
await api('PUT',`/api/${table}/${id}`,body);
|
||
editingRow[table]=null;toast('Guardado');
|
||
load(table);
|
||
}
|
||
function filterTable(table){
|
||
const colsMap={ordenes:ORD_GRID_COLS,tareas:TASK_GRID_COLS,bitacora:BITA_GRID_COLS};
|
||
const tblMap={ordenes:'tbl-ordenes',tareas:'tbl-tareas',bitacora:'tbl-bitacora'};
|
||
renderGrid(table,S[table],colsMap[table],tblMap[table]);
|
||
}
|
||
|
||
// ══════ FILE MANAGEMENT ══════
|
||
let currentFilesOrden=null;
|
||
let ordenFileCounts={};
|
||
|
||
// ── Batch file index — ONE API call replaces N parallel fetches ──
|
||
let fileIndex={}; // entity_id → { count, first_image }
|
||
|
||
async function loadFileCounts(){
|
||
try{
|
||
fileIndex=await api('GET','/api/file-counts');
|
||
}catch(e){fileIndex={};}
|
||
// Sync ordenFileCounts for backward compat
|
||
ordenFileCounts={};
|
||
for(const[k,v] of Object.entries(fileIndex)){
|
||
if(v.count) ordenFileCounts[k]=v.count;
|
||
}
|
||
// Build productFiles + proyectoFiles maps from the index (lightweight: just first_image)
|
||
productFiles={};
|
||
proyectoFiles={};
|
||
for(const[k,v] of Object.entries(fileIndex)){
|
||
if(!v.first_image) continue;
|
||
const fakeFile={name:v.first_image.split('/').pop(),url:v.first_image,is_image:true};
|
||
if(k.startsWith('prod-')) productFiles[k]=[fakeFile];
|
||
else if(k.startsWith('proy-')) proyectoFiles[k]=[fakeFile];
|
||
}
|
||
if(S.ordenes.length){
|
||
renderOrdKanban();
|
||
renderGrid('ordenes',S.ordenes,ORD_GRID_COLS,'tbl-ordenes');
|
||
}
|
||
}
|
||
|
||
function fileBadge(ordenId){
|
||
const count=ordenFileCounts[ordenId];
|
||
if(!count) return`<span class="tag file-badge" onclick="event.stopPropagation();openFiles('${ordenId}')">+</span>`;
|
||
return`<span class="tag file-badge" onclick="event.stopPropagation();openFiles('${ordenId}')">📎 ${count}</span>`;
|
||
}
|
||
|
||
// Detect entity type from ID prefix
|
||
function entityTypeOf(id){
|
||
if(!id) return 'otro';
|
||
if(id.startsWith('prod-')) return 'producto';
|
||
if(id.startsWith('proy-')) return 'proyecto';
|
||
if(id.startsWith('OC-')) return 'oc';
|
||
if(id.startsWith('ORD-')||id.startsWith('N_A')||id.startsWith('N/A')||id.startsWith('MUES')) return 'pedido';
|
||
// OC IDs sin prefijo OC- (formato YYYY-MM-XX-NN, ej "2026-05-FF-01")
|
||
if(/^\d{4}-\d{2}-[A-Z0-9]+-\d+$/.test(id)) return 'oc';
|
||
return 'otro';
|
||
}
|
||
|
||
// Returns the dropdown options for the file type, contextualized per entity type
|
||
function fileTipoOptions(entityType){
|
||
const groups={
|
||
producto:[
|
||
{v:'foto_producto_base',l:'Foto del producto base (sin logo)'},
|
||
{v:'ficha_tecnica',l:'Ficha técnica / Medidas'},
|
||
{v:'otro',l:'Otro'},
|
||
],
|
||
proyecto:[
|
||
{v:'logo_cliente',l:'Logo del cliente (archivo original)'},
|
||
{v:'foto_producto_terminado',l:'Foto del producto terminado'},
|
||
{v:'mockup',l:'Mockup / Vista previa del diseño'},
|
||
{v:'instrucciones_diseno',l:'Instrucciones de diseño / Plantilla'},
|
||
{v:'otro',l:'Otro'},
|
||
],
|
||
oc:[
|
||
{v:'foto_evidencia',l:'Foto de evidencia / entrega'},
|
||
{v:'propuesta_venta',l:'Propuesta de venta'},
|
||
{v:'contrato',l:'Contrato / Acuerdo'},
|
||
{v:'factura',l:'Factura'},
|
||
{v:'recibo_entrega',l:'Recibo de entrega firmado'},
|
||
{v:'comprobante_pago',l:'Comprobante de pago'},
|
||
{v:'otro',l:'Otro'},
|
||
],
|
||
pedido:[
|
||
{v:'foto_avance_produccion',l:'Foto de avance / producción'},
|
||
{v:'foto_producto_pedido',l:'Foto del producto de este pedido'},
|
||
{v:'soporte_trabajo',l:'Soporte interno del trabajo'},
|
||
{v:'otro',l:'Otro'},
|
||
],
|
||
otro:[{v:'otro',l:'Otro'}],
|
||
};
|
||
return groups[entityType]||groups.otro;
|
||
}
|
||
|
||
function entityLabel(entityType){
|
||
return{producto:'📦 Producto del catálogo',proyecto:'📐 Proyecto recurrente',oc:'📋 Orden de Compra',pedido:'🛠 Pedido individual',otro:'Archivos'}[entityType]||'Archivos';
|
||
}
|
||
function entityHint(entityType){
|
||
return{
|
||
producto:'Sólo foto del modelo sin personalización. El logo del cliente NO va aquí.',
|
||
proyecto:'Aquí van logo del cliente, foto del producto terminado y mockups. Estos archivos se reusan en todos los pedidos de este proyecto.',
|
||
oc:'Documentación comercial: propuesta, factura, recibo firmado, comprobante de pago.',
|
||
pedido:'Fotos específicas de este pedido (avance, producto final si no hay proyecto). Para factura/recibo, usa la Orden.',
|
||
otro:''
|
||
}[entityType]||'';
|
||
}
|
||
|
||
async function openFiles(ordenId){
|
||
currentFilesOrden=ordenId;
|
||
const eType=entityTypeOf(ordenId);
|
||
const eLabel=entityLabel(eType);
|
||
const hint=entityHint(eType);
|
||
$('files-h').innerHTML=`<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%">
|
||
<div style="min-width:0;flex:1">
|
||
<div style="font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:.5px;font-weight:600">${eLabel}</div>
|
||
<div style="font-size:14px;font-weight:700;color:var(--olive-dark);margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(ordenId)}</div>
|
||
</div>
|
||
<button class="mo-x" onclick="closeMo('mo-files')">×</button>
|
||
</div>
|
||
${hint?`<div style="font-size:10px;color:var(--t2);background:var(--s2);padding:6px 10px;border-radius:6px;margin-top:6px;line-height:1.4">${hint}</div>`:''}`;
|
||
|
||
// Rebuild the tipo dropdown contextually
|
||
const sel=$('upload-tipo');
|
||
const opts=fileTipoOptions(eType);
|
||
sel.innerHTML=opts.map(o=>`<option value="${o.v}">${o.l}</option>`).join('');
|
||
|
||
await refreshFiles(ordenId);
|
||
suggestFileLabel();
|
||
openMo('mo-files');
|
||
}
|
||
|
||
async function refreshFiles(ordenId){
|
||
const files=await api('GET',`/api/files/${encodeURIComponent(ordenId)}`);
|
||
const el=$('files-list');
|
||
if(!files.length){el.innerHTML='<div class="empty">Sin archivos</div>';return;}
|
||
el.innerHTML=files.map(f=>{
|
||
const icon=f.is_image?`<img class="file-thumb" src="${f.url}" loading="lazy">`:`<div class="file-icon" style="background:var(--s3)">${f.ext==='.pdf'?'PDF':f.ext==='.xlsx'?'XLS':'DOC'}</div>`;
|
||
const sz=f.size>1e6?(f.size/1e6).toFixed(1)+'MB':(f.size/1e3).toFixed(0)+'KB';
|
||
return`<div class="file-item" onclick="openFile('${f.url}',${f.is_image})">
|
||
${icon}<span class="file-name">${f.name}</span><span class="file-size">${sz}</span>
|
||
<button class="file-del" onclick="event.stopPropagation();deleteFile('${encodeURIComponent(ordenId)}','${f.name}')">×</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function openFile(url,isImage){
|
||
if(isImage){
|
||
$('lb-img').src=url;$('lb-name').textContent=url.split('/').pop();$('lb-dl').href=url;
|
||
$('lightbox').classList.add('show');
|
||
}else{window.open(url,'_blank')}
|
||
}
|
||
function closeLightbox(e){if(!e||e.target===$('lightbox')){$('lightbox').classList.remove('show')}}
|
||
|
||
async function deleteFile(ordenId,filename){
|
||
if(!confirm('Eliminar archivo?'))return;
|
||
await api('DELETE',`/api/files/${ordenId}/${filename}`);
|
||
toast('Eliminado');refreshFiles(decodeURIComponent(ordenId));
|
||
if(ordenFileCounts[decodeURIComponent(ordenId)])ordenFileCounts[decodeURIComponent(ordenId)]--;
|
||
refreshActiveView();
|
||
}
|
||
|
||
// Upload handlers
|
||
const uz=$('upload-zone');
|
||
uz.addEventListener('dragover',e=>{e.preventDefault();uz.classList.add('dragover')});
|
||
uz.addEventListener('dragleave',()=>uz.classList.remove('dragover'));
|
||
uz.addEventListener('drop',e=>{e.preventDefault();uz.classList.remove('dragover');uploadFiles(e.dataTransfer.files)});
|
||
$('file-input').addEventListener('change',e=>uploadFiles(e.target.files));
|
||
|
||
// Compress an image File to JPEG with max dimension and quality. Skips non-images, tiny files, and animated formats.
|
||
async function compressImageFile(file,maxDim,quality){
|
||
if(!file.type.startsWith('image/')) return file;
|
||
if(file.type==='image/gif'||file.type==='image/svg+xml') return file; // preserve animations/vectors
|
||
if(file.size<200*1024) return file; // already small
|
||
return new Promise(resolve=>{
|
||
const reader=new FileReader();
|
||
reader.onload=e=>{
|
||
const img=new Image();
|
||
img.onload=()=>{
|
||
const ratio=Math.min(maxDim/Math.max(img.width,img.height),1);
|
||
if(ratio>=1) return resolve(file); // already small enough
|
||
const w=Math.round(img.width*ratio);
|
||
const h=Math.round(img.height*ratio);
|
||
const c=document.createElement('canvas');
|
||
c.width=w;c.height=h;
|
||
c.getContext('2d').drawImage(img,0,0,w,h);
|
||
c.toBlob(blob=>{
|
||
if(!blob||blob.size>=file.size) return resolve(file);
|
||
// Replace extension with .jpg
|
||
const newName=file.name.replace(/\.(png|webp|bmp|tiff?|heic|heif)$/i,'.jpg');
|
||
resolve(new File([blob],newName,{type:'image/jpeg'}));
|
||
},'image/jpeg',quality);
|
||
};
|
||
img.onerror=()=>resolve(file);
|
||
img.src=e.target.result;
|
||
};
|
||
reader.onerror=()=>resolve(file);
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
// Sugiere un nombre legible según el tipo de archivo y el contexto (entidad abierta)
|
||
function suggestFileLabel(){
|
||
const inp=$('upload-label'); if(!inp) return;
|
||
const tipo=$('upload-tipo').value;
|
||
const id=currentFilesOrden||'';
|
||
// Buscar la entidad del contexto
|
||
const ped=(S.ordenes||[]).find(o=>o.orden_id===id);
|
||
const oc=(S.ocs||[]).find(o=>o.oc_id===id);
|
||
let s='';
|
||
const clean=v=>(v||'').trim();
|
||
if(tipo==='factura') s=`Factura - ${oc?oc.oc_id:id}`;
|
||
else if(tipo==='recibo_entrega') s=`Recibo - ${id}`;
|
||
else if(tipo==='comprobante_pago') s=`Comprobante - ${oc?oc.oc_id:id}`;
|
||
else if(tipo==='propuesta_venta') s=`Propuesta - ${oc?oc.cliente:id}`;
|
||
else if(tipo==='contrato') s=`Contrato - ${oc?oc.cliente:id}`;
|
||
else if(tipo==='foto_producto_base') s=clean(ped&&ped.producto);
|
||
else if(tipo==='foto_avance_produccion'||tipo==='foto_producto'||tipo==='foto_producto_pedido'){
|
||
if(ped) s=[ped.producto,ped.tipo_trabajo,ped.cliente].map(clean).filter(Boolean).join(' - ');
|
||
}
|
||
else if(tipo==='logo_diseno') s=ped?`Logo - ${clean(ped.cliente)}`:`Logo - ${id}`;
|
||
// Para prod-/proy- usar el nombre del producto/proyecto
|
||
if(!s && id.startsWith('prod-')){const p=(S.productos||[]).find(x=>productEntityKey(x)===id);if(p)s=clean(p.nombre);}
|
||
inp.value=s;
|
||
}
|
||
|
||
async function uploadFiles(files){
|
||
if(!files.length||!currentFilesOrden)return;
|
||
const tipo=$('upload-tipo').value;
|
||
const label=($('upload-label')?.value||'').trim();
|
||
// Compress images before upload (max 1600px, quality 85%)
|
||
const filesArr=[...files];
|
||
const totalSizeOrig=filesArr.reduce((s,f)=>s+f.size,0);
|
||
const compressed=await Promise.all(filesArr.map(f=>compressImageFile(f,1600,0.85)));
|
||
const totalSizeNew=compressed.reduce((s,f)=>s+f.size,0);
|
||
const saved=totalSizeOrig-totalSizeNew;
|
||
if(saved>50*1024) toast(`Optimizando imágenes (-${Math.round(saved/1024)} KB)...`);
|
||
|
||
const fd=new FormData();
|
||
for(const f of compressed) fd.append('file',f,f.name);
|
||
try{
|
||
const labelQ=label?`&label=${encodeURIComponent(label)}`:'';
|
||
const resp=await fetch(`/api/upload/${encodeURIComponent(currentFilesOrden)}?tipo=${tipo}${labelQ}`,{method:'POST',body:fd});
|
||
const result=await resp.json();
|
||
if(result.files){
|
||
toast(`${result.files.length} archivo(s) subido(s)`);
|
||
refreshFiles(currentFilesOrden);
|
||
$('file-input').value='';
|
||
ordenFileCounts[currentFilesOrden]=(ordenFileCounts[currentFilesOrden]||0)+result.files.length;
|
||
// Refresh maps if relevant
|
||
if(currentFilesOrden.startsWith('proy-')) await loadProyectoFiles();
|
||
else if(currentFilesOrden.startsWith('prod-')){
|
||
try{const fl=await api('GET',`/api/files/${encodeURIComponent(currentFilesOrden)}`);productFiles[currentFilesOrden]=fl;}catch(e){}
|
||
} else if(currentFilesOrden.startsWith('ORD-')||currentFilesOrden.startsWith('MUES')){
|
||
// Refresh pedido's own files cache for photo fallback
|
||
try{const fl=await api('GET',`/api/files/${encodeURIComponent(currentFilesOrden)}`);pedidoFilesCache[currentFilesOrden]=fl;}catch(e){}
|
||
}
|
||
refreshActiveView();
|
||
}else{toast(result.error||'Error al subir')}
|
||
}catch(err){toast('Error de conexion')}
|
||
}
|
||
|
||
// ══════ CATÁLOGOS — builder + preview imprimible ══════
|
||
let catalogosData=[];
|
||
let catEdit=null;
|
||
const CAT_DEFAULT_MIN_PZAS=50;
|
||
const CAT_DEFAULT_LEAD_TIME='2 semanas';
|
||
const CAT_BRAND_MARK='Art4Hotel';
|
||
// Parse items JSON con fallback seguro a []
|
||
function catParseItems(c){try{return JSON.parse(c.items||'[]')}catch(e){return []}}
|
||
|
||
async function loadCatalogos(){
|
||
catalogosData=await api('GET','/api/catalogos');
|
||
// Asegurar que tenemos productos, proyectos, ordenes y clientes para el picker + social proof
|
||
if(!S.productos?.length){S.productos=await api('GET','/api/productos');}
|
||
if(!S.proyectos?.length){try{S.proyectos=await api('GET','/api/proyectos');}catch(e){}}
|
||
if(!S.trabajos?.length){try{S.trabajos=await api('GET','/api/trabajos');}catch(e){}}
|
||
if(!S.ordenes?.length){try{S.ordenes=await api('GET','/api/ordenes');}catch(e){}}
|
||
if(!S.clientes?.length){try{S.clientes=await api('GET','/api/clientes');}catch(e){}}
|
||
renderCatalogosList();
|
||
}
|
||
|
||
// Reconocimiento: solo hoteles/restaurantes que han pedido este modelo (entregado).
|
||
// Filtramos por tipo de cliente para que el social proof se sienta como prestigio.
|
||
const CAT_TIPOS_RECONOCIBLES=new Set(['hotel','restaurante']);
|
||
|
||
function renderCatalogosList(){
|
||
const q=($('search-catalogos')?.value||'').toLowerCase();
|
||
let items=catalogosData;
|
||
if(q) items=items.filter(c=>(c.nombre+' '+c.segmento+' '+c.cliente_nombre).toLowerCase().includes(q));
|
||
items.sort((a,b)=>b.id-a.id);
|
||
if(!items.length){
|
||
$('catalogos-content').innerHTML=`<div class="empty" style="padding:30px;text-align:center">
|
||
<div style="font-size:14px;color:var(--t2);margin-bottom:6px">Sin catálogos aún</div>
|
||
<div style="font-size:11px;color:var(--t3)">Crea el primero para presentar productos a wedding planners, hoteles o cualquier audiencia</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
$('catalogos-content').innerHTML=items.map(c=>{
|
||
const itemCount=catParseItems(c).length;
|
||
return`<div class="cat-list-card" onclick="openCatalogo(${c.id})">
|
||
<div style="flex:1;min-width:0">
|
||
<div class="cl-title">${esc(c.nombre)}</div>
|
||
<div class="cl-meta">
|
||
${c.segmento?`${esc(c.segmento)} · `:''}
|
||
${c.cliente_nombre?`Para ${esc(c.cliente_nombre)} · `:''}
|
||
${itemCount} producto${itemCount!==1?'s':''}${c.fecha?` · ${c.fecha}`:''}
|
||
</div>
|
||
<div class="cl-tags">
|
||
<span class="cl-tag" style="${c.status==='listo'?'background:var(--gnd);color:var(--gn)':''}">${esc(c.status||'borrador')}</span>
|
||
${c.show_prices?'<span class="cl-tag">Con precios</span>':'<span class="cl-tag" style="background:var(--s2);color:var(--t2)">Sin precios</span>'}
|
||
</div>
|
||
</div>
|
||
<button class="btn btn-ac" onclick="event.stopPropagation();previewCatalogo(${c.id})">👁 Vista previa</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function newCatalogo(){
|
||
catEdit={
|
||
nombre:'',segmento:'',cliente_nombre:'',
|
||
fecha:new Date().toISOString().slice(0,10),
|
||
items:[],show_prices:1,show_clientes:1,show_lead_time:1,
|
||
notas:'',status:'borrador',
|
||
entrega:'2-4 semanas',minimo_compra:'50 piezas',terminos:'',show_contacto:1
|
||
};
|
||
renderCatalogoEditor();
|
||
}
|
||
|
||
async function openCatalogo(id){
|
||
const c=catalogosData.find(x=>x.id===id);
|
||
if(!c) return;
|
||
const items=catParseItems(c).map(catRehydrateItem);
|
||
catEdit={...c, items};
|
||
renderCatalogoEditor();
|
||
}
|
||
|
||
// Refresca los datos de un item desde su fuente viva (producto/proyecto/pedido),
|
||
// conservando SOLO los datos propios del catálogo (precio_unit manual).
|
||
function catRehydrateItem(it){
|
||
const precio=it.precio_unit; // se preserva el precio manual
|
||
if(it.tipo==='producto'){
|
||
const p=(S.productos||[]).find(x=>x.id===it.ref_id);
|
||
if(p){
|
||
const foto=(productFiles[productEntityKey(p)]||[]).find(f=>f.is_image)?.url||it.foto_url||'';
|
||
const desc=(p.descripcion_web||'').trim()||[p.material,p.medidas].filter(Boolean).join(' · ');
|
||
const pers=(p.tipos_trabajo_disponibles||'').split(/[,;|]/).map(s=>s.trim()).filter(Boolean);
|
||
return {...it, nombre:p.nombre, foto_url:foto, descripcion:desc, personalizaciones:pers, precio_unit:precio};
|
||
}
|
||
} else if(it.tipo==='proyecto'){
|
||
const p=(S.proyectos||[]).find(x=>x.id===it.ref_id);
|
||
if(p){
|
||
const foto=(proyectoFiles['proy-'+p.id]||[]).find(f=>f.is_image)?.url||it.foto_url||'';
|
||
return {...it, nombre:p.producto_nombre||p.nombre, foto_url:foto,
|
||
descripcion:p.logo_descripcion||it.descripcion||'',
|
||
personalizaciones:p.tipo_trabajo?[p.tipo_trabajo]:[], precio_unit:precio};
|
||
}
|
||
} else if(it.tipo==='pedido'){
|
||
const o=(S.ordenes||[]).find(x=>x.id===it.ref_id);
|
||
if(o){
|
||
const foto=fileIndex[o.orden_id]?.first_image||it.foto_url||'';
|
||
return {...it, nombre:`${o.producto}${o.tipo_trabajo?' · '+o.tipo_trabajo:''}`,
|
||
foto_url:foto, personalizaciones:o.tipo_trabajo?[o.tipo_trabajo]:[], precio_unit:precio};
|
||
}
|
||
}
|
||
return it; // si no se encuentra la fuente, deja el snapshot
|
||
}
|
||
|
||
function renderCatalogoEditor(){
|
||
const c=catEdit;
|
||
$('cat-header').innerHTML=`<div class="qv-head">
|
||
<div class="qv-head-main">
|
||
<div class="qv-head-row">
|
||
<span class="qv-badge" style="background:var(--bl)">CATÁLOGO</span>
|
||
<h2>${esc(c.nombre||'Sin nombre')}</h2>
|
||
</div>
|
||
<div class="qv-head-cli">${c.segmento?esc(c.segmento):'<span style="color:var(--t3)">Sin segmento</span>'}${c.cliente_nombre?' · Para '+esc(c.cliente_nombre):''}</div>
|
||
</div>
|
||
<button class="mo-x" onclick="closeMo('mo-catalogo')">×</button>
|
||
</div>`;
|
||
|
||
$('cat-body').innerHTML=`
|
||
<div class="od-section">
|
||
<div class="od-config-grid">
|
||
<div class="fg" style="grid-column:1/-1"><label>Nombre del catálogo</label>
|
||
<input id="cat-nombre" value="${esc(c.nombre||'')}" placeholder='ej: "Catálogo Wedding Planners 2026"' oninput="catEdit.nombre=this.value">
|
||
</div>
|
||
<div class="fg"><label>Segmento / Audiencia</label>
|
||
<input id="cat-segmento" value="${esc(c.segmento||'')}" placeholder="Bodas, Hoteles, Eventos" oninput="catEdit.segmento=this.value">
|
||
</div>
|
||
<div class="fg"><label>Cliente específico (opcional)</label>
|
||
<input id="cat-cliente" value="${esc(c.cliente_nombre||'')}" placeholder="Nombre del cliente" oninput="catEdit.cliente_nombre=this.value">
|
||
</div>
|
||
<div class="fg"><label>Fecha</label>
|
||
<input type="date" id="cat-fecha" value="${esc(c.fecha||'')}" oninput="catEdit.fecha=this.value">
|
||
</div>
|
||
<div class="fg"><label>Status</label>
|
||
<select onchange="catEdit.status=this.value">
|
||
${['borrador','listo'].map(s=>`<option value="${s}"${s===c.status?' selected':''}>${s.charAt(0).toUpperCase()+s.slice(1)}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cat-toggles">
|
||
<label class="cat-toggle"><input type="checkbox" ${c.show_prices?'checked':''} onchange="catEdit.show_prices=this.checked?1:0"> Mostrar precios</label>
|
||
<label class="cat-toggle"><input type="checkbox" ${c.show_clientes?'checked':''} onchange="catEdit.show_clientes=this.checked?1:0"> Mostrar clientes que lo han pedido</label>
|
||
<label class="cat-toggle"><input type="checkbox" ${c.show_contacto!==0?'checked':''} onchange="catEdit.show_contacto=this.checked?1:0"> Mostrar contacto (correo/tel/web) en el pie</label>
|
||
</div>
|
||
|
||
<div class="od-section">
|
||
<h4 style="margin:0 0 6px 0">Productos del catálogo <span style="font-weight:400;font-size:10px;color:var(--t2)">${c.items.length} agregado${c.items.length!==1?'s':''}</span></h4>
|
||
<div class="cat-items-list" id="cat-items-list">${renderCatalogoItems()}</div>
|
||
<details style="margin-top:10px">
|
||
<summary style="cursor:pointer;font-size:12px;color:var(--olive);font-weight:600;padding:6px">+ Agregar productos al catálogo</summary>
|
||
<div class="cat-picker" id="cat-picker">${renderCatalogoPicker()}</div>
|
||
</details>
|
||
</div>
|
||
|
||
<div class="od-section">
|
||
<h4 style="margin:0 0 6px 0">Términos comerciales <span style="font-weight:400;font-size:10px;color:var(--t2)">aparecen al final del catálogo</span></h4>
|
||
<div class="od-config-grid">
|
||
<div class="fg"><label>Tiempo de entrega</label>
|
||
<input id="cat-entrega" value="${esc(c.entrega||'')}" placeholder="2-4 semanas" oninput="catEdit.entrega=this.value">
|
||
</div>
|
||
<div class="fg"><label>Volumen mínimo de compra</label>
|
||
<input id="cat-minimo" value="${esc(c.minimo_compra||'')}" placeholder="50 piezas" oninput="catEdit.minimo_compra=this.value">
|
||
</div>
|
||
<div class="fg" style="grid-column:1/-1"><label>Términos y condiciones</label>
|
||
<textarea id="cat-terminos" rows="3" placeholder="Ej: Precios sujetos a confirmación. Anticipo del 50% para iniciar producción. Vigencia de cotización 15 días. Logística incluida dentro de Los Cabos." oninput="catEdit.terminos=this.value">${esc(c.terminos||'')}</textarea>
|
||
</div>
|
||
<div class="fg" style="grid-column:1/-1"><label>Notas internas (no se muestran)</label>
|
||
<textarea id="cat-notas" rows="2" oninput="catEdit.notas=this.value">${esc(c.notas||'')}</textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="od-actions">
|
||
<button class="btn btn-ac" onclick="saveCatalogo()">✓ Guardar</button>
|
||
<button class="btn" onclick="previewCatalogoEdit()" style="border-color:var(--olive);color:var(--olive)">👁 Vista previa</button>
|
||
${c.id?`<button class="btn" style="border-color:var(--rd);color:var(--rd);margin-left:auto" onclick="deleteCatalogo()">🗑</button>`:''}
|
||
</div>
|
||
`;
|
||
openMo('mo-catalogo');
|
||
}
|
||
|
||
function renderCatalogoItems(){
|
||
if(!catEdit.items.length) return '<div class="empty" style="font-size:11px;padding:10px">Sin productos. Agrega desde el picker abajo.</div>';
|
||
return catEdit.items.map((it,i)=>{
|
||
const foto=it.foto_url?`<img src="${it.foto_url}">`:'<div class="cat-item-empty">—</div>';
|
||
const pers=(it.personalizaciones||[]).join(', ')||'Sin personalización';
|
||
return`<div class="cat-item-row">
|
||
${foto}
|
||
<div class="cat-item-info">
|
||
<div class="cat-item-name">${esc(it.nombre)}</div>
|
||
<div class="cat-item-meta">${esc(pers)}</div>
|
||
</div>
|
||
<div class="cat-precio-inline" title="Precio unitario manual (vacío = no se muestra)">
|
||
<span>$</span>
|
||
<input type="number" step="0.01" min="0" value="${it.precio_unit||''}" placeholder="—"
|
||
onchange="catSetPrecio(${i},this.value)" onclick="event.stopPropagation()">
|
||
<small>/pza</small>
|
||
</div>
|
||
<div class="cat-item-actions">
|
||
<button class="kc-btn" onclick="catMoveItem(${i},-1)" title="Subir">▲</button>
|
||
<button class="kc-btn" onclick="catMoveItem(${i},1)" title="Bajar">▼</button>
|
||
<button class="kc-btn" onclick="catRemoveItem(${i})" title="Quitar">×</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderCatalogoPicker(){
|
||
const usedKeys=new Set(catEdit.items.map(it=>`${it.tipo}-${it.ref_id}`));
|
||
let html='';
|
||
|
||
// ─── Sección 1: Trabajos realizados (pedidos entregados con foto, hoteles/restaurantes) ───
|
||
const tipoPorNombre=new Map((S.clientes||[]).map(c=>[c.nombre,c.tipo]));
|
||
const candidatos=(S.ordenes||[]).filter(o=>
|
||
o.stage==='Entregado' &&
|
||
fileIndex[o.orden_id]?.first_image &&
|
||
CAT_TIPOS_RECONOCIBLES.has(tipoPorNombre.get(o.cliente))
|
||
);
|
||
candidatos.sort((a,b)=>(b.fecha_entrega||'').localeCompare(a.fecha_entrega||'')||b.id-a.id);
|
||
// Dedupe: una entrada por (producto+trabajo+cliente), conservar el más reciente
|
||
const seenWork=new Set();
|
||
const trabajos=[];
|
||
for(const t of candidatos){
|
||
const k=`${t.producto}|${t.tipo_trabajo}|${t.cliente}`;
|
||
if(seenWork.has(k)) continue;
|
||
seenWork.add(k);
|
||
trabajos.push(t);
|
||
}
|
||
if(trabajos.length){
|
||
html+='<div class="cat-picker-section">🎨 Trabajos realizados (con foto)</div>';
|
||
html+=trabajos.map(t=>{
|
||
const key=`pedido-${t.id}`;
|
||
const used=usedKeys.has(key);
|
||
return`<div class="cat-picker-row" onclick="catAddPedido(${t.id})" style="${used?'opacity:.4;pointer-events:none':''}">
|
||
<img src="${fileIndex[t.orden_id].first_image}">
|
||
<div class="cp-name">${esc(t.producto)} <span style="color:var(--t3);font-weight:400">· ${esc(t.cliente)}</span></div>
|
||
${t.tipo_trabajo?`<span class="cp-tag">${esc(t.tipo_trabajo)}</span>`:''}
|
||
${used?'<span style="font-size:9px;color:var(--gn)">✓ ya</span>':''}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ─── Sección 2: Proyectos recurrentes (recetas autorizadas) ───
|
||
const proys=(S.proyectos||[]).filter(p=>p.activo!==0);
|
||
if(proys.length){
|
||
html+='<div class="cat-picker-section">📐 Proyectos recurrentes (logo + trabajo + cliente)</div>';
|
||
html+=proys.map(p=>{
|
||
const key=`proyecto-${p.id}`;
|
||
const used=usedKeys.has(key);
|
||
const fotoUrl=(proyectoFiles['proy-'+p.id]||[]).find(f=>f.is_image)?.url||'';
|
||
const img=fotoUrl?`<img src="${fotoUrl}">`:'<div class="cat-item-empty">—</div>';
|
||
return`<div class="cat-picker-row" onclick="catAddProyecto(${p.id})" style="${used?'opacity:.4;pointer-events:none':''}">
|
||
${img}
|
||
<div class="cp-name">${esc(p.nombre)}</div>
|
||
${p.tipo_trabajo?`<span class="cp-tag">${esc(p.tipo_trabajo)}</span>`:''}
|
||
${used?'<span style="font-size:9px;color:var(--gn)">✓ ya</span>':''}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ─── Sección 3: Productos base del catálogo ───
|
||
const prods=(S.productos||[]).filter(p=>p.activo!==0);
|
||
if(prods.length){
|
||
html+='<div class="cat-picker-section">📦 Productos del catálogo (base)</div>';
|
||
html+=prods.map(p=>{
|
||
const key=`producto-${p.id}`;
|
||
const used=usedKeys.has(key);
|
||
const fotoUrl=(productFiles[productEntityKey(p)]||[]).find(f=>f.is_image)?.url||'';
|
||
const img=fotoUrl?`<img src="${fotoUrl}">`:'<div class="cat-item-empty">—</div>';
|
||
return`<div class="cat-picker-row" onclick="catAddProducto(${p.id})" style="${used?'opacity:.4;pointer-events:none':''}">
|
||
${img}
|
||
<div class="cp-name">${esc(p.nombre)}</div>
|
||
<span style="font-size:9px;color:var(--t3)">${esc(p.categoria||'')}</span>
|
||
${used?'<span style="font-size:9px;color:var(--gn)">✓ ya</span>':''}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
if(!html) html='<div class="empty" style="font-size:11px;padding:10px">Sin productos ni proyectos disponibles.</div>';
|
||
return html;
|
||
}
|
||
|
||
function catAddPedido(id){
|
||
const t=(S.ordenes||[]).find(x=>x.id===id);
|
||
if(!t) return;
|
||
const foto=fileIndex[t.orden_id]?.first_image||'';
|
||
const pers=t.tipo_trabajo?[t.tipo_trabajo]:[];
|
||
const clientes=catClientesQueHanPedido(t.producto,t.tipo_trabajo);
|
||
// Descripción: logo_instrucciones si existe, si no, "Producto terminado para [cliente]"
|
||
const desc=t.logo_instrucciones?t.logo_instrucciones.trim():`Trabajo realizado para ${t.cliente}`;
|
||
catEdit.items.push({
|
||
tipo:'pedido', ref_id:t.id,
|
||
nombre:`${t.producto}${t.tipo_trabajo?' · '+t.tipo_trabajo:''}`,
|
||
foto_url:foto,
|
||
descripcion:desc,
|
||
personalizaciones:pers,
|
||
precio_unit:(+t.costo_producto+ +t.costo_trabajo)||0,
|
||
minimo_pzas:CAT_DEFAULT_MIN_PZAS,
|
||
lead_time:CAT_DEFAULT_LEAD_TIME,
|
||
clientes_vendido:clientes,
|
||
});
|
||
refreshCatalogoEditor();
|
||
}
|
||
|
||
function catClientesQueHanPedido(productoNombre,tipoTrabajo){
|
||
// Clientes únicos que han pedido este modelo (entregado), filtrados a hoteles/restaurantes
|
||
// para que el reconocimiento se sienta como prestigio (no listar tiendas/distribuidores).
|
||
const tipoPorNombre=new Map((S.clientes||[]).map(c=>[c.nombre,c.tipo]));
|
||
const set=new Set();
|
||
(S.ordenes||[]).forEach(o=>{
|
||
if(o.stage!=='Entregado') return;
|
||
if(o.producto!==productoNombre) return;
|
||
if(tipoTrabajo && o.tipo_trabajo!==tipoTrabajo) return;
|
||
if(!o.cliente) return;
|
||
if(!CAT_TIPOS_RECONOCIBLES.has(tipoPorNombre.get(o.cliente))) return;
|
||
set.add(o.cliente);
|
||
});
|
||
return [...set];
|
||
}
|
||
|
||
function catAddProyecto(id){
|
||
const p=S.proyectos.find(x=>x.id===id);
|
||
if(!p) return;
|
||
const fotoUrl=(proyectoFiles['proy-'+p.id]||[]).find(f=>f.is_image)?.url||'';
|
||
const pers=p.tipo_trabajo?[p.tipo_trabajo]:[];
|
||
const clientes=catClientesQueHanPedido(p.producto_nombre,p.tipo_trabajo);
|
||
catEdit.items.push({
|
||
tipo:'proyecto', ref_id:p.id,
|
||
nombre:p.producto_nombre||p.nombre,
|
||
foto_url:fotoUrl,
|
||
descripcion:p.logo_descripcion||'',
|
||
personalizaciones:pers,
|
||
precio_unit:p.costo_unitario+p.costo_trabajo||0,
|
||
minimo_pzas:CAT_DEFAULT_MIN_PZAS,
|
||
lead_time:CAT_DEFAULT_LEAD_TIME,
|
||
clientes_vendido:clientes,
|
||
});
|
||
refreshCatalogoEditor();
|
||
}
|
||
|
||
function catAddProducto(id){
|
||
const p=S.productos.find(x=>x.id===id);
|
||
if(!p) return;
|
||
const fotoUrl=(productFiles[productEntityKey(p)]||[]).find(f=>f.is_image)?.url||'';
|
||
// Personalizaciones disponibles desde producto.tipos_trabajo_disponibles o lista canónica
|
||
let pers=[];
|
||
if(p.tipos_trabajo_disponibles){
|
||
pers=p.tipos_trabajo_disponibles.split(/[,;|]/).map(s=>s.trim()).filter(Boolean);
|
||
}
|
||
const clientes=catClientesQueHanPedido(p.nombre,'');
|
||
catEdit.items.push({
|
||
tipo:'producto', ref_id:p.id,
|
||
nombre:p.nombre,
|
||
foto_url:fotoUrl,
|
||
descripcion:[p.material,p.talla,p.color].filter(Boolean).join(' · '),
|
||
personalizaciones:pers,
|
||
precio_unit:p.costo_base||0,
|
||
minimo_pzas:CAT_DEFAULT_MIN_PZAS,
|
||
lead_time:CAT_DEFAULT_LEAD_TIME,
|
||
clientes_vendido:clientes,
|
||
});
|
||
refreshCatalogoEditor();
|
||
}
|
||
|
||
function catRemoveItem(i){catEdit.items.splice(i,1);refreshCatalogoEditor();}
|
||
function catSetPrecio(i,val){
|
||
// Sobre-escribe el precio unitario manual (vacío = sin precio)
|
||
catEdit.items[i].precio_unit = val===''?0:(+val||0);
|
||
}
|
||
function catMoveItem(i,dir){
|
||
const j=i+dir;
|
||
if(j<0||j>=catEdit.items.length) return;
|
||
const tmp=catEdit.items[i]; catEdit.items[i]=catEdit.items[j]; catEdit.items[j]=tmp;
|
||
refreshCatalogoEditor();
|
||
}
|
||
function catEditItem(i){
|
||
const it=catEdit.items[i];
|
||
const nuevo=prompt(
|
||
`Edita el item (formato libre, una línea por campo):\n`+
|
||
`Nombre: ${it.nombre}\nDescripción: ${it.descripcion||''}\nPrecio/pza: ${it.precio_unit||0}\nMínimo pzas: ${it.minimo_pzas||CAT_DEFAULT_MIN_PZAS}\nLead time: ${it.lead_time||''}`,
|
||
`${it.nombre}\n${it.descripcion||''}\n${it.precio_unit||0}\n${it.minimo_pzas||CAT_DEFAULT_MIN_PZAS}\n${it.lead_time||''}`
|
||
);
|
||
if(!nuevo) return;
|
||
const [nombre,desc,precio,minimo,lead]=nuevo.split('\n');
|
||
if(nombre) it.nombre=nombre.trim();
|
||
if(desc!==undefined) it.descripcion=desc.trim();
|
||
if(precio) it.precio_unit=+precio||0;
|
||
if(minimo) it.minimo_pzas=+minimo||0;
|
||
if(lead!==undefined) it.lead_time=lead.trim();
|
||
refreshCatalogoEditor();
|
||
}
|
||
|
||
function refreshCatalogoEditor(){
|
||
$('cat-items-list').innerHTML=renderCatalogoItems();
|
||
$('cat-picker').innerHTML=renderCatalogoPicker();
|
||
}
|
||
|
||
async function saveCatalogo(){
|
||
if(!catEdit.nombre){toast('Falta nombre del catálogo');return;}
|
||
const body={
|
||
...catEdit,
|
||
items:JSON.stringify(catEdit.items||[]),
|
||
};
|
||
delete body.id; delete body.created_at; delete body.updated_at;
|
||
if(catEdit.id){
|
||
await api('PUT',`/api/catalogos/${catEdit.id}`,body);
|
||
} else {
|
||
const r=await api('POST','/api/catalogos',body);
|
||
if(r&&r.id) catEdit.id=r.id;
|
||
}
|
||
toast('Catálogo guardado');
|
||
await loadCatalogos();
|
||
closeMo('mo-catalogo');
|
||
}
|
||
|
||
async function deleteCatalogo(){
|
||
if(!catEdit.id) return;
|
||
if(!confirm(`Eliminar catálogo "${catEdit.nombre}"?`)) return;
|
||
await api('DELETE',`/api/catalogos/${catEdit.id}`);
|
||
toast('Eliminado');
|
||
closeMo('mo-catalogo');
|
||
await loadCatalogos();
|
||
}
|
||
|
||
function previewCatalogoEdit(){
|
||
if(!catEdit.items.length){toast('Agrega al menos un producto');return;}
|
||
renderCatalogoPreview(catEdit);
|
||
}
|
||
|
||
function previewCatalogo(id){
|
||
const c=catalogosData.find(x=>x.id===id);
|
||
if(!c) return;
|
||
const data={...c, items:catParseItems(c).map(catRehydrateItem)};
|
||
if(!data.items.length){toast('Este catálogo está vacío');return;}
|
||
renderCatalogoPreview(data);
|
||
}
|
||
|
||
function renderCatalogoPreview(c){
|
||
// Toolbar
|
||
$('cat-pv-toolbar').innerHTML=`
|
||
<div style="display:flex;align-items:center;gap:10px">
|
||
<span style="color:#fff;font-weight:700;font-size:13px">${esc(c.nombre)}</span>
|
||
<span style="color:rgba(255,255,255,.7);font-size:11px">${c.items.length} productos</span>
|
||
</div>
|
||
<div style="display:flex;gap:6px">
|
||
<button class="btn" style="background:#fff;color:var(--olive-dark);border-color:#fff" onclick="catPrintCatalog()">🖨 Imprimir / Guardar PDF</button>
|
||
<button class="btn" style="background:transparent;color:#fff;border-color:rgba(255,255,255,.5)" onclick="closeMo('mo-catalogo-preview')">✕ Cerrar</button>
|
||
</div>`;
|
||
|
||
// Build slides: portada + grid 2 productos por página
|
||
const slides=[];
|
||
// ── Portada — logo OFICIAL principal, auto-centrado con text-anchor=middle ──
|
||
// El wordmark "art 4 hotel" y el tagline se centran ambos en x=160 (mitad del viewBox 320)
|
||
const brandLogoHero=`<svg viewBox="0 0 320 90" xmlns="http://www.w3.org/2000/svg">
|
||
<text x="160" y="52" text-anchor="middle" dominant-baseline="alphabetic">
|
||
<tspan font-family="'Outfit', sans-serif" font-weight="600" font-size="44" fill="#3D4A33" letter-spacing="3">art</tspan>
|
||
<tspan dx="10" font-family="'Playfair Display', serif" font-weight="400" font-size="50" fill="#6B4F3C" font-style="italic">4</tspan>
|
||
<tspan dx="8" font-family="'Outfit', sans-serif" font-weight="600" font-size="44" fill="#3D4A33" letter-spacing="3">hotel</tspan>
|
||
</text>
|
||
<text x="160" y="78" text-anchor="middle" font-family="'DM Sans', sans-serif" font-weight="500" font-size="9.5" fill="#8A8075" letter-spacing="2.8">SUPPLIES & CUSTOM PROJECTS</text>
|
||
</svg>`;
|
||
const fechaFmt=(()=>{
|
||
if(!c.fecha) return '';
|
||
try{
|
||
const d=new Date(c.fecha+'T12:00:00');
|
||
const meses=['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre'];
|
||
return `${meses[d.getMonth()]} ${d.getFullYear()}`;
|
||
}catch(e){return c.fecha;}
|
||
})();
|
||
const yearOnly=c.fecha?c.fecha.slice(0,4):'';
|
||
slides.push(`<div class="cat-slide portada">
|
||
<div class="cs-frame">
|
||
<div class="cs-top">
|
||
<div class="cs-segmento">${esc(c.segmento||'Colección Especial')}</div>
|
||
${yearOnly?`<div class="cs-year">${yearOnly}</div>`:'<span></span>'}
|
||
</div>
|
||
<div class="cs-mid">
|
||
<div class="cs-hero-logo">${brandLogoHero}</div>
|
||
<div class="cs-ornament">
|
||
<span class="cs-ornament-line"></span>
|
||
<span class="cs-ornament-dot"></span>
|
||
<span class="cs-ornament-line"></span>
|
||
</div>
|
||
<div class="cs-eyebrow">Catálogo</div>
|
||
<h1 class="cs-title">${esc(c.nombre)}</h1>
|
||
<div class="cs-tagline">Bolsas, accesorios y piezas personalizadas para experiencias únicas en Los Cabos.</div>
|
||
${c.cliente_nombre?`
|
||
<div class="cs-divider"></div>
|
||
<div class="cs-cliente-label">Preparado para</div>
|
||
<div class="cs-cliente">${esc(c.cliente_nombre)}</div>
|
||
`:''}
|
||
</div>
|
||
<div class="cs-bot">
|
||
${fechaFmt?`<div class="cs-fecha">${esc(fechaFmt)}</div>`:''}
|
||
<div class="cs-tagline-foot">Supplies & Custom Projects · Los Cabos</div>
|
||
</div>
|
||
</div>
|
||
</div>`);
|
||
|
||
// Pie de términos comerciales — se repite en cada página de productos
|
||
const termsParts=[];
|
||
if(c.entrega) termsParts.push(`<b>Entrega:</b> ${esc(c.entrega)}`);
|
||
if(c.minimo_compra) termsParts.push(`<b>Mínimo:</b> ${esc(c.minimo_compra)}`);
|
||
const termsLine=termsParts.join(' · ');
|
||
const termsExtra=c.terminos?`<div class="cat-foot-terminos">${esc(c.terminos).replace(/\n/g,' · ')}</div>`:'';
|
||
const showContacto = c.show_contacto!==0;
|
||
const footerHtml=`<div class="cat-slide-foot">
|
||
<div class="cat-foot-terms">${termsLine}</div>
|
||
${showContacto?'<div class="cat-foot-contact">ventas@art4hotel.com · 624 117 0675 · art4hotel.com</div>':''}
|
||
</div>${termsExtra}`;
|
||
|
||
// Productos en grid de 2 por slide
|
||
for(let i=0;i<c.items.length;i+=2){
|
||
const pair=c.items.slice(i,i+2);
|
||
const productHtml=p=>{
|
||
const img=p.foto_url?`<img class="cat-product-img" src="${p.foto_url}" loading="eager">`:'<div class="cat-product-img-empty">📦</div>';
|
||
const persHtml=(p.personalizaciones||[]).length?`<div class="cp-pers">
|
||
<span class="cp-pers-label">Personalizaciones disponibles</span>
|
||
<span class="cp-pers-list">${p.personalizaciones.map(esc).join(' · ')}</span>
|
||
</div>`:'';
|
||
const precioHtml=c.show_prices&&p.precio_unit?`<div class="cp-price">${fmt$(p.precio_unit)}/pza</div>`:'';
|
||
const leadHtml='';
|
||
// Recalcular clientes desde datos vivos para que catálogos viejos también queden filtrados
|
||
const clientesVivos=catClientesQueHanPedido(p.nombre,(p.personalizaciones||[])[0]||'');
|
||
const clientesList=clientesVivos.length?clientesVivos:(p.clientes_vendido||[]);
|
||
const clientesHtml=c.show_clientes&&clientesList.length?`<div class="cp-clientes">
|
||
<span class="cp-clientes-label">Proyectos de este modelo en</span>
|
||
<span class="cp-clientes-list">${clientesList.slice(0,6).map(esc).join(' · ')}${clientesList.length>6?' · …':''}</span>
|
||
</div>`:'';
|
||
return`<div class="cat-product">
|
||
${img}
|
||
<div class="cat-product-info">
|
||
<h3>${esc(p.nombre)}</h3>
|
||
${p.descripcion?`<div class="cp-desc">${esc(p.descripcion)}</div>`:''}
|
||
${persHtml}
|
||
${precioHtml}
|
||
${leadHtml}
|
||
${clientesHtml}
|
||
</div>
|
||
</div>`;
|
||
};
|
||
slides.push(`<div class="cat-slide">
|
||
<div class="cat-grid">
|
||
${pair.map(productHtml).join('')}
|
||
${pair.length<2?'<div></div>':''}
|
||
</div>
|
||
${footerHtml}
|
||
</div>`);
|
||
}
|
||
|
||
$('cat-pv-body').innerHTML=`<div class="cat-slides">${slides.join('')}</div>`;
|
||
openMo('mo-catalogo-preview');
|
||
}
|
||
|
||
function catPrintCatalog(){
|
||
// Marca el body para activar las reglas @media print del catálogo
|
||
document.body.classList.add('cat-printing');
|
||
// Esperar un tick para que el navegador aplique el estilo antes de imprimir
|
||
requestAnimationFrame(()=>{
|
||
window.print();
|
||
// Limpiar después de imprimir (Chrome/Safari devuelven control después del diálogo)
|
||
setTimeout(()=>document.body.classList.remove('cat-printing'),300);
|
||
});
|
||
}
|
||
|
||
// ══════ MANUAL — scrollspy + smooth scroll ══════
|
||
(function initManual(){
|
||
const toc=document.querySelector('.manual-toc');
|
||
if(!toc) return;
|
||
const content=document.querySelector('.manual-content');
|
||
// Smooth scroll en clicks del TOC
|
||
toc.addEventListener('click',e=>{
|
||
const a=e.target.closest('a[href^="#"]');
|
||
if(!a) return;
|
||
e.preventDefault();
|
||
const id=a.getAttribute('href').slice(1);
|
||
const sec=document.getElementById(id);
|
||
if(sec) sec.scrollIntoView({behavior:'smooth',block:'start'});
|
||
});
|
||
// Scrollspy: marca la sección visible como activa
|
||
const sections=Array.from(content.querySelectorAll('section[id]'));
|
||
const links=Object.fromEntries(Array.from(toc.querySelectorAll('a')).map(a=>[a.getAttribute('href').slice(1),a]));
|
||
const obs=new IntersectionObserver(entries=>{
|
||
entries.forEach(en=>{
|
||
if(en.isIntersecting){
|
||
Object.values(links).forEach(l=>l.classList.remove('active'));
|
||
const l=links[en.target.id];
|
||
if(l) l.classList.add('active');
|
||
}
|
||
});
|
||
},{rootMargin:'-30% 0px -60% 0px',threshold:0});
|
||
sections.forEach(s=>obs.observe(s));
|
||
})();
|
||
|
||
// ══════ INIT ══════
|
||
loadOrdenes();
|
||
loadFileCounts();
|
||
</script>
|
||
</body>
|
||
</html>
|