Files
Autoparts-DB/pos/static/js/catalog.js
consultoria-as 2b73c2c6db feat: Fase 1-3 completas - precios proveedor, multi-sucursal, factura global
Fase 1: Lista de precios de proveedor
- Tabla supplier_catalog_prices en master DB
- Endpoints GET/POST/PUT/DELETE /supplier-catalog/prices
- Upload CSV/Excel de precios de proveedor
- Visualizacion de supplier_price en catalogo y POS

Fase 2: Multi-sucursal completo
- Migracion v4.0: inventory.branch_id=NULL, tabla inventory_stock
- Campos fiscales en branches (RFC, regimen, CP, serie CFDI, certificados)
- Trigger trg_update_inventory_stock para sincronizar stock por sucursal
- Backend config_bp.py con CRUD de sucursales fiscales
- Backend inventory_bp.py y pos_bp.py refactorizados para inventario compartido
- Backend invoicing_bp.py usa datos fiscales de la sucursal de la venta
- Frontend config.html/js con modal de sucursales expandido

Fase 3: Factura global mensual
- Migracion v4.1: tablas global_invoice_sales, sales.global_invoiced_at
- build_global_invoice_xml() con InformacionGlobal SAT-compliant
- Servicio global_invoice.py para agrupar ventas PUE <=000
- Endpoints POST/GET /global-invoice y /global-invoice/eligible-sales
- Frontend invoicing.html/js con boton y modal de factura global
2026-06-11 08:59:56 +00:00

2192 lines
99 KiB
JavaScript

