feat(catalog): supplier catalog cleanup, fuzzy matching, and navigation fixes

- 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%
This commit is contained in:
2026-06-09 07:47:42 +00:00
parent 5ea667b80e
commit ea29cc31c0
53 changed files with 7727 additions and 548 deletions

View File

@@ -812,6 +812,18 @@
box-shadow: var(--shadow-md);
}
.chart-canvas-wrap {
position: relative;
height: 220px;
width: 100%;
}
.chart-canvas-wrap canvas {
display: block;
width: 100% !important;
height: 100% !important;
}
.rank-list {
display: flex;
flex-direction: column;

View File

@@ -197,4 +197,14 @@
}).catch(function() {});
} catch(e) {}
// ─── Service Worker update handler ───
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', function (event) {
if (event.data && event.data.type === 'SW_UPDATED') {
console.log('[AppInit] SW updated to', event.data.cacheName, '— reloading...');
window.location.reload();
}
});
}
})();

View File

@@ -195,7 +195,19 @@
currentAbort = null;
}
var opts = { headers: headers };
if (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) {
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;
}
@@ -233,7 +245,7 @@
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
return d.innerHTML.replace(/"/g, '"');
}
// ─── Breadcrumb ───
@@ -290,9 +302,9 @@
function resetNav() {
nav.level = 'brands';
pushNavState();
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) {
@@ -927,9 +939,14 @@
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));
});
});
@@ -988,17 +1005,23 @@
partsGrid.innerHTML = data.data.map(function (p) {
// Stock badge — prefer tenant stock, then warehouse network, else fallback
var stockBadge;
if (p.local_stock > 0) {
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>';
}
// Local inventory native badge
var sourceBadge = p.source === 'local_inventory'
? '<span class="stock-badge stock-badge--local" style="margin-left:4px;background:#4f46e5;">Stock Local</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">'
@@ -1039,10 +1062,15 @@
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));
});
});
@@ -1185,6 +1213,73 @@
});
}
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');
@@ -1398,17 +1493,22 @@
}
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 localBadge = isLocal
? '<span class="stock-badge stock-badge--local" style="margin-right:4px;background:#4f46e5;">Stock Local</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 || '');
return '<div class="search-result-item" data-part-id="' + r.id_part + '" data-name="' + esc(r.name) + '" data-pn="' + esc(oemNum) + '" data-price="' + (r.local_price || '') + '" data-stock="' + (r.local_stock || 0) + '">' +
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">' + localBadge + esc(oemNum) + '</div>' +
'<div class="search-result__name">' + esc(r.name) + '</div>' +
'<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 +
@@ -1420,6 +1520,7 @@
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' +
@@ -1429,6 +1530,10 @@
alert(info);
return;
}
if (src === 'supplier_catalog' || (typeof pid === 'string' && pid.indexOf('sc:') === 0)) {
openSupplierDetail(pid.replace('sc:', ''));
return;
}
openPartDetail(parseInt(pid));
});
});

View File

