// /home/Autopartes/pos/static/js/catalog.js // Catalog UI: TecDoc vehicle navigation with local stock enrichment, cart, detail panel. (function () { 'use strict'; var API = '/pos/api/catalog'; var token = localStorage.getItem('pos_token'); if (!token) { window.location.href = '/pos/login'; return; } var headers = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }; // ─── DOM refs ─── var breadcrumb = document.getElementById('breadcrumb'); var searchInput = document.getElementById('searchInput'); var searchDropdown = document.getElementById('searchDropdown'); var levelTitle = document.getElementById('levelTitle'); var levelFilter = document.getElementById('levelFilter'); var loading = document.getElementById('loading'); var emptyState = document.getElementById('emptyState'); var emptyTitle = document.getElementById('emptyTitle'); var emptySubtitle = document.getElementById('emptySubtitle'); var navGrid = document.getElementById('navGrid'); var partsGrid = document.getElementById('partsGrid'); var paginationNav = document.getElementById('pagination'); var pageBody = document.getElementById('pageBody'); // Detail panel var detailPanel = document.getElementById('detailPanel'); var detailOverlay = document.getElementById('detailOverlay'); var detailBody = document.getElementById('detailBody'); var detailFooter = document.getElementById('detailFooter'); var detailClose = document.getElementById('detailClose'); var qtyMinus = document.getElementById('qtyMinus'); var qtyPlus = document.getElementById('qtyPlus'); var qtyDisplay = document.getElementById('qtyDisplay'); var addToCartBtn = document.getElementById('addToCartBtn'); // Cart var cartSidebar = document.getElementById('cartSidebar'); var cartOverlay = document.getElementById('cartOverlay'); var cartItemsEl = document.getElementById('cartItems'); var cartEmptyEl = document.getElementById('cartEmpty'); var cartSubtotalEl= document.getElementById('cartSubtotal'); var cartTaxEl = document.getElementById('cartTax'); var cartTotalEl = document.getElementById('cartTotal'); var cartBadge = document.getElementById('cartBadge'); var checkoutBtn = document.getElementById('checkoutBtn'); var cartFab = document.getElementById('cartFab'); var cartCloseBtn = document.getElementById('cartCloseBtn'); // Supplier prices upload var uploadPricesBtn = document.getElementById('uploadPricesBtn'); var uploadPricesModal= document.getElementById('uploadPricesModal'); var uploadPricesFile = document.getElementById('uploadPricesFile'); var uploadPricesStatus=document.getElementById('uploadPricesStatus'); // ─── Navigation State ─── var nav = { level: 'brands', // brands|models|years|engines|categories|groups|part_types|parts brand: null, // {id, name} model: null, // {id, name} year: null, // {id, year} engine: null, // {id_mye, name} // OEM mode (TecDoc) navigation state — integer IDs category: null, // {id, name} group: null, // {id, name} partType: null, // {slug, name} ← 3rd-level subcategory (Nexpart-style) // Local mode (Nexpart) navigation state — string slugs. // These live in parallel with category/group/partType so transitioning // between modes doesn't trash the other branch's state. nxGroup: null, // {slug, name} ← top-level Nexpart group (14 total) nxSubgroup: null, // {slug, name} ← Nexpart subgroup nxPartType: null, // {slug, name} ← Nexpart part type (3rd level) }; // ─── Catalog mode (OEM / Local) ─── // OEM catalog is disabled until fully completed — force local mode var catalogMode = 'local'; localStorage.setItem('catalog_mode', 'local'); function updateModeToggleUI() { var btns = document.querySelectorAll('#modeToggle button'); btns.forEach(function (b) { if (b.getAttribute('data-mode') === catalogMode) { b.classList.add('is-active'); } else { b.classList.remove('is-active'); } }); } function setCatalogMode(mode) { if (mode !== 'oem' && mode !== 'local' && mode !== 'supplies') return; if (mode === 'oem') { // OEM catalog is disabled until fully completed alert('Catálogo OEM próximamente. Por favor usa el modo Local o Shop Supplies.'); return; } if (mode === catalogMode) return; catalogMode = mode; localStorage.setItem('catalog_mode', mode); updateModeToggleUI(); // Clear category-and-below state regardless of mode nav.category = nav.group = nav.partType = null; nav.nxGroup = nav.nxSubgroup = nav.nxPartType = null; currentPage = 1; if (mode === 'supplies') { // Supplies mode skips the vehicle chain entirely. // Clear the vehicle state for visual clarity and go directly // to the Shop Supplies top-level group list. try { vsClearAll(); } catch (e) {} nav.brand = nav.model = nav.year = nav.engine = null; nav.level = 'categories'; loadShopSuppliesGroups(); return; } // OEM/Local: smart reset — if the user already picked a vehicle, // stay at the categories level. Otherwise reset to brand selection. var hasVehicle = !!(nav.engine && nav.engine.id_mye); if (hasVehicle) { nav.level = 'categories'; loadCategoriesForMode(); return; } try { vsClearAll(); } catch (e) {} nav.level = 'brands'; nav.brand = nav.model = nav.year = nav.engine = null; loadBrands(); } var currentPage = 1; var currentDetailPart = null; var detailQty = 1; var isOffline = false; var skipPush = false; // flag to avoid pushing state on popstate // ─── Browser History Management ─── function pushNavState() { if (skipPush) return; var s = JSON.parse(JSON.stringify(nav)); s.page = currentPage; history.pushState(s, '', '/pos/catalog'); } window.addEventListener('popstate', function (e) { if (e.state) { skipPush = true; nav.level = e.state.level || 'brands'; nav.brand = e.state.brand; nav.model = e.state.model; nav.year = e.state.year; nav.engine = e.state.engine; nav.category = e.state.category; nav.group = e.state.group; nav.partType = e.state.partType || null; nav.nxGroup = e.state.nxGroup || null; nav.nxSubgroup = e.state.nxSubgroup || null; nav.nxPartType = e.state.nxPartType || null; currentPage = e.state.page || 1; // Reload the correct level if (nav.level === 'brands') loadBrands(); else if (nav.level === 'models') loadModels(); else if (nav.level === 'years') loadYears(); else if (nav.level === 'engines') loadEngines(); // When restoring from history, dispatch between OEM and Nexpart // based on which branch of state is populated — this survives // toggle changes made mid-session. else if (nav.level === 'categories') loadCategoriesForMode(); else if (nav.level === 'groups') { if (nav.nxGroup) loadNexpartSubgroups(); else loadGroups(); } else if (nav.level === 'part_types') { if (nav.nxSubgroup) loadNexpartPartTypes(); else loadPartTypes(); } else if (nav.level === 'parts') loadParts(currentPage); else loadBrands(); skipPush = false; } }); // Push initial state history.replaceState(JSON.parse(JSON.stringify(nav)), '', '/pos/catalog'); // ─── Cart State ─── var cartItems = JSON.parse(localStorage.getItem('pos_cart') || '[]'); // ─── API helper ─── var currentAbort = null; function apiFetch(url) { // Cancel previous navigation/search GETs to avoid race conditions if (currentAbort) { currentAbort.abort(); currentAbort = null; } var opts = { headers: headers }; var isCatalogNav = url.indexOf('/pos/api/') === 0 && ( url.indexOf('mode=') !== -1 || url.indexOf('/years') !== -1 || url.indexOf('/brands') !== -1 || url.indexOf('/models') !== -1 || url.indexOf('/engines') !== -1 || url.indexOf('/categories') !== -1 || url.indexOf('/groups') !== -1 || url.indexOf('/part-types') !== -1 || url.indexOf('/parts') !== -1 || url.indexOf('/search') !== -1 ); if (isCatalogNav) { currentAbort = new AbortController(); opts.signal = currentAbort.signal; } return fetch(url, opts) .then(function (resp) { if (resp.status === 401) { localStorage.removeItem('pos_token'); window.location.href = '/pos/login'; return null; } return resp.json(); }) .catch(function (e) { if (e.name === 'AbortError') return null; console.error('API error:', e); return null; }); } // ─── UI helpers ─── function showLoading() { loading.classList.add('is-visible'); navGrid.innerHTML = ''; partsGrid.style.display = 'none'; partsGrid.innerHTML = ''; emptyState.classList.remove('is-visible'); paginationNav.innerHTML = ''; var dl = document.getElementById('diagLink'); if (dl && nav.level !== 'categories') dl.style.display = 'none'; } function hideLoading() { loading.classList.remove('is-visible'); } function showEmpty(title, subtitle) { emptyTitle.textContent = title; emptySubtitle.textContent = subtitle || ''; emptyState.classList.add('is-visible'); navGrid.innerHTML = ''; partsGrid.style.display = 'none'; } function fmt(n) { return (parseFloat(n) || 0).toFixed(2); } function esc(s) { if (!s) return ''; var d = document.createElement('div'); d.textContent = s; return d.innerHTML.replace(/"/g, '"'); } // ─── Breadcrumb ─── function updateBreadcrumb() { var parts = []; parts.push({ label: 'Catalogo', action: 'loadBrands' }); if (nav.brand) parts.push({ label: nav.brand.name, action: 'loadModels' }); if (nav.model) parts.push({ label: nav.model.name, action: 'loadYears' }); if (nav.year) parts.push({ label: String(nav.year.year), action: 'loadEngines' }); if (nav.engine) parts.push({ label: nav.engine.name, action: 'loadCategories' }); // The category/group/part_type trio is rendered from EITHER the Nexpart // branch (nxGroup/nxSubgroup/nxPartType) or the OEM branch (category/ // group/partType), depending on which is populated. Only one branch // should be active at a time after a navigation reset. if (nav.nxGroup) parts.push({ label: nav.nxGroup.name, action: 'loadNxSubgroups' }); else if (nav.category) parts.push({ label: nav.category.name, action: 'loadGroups' }); if (nav.nxSubgroup) parts.push({ label: nav.nxSubgroup.name, action: 'loadNxPartTypes' }); else if (nav.group) parts.push({ label: nav.group.name, action: 'loadPartTypes' }); if (nav.nxPartType) parts.push({ label: nav.nxPartType.name, action: null }); else if (nav.partType) parts.push({ label: nav.partType.name, action: null }); var html = ''; for (var i = 0; i < parts.length; i++) { if (i > 0) html += ''; if (i < parts.length - 1 && parts[i].action) { html += '' + esc(parts[i].label) + ''; } else { html += '' + esc(parts[i].label) + ''; } } breadcrumb.innerHTML = html; // Wire breadcrumb clicks breadcrumb.querySelectorAll('[data-bc-action]').forEach(function (el) { el.addEventListener('click', function () { var action = this.dataset.bcAction; if (action === 'loadBrands') { resetNav(); loadBrands(); } else if (action === 'loadModels') { resetNavFrom('models'); loadModels(); } else if (action === 'loadYears') { resetNavFrom('years'); loadYears(); } else if (action === 'loadEngines') { resetNavFrom('engines'); loadEngines(); } else if (action === 'loadCategories') { resetNavFrom('categories'); loadCategoriesForMode(); } else if (action === 'loadGroups') { resetNavFrom('groups'); loadGroups(); } else if (action === 'loadPartTypes') { resetNavFrom('part_types'); loadPartTypes(); } // Nexpart-branch breadcrumb actions else if (action === 'loadNxSubgroups') { resetNavFrom('groups'); loadNexpartSubgroups(); } else if (action === 'loadNxPartTypes') { resetNavFrom('part_types'); loadNexpartPartTypes(); } }); }); } function resetNav() { nav.level = 'brands'; nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = nav.partType = null; nav.nxGroup = nav.nxSubgroup = nav.nxPartType = null; pushNavState(); } function resetNavFrom(level) { var levels = ['brands', 'models', 'years', 'engines', 'categories', 'groups', 'part_types', 'parts']; var idx = levels.indexOf(level); if (idx <= 0) { resetNav(); return; } nav.level = level; // For each level, the corresponding state key(s) to clear. // In Local mode, 'categories' clears nxGroup, 'groups' clears nxSubgroup, etc. // We clear BOTH mode-specific keys at each level so a mode switch mid-navigation // is always clean. var keys = [ null, // brands (nothing to clear above) ['model'], // models ['year'], // years ['engine'], // engines ['category', 'nxGroup'], // categories ← both OEM + Nexpart ['group', 'nxSubgroup'], // groups ← both OEM + Nexpart ['partType', 'nxPartType'], // part_types ← both OEM + Nexpart null, // parts ]; for (var i = idx; i < keys.length; i++) { if (!keys[i]) continue; var ks = keys[i]; for (var j = 0; j < ks.length; j++) nav[ks[j]] = null; } } // ─── Level filter ─── function setupLevelFilter(show) { if (!show) { levelFilter.style.display = 'none'; levelFilter.value = ''; return; } levelFilter.style.display = ''; levelFilter.value = ''; levelFilter.oninput = function () { var q = this.value.toLowerCase(); var cards = navGrid.querySelectorAll('.nav-card'); cards.forEach(function (card) { var text = card.textContent.toLowerCase(); card.style.display = text.indexOf(q) >= 0 ? '' : 'none'; }); }; } // ─── LEVEL LOADERS ─── function loadBrands() { nav.level = 'brands'; pushNavState(); updateBreadcrumb(); levelTitle.textContent = 'Selecciona una marca'; setupLevelFilter(true); showLoading(); var cacheKey = 'nexus:brands:' + catalogMode; var cached = sessionStorage.getItem(cacheKey); if (cached) { try { hideLoading(); var data = JSON.parse(cached); renderBrands(data); return; } catch (e) { sessionStorage.removeItem(cacheKey); } } apiFetch(API + '/brands?mode=' + catalogMode).then(function (data) { hideLoading(); if (data && data.data) sessionStorage.setItem(cacheKey, JSON.stringify(data)); if (!data || !data.data || !data.data.length) { if (!data) { enterOfflineMode(); return; } showEmpty('Sin marcas', 'El catalogo no tiene marcas con partes disponibles.'); return; } navGrid.className = 'nav-grid'; navGrid.innerHTML = data.data.map(function (b) { return ''; }).join(''); navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.brand = { id: parseInt(this.dataset.brandId), name: this.dataset.name }; loadModels(); }); }); }); } function renderBrands(data) { if (!data || !data.data || !data.data.length) { if (!data) { enterOfflineMode(); return; } showEmpty('Sin marcas', 'El catalogo no tiene marcas con partes disponibles.'); return; } navGrid.className = 'nav-grid'; navGrid.innerHTML = data.data.map(function (b) { return ''; }).join(''); navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.brand = { id: parseInt(this.dataset.brandId), name: this.dataset.name }; loadModels(); }); }); } function loadModels() { nav.level = 'models'; pushNavState(); updateBreadcrumb(); levelTitle.textContent = 'Modelos de ' + nav.brand.name; setupLevelFilter(true); showLoading(); apiFetch(API + '/models?brand_id=' + nav.brand.id).then(function (data) { hideLoading(); if (!data || !data.data || !data.data.length) { showEmpty('Sin modelos', 'No hay modelos con partes para ' + nav.brand.name); return; } navGrid.className = 'nav-grid'; navGrid.innerHTML = data.data.map(function (m) { return ''; }).join(''); navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.model = { id: parseInt(this.dataset.modelId), name: this.dataset.name }; loadYears(); }); }); }); } function loadYears() { nav.level = 'years'; pushNavState(); updateBreadcrumb(); levelTitle.textContent = nav.brand.name + ' ' + nav.model.name + ' — Anios'; setupLevelFilter(false); showLoading(); apiFetch(API + '/years?model_id=' + nav.model.id).then(function (data) { hideLoading(); if (!data || !data.data || !data.data.length) { showEmpty('Sin anios', 'No hay anios con partes para este modelo.'); return; } navGrid.className = 'nav-grid nav-grid--years'; navGrid.innerHTML = data.data.map(function (y) { return ''; }).join(''); navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.year = { id: parseInt(this.dataset.yearId), year: parseInt(this.dataset.year) }; loadEngines(); }); }); }); } function loadEngines() { nav.level = 'engines'; pushNavState(); updateBreadcrumb(); levelTitle.textContent = nav.brand.name + ' ' + nav.model.name + ' ' + nav.year.year + ' — Motor'; setupLevelFilter(false); showLoading(); apiFetch(API + '/engines?model_id=' + nav.model.id + '&year_id=' + nav.year.id).then(function (data) { hideLoading(); if (!data || !data.data || !data.data.length) { showEmpty('Sin motores', 'No hay configuraciones de motor para esta combinacion.'); return; } // If only one engine, auto-select it if (data.data.length === 1) { var e = data.data[0]; nav.engine = { id_mye: e.id_mye, name: e.name_engine + (e.trim_level ? ' ' + e.trim_level : '') }; loadCategoriesForMode(); return; } navGrid.className = 'nav-grid'; navGrid.innerHTML = data.data.map(function (e) { var label = e.name_engine + (e.trim_level ? ' — ' + e.trim_level : ''); return ''; }).join(''); navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.engine = { id_mye: parseInt(this.dataset.myeId), name: this.dataset.name }; loadCategoriesForMode(); }); }); }); } function loadCategories() { nav.level = 'categories'; pushNavState(); updateBreadcrumb(); levelTitle.textContent = 'Categorias de partes'; setupLevelFilter(true); showLoading(); apiFetch(API + '/categories?mye_id=' + nav.engine.id_mye).then(function (data) { hideLoading(); if (!data || !data.data || !data.data.length) { showEmpty('Sin categorias', 'No hay partes catalogadas para este vehiculo.'); return; } navGrid.className = 'nav-grid'; navGrid.innerHTML = data.data.map(function (c) { return ''; }).join(''); navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.category = { id: parseInt(this.dataset.catId), name: this.dataset.name }; loadGroups(); }); }); }); } function loadGroups() { nav.level = 'groups'; pushNavState(); updateBreadcrumb(); levelTitle.textContent = nav.category.name; setupLevelFilter(true); showLoading(); apiFetch(API + '/groups?mye_id=' + nav.engine.id_mye + '&category_id=' + nav.category.id).then(function (data) { hideLoading(); if (!data || !data.data || !data.data.length) { showEmpty('Sin subcategorias', 'No hay subcategorias para ' + nav.category.name); return; } navGrid.className = 'nav-grid'; navGrid.innerHTML = data.data.map(function (g) { return ''; }).join(''); navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.group = { id: parseInt(this.dataset.groupId), name: this.dataset.name }; nav.partType = null; // reset deeper levels loadPartTypes(); }); }); }); } // ─── Part Types (3rd subcategory level — Nexpart-style) ─── function loadPartTypes() { nav.level = 'part_types'; nav.partType = null; pushNavState(); updateBreadcrumb(); levelTitle.textContent = nav.group.name; setupLevelFilter(true); showLoading(); apiFetch(API + '/part-types?mye_id=' + nav.engine.id_mye + '&group_id=' + nav.group.id).then(function (data) { hideLoading(); if (!data || !data.data || !data.data.length) { // No part types? Skip directly to all parts in the group. loadParts(1); return; } // Single part type? Skip the picker — go straight to parts. if (data.data.length === 1) { var only = data.data[0]; nav.partType = { slug: only.slug, name: only.name }; loadParts(1); return; } navGrid.className = 'nav-grid'; navGrid.innerHTML = data.data.map(function (pt) { var img = pt.sample_image ? '' : ''; return ''; }).join(''); navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.partType = { slug: this.dataset.ptSlug, name: this.dataset.ptName }; loadParts(1); }); }); }); } // ═══════════════════════════════════════════════════════════════════ // NEXPART (Local mode) — parallel navigation functions // ═══════════════════════════════════════════════════════════════════ // These run in parallel to loadCategories / loadGroups / loadPartTypes // and are only invoked when catalogMode === 'local'. They share the // same DOM hooks (navGrid, breadcrumb, levelTitle) but fetch from the // Nexpart-filtered endpoints and store state in nav.nxGroup / nxSubgroup // / nxPartType instead of nav.category / group / partType. function loadCategoriesForMode() { // Dispatcher — called by every place that used to call loadCategories() if (catalogMode === 'local') { loadNexpartCategories(); } else { loadCategories(); } } function loadNexpartCategories() { nav.level = 'categories'; pushNavState(); updateBreadcrumb(); levelTitle.textContent = 'Categorias (Local)'; setupLevelFilter(true); showLoading(); apiFetch(API + '/categories?mode=local&mye_id=' + nav.engine.id_mye).then(function (data) { hideLoading(); if (!data || !data.data || !data.data.length) { showEmpty('Sin categorias Local', 'Ninguna parte de este vehiculo mapea al catalogo Local.'); return; } navGrid.className = 'nav-grid'; navGrid.innerHTML = data.data.map(function (c) { return ''; }).join(''); navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.nxGroup = { slug: this.dataset.slug, name: this.dataset.name }; // Reset deeper Nexpart state so a re-click always goes to // a clean subgroup list. nav.nxSubgroup = null; nav.nxPartType = null; loadNexpartSubgroups(); }); }); }); } function loadNexpartSubgroups() { nav.level = 'groups'; pushNavState(); updateBreadcrumb(); levelTitle.textContent = nav.nxGroup.name; setupLevelFilter(true); showLoading(); var url = API + '/groups?mode=local&mye_id=' + nav.engine.id_mye + '&category_slug=' + encodeURIComponent(nav.nxGroup.slug); apiFetch(url).then(function (data) { hideLoading(); if (!data || !data.data || !data.data.length) { showEmpty('Sin subcategorias', 'No hay subcategorias en ' + nav.nxGroup.name); return; } navGrid.className = 'nav-grid'; navGrid.innerHTML = data.data.map(function (s) { return ''; }).join(''); navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.nxSubgroup = { slug: this.dataset.slug, name: this.dataset.name }; nav.nxPartType = null; loadNexpartPartTypes(); }); }); }); } function loadNexpartPartTypes() { nav.level = 'part_types'; nav.nxPartType = null; pushNavState(); updateBreadcrumb(); levelTitle.textContent = nav.nxSubgroup.name; setupLevelFilter(true); showLoading(); var url = API + '/part-types?mode=local&mye_id=' + nav.engine.id_mye + '&group_slug=' + encodeURIComponent(nav.nxGroup.slug) + '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug); apiFetch(url).then(function (data) { hideLoading(); if (!data || !data.data || !data.data.length) { showEmpty('Sin tipos de parte', 'No hay tipos de parte en ' + nav.nxSubgroup.name); return; } // Single part type? Auto-drill-down to parts (UX shortcut). if (data.data.length === 1) { var only = data.data[0]; nav.nxPartType = { slug: only.slug, name: only.name }; loadParts(1); return; } navGrid.className = 'nav-grid'; navGrid.innerHTML = data.data.map(function (pt) { var img = pt.sample_image ? '' : ''; return ''; }).join(''); navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.nxPartType = { slug: this.dataset.slug, name: this.dataset.name }; loadParts(1); }); }); }); } // ═══════════════════════════════════════════════════════════════════ // SHOP SUPPLIES (Supplies mode) — vehicle-independent // ═══════════════════════════════════════════════════════════════════ // Uses nav.nxGroup / nav.nxSubgroup / nav.nxPartType for state (reuses // the Nexpart slot because Supplies is a subset of the Nexpart taxonomy) // but calls a different set of endpoints (/shop-supplies/*) that don't // require an mye_id. function loadShopSuppliesGroups() { nav.level = 'categories'; pushNavState(); updateBreadcrumb(); levelTitle.textContent = 'Shop Supplies (sin vehiculo)'; setupLevelFilter(true); showLoading(); apiFetch(API + '/shop-supplies/groups').then(function (data) { hideLoading(); if (!data || !data.data || !data.data.length) { showEmpty('Sin supplies', 'No hay grupos de Shop Supplies disponibles.'); return; } navGrid.className = 'nav-grid'; navGrid.innerHTML = data.data.map(function (g) { return ''; }).join(''); navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.nxGroup = { slug: this.dataset.slug, name: this.dataset.name }; nav.nxSubgroup = null; nav.nxPartType = null; loadShopSuppliesSubgroups(); }); }); }); } function loadShopSuppliesSubgroups() { nav.level = 'groups'; pushNavState(); updateBreadcrumb(); levelTitle.textContent = nav.nxGroup.name; setupLevelFilter(true); showLoading(); var url = API + '/shop-supplies/subgroups?group_slug=' + encodeURIComponent(nav.nxGroup.slug); apiFetch(url).then(function (data) { hideLoading(); if (!data || !data.data || !data.data.length) { showEmpty('Sin subgrupos', 'No hay subgrupos con partes disponibles.'); return; } navGrid.className = 'nav-grid'; navGrid.innerHTML = data.data.map(function (s) { return ''; }).join(''); navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.nxSubgroup = { slug: this.dataset.slug, name: this.dataset.name }; nav.nxPartType = null; loadShopSuppliesPartTypes(); }); }); }); } function loadShopSuppliesPartTypes() { nav.level = 'part_types'; nav.nxPartType = null; pushNavState(); updateBreadcrumb(); levelTitle.textContent = nav.nxSubgroup.name; setupLevelFilter(true); showLoading(); var url = API + '/shop-supplies/part-types' + '?group_slug=' + encodeURIComponent(nav.nxGroup.slug) + '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug); apiFetch(url).then(function (data) { hideLoading(); if (!data || !data.data || !data.data.length) { showEmpty('Sin tipos', 'No hay tipos de parte en este subgrupo.'); return; } // Single part type? Skip the picker. if (data.data.length === 1) { var only = data.data[0]; nav.nxPartType = { slug: only.slug, name: only.name }; loadShopSuppliesParts(1); return; } navGrid.className = 'nav-grid'; navGrid.innerHTML = data.data.map(function (pt) { var img = pt.sample_image ? '' : ''; return ''; }).join(''); navGrid.querySelectorAll('.nav-card').forEach(function (card) { card.addEventListener('click', function () { nav.nxPartType = { slug: this.dataset.slug, name: this.dataset.name }; loadShopSuppliesParts(1); }); }); }); } function loadShopSuppliesParts(page) { nav.level = 'parts'; pushNavState(); currentPage = page || 1; updateBreadcrumb(); levelTitle.textContent = nav.nxPartType.name; setupLevelFilter(false); showLoading(); navGrid.innerHTML = ''; var url = API + '/shop-supplies/parts' + '?group_slug=' + encodeURIComponent(nav.nxGroup.slug) + '&subgroup_slug=' + encodeURIComponent(nav.nxSubgroup.slug) + '&part_type_slug=' + encodeURIComponent(nav.nxPartType.slug) + '&page=' + currentPage + '&per_page=30'; apiFetch(url).then(function (data) { hideLoading(); if (!data || !data.data || !data.data.length) { showEmpty('Sin partes', 'No hay partes en este tipo.'); return; } // Reuse the same aftermarket-styled rendering as Local mode. partsGrid.style.display = ''; partsGrid.innerHTML = data.data.map(function (p) { var stockBadge; if (p.in_stock_network || p.bodega_count > 0) { stockBadge = '' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + ''; } else { stockBadge = 'Sin stock'; } var imgHtml = p.image_url ? '' + esc(p.name) + '' : ''; var tierClass = p.priority_tier === 1 ? ' part-card--tier1' : (p.priority_tier === 2 ? ' part-card--tier2' : ''); var manuBadge = ''; if (p.manufacturer) { var tierStar = p.priority_tier === 1 ? '' : ''; manuBadge = '
' + esc(p.manufacturer) + '' + tierStar + '
'; } var skuLine = p.part_number ? esc(p.part_number) + ' · OEM: ' + esc(p.oem_part_number || '') + '' : esc(p.oem_part_number || ''); return '
' + '
' + imgHtml + '
' + '
' + manuBadge + '
' + skuLine + '
' + '
' + esc(p.name) + '
' + '
' + '' + '
'; }).join(''); partsGrid.querySelectorAll('.part-card').forEach(function (card) { card.addEventListener('click', function () { var pid = this.dataset.partId; var src = this.dataset.source || ''; if (typeof pid === 'string' && pid.indexOf('inv:') === 0) { return; } if (src === 'supplier_catalog' || (typeof pid === 'string' && pid.indexOf('sc:') === 0)) { openSupplierDetail(pid.replace('sc:', '')); return; } openPartDetail(parseInt(pid)); }); }); if (data.pagination) renderPagination(data.pagination); }); } function loadParts(page) { nav.level = 'parts'; pushNavState(); currentPage = page || 1; updateBreadcrumb(); // Title: Nexpart part type > TecDoc part type > TecDoc group if (nav.nxPartType) { levelTitle.textContent = nav.nxPartType.name; } else if (nav.partType) { levelTitle.textContent = nav.partType.name; } else if (nav.group) { levelTitle.textContent = nav.group.name; } else { levelTitle.textContent = 'Partes'; } setupLevelFilter(false); showLoading(); navGrid.innerHTML = ''; // Build the URL based on which navigation branch the user took. // Nexpart branch uses slug-based params; OEM branch uses integer ids. var url; if (nav.nxGroup && nav.nxSubgroup && nav.nxPartType) { url = API + '/parts?mode=local' + '&mye_id=' + nav.engine.id_mye + '&page=' + currentPage + '&per_page=30' + '&nexpart_group=' + encodeURIComponent(nav.nxGroup.slug) + '&nexpart_subgroup=' + encodeURIComponent(nav.nxSubgroup.slug) + '&nexpart_part_type=' + encodeURIComponent(nav.nxPartType.slug); } else { var ptParam = nav.partType ? '&part_type=' + encodeURIComponent(nav.partType.slug) : ''; url = API + '/parts?mye_id=' + nav.engine.id_mye + '&group_id=' + nav.group.id + '&page=' + currentPage + '&per_page=30' + '&mode=' + catalogMode + ptParam; } apiFetch(url).then(function (data) { hideLoading(); if (!data || !data.data || !data.data.length) { showEmpty('Sin partes', 'No hay partes en este grupo.'); return; } var isLocal = (catalogMode === 'local'); partsGrid.style.display = ''; partsGrid.innerHTML = data.data.map(function (p) { // Stock badge — prefer tenant stock, then warehouse network, else fallback var stockBadge; var isSupplier = p.source === 'supplier_catalog' || (typeof p.id_part === 'string' && p.id_part.indexOf('sc:') === 0); if (isSupplier) { stockBadge = 'Cat. Proveedor'; } else if (p.local_stock > 0) { stockBadge = 'En stock: ' + p.local_stock + ''; } else if (p.in_stock_network || p.bodega_count > 0) { stockBadge = '' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + ''; } else { stockBadge = 'Sin stock'; } // Source badge for local inventory or supplier catalog var sourceBadge = ''; if (p.source === 'local_inventory') { sourceBadge = 'Stock Local'; } else if (isSupplier) { sourceBadge = 'Cat. Proveedor'; } var imgHtml = p.image_url ? '' + esc(p.name) + '' : ''; // Local-mode extras: manufacturer badge + priority tier indicator var manuBadge = ''; var tierClass = ''; if (isLocal && p.manufacturer) { var tierLabel = ''; if (p.priority_tier === 1) { tierClass = ' part-card--tier1'; tierLabel = '★'; } else if (p.priority_tier === 2) { tierClass = ' part-card--tier2'; tierLabel = ''; } manuBadge = '
' + esc(p.manufacturer) + '' + (tierLabel ? '' + tierLabel + '' : '') + '
'; } // SKU to show: aftermarket part_number in local mode, OEM number otherwise var skuLine = isLocal && p.part_number ? esc(p.part_number) + ' · OEM: ' + esc(p.oem_part_number) + '' : esc(p.oem_part_number); return '
' + '
' + imgHtml + '
' + '
' + manuBadge + sourceBadge + '
' + skuLine + '
' + '
' + esc(p.name) + '
' + '
' + '' + '
'; }).join(''); // Wire part card clicks → open detail panel (skip local-inventory items) partsGrid.querySelectorAll('.part-card').forEach(function (card) { card.addEventListener('click', function () { var pid = this.dataset.partId; var src = this.dataset.source || ''; if (typeof pid === 'string' && pid.indexOf('inv:') === 0) { // local-inventory item: info already visible on card return; } if (src === 'supplier_catalog' || (typeof pid === 'string' && pid.indexOf('sc:') === 0)) { openSupplierDetail(pid.replace('sc:', '')); return; } openPartDetail(parseInt(pid)); }); }); // Pagination if (data.pagination) renderPagination(data.pagination); }); } // ─── PAGINATION ─── function renderPagination(pg) { if (!pg || pg.total_pages <= 1) { paginationNav.innerHTML = ''; return; } var html = ''; if (pg.page <= 1) { html += ''; } else { html += ''; } var pages = buildPageNumbers(pg.page, pg.total_pages); pages.forEach(function (p) { if (p === '...') { html += '...'; } else if (p === pg.page) { html += ''; } else { html += ''; } }); if (pg.page >= pg.total_pages) { html += ''; } else { html += ''; } paginationNav.innerHTML = html; paginationNav.querySelectorAll('[data-page]').forEach(function (btn) { btn.addEventListener('click', function () { pageBody.scrollTo({ top: 0, behavior: 'smooth' }); loadParts(parseInt(this.dataset.page)); }); }); } function buildPageNumbers(current, total) { if (total <= 7) { var a = []; for (var i = 1; i <= total; i++) a.push(i); return a; } var p = [1]; if (current > 3) p.push('...'); for (var j = Math.max(2, current - 1); j <= Math.min(total - 1, current + 1); j++) p.push(j); if (current < total - 2) p.push('...'); p.push(total); return p; } // ─── DETAIL PANEL ─── function openPartDetail(partId) { detailBody.innerHTML = '
'; detailFooter.style.display = 'none'; detailPanel.classList.add('is-open'); detailOverlay.classList.add('is-visible'); detailQty = 1; qtyDisplay.textContent = '1'; apiFetch(API + '/part/' + partId).then(function (data) { if (!data || data.error) { detailBody.innerHTML = '

