Files
art4hotel-hub/index.html
consultoria-as c2ae140078 Art4Hotel Hub: código + documentación extensiva
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>
2026-06-09 00:10:07 -07:00

9772 lines
555 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.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')">&times;</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')">&times;</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>&gt;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>&gt;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()">&times;</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')">&times;</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')">&times;</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')">&times;</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()">&#10003; 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')">&times;</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')">&times;</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">&#9998;</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">&#10003; Registrar y cerrar</button>
<button class="mo-sub" onclick="guardarHistorico(true)" style="flex:1;background:var(--olive-dark)">&#10003; 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')">&times;</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()">&#10003; 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')">&times;</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')">&times;</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()">&#10003; 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')">&times;</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')">&times;</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()">&#10003; 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')">&times;</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">&#128203; Recibo</button>
<button class="doc-btn" onclick="entregaUploadTipo('foto_producto')" id="ent-btn-foto">&#128247; Fotos</button>
<button class="doc-btn" onclick="entregaUploadTipo('factura')" id="ent-btn-factura">&#128196; 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()">&#10003; 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')">&times;</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')">&times;</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')">&times;</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})">&#9998; Editar producto</button>
<button class="btn-sec" onclick="openProductFiles('${esc(key)}','${esc(p.nombre)}')" title="Fotos / archivos">&#128247;</button>
<button class="btn-sec danger" onclick="closeMo('mo-producto-view');delItem('productos',${p.id})" title="Eliminar">&#128465;</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')">&times;</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()">&#10003; Guardar</button>
<button class="btn" onclick="openProductFiles('${esc(productEntityKey(p))}','${esc(p.nombre)}')" style="border-color:var(--olive);color:var(--olive)">&#128247; 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">&#9664;</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">&#9654;</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">&#9998;</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">&#128666; 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})">&#10003; 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?'&#9989;':'&#128196;'} 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?'&#9989;':'&#128203;'} Recibo ${recibos.length?'('+recibos.length+')':''}
</button>`:''}
<button class="doc-btn ${fotos.length?'has-file':''}" onclick="openFilesWithTipo('${o.orden_id}','foto_producto')">
${fotos.length?'&#9989;':'&#128247;'} Fotos ${fotos.length?'('+fotos.length+')':''}
</button>
<button class="doc-btn" onclick="toggleEntregaCostos('${cardId}')" style="margin-left:auto">&#9998; 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">&#9998;</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')">&#215;</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">&#9989; 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">&#129534; 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>&#128230; 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">&#128308;</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})">&#9998;</button>
<button class="kc-btn" onclick="event.stopPropagation();delItem('ordenes',${item.id})">&#215;</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">&#128279; ${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">&#128279; ${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})">&#10003; 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})">&#128230; 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})">&#128257; 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">&#9998;</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)">&#128200; 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)">&#9888; 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)">&#10003; Todo bien</div>'}
</div>
</div>
<div class="crd">
<div class="crd-h" style="color:var(--t3)">&#128564; 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 &#128588;</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">&#9998;</button>
<button class="kc-btn" onclick="delItem('productos',${p.id})" title="Eliminar">&times;</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">&#9998;</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')">&times;</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})">&#9998;</button>
<button class="kc-btn" onclick="delItem('tareas',${item.id})">&#215;</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>&#128279; 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})">&times;</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})">&times;</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')">&times;</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')">&#128247; ${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})">&#128230; Recoger</button>`;
}
if(o.stage==='En Vehiculo'){
primaryActions+=`<button class="btn btn-ac" onclick="closeMo('mo-quickview');openEntregaModal(${o.id})">&#10003; Entregar</button>`;
}
primaryActions+=`<button class="btn btn-ac" onclick="closeMo('mo-quickview');editItem('ordenes',${o.id})">&#9998; Editar</button>`;
// Secundarias: archivos, logo, duplicar, vínculos
secondaryActions+=`<button class="btn" onclick="closeMo('mo-quickview');openFilesWithTipo('${o.orden_id}','soporte_trabajo')">&#128193; 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">&#127912; Logo cliente</button>`;
}
}
secondaryActions+=`<button class="btn" onclick="closeMo('mo-quickview');duplicarOrden(${o.id})">&#128203; 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})">&#128190; 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})">&#128279; 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">&#128465; 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)">&#128279; 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')">&times;</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">&#128465;</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})">&#10003; Guardar</button>
<button class="btn" onclick="openFilesWithTipo('${esc(oc.oc_id)}','factura')">&#128193; 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">&#9994; Parcial</button>`:''}
<button class="btn" style="border-color:var(--rd);color:var(--rd);margin-left:auto" onclick="deleteOrdenDetail(${oc.id})">&#128465;</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')">&times;</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()">&#10003; 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">&#128465; 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')">&times;</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">&#215;</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()">&#10003; 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')">&times;</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})">&#9998; Editar</button>
<button class="btn" onclick="openFiles('proy-${p.id}')">&#128206; 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')">&times;</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}')">&#128206; 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()">&#10003; Guardar</button>
${p.id?`<button class="btn" style="border-color:var(--rd);color:var(--rd);margin-left:auto" onclick="deleteProyecto()">&#128465;</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')">&times;</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">&#215;</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()">&#10003; 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})">&#128279; Ver Orden ${esc(existingOc.oc_id)}</button>`
:`<button class="btn btn-ac" style="background:var(--bl);border-color:var(--bl)" onclick="convertirPropuestaAOrden()">&#10140; Convertir a Orden</button>`;
})():''}
${p.id?`<button class="btn" style="border-color:var(--rd);color:var(--rd);margin-left:auto" onclick="deletePropuesta()">&#128465;</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})">&#9998; 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">&#128206;</button>
<button class="kc-btn edit" onclick="event.stopPropagation();openOrdenDetail(${oc.id})" title="Ver Orden">&#128065;</button>
<button class="kc-btn" onclick="event.stopPropagation();delItem('oc',${oc.id})" title="Eliminar Orden">&#215;</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">&#128065;</button>
<button class="oc-link-btn" onclick="unlinkFromOC(${l.id})" title="Desvincular" style="border-color:var(--rd);color:var(--rd);background:var(--rdd)">&#10005;</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">&#128065;</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>&nbsp;</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')">&times;</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?'&#128308;':''}</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}')">&#128206; ${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')">&#215;</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}')">&#215;</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')">&times;</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()">&#10003; 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()">&#128465;</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">&times;</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 &amp; 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 &amp; 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('&nbsp;&nbsp;·&nbsp;&nbsp;');
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>