@@ -8,6 +8,9 @@
const token = localStorage.getItem('pos_token') || '';
if (!token) return;
let hourlyChart = null;
let topProductsChart = null;
function headers() {
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
}
@@ -31,10 +34,11 @@
function renderHourlyChart(hourly) {
const ctx = document.getElementById('hourlySalesChart');
if (!ctx) return;
if (hourlyChart) { hourlyChart.destroy(); hourlyChart = null; }
const labels = hourly.map(function (h) { return h.hour + ':00'; });
const totals = hourly.map(function (h) { return h.total; });
new Chart(ctx, {
hourlyChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
@@ -62,10 +66,38 @@
function renderTopProductsChart(topProducts) {
const ctx = document.getElementById('topProductsChart');
if (!ctx) return;
if (topProductsChart) { topProductsChart.destroy(); topProductsChart = null; }
if (!topProducts || topProducts.length === 0) {
// No sales today — render a friendly empty-state mini chart so the canvas
// doesn't collapse or leave a blank hole.
topProductsChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Sin ventas hoy'],
datasets: [{
data: [1],
backgroundColor: ['rgba(136, 136, 136, 0.25)'],
borderWidth: 0,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'right',
labels: { color: '#888', font: { size: 10 }, boxWidth: 10 }
}
}
}
});
return;
}
const labels = topProducts.map(function (p) { return p.name.substring(0, 20); });
const revenues = topProducts.map(function (p) { return p.revenue; });
new Chart(ctx, {
topProductsChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,

View File

@@ -287,8 +287,41 @@
// CREATE ITEM (createModal)
// =====================================================================
function loadCategories() {
var sel = document.getElementById('newCategory');
if (!sel) return;
apiFetch(API + '/categories').then(function(data) {
if (!data || !data.categories) return;
sel.innerHTML = '<option value="">Selecciona categoría</option>';
data.categories.forEach(function(c) {
sel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
});
});
}
window.loadCategories = loadCategories;
function onCategoryChange(categoryId) {
var subSel = document.getElementById('newSubcategory');
if (!subSel) return;
if (!categoryId) {
subSel.innerHTML = '<option value="">Selecciona categoría primero</option>';
subSel.disabled = true;
return;
}
apiFetch(API + '/categories/' + categoryId + '/subcategories').then(function(data) {
if (!data || !data.subcategories) return;
subSel.innerHTML = '<option value="">Selecciona subcategoría</option>';
data.subcategories.forEach(function(s) {
subSel.innerHTML += '<option value="' + s.id + '">' + esc(s.name) + '</option>';
});
subSel.disabled = false;
});
}
window.onCategoryChange = onCategoryChange;
function showCreateModal() {
document.getElementById('createModal').classList.add('is-open');
loadCategories();
// Attach AI classification on part number blur
var pnInput = document.getElementById('newPartNumber');
if (pnInput && !pnInput._classifyBound) {
@@ -334,6 +367,10 @@
function closeCreateModal() {
document.getElementById('createModal').classList.remove('is-open');
document.getElementById('createResult').innerHTML = '';
var catSel = document.getElementById('newCategory');
var subSel = document.getElementById('newSubcategory');
if (catSel) catSel.innerHTML = '<option value="">Selecciona categoría</option>';
if (subSel) { subSel.innerHTML = '<option value="">Selecciona categoría primero</option>'; subSel.disabled = true; }
}
function createItem() {
@@ -350,8 +387,20 @@
price_3: elPrice3 ? (parseFloat(elPrice3.value) || 0) : 0,
min_stock: parseInt(document.getElementById('newMinStock').value) || 0,
initial_stock: parseInt(document.getElementById('newInitialStock').value) || 0,
location: document.getElementById('newLocation').value.trim()
location: document.getElementById('newLocation').value.trim(),
sku_aliases: []
};
var sku2 = document.getElementById('newSku2').value.trim();
var sku3 = document.getElementById('newSku3').value.trim();
var categoryId = document.getElementById('newCategory').value;
var subcategoryId = document.getElementById('newSubcategory').value;
if (sku2) data.sku_aliases.push({sku: sku2, label: 'Alternativo 1'});
if (sku3) data.sku_aliases.push({sku: sku3, label: 'Alternativo 2'});
if (subcategoryId) {
data.category_id = parseInt(subcategoryId);
} else if (categoryId) {
data.category_id = parseInt(categoryId);
}
if (!data.part_number || !data.name) {
document.getElementById('createResult').innerHTML = '<span style="color:var(--color-error);">Numero de parte y nombre son obligatorios</span>';
return;
@@ -366,7 +415,7 @@
loadItems(currentPage);
// Close modal, clear form, refresh badges
closeCreateModal();
['newPartNumber','newName','newBrand','newBarcode','newCost','newPrice1','newMinStock','newInitialStock','newLocation'].forEach(function(id) {
['newPartNumber','newName','newBrand','newBarcode','newSku2','newSku3','newCost','newPrice1','newMinStock','newInitialStock','newLocation'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.value = '';
});
@@ -377,6 +426,54 @@
});
}
function submitBulkImport() {
var fileInput = document.getElementById('bulkImportFile');
var resultEl = document.getElementById('bulkImportResult');
var mode = document.getElementById('bulkImportMode').value;
var strategy = document.getElementById('bulkImportStrategy').value;
if (!fileInput.files || !fileInput.files[0]) {
resultEl.style.display = 'block';
resultEl.innerHTML = '<span style="color:var(--color-error);">Selecciona un archivo CSV o Excel.</span>';
return;
}
var file = fileInput.files[0];
var formData = new FormData();
formData.append('file', file);
resultEl.style.display = 'block';
resultEl.innerHTML = '<span style="color:var(--color-text-muted);">Importando...</span>';
fetch(API + '/items/bulk-import', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'X-Import-Mode': mode,
'X-Import-Strategy': strategy
},
body: formData
}).then(function(resp) { return resp.json(); }).then(function(data) {
if (data.error) {
resultEl.innerHTML = '<span style="color:var(--color-error);">' + esc(data.error) + '</span>';
return;
}
var html = '<div style="color:var(--color-success);">Importacion completada: <strong>' + data.created + '</strong> producto(s) creado(s)';
if (data.skipped > 0) html += ', <strong>' + data.skipped + '</strong> saltado(s)';
html += '</div>';
if (data.warnings && data.warnings.length) {
html += '<div style="margin-top:8px;max-height:160px;overflow:auto;background:var(--color-surface);padding:8px;border-radius:6px;font-size:var(--text-caption);">';
html += '<strong style="color:var(--color-warning);">Advertencias (' + data.warnings.length + '):</strong><ul style="margin:4px 0 0 16px;padding:0;">';
data.warnings.forEach(function(w) {
html += '<li>' + esc(w) + '</li>';
});
html += '</ul></div>';
}
resultEl.innerHTML = html;
loadItems(currentPage);
if (window.loadInventoryStats) window.loadInventoryStats();
}).catch(function(err) {
resultEl.innerHTML = '<span style="color:var(--color-error);">Error de red: ' + esc(err.message) + '</span>';
});
}
window.submitBulkImport = submitBulkImport;
// =====================================================================
// PURCHASE / ENTRADA (purchaseModal)
// =====================================================================
@@ -1006,17 +1103,36 @@
var attrName = esc(attr.name || attr.id);
var inputHtml = '<input type="text" class="meli-title-input" id="meliAttr-' + attrId + '" placeholder="' + attrName + '">';
if (attr.values && attr.values.length) {
inputHtml = '<select class="meli-title-input" id="meliAttr-' + attrId + '">' +
// Some ML attributes (like BRAND) have a closed list but the API still
// accepts free-text via value_name. Provide a select + "Other" fallback.
var selectId = 'meliAttrSel-' + attrId;
var otherId = 'meliAttrOther-' + attrId;
inputHtml = '<select class="meli-title-input" id="' + selectId + '" onchange="onMeliAttrSelectChange(\'' + attrId + '\')">' +
'<option value="">Selecciona ' + attrName + '</option>' +
attr.values.map(function(v) { return '<option value="' + esc(v.name) + '">' + esc(v.name) + '</option>'; }).join('') +
'</select>';
'<option value="__other__">Otra marca (escribir)...</option>' +
'</select>' +
'<input type="text" class="meli-title-input" id="' + otherId + '" placeholder="Escribe la ' + attrName + '" style="display:none;margin-top:6px;">';
}
html += '<div class="inv-field"><label>' + attrName + (attr.tags && attr.tags.required ? ' *' : '') + '</label>' + inputHtml + '</div>';
html += '<div class="inv-field" id="meliAttrWrap-' + attrId + '"><label>' + attrName + (attr.tags && attr.tags.required ? ' *' : '') + '</label>' + inputHtml + '</div>';
});
grid.innerHTML = html;
}).catch(function() { grid.innerHTML = '<p style="color:var(--color-error);font-size:var(--text-caption);">Error cargando atributos</p>'; });
};
window.onMeliAttrSelectChange = function(attrId) {
var sel = document.getElementById('meliAttrSel-' + attrId);
var other = document.getElementById('meliAttrOther-' + attrId);
if (!sel || !other) return;
if (sel.value === '__other__') {
other.style.display = 'block';
other.focus();
} else {
other.style.display = 'none';
other.value = '';
}
};
window.handleMeliCatKeydown = function(e) {
if (!meliCatItems.length) return;
if (e.key === 'ArrowDown') {
@@ -1071,9 +1187,21 @@
if (stockEl) customData.stocks[id] = parseInt(stockEl.value);
var attrs = [];
meliCategoryAttrs.forEach(function(attr) {
var el = document.getElementById('meliAttr-' + attr.id);
if (el && el.value) {
attrs.push({ id: attr.id, value_name: el.value });
var val = '';
var sel = document.getElementById('meliAttrSel-' + attr.id);
if (sel) {
if (sel.value === '__other__') {
var otherEl = document.getElementById('meliAttrOther-' + attr.id);
val = otherEl ? otherEl.value : '';
} else {
val = sel.value;
}
} else {
var el = document.getElementById('meliAttr-' + attr.id);
if (el) val = el.value;
}
if (val) {
attrs.push({ id: attr.id, value_name: val });
}
});
if (attrs.length) customData.attributes[id] = attrs;
@@ -1298,6 +1426,26 @@
var history = data.history || [];
var html = '';
// Tab styles
html += '<style>';
html += '.compat-tabs{display:flex;gap:4px;border-bottom:1px solid var(--color-border);margin-bottom:16px;}';
html += '.compat-tab-btn{padding:8px 16px;font-size:var(--text-body-sm);font-weight:600;cursor:pointer;border:none;background:transparent;color:var(--color-text-muted);border-bottom:2px solid transparent;margin-bottom:-1px;}';
html += '.compat-tab-btn.is-active{color:var(--color-primary);border-bottom-color:var(--color-primary);background:var(--color-surface-0);border-radius:var(--radius-sm) var(--radius-sm) 0 0;}';
html += '.compat-tab-panel{display:none;}';
html += '.compat-tab-panel.is-active{display:block;}';
html += '.compat-form{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;}';
html += '.compat-form label{font-size:var(--text-caption);color:var(--color-text-muted);display:block;margin-bottom:4px;}';
html += '</style>';
// Tabs
html += '<div class="compat-tabs">';
html += '<button class="compat-tab-btn is-active" onclick="switchCompatTab(\'detail\',this)">Detalle</button>';
html += '<button class="compat-tab-btn" onclick="switchCompatTab(\'compat\',this)">Compatibilidad</button>';
html += '</div>';
// Detail panel
html += '<div id="compatTab-detail" class="compat-tab-panel is-active">';
// Product image section
html += '<div style="text-align:center;margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
if (data.image_url) {
@@ -1330,12 +1478,19 @@
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">ID Inventario</span><strong style="font-family:var(--font-mono);">' + data.id + '</strong></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">No. Parte</span><strong>' + esc(data.part_number) + '</strong></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Nombre</span><strong>' + esc(data.name) + '</strong></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Marca</span>' + esc(data.brand) + '</div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Marca</span>' + esc(data.brand || '-') + '</div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Categoría</span>' + esc(data.category_name || '-') + '</div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Codigo de Barras</span><span style="font-family:var(--font-mono);">' + esc(data.barcode) + '</span></div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Ubicacion</span>' + esc(data.location || '-') + '</div>';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Stock</span><strong style="font-size:1.2em;">' + (data.stock || 0) + '</strong></div>';
html += '</div>';
// SKU Aliases section
html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-widest);margin-bottom:8px;">SKU Alternativos</div>';
html += '<div id="skuAliasContent" style="margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
html += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando SKU alternativos...</p>';
html += '</div>';
// Prices
html += '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
html += '<div><span style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;display:block;">Costo</span><span class="td--amount">$' + fmt(data.cost) + '</span></div>';
@@ -1415,14 +1570,67 @@
el.innerHTML = html2;
}
// Vehicle compatibility section
html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-widest);margin-bottom:8px;">Vehiculos Compatibles</div>';
// Close detail panel
html += '</div>';
// Compatibility panel
html += '<div id="compatTab-compat" class="compat-tab-panel">';
// Existing compatibilities
html += '<div id="compatContent" style="margin-bottom:16px;padding-bottom:16px;border-bottom:1px solid var(--color-border);">';
html += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Cargando compatibilidades...</p>';
html += '</div>';
// Load vehicle compatibilities
(function loadCompat() {
// Manual add form
html += '<div style="font-size:var(--text-caption);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:var(--tracking-widest);margin-bottom:8px;">Agregar Manualmente</div>';
html += '<div class="compat-form">';
html += '<div><label>Marca</label><select class="select-filter" id="manualMake" onchange="onManualMakeChange(' + itemId + ')" style="width:100%;"><option value="">Cargando...</option></select></div>';
html += '<div><label>Modelo</label><select class="select-filter" id="manualModel" onchange="onManualModelChange(' + itemId + ')" style="width:100%;" disabled><option value="">Selecciona marca</option></select></div>';
html += '<div><label>Ano</label><select class="select-filter" id="manualYear" onchange="onManualYearChange(' + itemId + ')" style="width:100%;" disabled><option value="">Selecciona modelo</option></select></div>';
html += '<div><label>Motor</label><select class="select-filter" id="manualEngine" style="width:100%;" disabled><option value="">Selecciona ano</option></select></div>';
html += '</div>';
html += '<button class="btn btn--primary btn--sm" onclick="submitManualCompat(' + itemId + ')">Agregar compatibilidad</button>';
// Auto-match button
var btnLabel = compatSource === 'qwen' ? 'Auto-Match con IA (QWEN)' : (compatSource === 'both' ? 'Auto-Match (TecDoc + IA)' : 'Auto-Match por TecDoc');
html += '<div style="margin-top:16px;"><button class="btn btn--ghost btn--sm" onclick="autoMatchCompat(' + itemId + ')">' + btnLabel + '</button></div>';
html += '</div>';
// Load SKU aliases
(function loadSkuAliases() {
fetch('/pos/api/inventory/items/' + itemId + '/skus', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var el = document.getElementById('skuAliasContent');
if (!el) return;
var list = d.aliases || [];
var html2 = '';
if (list.length > 0) {
html2 += '<table class="data-table"><thead><tr><th>SKU</th><th>Etiqueta</th><th></th></tr></thead><tbody>';
list.forEach(function(a) {
html2 += '<tr><td class="td--mono">' + esc(a.sku) + '</td><td>' + esc(a.label || '-') + '</td>';
html2 += '<td><button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="removeSkuAlias(' + itemId + ',' + a.id + ')">Quitar</button></td></tr>';
});
html2 += '</tbody></table>';
} else {
html2 += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Sin SKU alternativos.</p>';
}
html2 += '<div style="margin-top:8px;display:flex;gap:8px;">';
html2 += '<input type="text" class="meli-title-input" id="newAliasSku-' + itemId + '" placeholder="Nuevo SKU" style="flex:1;">';
html2 += '<input type="text" class="meli-title-input" id="newAliasLabel-' + itemId + '" placeholder="Etiqueta (opcional)" style="flex:1;">';
html2 += '<button class="btn btn--primary btn--sm" onclick="addSkuAlias(' + itemId + ')">Agregar</button>';
html2 += '</div>';
el.innerHTML = html2;
})
.catch(function() {
var el = document.getElementById('skuAliasContent');
if (el) el.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Error al cargar SKU alternativos.</p>';
});
})();
// Load vehicle compatibilities and makes
(function loadCompatPanel() {
fetch('/pos/api/inventory/items/' + itemId + '/vehicles', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
@@ -1441,15 +1649,28 @@
} else {
html2 += '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Sin vehiculos vinculados.</p>';
}
var btnLabel = compatSource === 'qwen' ? 'Auto-Match con IA (QWEN)' : (compatSource === 'both' ? 'Auto-Match (TecDoc + IA)' : 'Auto-Match por TecDoc');
var btnDesc = compatSource === 'qwen' ? 'Busca compatibilidad usando inteligencia artificial' : (compatSource === 'both' ? 'Busca en catalogo central y con IA' : 'Busca en catalogo central y vincula automaticamente');
html2 += '<div style="margin-top:8px;"><button class="btn btn--primary btn--sm" onclick="autoMatchCompat(' + itemId + ')">' + btnLabel + '</button> <span style="font-size:var(--text-caption);color:var(--color-text-muted);">' + btnDesc + '</span></div>';
el.innerHTML = html2;
})
.catch(function() {
var el = document.getElementById('compatContent');
if (el) el.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-caption);">Error al cargar compatibilidades.</p>';
});
// Load makes
fetch('/pos/api/inventory/vehicles/makes', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var sel = document.getElementById('manualMake');
if (!sel) return;
var opts = '<option value="">Selecciona marca</option>';
(d.makes || []).forEach(function(m) { opts += '<option value="' + esc(m.name) + '" data-id="' + m.id + '">' + esc(m.name) + '</option>'; });
sel.innerHTML = opts;
sel.disabled = false;
})
.catch(function() {
var sel = document.getElementById('manualMake');
if (sel) { sel.innerHTML = '<option value="">Error al cargar</option>'; }
});
})();
// Movement history
@@ -1510,6 +1731,150 @@
}).catch(function() { alert('Error al quitar compatibilidad'); });
}
// Manual compatibility tab functions
window.switchCompatTab = function(tab, btn) {
document.querySelectorAll('.compat-tab-btn').forEach(function(b) { b.classList.remove('is-active'); });
document.querySelectorAll('.compat-tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
btn.classList.add('is-active');
document.getElementById('compatTab-' + tab).classList.add('is-active');
};
window.onManualMakeChange = function(itemId) {
var sel = document.getElementById('manualMake');
var modelSel = document.getElementById('manualModel');
var yearSel = document.getElementById('manualYear');
var engineSel = document.getElementById('manualEngine');
if (!sel || !modelSel) return;
var opt = sel.options[sel.selectedIndex];
var brandId = opt ? opt.getAttribute('data-id') : null;
modelSel.innerHTML = '<option value="">Cargando...</option>';
modelSel.disabled = true;
yearSel.innerHTML = '<option value="">Selecciona modelo</option>';
yearSel.disabled = true;
engineSel.innerHTML = '<option value="">Selecciona ano</option>';
engineSel.disabled = true;
if (!brandId) {
modelSel.innerHTML = '<option value="">Selecciona marca</option>';
return;
}
fetch('/pos/api/inventory/vehicles/models?brand_id=' + brandId, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var opts = '<option value="">Selecciona modelo</option>';
(d.models || []).forEach(function(m) { opts += '<option value="' + esc(m.name) + '" data-id="' + m.id + '">' + esc(m.name) + '</option>'; });
modelSel.innerHTML = opts;
modelSel.disabled = false;
})
.catch(function() { modelSel.innerHTML = '<option value="">Error</option>'; });
};
window.onManualModelChange = function(itemId) {
var modelSel = document.getElementById('manualModel');
var yearSel = document.getElementById('manualYear');
var engineSel = document.getElementById('manualEngine');
if (!modelSel || !yearSel) return;
var opt = modelSel.options[modelSel.selectedIndex];
var modelId = opt ? opt.getAttribute('data-id') : null;
yearSel.innerHTML = '<option value="">Cargando...</option>';
yearSel.disabled = true;
engineSel.innerHTML = '<option value="">Selecciona ano</option>';
engineSel.disabled = true;
if (!modelId) {
yearSel.innerHTML = '<option value="">Selecciona modelo</option>';
return;
}
fetch('/pos/api/inventory/vehicles/years?model_id=' + modelId, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var opts = '<option value="">Selecciona ano</option>';
(d.years || []).forEach(function(y) { opts += '<option value="' + y.year + '" data-id="' + y.id + '">' + y.year + '</option>'; });
yearSel.innerHTML = opts;
yearSel.disabled = false;
})
.catch(function() { yearSel.innerHTML = '<option value="">Error</option>'; });
};
window.onManualYearChange = function(itemId) {
var modelSel = document.getElementById('manualModel');
var yearSel = document.getElementById('manualYear');
var engineSel = document.getElementById('manualEngine');
if (!modelSel || !yearSel || !engineSel) return;
var mOpt = modelSel.options[modelSel.selectedIndex];
var yOpt = yearSel.options[yearSel.selectedIndex];
var modelId = mOpt ? mOpt.getAttribute('data-id') : null;
var yearId = yOpt ? yOpt.getAttribute('data-id') : null;
engineSel.innerHTML = '<option value="">Cargando...</option>';
engineSel.disabled = true;
if (!modelId || !yearId) {
engineSel.innerHTML = '<option value="">Selecciona ano</option>';
return;
}
fetch('/pos/api/inventory/vehicles/engines?model_id=' + modelId + '&year_id=' + yearId, { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var opts = '<option value="">Selecciona motor</option>';
(d.engines || []).forEach(function(e) { opts += '<option value="' + esc(e.name) + '" data-code="' + esc(e.code || '') + '">' + esc(e.name + (e.code ? ' (' + e.code + ')' : '')) + '</option>'; });
engineSel.innerHTML = opts;
engineSel.disabled = false;
})
.catch(function() { engineSel.innerHTML = '<option value="">Error</option>'; });
};
window.submitManualCompat = function(itemId) {
var makeSel = document.getElementById('manualMake');
var modelSel = document.getElementById('manualModel');
var yearSel = document.getElementById('manualYear');
var engineSel = document.getElementById('manualEngine');
if (!makeSel || !modelSel || !yearSel) return;
var make = makeSel.value;
var model = modelSel.value;
var year = yearSel.value;
var engine = engineSel ? engineSel.value : '';
var engineCode = engineSel && engineSel.selectedIndex > 0 ? (engineSel.options[engineSel.selectedIndex].getAttribute('data-code') || '') : '';
if (!make || !model || !year) {
alert('Selecciona al menos marca, modelo y ano');
return;
}
fetch('/pos/api/inventory/items/' + itemId + '/vehicles/manual', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ make: make, model: model, year: parseInt(year), engine: engine, engine_code: engineCode })
}).then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) { alert(d.error); return; }
viewProductDetail(itemId);
}).catch(function() { alert('Error al agregar compatibilidad'); });
};
// SKU alias actions
window.addSkuAlias = function(itemId) {
var skuEl = document.getElementById('newAliasSku-' + itemId);
var labelEl = document.getElementById('newAliasLabel-' + itemId);
var sku = skuEl ? skuEl.value.trim() : '';
var label = labelEl ? labelEl.value.trim() : '';
if (!sku) { alert('Ingresa un SKU'); return; }
fetch('/pos/api/inventory/items/' + itemId + '/skus', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: sku, label: label })
}).then(function(r) { return r.json(); })
.then(function(d) {
if (d.error) { alert(d.error); return; }
viewProductDetail(itemId);
}).catch(function() { alert('Error al agregar SKU'); });
};
window.removeSkuAlias = function(itemId, aliasId) {
if (!confirm('Eliminar este SKU alternativo?')) return;
fetch('/pos/api/inventory/items/' + itemId + '/skus/' + aliasId, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) { return r.json(); })
.then(function() {
viewProductDetail(itemId);
}).catch(function() { alert('Error al eliminar SKU'); });
};
// =====================================================================
// EXPOSE GLOBALS (for onclick handlers in HTML)
// =====================================================================