Error al cargar detalle.

'; return; } currentDetailPart = data; var p = data.part; var local = data.local; var bodegas = data.bodegas || []; var alts = data.alternatives || []; var html = ''; // Part info html += '
'; if (p.category_name) html += '
' + esc(p.category_name) + ' > ' + esc(p.group_name) + '
'; html += '
' + esc(p.oem_part_number) + '
'; html += '
' + esc(p.name) + '
'; if (p.description) html += '
' + esc(p.description) + '
'; if (p.image_url) html += '
'; html += '
'; // Local stock html += '
'; html += '
Mi stock
'; if (local && local.stock > 0) { html += '
Cantidad' + local.stock + ' ' + (local.unit || 'PZA') + '
'; html += '
Precio publico$' + fmt(local.price_1) + '
'; if (local.price_2) html += '
Precio mayoreo$' + fmt(local.price_2) + '
'; if (local.price_3) html += '
Precio taller$' + fmt(local.price_3) + '
'; if (local.location) html += '
Ubicacion' + esc(local.location) + '
'; } else { html += '
No tienes esta parte en inventario.
'; } html += '
'; // Bodegas if (bodegas.length) { html += '
'; html += '
Disponible en bodegas
'; html += ''; bodegas.forEach(function (b) { html += ''; }); html += '
BodegaPrecioStock
' + esc(b.business_name) + '' + (b.price ? '$' + fmt(b.price) : '--') + '' + b.stock + '
'; } // Alternatives if (alts.length) { html += '
'; html += '
Alternativas / Cross-references
'; alts.forEach(function (a) { var stockLabel = a.local_stock > 0 ? 'Stock: ' + a.local_stock + '' : (a.bodega_count > 0 ? '' + a.bodega_count + ' bod.' : ''); html += '
' + '
' + esc(a.part_number) + '
' + '
' + esc(a.manufacturer) + (a.name ? ' — ' + esc(a.name) : '') + '
' + '
' + stockLabel + '
' + '
'; }); html += '
'; } detailBody.innerHTML = html; // Show footer only if we have local stock if (local && local.stock > 0) { detailFooter.style.display = ''; } else { detailFooter.style.display = 'none'; } }); } function openSupplierDetail(supplierId) { detailBody.innerHTML = '
'; detailFooter.style.display = 'none'; detailPanel.classList.add('is-open'); detailOverlay.classList.add('is-visible'); apiFetch('/pos/api/supplier-catalog/items/' + supplierId).then(function (data) { if (!data || data.error) { detailBody.innerHTML = '