// /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, '&quot;');
}
// ─── 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 += '<span class="breadcrumb__sep" aria-hidden="true">/</span>';
if (i < parts.length - 1 && parts[i].action) {
html += '<a class="breadcrumb__link" data-bc-action="' + parts[i].action + '">' + esc(parts[i].label) + '</a>';
} else {
html += '<span class="breadcrumb__current">' + esc(parts[i].label) + '</span>';
}
}
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 '<div class="nav-card" role="listitem" data-brand-id="' + b.id_brand + '" data-name="' + esc(b.name_brand) + '">' +
'<div class="nav-card__name">' + esc(b.name_brand) + '</div>' +
'</div>';
}).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 '<div class="nav-card" role="listitem" data-brand-id="' + b.id_brand + '" data-name="' + esc(b.name_brand) + '">' +
'<div class="nav-card__name">' + esc(b.name_brand) + '</div>' +
'</div>';
}).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 '<div class="nav-card" role="listitem" data-model-id="' + m.id_model + '" data-name="' + esc(m.display_name || m.name_model) + '">' +
'<div class="nav-card__name">' + esc(m.display_name || m.name_model) + '</div>' +
'</div>';
}).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 '<div class="nav-card nav-card--year" role="listitem" data-year-id="' + y.id_year + '" data-year="' + y.year_car + '">' +
'<div class="nav-card__name">' + y.year_car + '</div>' +
'</div>';
}).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 '<div class="nav-card" role="listitem" data-mye-id="' + e.id_mye + '" data-name="' + esc(label) + '">' +
'<div class="nav-card__name">' + esc(e.name_engine) + '</div>' +
(e.trim_level ? '<div class="nav-card__sub">' + esc(e.trim_level) + '</div>' : '') +
'</div>';
}).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 '<div class="nav-card" role="listitem" data-cat-id="' + c.id_part_category + '" data-name="' + esc(c.name) + '">' +
'<div class="nav-card__name">' + esc(c.name) + '</div>' +
'<div class="nav-card__count">' + c.part_count + ' partes</div>' +
'</div>';
}).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 '<div class="nav-card" role="listitem" data-group-id="' + g.id_part_group + '" data-name="' + esc(g.name) + '">' +
'<div class="nav-card__name">' + esc(g.name) + '</div>' +
'<div class="nav-card__count">' + g.part_count + ' partes</div>' +
'</div>';
}).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
? '<img src="' + esc(pt.sample_image) + '" alt="" style="width:32px;height:32px;object-fit:contain;margin-right:8px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
: '';
return '<div class="nav-card" role="listitem" data-pt-slug="' + esc(pt.slug) + '" data-pt-name="' + esc(pt.name) + '">' +
'<div class="nav-card__name">' + img + esc(pt.name) + '</div>' +
'<div class="nav-card__count">' + pt.variant_count + ' variante' + (pt.variant_count > 1 ? 's' : '') + '</div>' +
'</div>';
}).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 '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(c.slug) + '" data-name="' + esc(c.name) + '">' +
'<div class="nav-card__name">' + esc(c.name) + '</div>' +
'<div class="nav-card__count">' + c.part_count + ' partes</div>' +
'</div>';
}).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 '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(s.slug) + '" data-name="' + esc(s.name) + '">' +
'<div class="nav-card__name">' + esc(s.name) + '</div>' +
'<div class="nav-card__count">' + s.part_count + ' partes</div>' +
'</div>';
}).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
? '<img src="' + esc(pt.sample_image) + '" alt="" style="width:32px;height:32px;object-fit:contain;margin-right:8px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
: '';
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(pt.slug) + '" data-name="' + esc(pt.name) + '">' +
'<div class="nav-card__name">' + img + esc(pt.name) + '</div>' +
'<div class="nav-card__count">' + pt.variant_count + ' variante' + (pt.variant_count > 1 ? 's' : '') + '</div>' +
'</div>';
}).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 '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(g.slug) + '" data-name="' + esc(g.name) + '">' +
'<div class="nav-card__name">' + esc(g.name) + '</div>' +
'<div class="nav-card__count">' + g.subgroup_count + ' subgrupos</div>' +
'</div>';
}).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 '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(s.slug) + '" data-name="' + esc(s.name) + '">' +
'<div class="nav-card__name">' + esc(s.name) + '</div>' +
'<div class="nav-card__count">' + s.part_count + ' partes</div>' +
'</div>';
}).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
? '<img src="' + esc(pt.sample_image) + '" alt="" style="width:32px;height:32px;object-fit:contain;margin-right:8px;vertical-align:middle;" onerror="this.style.display=\'none\'">'
: '';
return '<div class="nav-card" role="listitem" ' +
'data-slug="' + esc(pt.slug) + '" data-name="' + esc(pt.name) + '">' +
'<div class="nav-card__name">' + img + esc(pt.name) + '</div>' +
'<div class="nav-card__count">' + pt.variant_count + ' variante' + (pt.variant_count > 1 ? 's' : '') + '</div>' +
'</div>';
}).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 = '<span class="stock-badge stock-badge--bodega">' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</span>';
} else {
stockBadge = '<span class="stock-badge stock-badge--none">Sin stock</span>';
}
var imgHtml = p.image_url
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '" loading="lazy" decoding="async">'
: '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>';
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 ? '<span class="manu-tier">★</span>' : '';
manuBadge = '<div class="part-card__manu"><span class="manu-name">' + esc(p.manufacturer) + '</span>' + tierStar + '</div>';
}
var skuLine = p.part_number
? esc(p.part_number) + '<span class="part-card__oem-sub"> · OEM: ' + esc(p.oem_part_number || '') + '</span>'
: esc(p.oem_part_number || '');
return '<article class="part-card' + tierClass + '" role="listitem" data-part-id="' + p.id_part + '">' +
'<div class="part-card__image">' + imgHtml + '</div>' +
'<div class="part-card__body">' +
manuBadge +
'<div class="part-card__oem">' + skuLine + '</div>' +
'<div class="part-card__name">' + esc(p.name) + '</div>' +
'</div>' +
'<div class="part-card__footer">' +
'<span class="part-card__price" style="color:var(--color-text-muted);">Sin precio</span>' +
stockBadge +
'</div>' +
'</article>';
}).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 = '<span class="stock-badge stock-badge--none" style="background:#f59e0b;color:#fff;">Cat. Proveedor</span>';
} else if (p.local_stock > 0) {
stockBadge = '<span class="stock-badge stock-badge--local">En stock: ' + p.local_stock + '</span>';
} else if (p.in_stock_network || p.bodega_count > 0) {
stockBadge = '<span class="stock-badge stock-badge--bodega">' + p.bodega_count + ' bodega' + (p.bodega_count > 1 ? 's' : '') + '</span>';
} else {
stockBadge = '<span class="stock-badge stock-badge--none">Sin stock</span>';
}
// Source badge for local inventory or supplier catalog
var sourceBadge = '';
if (p.source === 'local_inventory') {
sourceBadge = '<span class="stock-badge stock-badge--local" style="margin-left:4px;background:#4f46e5;">Stock Local</span>';
} else if (isSupplier) {
sourceBadge = '<span class="stock-badge stock-badge--local" style="margin-left:4px;background:#f59e0b;color:#fff;">Cat. Proveedor</span>';
}
var imgHtml = p.image_url
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '" loading="lazy" decoding="async">'
: '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7h8M8 12h8M8 17h5"/></svg>';
// 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 = '<div class="part-card__manu"><span class="manu-name">' + esc(p.manufacturer) + '</span>' +
(tierLabel ? '<span class="manu-tier">' + tierLabel + '</span>' : '') + '</div>';
}
// SKU to show: aftermarket part_number in local mode, OEM number otherwise
var skuLine = isLocal && p.part_number
? esc(p.part_number) + '<span class="part-card__oem-sub"> · OEM: ' + esc(p.oem_part_number) + '</span>'
: esc(p.oem_part_number);
return '<article class="part-card' + tierClass + '" role="listitem" data-part-id="' + p.id_part + '" data-source="' + (p.source || '') + '">' +
'<div class="part-card__image">' + imgHtml + '</div>' +
'<div class="part-card__body">' +
manuBadge +
sourceBadge +
'<div class="part-card__oem">' + skuLine + '</div>' +
'<div class="part-card__name">' + esc(p.name) + '</div>' +
'</div>' +
'<div class="part-card__footer">' +
(p.local_price ? '<span class="part-card__price">$' + fmt(p.local_price) + '</span>' : '<span class="part-card__price" style="color:var(--color-text-muted);">Sin precio</span>') +
(p.supplier_price ? '<span class="part-card__price" style="color:#2d7d46;font-size:0.85em;">Prov: $' + fmt(p.supplier_price) + '</span>' : '') +
stockBadge +
'</div>' +
'</article>';
}).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 += '<button class="page-item page-item--wide is-disabled" disabled>Anterior</button>';
} else {
html += '<button class="page-item page-item--wide" data-page="' + (pg.page - 1) + '">Anterior</button>';
}
var pages = buildPageNumbers(pg.page, pg.total_pages);
pages.forEach(function (p) {
if (p === '...') {
html += '<span style="padding:0 4px;color:var(--color-text-muted);">...</span>';
} else if (p === pg.page) {
html += '<button class="page-item is-active">' + p + '</button>';
} else {
html += '<button class="page-item" data-page="' + p + '">' + p + '</button>';
}
});
if (pg.page >= pg.total_pages) {
html += '<button class="page-item page-item--wide is-disabled" disabled>Siguiente</button>';
} else {
html += '<button class="page-item page-item--wide" data-page="' + (pg.page + 1) + '">Siguiente</button>';
}
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 = '<div class="loading is-visible"><div class="spinner"></div></div>';
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 = '<p style="color:var(--color-error);padding:var(--space-4);">Error al cargar detalle.</p>';
return;
}
currentDetailPart = data;
var p = data.part;
var local = data.local;
var bodegas = data.bodegas || [];
var alts = data.alternatives || [];
var html = '';
// Part info
html += '<div class="detail-section">';
if (p.category_name) html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-1);">' + esc(p.category_name) + ' > ' + esc(p.group_name) + '</div>';
html += '<div class="detail-oem">' + esc(p.oem_part_number) + '</div>';
html += '<div class="detail-name">' + esc(p.name) + '</div>';
if (p.description) html += '<div class="detail-desc">' + esc(p.description) + '</div>';
if (p.image_url) html += '<div style="margin-top:var(--space-3);text-align:center;"><img src="' + esc(p.image_url) + '" alt="" loading="lazy" decoding="async" style="max-width:100%;max-height:200px;object-fit:contain;border-radius:var(--radius-sm);"></div>';
html += '</div>';
// Local stock
html += '<div class="detail-section">';
html += '<div class="detail-section__title">Mi stock</div>';
if (local && local.stock > 0) {
html += '<div class="stock-row"><span class="stock-label">Cantidad</span><span class="stock-value stock-value--ok">' + local.stock + ' ' + (local.unit || 'PZA') + '</span></div>';
html += '<div class="stock-row"><span class="stock-label">Precio publico</span><span class="stock-value">$' + fmt(local.price_1) + '</span></div>';
if (local.price_2) html += '<div class="stock-row"><span class="stock-label">Precio mayoreo</span><span class="stock-value">$' + fmt(local.price_2) + '</span></div>';
if (local.price_3) html += '<div class="stock-row"><span class="stock-label">Precio taller</span><span class="stock-value">$' + fmt(local.price_3) + '</span></div>';
if (local.location) html += '<div class="stock-row"><span class="stock-label">Ubicacion</span><span class="stock-value">' + esc(local.location) + '</span></div>';
} else {
html += '<div style="color:var(--color-text-muted);font-size:var(--text-body-sm);">No tienes esta parte en inventario.</div>';
}
html += '</div>';
// Bodegas
if (bodegas.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section__title">Disponible en bodegas</div>';
html += '<table class="bodega-table"><thead><tr><th>Bodega</th><th>Precio</th><th>Stock</th></tr></thead><tbody>';
bodegas.forEach(function (b) {
html += '<tr><td>' + esc(b.business_name) + '</td><td>' + (b.price ? '$' + fmt(b.price) : '--') + '</td><td>' + b.stock + '</td></tr>';
});
html += '</tbody></table></div>';
}
// Alternatives
if (alts.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section__title">Alternativas / Cross-references</div>';
alts.forEach(function (a) {
var stockLabel = a.local_stock > 0
? '<span class="stock-badge stock-badge--local">Stock: ' + a.local_stock + '</span>'
: (a.bodega_count > 0 ? '<span class="stock-badge stock-badge--bodega">' + a.bodega_count + ' bod.</span>' : '');
html += '<div class="alt-item">' +
'<div><div class="alt-item__pn">' + esc(a.part_number) + '</div>' +
'<div class="alt-item__mfr">' + esc(a.manufacturer) + (a.name ? ' — ' + esc(a.name) : '') + '</div></div>' +
'<div class="alt-item__stock">' + stockLabel + '</div>' +
'</div>';
});
html += '</div>';
}
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 = '<div class="loading is-visible"><div class="spinner"></div></div>';
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 = '<p style="color:var(--color-error);padding:var(--space-4);">Error al cargar detalle.</p>';
return;
}
var p = data;
var html = '';
html += '<div class="detail-section">';
html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-1);">' + esc(p.supplier_name) + ' &gt; ' + esc(p.category || '') + '</div>';
html += '<div class="detail-oem">' + esc(p.sku) + '</div>';
html += '<div class="detail-name">' + esc((p.name || '').replace(/\\n/g, ' ')) + '</div>';
if (p.description) html += '<div class="detail-desc">' + esc(p.description) + '</div>';
if (p.image_url) html += '<div style="margin-top:var(--space-3);text-align:center;"><img src="' + esc(p.image_url) + '" alt="" loading="lazy" decoding="async" style="max-width:100%;max-height:200px;object-fit:contain;border-radius:var(--radius-sm);"></div>';
html += '</div>';
// Interchanges
if (p.interchanges && p.interchanges.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section__title">Intercambios OEM</div>';
var seen = {};
p.interchanges.forEach(function(ix) {
var key = (ix.brand || '') + '|' + (ix.interchange_number || '');
if (seen[key]) return;
seen[key] = true;
html += '<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--color-border);">' +
'<span style="font-weight:600;">' + esc(ix.brand || '') + '</span>' +
'<span style="color:var(--color-text-muted);font-family:monospace;">' + esc(ix.interchange_number || '') + '</span>' +
'</div>';
});
html += '</div>';
}
// Compatibilities — deduplicate by (make, model, year, engine)
if (p.compatibilities && p.compatibilities.length) {
html += '<div class="detail-section">';
html += '<div class="detail-section__title">Vehiculos compatibles</div>';
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 += '<div style="font-weight:600;margin-top:8px;">' + esc(c.make) + '</div>';
}
html += '<div style="padding-left:12px;color:var(--color-text-muted);font-size:var(--text-body-sm);">' +
esc(c.model) + ' ' + c.year + ' ' + esc(c.engine || '') + '</div>';
});
html += '</div>';
}
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 = '<div style="padding:var(--space-4);color:var(--color-text-muted);font-size:var(--text-body-sm);">Sin resultados para "' + esc(q) + '"</div>';
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
? '<span class="stock-badge stock-badge--local" style="margin-left:auto;">Stock: ' + r.local_stock + '</span>'
: '';
var sourceBadge = '';
if (isLocal) {
sourceBadge = '<span class="stock-badge stock-badge--local" style="margin-right:4px;background:#4f46e5;">Stock Local</span>';
} else if (isSupplier) {
sourceBadge = '<span class="stock-badge stock-badge--local" style="margin-right:4px;background:#f59e0b;color:#fff;">Cat. Proveedor</span>';
}
var oemNum = isLocal ? (r.oem_part_number || r.part_number || '') : (r.oem_part_number || '');
var cleanName = (r.name || '').replace(/\\n/g, ' ');
return '<div class="search-result-item" data-part-id="' + r.id_part + '" data-name="' + esc(cleanName) + '" data-pn="' + esc(oemNum) + '" data-price="' + (r.local_price || '') + '" data-stock="' + (r.local_stock || 0) + '" data-source="' + (r.source || '') + '">' +
'<div style="flex:1;">' +
'<div class="search-result__oem">' + sourceBadge + esc(oemNum) + '</div>' +
'<div class="search-result__name">' + esc(cleanName) + '</div>' +
(r.vehicle_info ? '<div class="search-result__vehicle">' + esc(r.vehicle_info) + '</div>' : '') +
'</div>' +
stockLabel +
'</div>';
}).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 '<div class="cart-item">' +
'<div style="flex:1;">' +
'<div style="font-weight:600;font-size:0.85rem;color:var(--color-text-primary);">' + esc(c.name) + '</div>' +
'<div style="font-size:0.75rem;color:var(--color-text-muted);">' + esc(c.part_number) + '</div>' +
'<div style="margin-top:4px;display:flex;align-items:center;gap:6px;">' +
'<button data-cart-action="dec" data-idx="' + i + '" style="width:24px;height:24px;border:1px solid var(--color-border);background:var(--color-bg-base);border-radius:var(--radius-sm);cursor:pointer;color:var(--color-text-primary);">-</button>' +
'<span style="font-weight:600;color:var(--color-text-primary);">' + c.quantity + '</span>' +
'<button data-cart-action="inc" data-idx="' + i + '" style="width:24px;height:24px;border:1px solid var(--color-border);background:var(--color-bg-base);border-radius:var(--radius-sm);cursor:pointer;color:var(--color-text-primary);">+</button>' +
'</div></div>' +
'<div style="text-align:right;">' +
'<div style="font-weight:600;color:var(--color-text-primary);">$' + fmt(lineTotal) + '</div>' +
'<button data-cart-action="remove" data-idx="' + i + '" style="font-size:0.75rem;color:var(--color-error,#ef4444);background:none;border:none;cursor:pointer;margin-top:4px;">Quitar</button>' +
'</div></div>';
}).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 = '<strong>Modo offline</strong> — 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 = '<option value="">Año...</option>' +
years.map(function (y) {
return '<option value="' + y.id_year + '">' + y.year_car + '</option>';
}).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 = '<option value="">Año...</option>' +
years.map(function (y) {
return '<option value="' + y.id_year + '">' + y.year_car + '</option>';
}).join('');
}).catch(function () {
// Fallback: generate years statically
var fallbackYears = [];
for (var y = 2026; y >= 1990; y--) fallbackYears.push(y);
vsYear.innerHTML = '<option value="">Año...</option>' +
fallbackYears.map(function (y) {
return '<option value="' + y + '">' + y + '</option>';
}).join('');
});
}
function vsYearChanged() {
var yearId = vsYear.value;
vsBrand.innerHTML = '<option value="">Marca...</option>';
vsModel.innerHTML = '<option value="">Modelo...</option>';
vsEngine.innerHTML = '<option value="">Motor...</option>';
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 = '<option value="">Marca...</option>' +
brands.map(function (b) {
return '<option value="' + b.id_brand + '">' + esc(b.name_brand) + '</option>';
}).join('');
});
}
function vsBrandChanged() {
var brandId = vsBrand.value;
var yearId = vsYear.value;
vsModel.innerHTML = '<option value="">Modelo...</option>';
vsEngine.innerHTML = '<option value="">Motor...</option>';
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 = '<option value="">Modelo...</option>' +
models.map(function (m) {
return '<option value="' + m.id_model + '">' + esc(m.display_name || m.name_model) + '</option>';
}).join('');
});
}
function vsModelChanged() {
var modelId = vsModel.value;
var yearVal = vsYear.value;
vsEngine.innerHTML = '<option value="">Motor...</option>';
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 = '<option value="">Motor...</option>' +
engines.map(function (e) {
var label = e.name_engine + (e.trim_level ? ' (' + e.trim_level + ')' : '');
return '<option value="' + e.id_mye + '">' + esc(label) + '</option>';
}).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 = '<option value="">Marca...</option>';
vsModel.innerHTML = '<option value="">Modelo...</option>';
vsEngine.innerHTML = '<option value="">Motor...</option>';
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. <a href="/pos/customers" style="color:var(--color-primary);">Registrar vehiculo</a>';
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 = '<option value="">Marca...</option>' +
brands.map(function (b) {
return '<option value="' + b.id_brand + '">' + esc(b.name_brand) + '</option>';
}).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 = '<option value="">Modelo...</option>' +
models.map(function (m) {
return '<option value="' + m.id_model + '">' + esc(m.display_name || m.name_model) + '</option>';
}).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 = '<option value="">Motor...</option>' +
engines.map(function (e) {
var elabel = e.name_engine + (e.trim_level ? ' (' + e.trim_level + ')' : '');
return '<option value="' + e.id_mye + '">' + esc(elabel) + '</option>';
}).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 = '<span style="color:var(--color-error);">Selecciona un archivo primero.</span>';
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 = '<span style="color:var(--color-success);">✓ Precios actualizados: ' + data.processed + ' (insertados: ' + data.inserted + ', actualizados: ' + data.updated + ')</span>';
uploadPricesFile.value = '';
} else {
var msg = data.error || 'Error al subir precios';
var details = (data.details || []).join('<br>');
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-error);">' + esc(msg) + '</span>' + (details ? '<div style="margin-top:4px;font-size:0.9em;">' + details + '</div>' : '');
}
} catch (e) {
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-error);">Error de red: ' + esc(e.message) + '</span>';
}
}
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();
})();