View File

@@ -27,6 +27,7 @@
});
if (tab === 'listings') loadListings();
if (tab === 'orders') loadOrders();
if (tab === 'questions') loadQuestions();
};
function closeModal(id) {
@@ -81,7 +82,7 @@
localStorage.setItem('meli_shipping', shipping);
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
var authUrl = 'https://auth.mercadolibre.com.mx/authorization?response_type=code&client_id=' + encodeURIComponent(clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri);
var authUrl = 'https://auth.mercadolibre.com.mx/authorization?response_type=code&client_id=' + encodeURIComponent(clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri) + '&scope=read+write+offline_access';
window.location.href = authUrl;
};
@@ -148,16 +149,18 @@
var statusClass = 'meli-status--' + (l.external_status || 'pending');
return '<div class="meli-card">'
+ '<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:var(--space-3);">'
+ '<div style="font-weight:700;font-size:var(--text-body-sm);line-height:1.3;">' + escapeHtml(l.title || l.inventory_name || 'Sin título') + '</div>'
+ '<a href="' + escapeHtml(l.external_permalink || '#') + '" target="_blank" rel="noopener" style="font-weight:700;font-size:var(--text-body-sm);line-height:1.3;color:var(--color-primary);text-decoration:none;">' + escapeHtml(l.title || l.inventory_name || 'Sin título') + ' ↗</a>'
+ '<span class="meli-status ' + statusClass + '">' + (l.external_status || '—') + '</span>'
+ '</div>'
+ '<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-2);">'
+ 'SKU: ' + escapeHtml(l.part_number || '—') + ' · ID ML: ' + escapeHtml(l.external_item_id || '—')
+ 'SKU: ' + escapeHtml(l.part_number || '—') + ' · ID ML: <a href="' + escapeHtml(l.external_permalink || '#') + '" target="_blank" rel="noopener" style="color:var(--color-primary);">' + escapeHtml(l.external_item_id || '—') + '</a>'
+ '</div>'
+ '<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3);">'
+ '<button class="btn btn--ghost btn--xs" onclick="syncListing(' + l.id + ')">Sync</button>'
+ (l.external_status === 'active' ? '<button class="btn btn--ghost btn--xs" onclick="pauseListing(' + l.id + ')">Pausar</button>' : '<button class="btn btn--ghost btn--xs" onclick="activateListing(' + l.id + ')">Activar</button>')
+ '<button class="btn btn--danger btn--xs" onclick="deleteListing(' + l.id + ')">Cerrar</button>'
+ (l.external_status === 'closed' || !l.is_active
? '<button class="btn btn--danger btn--xs" onclick="deleteListingPermanently(' + l.id + ')">Eliminar</button>'
: '<button class="btn btn--danger btn--xs" onclick="deleteListing(' + l.id + ')">Cerrar</button>')
+ '</div>'
+ '</div>';
}).join('');
@@ -202,6 +205,14 @@
} catch (e) { alert('Error: ' + e.message); }
};
window.deleteListingPermanently = async function(id) {
if (!confirm('¿Eliminar permanentemente esta publicación del listado local? Esta acción no se puede deshacer.')) return;
try {
var res = await fetch(API + '/listings/' + id + '/permanent', { method: 'DELETE', headers: headers() });
if (res.ok) { loadListings(); } else { alert('Error al eliminar'); }
} catch (e) { alert('Error: ' + e.message); }
};
// ─── Orders ────────────────────────────────────────────────────────────
var ordersData = [];
@@ -451,11 +462,144 @@
container.innerHTML = html;
}
// ─── Questions ─────────────────────────────────────────────────────────
var questionsData = [];
window.loadQuestions = async function() {
var container = document.getElementById('questionsContainer');
container.innerHTML = '<div class="skeleton-grid">' + Array(6).fill('<div class="skeleton skeleton--card"></div>').join('') + '</div>';
try {
var res = await fetch(API + '/questions', { headers: headers() });
if (!res.ok) throw new Error('Failed to load questions');
var data = await res.json();
questionsData = data.items || [];
renderQuestions();
} catch (e) {
container.innerHTML = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
title: 'Sin preguntas',
subtitle: 'No hay preguntas de compradores pendientes. Sincroniza con MercadoLibre para obtenerlas.',
action: '<button class="btn btn--meli btn--sm" onclick="syncQuestions()">Sincronizar con ML</button>'
});
}
};
function renderQuestions() {
var container = document.getElementById('questionsContainer');
var statusFilter = document.getElementById('questionStatusFilter').value;
var search = document.getElementById('questionSearch').value.toLowerCase();
var filtered = questionsData.filter(function(q) {
if (statusFilter && q.status !== statusFilter) return false;
if (search && !((q.question_text || '').toLowerCase().includes(search)) && !((q.listing_title || '').toLowerCase().includes(search))) return false;
return true;
});
// Stats bar
var unanswered = questionsData.filter(function(q) { return q.status === 'unanswered'; }).length;
var answered = questionsData.filter(function(q) { return q.status === 'answered'; }).length;
var total = questionsData.length;
var statsHtml = '<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4);">' +
'<div class="meli-card" style="flex:1;min-width:140px;text-align:center;"><div style="font-size:28px;font-weight:800;color:var(--color-primary);">' + total + '</div><div style="font-size:var(--text-caption);color:var(--color-text-muted);">Total preguntas</div></div>' +
'<div class="meli-card" style="flex:1;min-width:140px;text-align:center;"><div style="font-size:28px;font-weight:800;color:var(--color-error);">' + unanswered + '</div><div style="font-size:var(--text-caption);color:var(--color-text-muted);">Sin responder</div></div>' +
'<div class="meli-card" style="flex:1;min-width:140px;text-align:center;"><div style="font-size:28px;font-weight:800;color:var(--color-success);">' + answered + '</div><div style="font-size:var(--text-caption);color:var(--color-text-muted);">Respondidas</div></div>' +
'</div>';
document.getElementById('questionsStatsBar').innerHTML = statsHtml;
if (!filtered.length) {
container.innerHTML = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
title: 'Sin preguntas',
subtitle: statusFilter ? 'No hay preguntas con el filtro seleccionado.' : 'No hay preguntas sincronizadas.',
action: ''
});
return;
}
container.innerHTML = filtered.map(function(q) {
var statusClass = 'meli-status--' + (q.status || 'pending');
var statusLabel = q.status === 'unanswered' ? 'Sin responder' : (q.status === 'answered' ? 'Respondida' : (q.status || '—'));
var answerHtml = '';
if (q.status === 'unanswered') {
answerHtml = '<div style="margin-top:var(--space-2);">' +
'<textarea class="meli-title-input" id="qAnswer-' + q.id + '" rows="2" placeholder="Escribe tu respuesta..."></textarea>' +
'<button class="btn btn--primary btn--xs" style="margin-top:6px;" onclick="submitAnswer(' + q.id + ')">Enviar respuesta</button>' +
'</div>';
} else if (q.answer_text) {
answerHtml = '<div style="margin-top:var(--space-2);padding:var(--space-2);background:var(--color-surface-0);border-radius:var(--radius-sm);font-size:var(--text-caption);color:var(--color-text-secondary);">' +
'<strong>Respuesta:</strong> ' + escapeHtml(q.answer_text) +
'</div>';
}
return '<div class="meli-card">'
+ '<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:var(--space-3);">'
+ '<div style="font-weight:700;font-size:var(--text-body-sm);line-height:1.3;">' + escapeHtml(q.listing_title || 'Artículo sin título') + '</div>'
+ '<span class="meli-status ' + statusClass + '">' + statusLabel + '</span>'
+ '</div>'
+ '<div style="font-size:var(--text-caption);color:var(--color-text-muted);margin-bottom:var(--space-2);">'
+ 'Comprador: ' + escapeHtml(q.buyer_nickname || '—') + ' · ' + (q.question_date ? new Date(q.question_date).toLocaleString('es-MX') : '—')
+ '</div>'
+ '<div style="font-size:var(--text-body-sm);color:var(--color-text-primary);margin-bottom:var(--space-2);">'
+ '<strong>Pregunta:</strong> ' + escapeHtml(q.question_text)
+ '</div>'
+ answerHtml
+ '</div>';
}).join('');
}
window.filterQuestions = renderQuestions;
window.syncQuestions = async function() {
var btn = document.querySelector('#panel-questions .btn--primary');
if (btn) { btn.disabled = true; btn.textContent = 'Sincronizando...'; }
try {
var res = await fetch(API + '/questions/sync', { method: 'POST', headers: headers() });
var data = await res.json();
if (res.ok) {
showToast('Sincronizadas ' + (data.synced || 0) + ' preguntas', 'ok', { title: 'Sincronización' });
loadQuestions();
} else {
showToast(data.error || 'Error al sincronizar', 'error', { title: 'Error' });
}
} catch (e) {
showToast(e.message, 'error', { title: 'Error de red' });
} finally {
if (btn) { btn.disabled = false; btn.textContent = '🔄 Actualizar'; }
}
};
window.submitAnswer = async function(questionId) {
var textarea = document.getElementById('qAnswer-' + questionId);
if (!textarea) return;
var text = textarea.value.trim();
if (!text) {
showToast('Escribe una respuesta antes de enviar', 'error', { title: 'Respuesta vacía' });
return;
}
try {
var res = await fetch(API + '/questions/' + questionId + '/answer', {
method: 'POST',
headers: headers(),
body: JSON.stringify({ text: text })
});
var data = await res.json();
if (res.ok) {
showToast('Respuesta enviada correctamente', 'ok', { title: 'Pregunta respondida' });
loadQuestions();
} else {
showToast(data.error || 'Error al enviar respuesta', 'error', { title: 'Error' });
}
} catch (e) {
showToast(e.message, 'error', { title: 'Error de red' });
}
};
// Register Cmd+K items
if (typeof registerCmdKItem === 'function') {
registerCmdKItem({ group: 'MercadoLibre', label: 'Configuración ML', href: '/pos/marketplace-external', icon: '⚙️' });
registerCmdKItem({ group: 'MercadoLibre', label: 'Publicaciones ML', href: '/pos/marketplace-external#listings', icon: '📦' });
registerCmdKItem({ group: 'MercadoLibre', label: 'Órdenes ML', href: '/pos/marketplace-external#orders', icon: '🛒' });
registerCmdKItem({ group: 'MercadoLibre', label: 'Preguntas ML', href: '/pos/marketplace-external#questions', icon: '❓' });
}
document.addEventListener('DOMContentLoaded', function() {

View File

@@ -0,0 +1,299 @@
(function() {
'use strict';
const API = '/pos/api/supplier-catalog';
const VEHICLE_API = '/pos/api/inventory/vehicles';
const token = localStorage.getItem('pos_token') || '';
let state = {
q: '',
category: '',
make: '',
model: '',
year: '',
engine: '',
myeId: null,
page: 1,
perPage: 30,
totalPages: 1,
categories: [],
items: []
};
function headers() {
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
}
let scAbort = null;
let scSeq = 0;
async function apiFetch(url) {
if (scAbort) {
scAbort.abort();
scAbort = null;
}
const ctrl = new AbortController();
scAbort = ctrl;
try {
const resp = await fetch(url, { headers: headers(), signal: ctrl.signal });
if (resp.status === 401) { window.location.href = '/pos/login'; return null; }
if (!resp.ok) { console.error('API error', url, resp.status); return null; }
return resp.json();
} catch (e) {
if (e.name === 'AbortError') return null;
console.error('API error', url, e);
return null;
}
}
async function apiFetchSeq(url) {
const mySeq = ++scSeq;
const data = await apiFetch(url);
if (!data || scSeq !== mySeq) return null;
return data;
}
// ─── Categories ─────────────────────────────────────────────
async function loadCategories() {
const data = await apiFetch(API + '/categories');
if (!data) return;
state.categories = data.categories || [];
renderCategories();
}
function renderCategories() {
const el = document.getElementById('categoriesGrid');
if (!el) return;
let html = '<div class="sc-cat-card' + (state.category === '' ? ' active' : '') + '" onclick="selectCategory(\'\')">' +
'<div>Todas</div><div class="count">' + state.categories.reduce((a,c)=>a+c.count,0) + ' items</div></div>';
state.categories.forEach(function(c) {
html += '<div class="sc-cat-card' + (state.category === c.name ? ' active' : '') + '" onclick="selectCategory(\'' + escapeHtml(c.name) + '\')">' +
'<div>' + escapeHtml(c.name) + '</div><div class="count">' + c.count + ' items</div></div>';
});
el.innerHTML = html;
}
window.selectCategory = function(name) {
state.category = name;
state.page = 1;
renderCategories();
doSearch();
};
// ─── Vehicle filters ────────────────────────────────────────
async function loadMakes() {
const data = await apiFetch(VEHICLE_API + '/makes');
if (!data) return;
const sel = document.getElementById('filterMake');
sel.innerHTML = '<option value="">Marca vehiculo</option>';
(data.data || []).forEach(function(m) {
sel.innerHTML += '<option value="' + escapeHtml(m.name_brand) + '">' + escapeHtml(m.name_brand) + '</option>';
});
}
window.onMakeChange = async function() {
const sel = document.getElementById('filterMake');
state.make = sel.value;
state.model = ''; state.year = ''; state.engine = ''; state.myeId = null;
document.getElementById('filterModel').disabled = true;
document.getElementById('filterYear').disabled = true;
document.getElementById('filterEngine').disabled = true;
if (!state.make) { doSearch(); return; }
const makes = await apiFetchSeq(VEHICLE_API + '/makes');
if (!makes) return;
const brand = (makes.data || []).find(function(m) { return m.name_brand === state.make; });
if (!brand) { doSearch(); return; }
const models = await apiFetchSeq(VEHICLE_API + '/models?brand_id=' + brand.id_brand);
if (!models) return;
const msel = document.getElementById('filterModel');
msel.innerHTML = '<option value="">Modelo</option>';
(models.data || []).forEach(function(m) {
msel.innerHTML += '<option value="' + m.id_model + '">' + escapeHtml(m.name_model) + '</option>';
});
msel.disabled = false;
doSearch();
};
window.onModelChange = async function() {
const sel = document.getElementById('filterModel');
const modelId = sel.value;
state.model = modelId ? sel.options[sel.selectedIndex].text : '';
state.year = ''; state.engine = ''; state.myeId = null;
document.getElementById('filterYear').disabled = true;
document.getElementById('filterEngine').disabled = true;
if (!modelId) { doSearch(); return; }
const years = await apiFetchSeq(VEHICLE_API + '/years?model_id=' + modelId);
if (!years) return;
const ysel = document.getElementById('filterYear');
ysel.innerHTML = '<option value="">Año</option>';
(years.data || []).forEach(function(y) {
ysel.innerHTML += '<option value="' + y.id_year + '">' + y.year_car + '</option>';
});
ysel.disabled = false;
doSearch();
};
window.onYearChange = async function() {
const sel = document.getElementById('filterYear');
const yearId = sel.value;
const modelId = document.getElementById('filterModel').value;
state.year = yearId ? sel.options[sel.selectedIndex].text : '';
state.engine = ''; state.myeId = null;
document.getElementById('filterEngine').disabled = true;
if (!yearId || !modelId) { doSearch(); return; }
const engines = await apiFetchSeq(VEHICLE_API + '/engines?model_id=' + modelId + '&year_id=' + yearId);
if (!engines) return;
const esel = document.getElementById('filterEngine');
esel.innerHTML = '<option value="">Motorizacion</option>';
(engines.data || []).forEach(function(e) {
const label = escapeHtml(e.name_engine) + (e.trim_level ? ' (' + escapeHtml(e.trim_level) + ')' : '');
esel.innerHTML += '<option value="' + e.id_mye + '">' + label + '</option>';
});
esel.disabled = false;
doSearch();
};
// ─── Search ─────────────────────────────────────────────────
window.doSearch = async function() {
state.q = document.getElementById('searchInput').value.trim();
const engineSel = document.getElementById('filterEngine');
state.myeId = engineSel.value || null;
let url = API + '/search?page=' + state.page + '&per_page=' + state.perPage;
if (state.q) url += '&q=' + encodeURIComponent(state.q);
if (state.category) url += '&category=' + encodeURIComponent(state.category);
if (state.myeId) {
url += '&mye_id=' + state.myeId;
} else {
if (state.make) url += '&make=' + encodeURIComponent(state.make);
if (state.model) url += '&model=' + encodeURIComponent(state.model);
if (state.year) url += '&year=' + encodeURIComponent(state.year);
}
const data = await apiFetch(url);
if (!data) return;
state.items = data.data || [];
state.totalPages = (data.pagination || {}).total_pages || 1;
renderItems();
renderPagination();
};
window.clearFilters = function() {
document.getElementById('searchInput').value = '';
document.getElementById('filterMake').value = '';
document.getElementById('filterModel').innerHTML = '<option value="">Modelo</option>'; document.getElementById('filterModel').disabled = true;
document.getElementById('filterYear').innerHTML = '<option value="">Año</option>'; document.getElementById('filterYear').disabled = true;
document.getElementById('filterEngine').innerHTML = '<option value="">Motorizacion</option>'; document.getElementById('filterEngine').disabled = true;
state.q = ''; state.category = ''; state.make = ''; state.model = ''; state.year = ''; state.engine = ''; state.myeId = null; state.page = 1;
renderCategories();
doSearch();
};
// ─── Render results ─────────────────────────────────────────
function renderItems() {
const el = document.getElementById('partsGrid');
if (!el) return;
if (!state.items.length) {
el.innerHTML = '<div class="sc-empty" style="grid-column:1/-1;"><div style="font-size:48px;margin-bottom:var(--space-4);">🔍</div><h3>Sin resultados</h3><p>Intenta con otros filtros o terminos de busqueda.</p></div>';
return;
}
el.innerHTML = state.items.map(function(it) {
return '<div class="sc-card" onclick="openDetail(' + it.id + ')">' +
'<div class="sc-card__sku">' + escapeHtml(it.sku) + '</div>' +
'<div class="sc-card__name">' + escapeHtml(it.name) + '</div>' +
'<div class="sc-card__meta">' +
'<span class="sc-card__badge">' + escapeHtml(it.category || 'SIN CATEGORIA') + '</span>' +
' <span>' + escapeHtml(it.supplier_name) + '</span>' +
'</div>' +
'</div>';
}).join('');
}
function renderPagination() {
const el = document.getElementById('pagination');
if (!el) return;
if (state.totalPages <= 1) { el.innerHTML = ''; return; }
let html = '<button ' + (state.page <= 1 ? 'disabled' : '') + ' onclick="goPage(' + (state.page - 1) + ')">Anterior</button>';
html += '<span>Pagina ' + state.page + ' de ' + state.totalPages + '</span>';
html += '<button ' + (state.page >= state.totalPages ? 'disabled' : '') + ' onclick="goPage(' + (state.page + 1) + ')">Siguiente</button>';
el.innerHTML = html;
}
window.goPage = function(p) {
state.page = p;
doSearch();
};
// ─── Detail modal ───────────────────────────────────────────
window.openDetail = async function(id) {
const data = await apiFetch(API + '/items/' + id);
if (!data) return;
document.getElementById('modalTitle').textContent = escapeHtml(data.sku);
let html = '';
html += '<div><strong style="font-size:var(--text-h6);">' + escapeHtml(data.name) + '</strong></div>';
html += '<div class="sc-modal__section"><h4>Informacion</h4>' +
'<p>Proveedor: ' + escapeHtml(data.supplier_name) + '<br>Categoria: ' + escapeHtml(data.category || 'N/A') + '</p></div>';
if (data.interchanges && data.interchanges.length) {
html += '<div class="sc-modal__section"><h4>Intercambios</h4><div class="sc-interchange-list">' +
data.interchanges.map(function(ix) {
return '<span class="sc-interchange-chip">' + escapeHtml(ix.brand) + ' — ' + escapeHtml(ix.part_number) + '</span>';
}).join('') + '</div></div>';
}
if (data.compatibilities && data.compatibilities.length) {
var seenCompat = {};
var uniqCompat = data.compatibilities.filter(function(c) {
var key = (c.make || '') + '|' + (c.model || '') + '|' + (c.year || '') + '|' + (c.engine || '');
if (seenCompat[key]) return false;
seenCompat[key] = true;
return true;
});
html += '<div class="sc-modal__section"><h4>Vehiculos compatibles (' + uniqCompat.length + ')</h4>' +
'<div class="sc-compat-grid">' +
uniqCompat.slice(0, 50).map(function(c) {
return '<div class="sc-compat-item">' +
'<strong>' + escapeHtml(c.make || '') + ' ' + escapeHtml(c.model || '') + '</strong><br>' +
(c.year || '') + ' ' + escapeHtml(c.engine || '') +
'</div>';
}).join('') +
(uniqCompat.length > 50 ? '<div style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">... y ' + (uniqCompat.length - 50) + ' mas</div>' : '') +
'</div></div>';
}
document.getElementById('modalBody').innerHTML = html;
document.getElementById('detailModal').classList.add('open');
};
window.closeModal = function() {
document.getElementById('detailModal').classList.remove('open');
};
// ─── Utils ──────────────────────────────────────────────────
function escapeHtml(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ─── Init ───────────────────────────────────────────────────
function init() {
if (!token) { window.location.href = '/pos/login'; return; }
loadCategories();
loadMakes();
doSearch().then(function() {
var params = new URLSearchParams(window.location.search);
var id = params.get('id');
if (id) { openDetail(parseInt(id)); }
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -1,27 +1,27 @@
// /home/Autopartes/pos/static/pwa/sw.js
// Nexus POS — Service Worker v9
// Nexus POS — Service Worker v17
// Self-contained vanilla JS. No external imports.
//
// Bump CACHE_NAME whenever static assets change significantly.
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
// so templates can use cache-busting query params freely.
const CACHE_NAME = 'nexus-pos-v11';
const CACHE_NAME = 'nexus-pos-v17';
const APP_SHELL = [
'/pos/static/css/tokens.css',
'/pos/static/css/common.css',
'/pos/static/css/pos-ui.css',
'/pos/static/js/app-init.js',
'/pos/static/js/sidebar.js',
'/pos/static/js/login.js',
'/pos/static/js/pos.js',
'/pos/static/js/catalog.js',
'/pos/static/js/inventory.js',
'/pos/static/js/customers.js',
'/pos/static/js/invoicing.js',
'/pos/static/js/accounting.js',
'/pos/static/js/dashboard.js',
'/pos/static/js/config.js',
'/pos/static/js/reports.js',
'/pos/static/js/offline-banner.js',
'/pos/static/js/sync-engine.js',
'/pos/static/js/brand-catalog.js',
'/pos/static/js/i18n.js',
'/pos/static/js/kiosk.js',
'/pos/static/js/splash-loader.js',
'/pos/static/js/pos-utils.js',
'/pos/static/js/pwa-install.js',
'/pos/static/js/chat.js',
'/pos/static/pwa/manifest.json',
'/pos/static/pwa/icon-192.png',
'/pos/static/pwa/icon-512.png'
@@ -103,6 +103,12 @@ self.addEventListener('activate', function (event) {
);
}).then(function () {
return self.clients.claim();
}).then(function () {
return self.clients.matchAll({ type: 'window' }).then(function (clients) {
clients.forEach(function (client) {
client.postMessage({ type: 'SW_UPDATED', cacheName: CACHE_NAME });
});
});
})
);
});
@@ -117,6 +123,13 @@ self.addEventListener('fetch', function (event) {
return;
}
// Normalize cache key for static assets (strip query strings) so
// catalog.js?v=5 and catalog.js?v=6 share the same cache entry.
var cacheKey = req;
if (/\.(js|css|png|jpg|jpeg|webp|svg|gif|ico|woff|woff2|ttf|eot|json)$/.test(url.pathname)) {
cacheKey = new Request(url.pathname);
}
// Never cache auth endpoints
if (url.pathname.indexOf('/pos/api/auth/') !== -1) {
return;
@@ -172,16 +185,17 @@ self.addEventListener('fetch', function (event) {
}
// Everything else (JS, CSS, images) -> cache-first
event.respondWith(cacheFirst(req));
event.respondWith(cacheFirst(req, cacheKey));
});
function cacheFirst(request) {
return caches.match(request).then(function (cached) {
function cacheFirst(request, cacheKey) {
cacheKey = cacheKey || request;
return caches.match(cacheKey).then(function (cached) {
if (cached) {
fetch(request).then(function (response) {
if (response && response.status === 200 && request.method === 'GET') {
caches.open(CACHE_NAME).then(function (cache) {
cache.put(request, response);
cache.put(cacheKey, response);
});
}
}).catch(function () {});
@@ -191,7 +205,7 @@ function cacheFirst(request) {
if (response && response.status === 200 && request.method === 'GET') {
var clone = response.clone();
caches.open(CACHE_NAME).then(function (cache) {
cache.put(request, clone);
cache.put(cacheKey, clone);
});
}
return response;