Error al cargar detalle.

'; return; } var p = data; var html = ''; html += '
'; html += '
' + esc(p.supplier_name) + ' > ' + esc(p.category || '') + '
'; html += '
' + esc(p.sku) + '
'; html += '
' + esc((p.name || '').replace(/\\n/g, ' ')) + '
'; if (p.description) html += '
' + esc(p.description) + '
'; if (p.image_url) html += '
'; html += '
'; // Interchanges if (p.interchanges && p.interchanges.length) { html += '
'; html += '
Intercambios OEM
'; var seen = {}; p.interchanges.forEach(function(ix) { var key = (ix.brand || '') + '|' + (ix.interchange_number || ''); if (seen[key]) return; seen[key] = true; html += '
' + '' + esc(ix.brand || '') + '' + '' + esc(ix.interchange_number || '') + '' + '
'; }); html += '
'; } // Compatibilities — deduplicate by (make, model, year, engine) if (p.compatibilities && p.compatibilities.length) { html += '
'; html += '
Vehiculos compatibles
'; var seenCompat = {}; var uniqCompat = []; p.compatibilities.forEach(function(c) { var key = (c.make || '') + '|' + (c.model || '') + '|' + (c.year || '') + '|' + (c.engine || ''); if (seenCompat[key]) return; seenCompat[key] = true; uniqCompat.push(c); }); var currentMake = ''; uniqCompat.forEach(function(c) { if (c.make !== currentMake) { currentMake = c.make; html += '
' + esc(c.make) + '
'; } html += '
' + esc(c.model) + ' ' + c.year + ' ' + esc(c.engine || '') + '
'; }); html += '
'; } detailBody.innerHTML = html; }); } function closeDetail() { detailPanel.classList.remove('is-open'); detailOverlay.classList.remove('is-visible'); currentDetailPart = null; } detailClose.addEventListener('click', closeDetail); detailOverlay.addEventListener('click', closeDetail); qtyMinus.addEventListener('click', function () { if (detailQty > 1) { detailQty--; qtyDisplay.textContent = detailQty; } }); qtyPlus.addEventListener('click', function () { detailQty++; qtyDisplay.textContent = detailQty; }); addToCartBtn.addEventListener('click', function () { if (!currentDetailPart) return; var p = currentDetailPart.part; var local = currentDetailPart.local; if (!local) return; addToCart({ id: p.id_part, part_number: p.oem_part_number, name: p.name, brand: '', price: local.price_1, tax_rate: local.tax_rate || 0.16, unit: local.unit || 'PZA', stock: local.stock, source: 'local', inventory_id: local.inventory_id, }, detailQty); closeDetail(); }); // ─── SMART SEARCH ─── var searchTimeout = null; // ═══════════════════════════════════════════════════════════════════ // SMART SEARCH — auto-detect VIN / plate / part number / keyword // ═══════════════════════════════════════════════════════════════════ // Returns: 'vin' | 'plate' | 'part_number' | 'keyword' function detectQueryType(raw) { if (!raw) return 'keyword'; var q = raw.trim(); // Strip common separators for detection (VINs/parts rarely contain spaces) var compact = q.replace(/[\s\-]/g, '').toUpperCase(); // VIN: exactly 17 chars alphanumeric, no I/O/Q if (/^[A-HJ-NPR-Z0-9]{17}$/.test(compact)) return 'vin'; // Mexican license plate: 3 letters + 3-4 digits (with/without hyphen) if (/^[A-Z]{3}[-\s]?\d{3,4}$/.test(q.toUpperCase())) return 'plate'; // Part-number heuristic. Rules designed to avoid false positives on // natural-language Spanish/English queries: // 1. Original query must NOT contain lowercase letters. Real part // numbers are always uppercase ("4G0-857-951-A"); keywords aren't. // 2. No natural-language words allowed (para, de, con, for, the, etc.) // 3. Either has a dash/slash separator, or is a solid alphanumeric blob. var hasLowercase = /[a-z]/.test(q); if (hasLowercase) return 'keyword'; // Block queries that contain a year-like 4-digit number alongside // other tokens — those are "PART 2018" style vehicle refs, not parts. var tokens = q.split(/\s+/); var hasYear = tokens.some(function (t) { return /^(19|20)\d{2}$/.test(t); }); if (hasYear && tokens.length > 1) return 'keyword'; var qUpper = q.toUpperCase(); // Dashed/slashed part number: "4G0-857-951-A", "BP-1234" if (/^[A-Z0-9]{2,}[\-\/][A-Z0-9]{2,}([\-\/][A-Z0-9]+)*$/.test(qUpper) && compact.length >= 6) { return 'part_number'; } // Space-separated part number (rare but real, e.g. BOSCH "0 986 4B7 013") if (tokens.length >= 2 && tokens.every(function (t) { return /^[A-Z0-9]{1,}$/.test(t); }) && compact.length >= 6) { return 'part_number'; } // Solid alphanumeric blob 8+ chars with both letters+digits if (/^[A-Z0-9]{8,}$/.test(compact) && /[A-Z]/.test(compact) && /\d/.test(compact)) { return 'part_number'; } return 'keyword'; } // Hint badge shown next to the search input. Injected lazily so we don't // need to touch the HTML. var searchHint = null; function ensureSearchHint() { if (searchHint) return searchHint; searchHint = document.createElement('div'); searchHint.id = 'searchHint'; searchHint.style.cssText = 'position:absolute;top:100%;left:0;margin-top:4px;padding:3px 10px;' + 'background:var(--color-primary-muted);color:var(--color-text-accent);' + 'font-size:var(--text-caption);font-weight:var(--font-weight-semibold);' + 'border:1px dashed var(--color-border-accent);border-radius:var(--radius-sm);' + 'white-space:nowrap;pointer-events:none;z-index:10;display:none;'; searchInput.parentElement.appendChild(searchHint); return searchHint; } function updateSearchHint(type) { var hint = ensureSearchHint(); var labels = { vin: '🚗 VIN detectado — decodificando', plate: '🔖 Placa detectada — consultando registro', part_number: '🔩 Numero de parte detectado', keyword: null, }; var label = labels[type]; if (!label) { hint.style.display = 'none'; } else { hint.textContent = label; hint.style.display = ''; } } // Smart dispatcher — decides which endpoint to call based on input type. function runSmartSearch(q) { var type = detectQueryType(q); if (type === 'vin') { // Use the existing VIN decoder flow try { decodeVinWithValue(q); } catch (e) { runSearch(q); } return; } if (type === 'plate') { // Use the existing plate lookup flow — assume default state MX try { lookupPlateWithValue(q); } catch (e) { runSearch(q); } return; } // For part_number and keyword, both go through the existing /search // endpoint (which supports full-text + OEM number search). runSearch(q); } // Thin wrappers around existing VIN/plate handlers — they usually read // from their own input fields; these set the field and trigger. function decodeVinWithValue(vin) { var vinInput = document.getElementById('vinInput'); if (vinInput) { vinInput.value = vin; if (typeof decodeVin === 'function') decodeVin(); else runSearch(vin); } else { runSearch(vin); // fallback } } function lookupPlateWithValue(plate) { var plateInput = document.getElementById('plateInput'); if (plateInput) { plateInput.value = plate.toUpperCase(); if (typeof lookupPlate === 'function') lookupPlate(); else runSearch(plate); } else { runSearch(plate); // fallback } } searchInput.addEventListener('input', function () { clearTimeout(searchTimeout); var q = this.value.trim(); // Live type detection for the hint (runs on every keystroke) updateSearchHint(q.length >= 3 ? detectQueryType(q) : null); if (q.length < 2) { searchDropdown.classList.remove('is-visible'); return; } // For keyword queries, keep the debounced dropdown preview. // For VIN/plate/part-number, wait for Enter — they're one-shot lookups. var type = detectQueryType(q); if (type === 'keyword') { searchTimeout = setTimeout(function () { runSearch(q); }, 350); } }); searchInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); clearTimeout(searchTimeout); var q = this.value.trim(); if (q.length >= 2) runSmartSearch(q); } if (e.key === 'Escape') { searchDropdown.classList.remove('is-visible'); updateSearchHint(null); } }); // Close dropdown on outside click document.addEventListener('click', function (e) { if (!searchDropdown.contains(e.target) && e.target !== searchInput) { searchDropdown.classList.remove('is-visible'); } }); function runSearch(q) { var url = API + '/search?q=' + encodeURIComponent(q) + '&limit=20'; if (nav.engine && nav.engine.id_mye) { url += '&mye_id=' + nav.engine.id_mye; } apiFetch(url).then(function (data) { if (!data || !data.data || !data.data.length) { searchDropdown.innerHTML = '
Sin resultados para "' + esc(q) + '"
'; searchDropdown.classList.add('is-visible'); return; } searchDropdown.innerHTML = data.data.map(function (r) { var isLocal = r.source === 'local_inventory' || (typeof r.id_part === 'string' && r.id_part.indexOf('inv:') === 0); var isSupplier = r.source === 'supplier_catalog' || (typeof r.id_part === 'string' && r.id_part.indexOf('sc:') === 0); var stockLabel = r.local_stock > 0 ? 'Stock: ' + r.local_stock + '' : ''; var sourceBadge = ''; if (isLocal) { sourceBadge = 'Stock Local'; } else if (isSupplier) { sourceBadge = 'Cat. Proveedor'; } var oemNum = isLocal ? (r.oem_part_number || r.part_number || '') : (r.oem_part_number || ''); var cleanName = (r.name || '').replace(/\\n/g, ' '); return '
' + '
' + '
' + sourceBadge + esc(oemNum) + '
' + '
' + esc(cleanName) + '
' + (r.vehicle_info ? '
' + esc(r.vehicle_info) + '
' : '') + '
' + stockLabel + '
'; }).join(''); searchDropdown.classList.add('is-visible'); searchDropdown.querySelectorAll('.search-result-item').forEach(function (el) { el.addEventListener('click', function () { searchDropdown.classList.remove('is-visible'); var pid = this.dataset.partId; var src = this.dataset.source || ''; if (typeof pid === 'string' && pid.indexOf('inv:') === 0) { var info = '💠 Stock Local\n\n' + 'Parte: ' + (this.dataset.pn || 'N/A') + '\n' + 'Nombre: ' + (this.dataset.name || '') + '\n' + 'Precio: $' + (this.dataset.price || '—') + '\n' + 'Stock: ' + (this.dataset.stock || 0) + ' pzas'; alert(info); return; } if (src === 'supplier_catalog' || (typeof pid === 'string' && pid.indexOf('sc:') === 0)) { openSupplierDetail(pid.replace('sc:', '')); return; } openPartDetail(parseInt(pid)); }); }); }); } // ─── CART ─── function addToCart(item, qty) { qty = qty || 1; var existing = cartItems.find(function (c) { return c.id === item.id; }); if (existing) { existing.quantity += qty; } else { cartItems.push({ id: item.id, part_number: item.part_number, name: item.name, brand: item.brand || '', price: item.price, tax_rate: item.tax_rate || 0.16, unit: item.unit || 'PZA', stock: item.stock, source: item.source || 'local', inventory_id: item.inventory_id, quantity: qty, }); } saveCart(); renderCart(); if (!cartSidebar.classList.contains('open')) toggleCart(); } function removeFromCart(index) { cartItems.splice(index, 1); saveCart(); renderCart(); } function updateQuantity(index, qty) { qty = parseInt(qty); if (qty <= 0) { removeFromCart(index); return; } cartItems[index].quantity = qty; saveCart(); renderCart(); } function clearCart() { cartItems = []; saveCart(); renderCart(); } function saveCart() { localStorage.setItem('pos_cart', JSON.stringify(cartItems)); } function renderCart() { var total = cartItems.reduce(function (s, c) { return s + c.quantity; }, 0); if (cartBadge) { cartBadge.textContent = total; cartBadge.style.display = total > 0 ? 'flex' : 'none'; } if (!cartItems.length) { cartItemsEl.innerHTML = ''; cartEmptyEl.style.display = 'block'; if (checkoutBtn) checkoutBtn.disabled = true; cartSubtotalEl.textContent = '$0.00'; cartTaxEl.textContent = '$0.00'; cartTotalEl.textContent = '$0.00'; return; } cartEmptyEl.style.display = 'none'; if (checkoutBtn) checkoutBtn.disabled = false; var subtotal = 0; var tax = 0; cartItemsEl.innerHTML = cartItems.map(function (c, i) { var lineTotal = c.price * c.quantity; var lineTax = lineTotal * c.tax_rate; subtotal += lineTotal; tax += lineTax; return '
' + '
' + '
' + esc(c.name) + '
' + '
' + esc(c.part_number) + '
' + '
' + '' + '' + c.quantity + '' + '' + '
' + '
' + '
$' + fmt(lineTotal) + '
' + '' + '
'; }).join(''); cartSubtotalEl.textContent = '$' + fmt(subtotal); cartTaxEl.textContent = '$' + fmt(tax); cartTotalEl.textContent = '$' + fmt(subtotal + tax); } // Event delegation for cart buttons (attached once) cartItemsEl.addEventListener('click', function (e) { var btn = e.target.closest('[data-cart-action]'); if (!btn) return; var idx = parseInt(btn.dataset.idx); var action = btn.dataset.cartAction; if (action === 'dec') updateQuantity(idx, cartItems[idx].quantity - 1); else if (action === 'inc') updateQuantity(idx, cartItems[idx].quantity + 1); else if (action === 'remove') removeFromCart(idx); }); function toggleCart() { var isOpen = cartSidebar.classList.toggle('open'); cartOverlay.classList.toggle('open', isOpen); } function goToCheckout() { if (!cartItems.length) return; localStorage.setItem('pos_cart', JSON.stringify(cartItems)); window.location.href = '/pos/sale'; } cartFab.addEventListener('click', toggleCart); cartCloseBtn.addEventListener('click', toggleCart); cartOverlay.addEventListener('click', toggleCart); checkoutBtn.addEventListener('click', goToCheckout); // ─── OFFLINE FALLBACK ─── function enterOfflineMode() { isOffline = true; document.getElementById('offlineBanner').style.display = ''; document.getElementById('offlineBannerText').innerHTML = 'Modo offline — Mostrando solo tu inventario local.'; levelTitle.textContent = 'Inventario local'; setupLevelFilter(false); // TODO: load local inventory via legacy /pos/api/catalog/search endpoint showEmpty('Sin conexion al catalogo', 'Verifica tu conexion. El catalogo TecDoc requiere acceso al servidor central.'); } // ─── BARCODE SCANNER ─── var barcodeBuffer = ''; var barcodeTimeout = null; document.addEventListener('keydown', function (e) { // F1 → focus search if (e.key === 'F1') { e.preventDefault(); searchInput.focus(); return; } // Escape → close panels if (e.key === 'Escape') { closeDetail(); if (cartSidebar.classList.contains('open')) toggleCart(); return; } // Barcode scanner detection if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA') return; if (e.key === 'Enter' && barcodeBuffer.length >= 4) { var code = barcodeBuffer.trim(); barcodeBuffer = ''; // Search for the barcode searchInput.value = code; runSearch(code); return; } if (e.key.length === 1) { barcodeBuffer += e.key; clearTimeout(barcodeTimeout); barcodeTimeout = setTimeout(function () { barcodeBuffer = ''; }, 200); } }); // ─── THEME SWITCHER ─── document.querySelectorAll('[data-theme-switch]').forEach(function (btn) { btn.addEventListener('click', function () { var theme = this.dataset.themeSwitch; document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('pos_theme', theme); document.querySelectorAll('[data-theme-switch]').forEach(function (b) { b.classList.remove('is-active'); b.setAttribute('aria-pressed', 'false'); }); this.classList.add('is-active'); this.setAttribute('aria-pressed', 'true'); }); // Set initial active state var current = localStorage.getItem('pos_theme') || 'industrial'; if (btn.dataset.themeSwitch === current) { btn.classList.add('is-active'); btn.setAttribute('aria-pressed', 'true'); } else { btn.classList.remove('is-active'); btn.setAttribute('aria-pressed', 'false'); } }); // ─── VEHICLE SELECTOR (dropdown bar) ─── var vsYear = document.getElementById('vsYear'); var vsBrand = document.getElementById('vsBrand'); var vsModel = document.getElementById('vsModel'); var vsEngine = document.getElementById('vsEngine'); var vsClear = document.getElementById('vsClear'); // Load years on init function vsLoadYears() { var cacheKey = 'nexus:years-all'; var cached = sessionStorage.getItem(cacheKey); if (cached) { try { var data = JSON.parse(cached); var years = data.data || data || []; } catch (e) { sessionStorage.removeItem(cacheKey); var years = []; } if (!years.length) { years = []; for (var y = 2026; y >= 1990; y--) years.push({ id_year: y, year_car: y }); } vsYear.innerHTML = '' + years.map(function (y) { return ''; }).join(''); return; } apiFetch(API + '/years-all').then(function (data) { if (!data) return; var years = data.data || data; // If endpoint doesn't exist, generate from 1990-2026 if (!years || !years.length) { years = []; for (var y = 2026; y >= 1990; y--) years.push({ id_year: y, year_car: y }); } sessionStorage.setItem(cacheKey, JSON.stringify(data)); vsYear.innerHTML = '' + years.map(function (y) { return ''; }).join(''); }).catch(function () { // Fallback: generate years statically var fallbackYears = []; for (var y = 2026; y >= 1990; y--) fallbackYears.push(y); vsYear.innerHTML = '' + fallbackYears.map(function (y) { return ''; }).join(''); }); } function vsYearChanged() { var yearId = vsYear.value; vsBrand.innerHTML = ''; vsModel.innerHTML = ''; vsEngine.innerHTML = ''; vsBrand.disabled = true; vsModel.disabled = true; vsEngine.disabled = true; vsClear.style.display = yearId ? '' : 'none'; if (!yearId) return; // Load brands filtered by year vsBrand.disabled = false; apiFetch(API + '/brands?year_id=' + yearId + '&mode=' + catalogMode).then(function (data) { var brands = data.data || data; if (!brands) return; vsBrand.innerHTML = '' + brands.map(function (b) { return ''; }).join(''); }); } function vsBrandChanged() { var brandId = vsBrand.value; var yearId = vsYear.value; vsModel.innerHTML = ''; vsEngine.innerHTML = ''; vsModel.disabled = true; vsEngine.disabled = true; if (!brandId) return; // Load models filtered by brand AND year vsModel.disabled = false; apiFetch(API + '/models?brand_id=' + brandId + (yearId ? '&year_id=' + yearId : '')).then(function (data) { var models = data.data || data; if (!models) return; vsModel.innerHTML = '' + models.map(function (m) { return ''; }).join(''); }); } function vsModelChanged() { var modelId = vsModel.value; var yearVal = vsYear.value; vsEngine.innerHTML = ''; vsEngine.disabled = true; if (!modelId || !yearVal) return; vsEngine.disabled = false; apiFetch(API + '/engines?model_id=' + modelId + '&year_id=' + yearVal).then(function (data) { var engines = data.data || data; if (!engines) return; vsEngine.innerHTML = '' + engines.map(function (e) { var label = e.name_engine + (e.trim_level ? ' (' + e.trim_level + ')' : ''); return ''; }).join(''); // If only 1 engine, auto-select if (engines.length === 1) { vsEngine.value = engines[0].id_mye; vsEngineChanged(); } }); } function vsEngineChanged() { var myeId = vsEngine.value; if (!myeId) return; // Update state and load categories var yearText = vsYear.options[vsYear.selectedIndex].text; var brandText = vsBrand.options[vsBrand.selectedIndex].text; var modelText = vsModel.options[vsModel.selectedIndex].text; var engineText = vsEngine.options[vsEngine.selectedIndex].text; nav.brand = { id: parseInt(vsBrand.value), name: brandText }; nav.model = { id: parseInt(vsModel.value), name: modelText }; nav.year = { id: parseInt(vsYear.value), year: yearText }; nav.engine = { id_mye: parseInt(myeId), name: engineText }; nav.level = 'categories'; pushNavState(); loadCategoriesForMode(); // Scroll to catalog content setTimeout(function () { var body = document.getElementById('pageBody'); if (body) body.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 300); } function vsClearAll() { vsYear.value = ''; vsBrand.innerHTML = ''; vsModel.innerHTML = ''; vsEngine.innerHTML = ''; vsBrand.disabled = true; vsModel.disabled = true; vsEngine.disabled = true; vsClear.style.display = 'none'; nav.level = 'brands'; nav.brand = null; nav.model = null; nav.year = null; nav.engine = null; nav.category = null; nav.group = null; nav.partType = null; nav.nxGroup = null; nav.nxSubgroup = null; nav.nxPartType = null; currentPage = 1; pushNavState(); loadBrands(); } // ─── EXPOSE GLOBALS (for backward compat) ─── // ─── BARCODE CAMERA SCAN ─── function startBarcodeScan() { if (!window.NexusNative) { alert('El modulo de escaneo no esta cargado.'); return; } window.NexusNative.scanBarcode().then(function (code) { if (code) { searchInput.value = code; runSearch(code); } }); } // ─── PLATE LOOKUP ─── var plateInputWrap = document.getElementById('plateInputWrap'); var plateInput = document.getElementById('plateInput'); var plateStatus = document.getElementById('plateStatus'); var plateToggle = document.getElementById('plateToggle'); function togglePlate() { var isVisible = plateInputWrap.style.display !== 'none'; plateInputWrap.style.display = isVisible ? 'none' : ''; plateToggle.textContent = isVisible ? 'Tienes las placas?' : 'Ocultar placas'; if (!isVisible && plateInput) plateInput.focus(); } function showPlateStatus(msg, isError) { plateStatus.style.display = msg ? '' : 'none'; plateStatus.textContent = msg; plateStatus.style.color = isError ? 'var(--color-error)' : 'var(--color-text-muted)'; } function lookupPlate() { var plate = (plateInput.value || '').trim().toUpperCase(); if (!plate || plate.length < 5) { showPlateStatus('Ingresa una placa valida (Ej: ABC-1234).', true); return; } showPlateStatus('Buscando placa...', false); apiFetch(API + '/plate/' + encodeURIComponent(plate)).then(function (data) { if (!data) { showPlateStatus('Error de conexion al buscar placa.', true); return; } if (data.error) { showPlateStatus(data.error, true); return; } if (!data.found) { plateStatus.style.display = ''; plateStatus.innerHTML = 'Placa no registrada. Registrar vehiculo'; plateStatus.style.color = 'var(--color-warning, #e6a700)'; return; } var parts = []; if (data.year) parts.push(data.year); if (data.make) parts.push(data.make); if (data.model) parts.push(data.model); var label = parts.join(' ') || 'Vehiculo encontrado'; // If we got a catalog match, auto-fill the dropdowns var match = data.catalog_match; if (match && match.brand_id) { showPlateStatus(label + ' — Cargando catalogo...', false); _autoFillFromVin(match, data); } else { showPlateStatus(label + ' — No encontrado en el catalogo TecDoc.', false); } }); } if (plateInput) { plateInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); lookupPlate(); } }); } // ─── VIN DECODER ─── var vinInputWrap = document.getElementById('vinInputWrap'); var vinInput = document.getElementById('vinInput'); var vinStatus = document.getElementById('vinStatus'); var vinToggle = document.getElementById('vinToggle'); function toggleVin() { var isVisible = vinInputWrap.style.display !== 'none'; vinInputWrap.style.display = isVisible ? 'none' : ''; vinToggle.textContent = isVisible ? 'Tienes el VIN?' : 'Ocultar VIN'; if (!isVisible && vinInput) vinInput.focus(); } function decodeVin() { var vin = (vinInput.value || '').trim().toUpperCase(); if (vin.length !== 17) { showVinStatus('El VIN debe tener exactamente 17 caracteres.', true); return; } showVinStatus('Decodificando VIN...', false); apiFetch(API + '/vin/' + encodeURIComponent(vin)).then(function (data) { if (!data) { showVinStatus('Error de conexion al decodificar VIN.', true); return; } if (data.error && !data.make) { showVinStatus(data.error, true); return; } var parts = []; if (data.year) parts.push(data.year); if (data.make) parts.push(data.make); if (data.model) parts.push(data.model); if (data.engine) parts.push(data.engine); var label = parts.join(' ') || 'Vehiculo no reconocido'; // If we got a catalog match, auto-fill the dropdowns var match = data.catalog_match; if (match && match.brand_id) { showVinStatus(label + ' — Encontrado en catalogo, cargando...', false); _autoFillFromVin(match, data); } else { showVinStatus(label + ' — No encontrado en el catalogo TecDoc.', false); } }); } function _autoFillFromVin(match, vinData) { // Set year dropdown if (match.year_id) { vsYear.value = String(match.year_id); // Trigger brand load apiFetch(API + '/brands?year_id=' + match.year_id).then(function (brandData) { var brands = brandData && (brandData.data || brandData); if (!brands) return; vsBrand.innerHTML = '' + brands.map(function (b) { return ''; }).join(''); vsBrand.disabled = false; vsClear.style.display = ''; if (match.brand_id) { vsBrand.value = String(match.brand_id); // Load models apiFetch(API + '/models?brand_id=' + match.brand_id + '&year_id=' + match.year_id).then(function (modelData) { var models = modelData && (modelData.data || modelData); if (!models) return; vsModel.innerHTML = '' + models.map(function (m) { return ''; }).join(''); vsModel.disabled = false; if (match.model_id) { vsModel.value = String(match.model_id); // Load engines apiFetch(API + '/engines?model_id=' + match.model_id + '&year_id=' + match.year_id).then(function (engData) { var engines = engData && (engData.data || engData); if (!engines) return; vsEngine.innerHTML = '' + engines.map(function (e) { var elabel = e.name_engine + (e.trim_level ? ' (' + e.trim_level + ')' : ''); return ''; }).join(''); vsEngine.disabled = false; // Auto-select engine if only one or if match specifies it if (match.id_mye) { vsEngine.value = String(match.id_mye); vsEngineChanged(); showVinStatus('Vehiculo cargado desde VIN.', false); } else if (engines.length === 1) { vsEngine.value = engines[0].id_mye; vsEngineChanged(); showVinStatus('Vehiculo cargado desde VIN.', false); } else { showVinStatus('Selecciona el motor para continuar.', false); } }); } }); } }); } } function showVinStatus(msg, isError) { vinStatus.style.display = msg ? '' : 'none'; vinStatus.textContent = msg; vinStatus.style.color = isError ? 'var(--color-error)' : 'var(--color-text-muted)'; } // Allow Enter key in VIN input to trigger decode if (vinInput) { vinInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); decodeVin(); } }); } // ─── Supplier prices upload ───────────────────────────────────────────── function openUploadPricesModal() { if (uploadPricesModal) uploadPricesModal.style.display = 'flex'; if (uploadPricesStatus) uploadPricesStatus.innerHTML = ''; if (uploadPricesFile) uploadPricesFile.value = ''; } function closeUploadPricesModal() { if (uploadPricesModal) uploadPricesModal.style.display = 'none'; } async function submitUploadPrices() { if (!uploadPricesFile || !uploadPricesFile.files || !uploadPricesFile.files[0]) { if (uploadPricesStatus) uploadPricesStatus.innerHTML = 'Selecciona un archivo primero.'; return; } var form = new FormData(); form.append('file', uploadPricesFile.files[0]); if (uploadPricesStatus) uploadPricesStatus.innerHTML = 'Subiendo...'; try { var res = await fetch('/pos/api/supplier-catalog/prices/upload', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, body: form }); var data = await res.json(); if (res.ok && data.success) { if (uploadPricesStatus) uploadPricesStatus.innerHTML = '✓ Precios actualizados: ' + data.processed + ' (insertados: ' + data.inserted + ', actualizados: ' + data.updated + ')'; uploadPricesFile.value = ''; } else { var msg = data.error || 'Error al subir precios'; var details = (data.details || []).join('
'); if (uploadPricesStatus) uploadPricesStatus.innerHTML = '' + esc(msg) + '' + (details ? '
' + details + '
' : ''); } } catch (e) { if (uploadPricesStatus) uploadPricesStatus.innerHTML = 'Error de red: ' + esc(e.message) + ''; } } function shouldShowUploadPricesButton() { try { var user = JSON.parse(localStorage.getItem('pos_employee') || '{}'); return user.role === 'owner' || user.role === 'admin'; } catch (e) { return false; } } if (uploadPricesBtn && shouldShowUploadPricesButton()) { uploadPricesBtn.style.display = 'inline-flex'; } window.CatalogApp = { toggleCart: toggleCart, goToCheckout: goToCheckout, addToCart: addToCart, removeFromCart: removeFromCart, updateQty: updateQuantity, clearCart: clearCart, loadPage: function (p) { loadParts(p); }, vsYearChanged: vsYearChanged, vsBrandChanged: vsBrandChanged, vsModelChanged: vsModelChanged, vsEngineChanged: vsEngineChanged, vsClear: vsClearAll, startBarcodeScan: startBarcodeScan, toggleVin: toggleVin, decodeVin: decodeVin, togglePlate: togglePlate, lookupPlate: lookupPlate, setMode: setCatalogMode, openUploadPricesModal: openUploadPricesModal, closeUploadPricesModal: closeUploadPricesModal, submitUploadPrices: submitUploadPrices, }; // ─── INIT ─── renderCart(); updateModeToggleUI(); vsLoadYears(); loadBrands(); })();