Cambios implementados: 1. Lazy loading de imágenes: - catalog.js: loading="lazy" decoding="async" en part cards y detail panel - inventory.js: lazy loading en imagen de detalle de item 2. Minificación de assets: - scripts/minify-assets.sh: minifica JS (terser) y CSS para POS y Dashboard - 25 archivos .min.js + 5 .min.css generados en pos/static/ - 14 archivos .min.js + 8 .min.css generados en dashboard/ 3. Nginx auto-serve minified: - try_files $1.min.js antes de servir .js original - try_files $1.min.css antes de servir .css original - Transparente para los templates HTML (cero cambios en HTML) 4. Cache warming script: - scripts/warm_vehicle_cache.py: pobla Redis con vehicle info por batches - Mitiga DISTINCT ON + 4 JOINs sobre 2B filas - Corre en background, procesa ~1.5M parts Tests: 73/73 pasando
1 line
25 KiB
JavaScript
1 line
25 KiB
JavaScript
const Customers=(()=>{let e=localStorage.getItem("pos_token")||"",t=1,n=1,o=null,a=null;const i=e=>"$"+parseFloat(e||0).toLocaleString("es-MX",{minimumFractionDigits:2,maximumFractionDigits:2});async function l(t,n={}){n.headers={"Content-Type":"application/json",Authorization:"Bearer "+e};const o=await fetch(t,n);if(401===o.status)return void(window.location.href="/pos/login");const a=await o.json();if(!o.ok)throw new Error(a.error||`HTTP ${o.status}`);return a}const d={1:"Mostrador",2:"Taller",3:"Mayoreo"},r={1:"mostrador",2:"taller",3:"mayoreo"};function s(e){return e.credit_balance>0&&e.credit_limit>0&&e.credit_balance>e.credit_limit?'<span class="badge badge--warning"><span class="badge-dot"></span>Mora</span>':'<span class="badge badge--active"><span class="badge-dot"></span>Activo</span>'}function c(e){if(!e)return"--";const t=e.trim().split(/\s+/);return t.length>=2?(t[0][0]+t[1][0]).toUpperCase():e.substring(0,2).toUpperCase()}function m(e){if(!e)return"-";try{return new Date(e).toLocaleDateString("es-MX",{day:"2-digit",month:"short",year:"numeric"})}catch(t){return e}}async function p(e,a){e=e||t;const c=document.getElementById("searchInput");a=void 0!==a?a:c&&c.value||"";try{const c=new URLSearchParams({page:e,per_page:50});a&&c.append("q",a);const p=await l(`/pos/api/customers?${c}`);!function(e){const t=document.getElementById("customersBody");if(!t)return;if(t.innerHTML="",!e||0===e.length)return void(t.innerHTML='<tr><td colspan="9" style="text-align:center;padding:var(--space-8);color:var(--color-text-muted);">Sin resultados.</td></tr>');e.forEach(((e,n)=>{const a=d[e.price_tier]||"Mostrador",l=r[e.price_tier]||"mostrador",c=parseFloat(e.credit_limit||0),p=parseFloat(e.credit_balance||0),u=Math.max(0,c-p),v=c>0?Math.round(p/c*100):0,f=v>=80?"none":v>=60?"low":"",y=String(e.id).padStart(5,"0"),b=document.createElement("tr");o&&o.id===e.id&&(b.className="selected"),b.onclick=()=>g(e.id),b.innerHTML=`\n <td class="cell-num">${y}</td>\n <td>\n <div class="cell-name">${e.name||""}</div>\n <div class="cell-name-sub hide-mobile">${e.email||""}</div>\n </td>\n <td class="cell-rfc hide-mobile">${e.rfc||"-"}</td>\n <td class="hide-mobile">${e.phone||"-"}</td>\n <td class="hide-mobile" style="font-size:var(--text-caption);color:var(--color-text-secondary);">${e.email||"-"}</td>\n <td><span class="tipo-chip tipo-chip--${l}">${a}</span></td>\n <td class="cell-credit ${f}">${i(u)}</td>\n <td class="cell-date hide-mobile">${m(e.last_purchase||e.created_at)}</td>\n <td>${s(e)}</td>\n `,t.appendChild(b)}))}(p.data||[]),function(e){const o=document.querySelector(".pagination"),a=document.getElementById("tableInfo");if(!e||!e.total_pages)return o&&(o.innerHTML=""),void(a&&(a.textContent=""));n=e.total_pages,t=e.page;const i=e.total||0,l=e.per_page||50,d=(e.page-1)*l+1,r=Math.min(e.page*l,i);a&&(a.textContent=`Mostrando ${d}–${r} de ${i.toLocaleString("es-MX")} clientes`);if(!o)return;if(n<=1)return void(o.innerHTML="");let s="";s+=`<button class="page-btn" ${e.page<=1?"disabled":""} onclick="Customers.goToPage(${e.page-1})">‹</button>`;const c=7;let m=Math.max(1,e.page-3),p=Math.min(n,m+c-1);p-m<c-1&&(m=Math.max(1,p-c+1));for(let t=m;t<=p;t++)s+=`<button class="page-btn ${t===e.page?"active":""}" onclick="Customers.goToPage(${t})">${t}</button>`;p<n&&(s+='<span style="color:var(--color-text-muted);font-size:var(--text-caption);padding:0 4px;">...</span>',s+=`<button class="page-btn" onclick="Customers.goToPage(${n})">${n}</button>`);s+=`<button class="page-btn" ${e.page>=n?"disabled":""} onclick="Customers.goToPage(${e.page+1})">›</button>`,o.innerHTML=s}(p.pagination||{})}catch(e){console.error("Load customers failed:",e)}}function u(){clearTimeout(a),a=setTimeout((()=>{t=1,p(1)}),300)}async function g(e){try{const n=await l(`/pos/api/customers/${e}`);o=n;const a=document.getElementById("detailEmpty"),u=document.getElementById("detailContent");a&&(a.style.display="none"),u&&(u.style.display="flex");const g=document.getElementById("detailAvatar");g&&(g.textContent=c(n.name));const v=document.getElementById("detailName");v&&(v.textContent=(n.name||"").toUpperCase());const f=document.getElementById("detailRFC");f&&(f.textContent=n.rfc||"-");const y=document.getElementById("detailTipo");if(y){const e=d[n.price_tier]||"Mostrador",t=r[n.price_tier]||"mostrador";y.textContent=e,y.className=`tipo-chip tipo-chip--${t}`}const b=document.getElementById("detailStatus");b&&(b.innerHTML=s(n));const h=(e,t)=>{const n=document.getElementById(e);n&&(n.textContent=t||"-")};h("detailAddress",n.address),h("detailPhone",n.phone),h("detailEmail",n.email),h("detailSince",m(n.created_at)),h("detailLastPurchase",m(n.last_purchase));const x=parseFloat(n.credit_limit||0),E=parseFloat(n.credit_balance||0),C=Math.max(0,x-E),I=x>0?Math.round(E/x*100):0;h("detailCreditLimit",i(x)),h("detailCreditAvail",i(C)),h("detailCreditUsed",i(E)),h("detailCreditPct",I+"%");const B=document.getElementById("detailCreditBar");B&&(B.style.width=I+"%",B.className="progress-bar__fill "+(I<40?"low":I>75?"high":""));const M=document.getElementById("historyBody");if(M){const e=n.recent_purchases||[];0===e.length?M.innerHTML='<tr><td colspan="4" style="text-align:center;color:var(--color-text-muted);padding:var(--space-4);">Sin compras recientes</td></tr>':(M.innerHTML="",e.forEach((e=>{const t="paid"===e.status?"mbadge--paid":"overdue"===e.status?"mbadge--overdue":"mbadge--pending",n="paid"===e.status?"Pagado":"overdue"===e.status?"Vencido":"Pendiente",o=document.createElement("tr");o.innerHTML=`\n <td class="date">${m(e.created_at)}</td>\n <td class="folio">NX-${String(e.id).padStart(5,"0")}</td>\n <td class="total">${i(e.total)}</td>\n <td><span class="mbadge ${t}">${n}</span></td>\n `,M.appendChild(o)})))}!function(e){const t=(e,t)=>{const n=document.getElementById(e);n&&(n.textContent=t||"--")};t("panelAvatar",c(e.name)),t("panelName",e.name),t("panelRfc","RFC: "+(e.rfc||"-"));const n=parseFloat(e.credit_limit||0),o=parseFloat(e.credit_balance||0),a=Math.max(0,n-o),l=n>0?Math.round(o/n*100):0;t("panelCreditLimit",i(n)),t("panelCreditUsed",i(o)),t("panelCreditAvail",i(a));const d=document.getElementById("panelCreditBar");d&&(d.style.width=l+"%");const r=document.getElementById("panelVehicles");if(r){const t=e.vehicle_info||[];0===t.length?r.innerHTML='<span style="color:var(--color-text-muted);font-size:var(--text-body-sm);">Sin vehiculos registrados</span>':r.innerHTML=t.map((e=>`<div style="padding:4px 0;border-bottom:1px solid var(--color-border);">\n <strong>${e.make||""} ${e.model||""} ${e.year||""}</strong>\n ${e.plates?` <span style="color:var(--color-text-muted);">Placas: ${e.plates}</span>`:""}\n </div>`)).join("")}const s=document.getElementById("panelPurchases");if(s){const t=e.recent_purchases||[];0===t.length?s.innerHTML='<span style="color:var(--color-text-muted);font-size:var(--text-body-sm);">Sin compras recientes</span>':s.innerHTML=t.slice(0,5).map((e=>`<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--color-border);font-size:var(--text-body-sm);">\n <span>NX-${String(e.id).padStart(5,"0")} — ${m(e.created_at)}</span>\n <span>${i(e.total)}</span>\n </div>`)).join("")}}(n),p(t)}catch(e){console.error("Error loading customer:",e)}}function v(){const e=document.getElementById("customerModal");e&&(document.getElementById("modalTitle").textContent="Nuevo Cliente",document.getElementById("editId").value="",document.getElementById("fName").value="",document.getElementById("fRfc").value="",document.getElementById("fRazonSocial").value="",document.getElementById("fRegimenFiscal").value="",document.getElementById("fUsoCfdi").value="G03",document.getElementById("fCp").value="",document.getElementById("fPhone").value="",document.getElementById("fEmail").value="",document.getElementById("fAddress").value="",document.getElementById("fPriceTier").value="1",document.getElementById("fCreditLimit").value="0",e.classList.add("active"),document.getElementById("fName").focus())}function f(){if(!o)return;const e=o,t=document.getElementById("customerModal");t&&(document.getElementById("modalTitle").textContent="Editar Cliente",document.getElementById("editId").value=e.id,document.getElementById("fName").value=e.name||"",document.getElementById("fRfc").value=e.rfc||"",document.getElementById("fRazonSocial").value=e.razon_social||"",document.getElementById("fRegimenFiscal").value=e.regimen_fiscal||"",document.getElementById("fUsoCfdi").value=e.uso_cfdi||"G03",document.getElementById("fCp").value=e.cp||"",document.getElementById("fPhone").value=e.phone||"",document.getElementById("fEmail").value=e.email||"",document.getElementById("fAddress").value=e.address||"",document.getElementById("fPriceTier").value=e.price_tier||"1",document.getElementById("fCreditLimit").value=e.credit_limit||0,t.classList.add("active"))}function y(){const e=document.getElementById("customerModal");e&&e.classList.remove("active")}async function b(){if(!o)return;const e=document.getElementById("statementModal");if(!e)return;const t=document.getElementById("statementName");t&&(t.textContent=o.name);const n=document.getElementById("statementContent");n&&(n.innerHTML='<div style="text-align:center;padding:20px;color:var(--color-text-muted);">Cargando...</div>'),e.classList.add("active");try{const e=await l(`/pos/api/customers/${o.id}/statement`);let t=`<div style="margin-bottom:12px;font-size:14px;">\n <strong>Saldo actual: ${i(e.balance)}</strong> |\n Limite: ${i(e.customer?e.customer.credit_limit:0)}\n </div>`;e.entries&&0!==e.entries.length?(t+='<table style="width:100%;border-collapse:collapse;font-size:13px;">',t+='<tr style="background:var(--color-surface-2);"><th style="padding:8px;text-align:left;">Fecha</th><th style="text-align:left;">Concepto</th><th style="text-align:right;padding:8px;">Cargo</th><th style="text-align:right;padding:8px;">Abono</th><th style="text-align:right;padding:8px;">Saldo</th></tr>',e.entries.forEach((e=>{t+=`<tr style="border-bottom:1px solid var(--color-border);">\n <td style="padding:6px 8px;">${m(e.date)}</td>\n <td>${e.description||""}</td>\n <td style="text-align:right;padding:6px 8px;">${"charge"===e.type?i(e.amount):""}</td>\n <td style="text-align:right;padding:6px 8px;">${"payment"===e.type?i(e.amount):""}</td>\n <td style="text-align:right;padding:6px 8px;">${i(e.running_balance)}</td>\n </tr>`})),t+="</table>"):t+='<div style="color:var(--color-text-muted);padding:20px;text-align:center;">Sin movimientos</div>',n&&(n.innerHTML=t)}catch(e){n&&(n.innerHTML=`<div style="color:var(--color-error);padding:20px;text-align:center;">Error: ${e.message}</div>`)}}function h(){const e=document.getElementById("paymentModal");e&&e.classList.remove("active")}function x(e){g(e),"function"==typeof openSlidePanel&&openSlidePanel()}return window.filterCustomers=function(){u()},e?(window.openNewCustomerModal=v,function(){const e=document.querySelectorAll(".quick-actions .action-btn");e.length>=1&&(e[0].onclick=()=>{o&&(window.location.href="/pos/?customer="+o.id)}),e.length>=2&&(e[1].onclick=()=>f()),e.length>=3&&(e[2].onclick=()=>b()),e.length>=4&&(e[3].onclick=()=>{o&&g(o.id)})}(),window.viewCustomer=x,function(){if(!document.getElementById("customerModal")){const e=document.createElement("div");e.innerHTML='\n <div id="customerModal" class="modal-overlay" style="display:none;">\n <div class="modal-box">\n <div class="modal-header">\n <h3 id="modalTitle">Nuevo Cliente</h3>\n <button class="btn-close" onclick="Customers.closeModal()">×</button>\n </div>\n <div class="modal-body">\n <input type="hidden" id="editId" />\n <div class="form-row">\n <div class="form-group"><label>Nombre *</label><input type="text" id="fName" class="form-input" placeholder="Nombre del cliente" /></div>\n <div class="form-group"><label>RFC</label><input type="text" id="fRfc" class="form-input" placeholder="RFC" maxlength="13" /></div>\n </div>\n <div class="form-row">\n <div class="form-group"><label>Razon Social</label><input type="text" id="fRazonSocial" class="form-input" placeholder="Razon Social" /></div>\n <div class="form-group"><label>Regimen Fiscal</label>\n <select id="fRegimenFiscal" class="form-input">\n <option value="">-- Seleccionar --</option>\n <option value="601">601 - General de Ley PM</option>\n <option value="603">603 - Personas Morales Fines No Lucrativos</option>\n <option value="605">605 - Sueldos y Salarios</option>\n <option value="606">606 - Arrendamiento</option>\n <option value="608">608 - Demas Ingresos</option>\n <option value="612">612 - Personas Fisicas Empresariales</option>\n <option value="616">616 - Sin Obligaciones Fiscales</option>\n <option value="621">621 - Incorporacion Fiscal</option>\n <option value="625">625 - RESICO</option>\n <option value="626">626 - RESICO PM</option>\n </select>\n </div>\n </div>\n <div class="form-row">\n <div class="form-group"><label>Uso CFDI</label>\n <select id="fUsoCfdi" class="form-input">\n <option value="G01">G01 - Adquisicion de mercancias</option>\n <option value="G03" selected>G03 - Gastos en general</option>\n <option value="I01">I01 - Construcciones</option>\n <option value="I08">I08 - Otra maquinaria</option>\n <option value="P01">P01 - Por definir</option>\n <option value="S01">S01 - Sin efectos fiscales</option>\n </select>\n </div>\n <div class="form-group"><label>Codigo Postal</label><input type="text" id="fCp" class="form-input" placeholder="C.P." maxlength="5" /></div>\n </div>\n <div class="form-row">\n <div class="form-group"><label>Telefono</label><input type="text" id="fPhone" class="form-input" placeholder="Telefono" /></div>\n <div class="form-group"><label>Email</label><input type="email" id="fEmail" class="form-input" placeholder="correo@ejemplo.com" /></div>\n </div>\n <div class="form-group"><label>Direccion</label><input type="text" id="fAddress" class="form-input" placeholder="Direccion" /></div>\n <div class="form-row">\n <div class="form-group"><label>Tipo Precio</label>\n <select id="fPriceTier" class="form-input">\n <option value="1">Mostrador</option>\n <option value="2">Taller</option>\n <option value="3">Mayoreo</option>\n </select>\n </div>\n <div class="form-group"><label>Limite de Credito</label><input type="number" id="fCreditLimit" class="form-input" value="0" min="0" step="1000" /></div>\n </div>\n </div>\n <div class="modal-footer">\n <button class="btn btn-secondary" onclick="Customers.closeModal()">Cancelar</button>\n <button class="btn btn-primary" onclick="Customers.save()">Guardar</button>\n </div>\n </div>\n </div>',document.body.appendChild(e)}if(!document.getElementById("statementModal")){const e=document.createElement("div");e.innerHTML='\n <div id="statementModal" class="modal-overlay" style="display:none;">\n <div class="modal-box modal-box--wide">\n <div class="modal-header">\n <h3>Estado de Cuenta — <span id="statementName"></span></h3>\n <button class="btn-close" onclick="Customers.closeStatement()">×</button>\n </div>\n <div class="modal-body" id="statementContent" style="max-height:60vh;overflow-y:auto;">\n </div>\n <div class="modal-footer">\n <button class="btn btn-primary" onclick="Customers.showPaymentModal()">Registrar Abono</button>\n <button class="btn btn-secondary" onclick="Customers.closeStatement()">Cerrar</button>\n </div>\n </div>\n </div>',document.body.appendChild(e)}if(!document.getElementById("paymentModal")){const e=document.createElement("div");e.innerHTML='\n <div id="paymentModal" class="modal-overlay" style="display:none;">\n <div class="modal-box">\n <div class="modal-header">\n <h3>Registrar Abono</h3>\n <button class="btn-close" onclick="Customers.closePayment()">×</button>\n </div>\n <div class="modal-body">\n <p style="margin-bottom:var(--space-4);color:var(--color-text-secondary);">Cliente: <strong id="paymentCustomerName"></strong></p>\n <div class="form-row">\n <div class="form-group"><label>Monto</label><input type="number" id="paymentAmount" class="form-input" placeholder="0.00" min="0" step="0.01" /></div>\n <div class="form-group"><label>Metodo</label>\n <select id="paymentMethod" class="form-input">\n <option value="cash">Efectivo</option>\n <option value="transfer">Transferencia</option>\n <option value="card">Tarjeta</option>\n <option value="check">Cheque</option>\n </select>\n </div>\n </div>\n <div class="form-group"><label>Referencia</label><input type="text" id="paymentRef" class="form-input" placeholder="Num. referencia (opcional)" /></div>\n </div>\n <div class="modal-footer">\n <button class="btn btn-secondary" onclick="Customers.closePayment()">Cancelar</button>\n <button class="btn btn-primary" onclick="Customers.recordPayment()">Registrar</button>\n </div>\n </div>\n </div>',document.body.appendChild(e)}if(!document.getElementById("modalStyles")){const e=document.createElement("style");e.id="modalStyles",e.textContent="\n .modal-overlay {\n position: fixed; top: 0; left: 0; right: 0; bottom: 0;\n background: rgba(0,0,0,0.5); z-index: 9000;\n display: flex; align-items: center; justify-content: center;\n backdrop-filter: blur(2px);\n }\n .modal-overlay.active { display: flex !important; }\n .modal-box {\n background: var(--color-bg-elevated); border: 1px solid var(--color-border);\n border-radius: var(--radius-lg); width: 90%; max-width: 600px;\n max-height: 85vh; overflow-y: auto;\n box-shadow: var(--shadow-xl);\n }\n .modal-box--wide { max-width: 800px; }\n .modal-header {\n display: flex; align-items: center; justify-content: space-between;\n padding: var(--space-4) var(--space-5);\n border-bottom: 1px solid var(--color-border);\n }\n .modal-header h3 {\n font-family: var(--font-heading); font-size: var(--text-h6);\n font-weight: var(--heading-weight-primary); color: var(--color-text-primary);\n letter-spacing: var(--tracking-wide); text-transform: uppercase;\n }\n .modal-body { padding: var(--space-5); }\n .modal-footer {\n display: flex; justify-content: flex-end; gap: var(--space-3);\n padding: var(--space-4) var(--space-5);\n border-top: 1px solid var(--color-border);\n }\n .form-row { display: flex; gap: var(--space-4); margin-bottom: var(--space-4); }\n .form-row > .form-group { flex: 1; }\n .form-group { margin-bottom: var(--space-4); }\n .form-group label {\n display: block; font-size: var(--text-caption); font-weight: var(--font-weight-semibold);\n color: var(--color-text-muted); text-transform: uppercase; letter-spacing: var(--tracking-wider);\n margin-bottom: var(--space-1);\n }\n .form-input {\n width: 100%; height: 38px; padding: 0 var(--space-3);\n background: var(--color-bg-overlay); border: 1.5px solid var(--color-border);\n border-radius: var(--radius-md); font-family: var(--font-body);\n font-size: var(--text-body-sm); color: var(--color-text-primary); outline: none;\n transition: var(--transition-fast);\n }\n .form-input:focus { border-color: var(--color-border-focus); box-shadow: var(--shadow-focus); }\n select.form-input { cursor: pointer; }\n ",document.head.appendChild(e)}}(),p(),async function(){try{const e=await l("/pos/api/customers?page=1&per_page=1"),t=e.pagination?e.pagination.total:0,n=document.querySelectorAll(".summary-card__value");n.length>=1&&(n[0].textContent=t.toLocaleString("es-MX"))}catch(e){}}()):window.location.href="/pos/login",{search:u,goToPage:function(e){e<1||e>n||(t=e,p(e))},loadCustomers:p,showDetail:x,selectCustomer:g,closeDetail:function(){o=null;const e=document.getElementById("detailEmpty"),n=document.getElementById("detailContent");e&&(e.style.display="flex"),n&&(n.style.display="none"),p(t)},showCreateModal:v,editCurrent:f,closeModal:y,save:async function(){const e=document.getElementById("fName"),t=e?e.value.trim():"";if(!t)return void alert("Nombre es requerido");const n=e=>{const t=document.getElementById(e);return t?t.value.trim():""},a={name:t,rfc:n("fRfc")||null,razon_social:n("fRazonSocial")||null,regimen_fiscal:n("fRegimenFiscal")||null,uso_cfdi:n("fUsoCfdi")||"G03",cp:n("fCp")||null,phone:n("fPhone")||null,email:n("fEmail")||null,address:n("fAddress")||null,price_tier:parseInt(n("fPriceTier"))||1,credit_limit:parseFloat(n("fCreditLimit"))||0},i=n("editId");try{i?await l(`/pos/api/customers/${i}`,{method:"PUT",body:JSON.stringify(a)}):await l("/pos/api/customers",{method:"POST",body:JSON.stringify(a)}),y(),p(),i&&o&&g(i)}catch(e){alert("Error: "+e.message)}},showStatement:b,closeStatement:function(){const e=document.getElementById("statementModal");e&&e.classList.remove("active")},showPaymentModal:function(){if(!o)return;const e=document.getElementById("paymentModal");e&&(document.getElementById("paymentCustomerName").textContent=o.name,document.getElementById("paymentAmount").value="",document.getElementById("paymentMethod").value="cash",document.getElementById("paymentRef").value="",e.classList.add("active"),document.getElementById("paymentAmount").focus())},closePayment:h,recordPayment:async function(){if(!o)return;const e=parseFloat(document.getElementById("paymentAmount").value);if(!e||e<=0)return void alert("Ingresa un monto valido");const t=document.getElementById("paymentMethod").value,n=document.getElementById("paymentRef").value.trim();try{await l(`/pos/api/customers/${o.id}/payment`,{method:"POST",body:JSON.stringify({amount:e,method:t,reference:n})}),h(),g(o.id)}catch(e){alert("Error: "+e.message)}}}})(); |