- Cleaned 137+ fake engine-displacement models from supplier imports (v3/v4 scripts: Chevrolet, Ford, Chrysler, Dodge, Jeep, Nissan, etc.) - Removed 1,251+ corrupted models (INT. prefixes, year-suffix, torque specs, empty names, trailing-year variants) - Migrated supplier tables to master DB (supplier_catalog, supplier_catalog_compat, supplier_catalog_interchange) - Fixed _get_mye_ids_with_parts() to query supplier_catalog_compat from master DB so supplier-only vehicles appear for all tenants - Added fuzzy model matcher with parenthesis stripping, noise suffix removal, compact matching, prefix/substring fallback, model aliases, and ±3 year proximity - Matched compat rows: KEEP GREEN +14,152, KNADIAN +3,021, VAZLO +127,500, LUK +477, RAYBESTOS +1,743 - Added KNADIAN catalog importer with year-range expansion and future-year filtering - Added VAZLO catalog importer with position parsing and SKU-in-model cleanup - Added Keep Green, LUK, Yokomitsu, Raybestos catalog importers - Cache clearing after cleanups (_classify_cache_*, nexus:mye_ids:*, nexus:brand_mye_counts:*) Final match rates: - KEEP GREEN: 90.3% - VAZLO: 93.6% - YOKOMITSU: 100.0% - KNADIAN: 57.4% - LUK: 51.0% - RAYBESTOS: 55.9%
2136 lines
96 KiB
JavaScript
2136 lines
96 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');
|
|
|
|
// ─── 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 += '<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>') +
|
|
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) + ' > ' + 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();
|
|
}
|
|
});
|
|
}
|
|
|
|
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,
|
|
};
|
|
|
|
// ─── INIT ───
|
|
renderCart();
|
|
updateModeToggleUI();
|
|
vsLoadYears();
|
|
loadBrands();
|
|
|
|
})();
|