feat: complete session — catalog, marketplace, WhatsApp, peer-to-peer, install scripts
Major features: - Pixel-Perfect glassmorphism design (landing + POS + public catalog) - OEM/Local catalog toggle with Nexpart taxonomy (14 groups, 108 subgroups, 558 part types) - Marketplace B2B Phase 1 (bodegas, POs, status machine, WA+email notifications) - Peer-to-peer inventory (multi-instance, LAN discovery) - WhatsApp: photo→Vision AI, voice→Whisper, conversational quotations - Smart unified search (VIN/plate/part_number/keyword auto-detect) - Shop Supplies tab (vehicle-independent parts) - Chatbot AI fallback chain (5 models) + response cache - CSV inventory import tool + setup_instance.sh installer - Tablet-responsive CSS + sidebar toggle - Filters, export CSV, employee edit, business data save - Quotation system (WA→POS) with auto-print on confirmation - Live stats on landing page Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -390,7 +390,37 @@ const Accounting = (() => {
|
||||
|
||||
// ---- Exportar placeholder ----
|
||||
function exportarContabilidad() {
|
||||
alert('Exportar: proximamente');
|
||||
// Find the first visible table in the active accounting tab and export as CSV
|
||||
var tables = document.querySelectorAll('table');
|
||||
var table = null;
|
||||
for (var i = 0; i < tables.length; i++) {
|
||||
if (tables[i].offsetParent !== null && tables[i].querySelector('tbody tr')) {
|
||||
table = tables[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!table) {
|
||||
alert('No hay datos para exportar en la vista actual.');
|
||||
return;
|
||||
}
|
||||
var rows = [];
|
||||
var ths = table.querySelectorAll('thead th');
|
||||
if (ths.length) {
|
||||
rows.push(Array.from(ths).map(function(th) { return '"' + th.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
|
||||
}
|
||||
table.querySelectorAll('tbody tr').forEach(function(tr) {
|
||||
var cells = tr.querySelectorAll('td');
|
||||
rows.push(Array.from(cells).map(function(td) { return '"' + td.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
|
||||
});
|
||||
if (rows.length <= 1) { alert('Sin datos para exportar.'); return; }
|
||||
var csv = rows.join('\n');
|
||||
var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'contabilidad_nexus_' + new Date().toISOString().slice(0, 10) + '.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ---- Nueva Poliza modal ----
|
||||
|
||||
@@ -49,15 +49,77 @@
|
||||
|
||||
// ─── Navigation State ───
|
||||
var nav = {
|
||||
level: 'brands', // brands|models|years|engines|categories|groups|parts
|
||||
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) ───
|
||||
var catalogMode = (localStorage.getItem('catalog_mode') === 'local' ? 'local' : 'oem');
|
||||
|
||||
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 === 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;
|
||||
@@ -82,6 +144,10 @@
|
||||
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
|
||||
@@ -89,8 +155,16 @@
|
||||
else if (nav.level === 'models') loadModels();
|
||||
else if (nav.level === 'years') loadYears();
|
||||
else if (nav.level === 'engines') loadEngines();
|
||||
else if (nav.level === 'categories') loadCategories();
|
||||
else if (nav.level === 'groups') loadGroups();
|
||||
// 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();
|
||||
|
||||
@@ -151,8 +225,19 @@
|
||||
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' });
|
||||
if (nav.category) parts.push({ label: nav.category.name, action: 'loadGroups' });
|
||||
if (nav.group) parts.push({ label: nav.group.name, action: null });
|
||||
|
||||
// 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++) {
|
||||
@@ -173,8 +258,12 @@
|
||||
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'); loadCategories(); }
|
||||
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(); }
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -182,17 +271,33 @@
|
||||
function resetNav() {
|
||||
nav.level = 'brands';
|
||||
pushNavState();
|
||||
nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = null;
|
||||
nav.brand = nav.model = nav.year = nav.engine = nav.category = nav.group = nav.partType = null;
|
||||
nav.nxGroup = nav.nxSubgroup = nav.nxPartType = null;
|
||||
}
|
||||
|
||||
function resetNavFrom(level) {
|
||||
var levels = ['brands', 'models', 'years', 'engines', 'categories', 'groups', 'parts'];
|
||||
var levels = ['brands', 'models', 'years', 'engines', 'categories', 'groups', 'part_types', 'parts'];
|
||||
var idx = levels.indexOf(level);
|
||||
if (idx <= 0) { resetNav(); return; }
|
||||
nav.level = level;
|
||||
var keys = [null, 'model', 'year', 'engine', 'category', 'group', null];
|
||||
// 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]) nav[keys[i]] = null;
|
||||
if (!keys[i]) continue;
|
||||
var ks = keys[i];
|
||||
for (var j = 0; j < ks.length; j++) nav[ks[j]] = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +326,7 @@
|
||||
setupLevelFilter(true);
|
||||
showLoading();
|
||||
|
||||
apiFetch(API + '/brands').then(function (data) {
|
||||
apiFetch(API + '/brands?mode=' + catalogMode).then(function (data) {
|
||||
hideLoading();
|
||||
if (!data || !data.data || !data.data.length) {
|
||||
if (!data) {
|
||||
@@ -317,7 +422,7 @@
|
||||
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 : '') };
|
||||
loadCategories();
|
||||
loadCategoriesForMode();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -333,7 +438,7 @@
|
||||
navGrid.querySelectorAll('.nav-card').forEach(function (card) {
|
||||
card.addEventListener('click', function () {
|
||||
nav.engine = { id_mye: parseInt(this.dataset.myeId), name: this.dataset.name };
|
||||
loadCategories();
|
||||
loadCategoriesForMode();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -389,32 +494,345 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadParts(page) {
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// 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.group.name;
|
||||
levelTitle.textContent = nav.nxPartType.name;
|
||||
setupLevelFilter(false);
|
||||
showLoading();
|
||||
navGrid.innerHTML = '';
|
||||
|
||||
apiFetch(API + '/parts?mye_id=' + nav.engine.id_mye + '&group_id=' + nav.group.id + '&page=' + currentPage + '&per_page=30').then(function (data) {
|
||||
hideLoading();
|
||||
if (!data || !data.data || !data.data.length) { showEmpty('Sin partes', 'No hay partes en este grupo.'); return; }
|
||||
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.local_stock > 0) {
|
||||
stockBadge = '<span class="stock-badge stock-badge--local">En stock: ' + p.local_stock + '</span>';
|
||||
} else if (p.bodega_count > 0) {
|
||||
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>';
|
||||
@@ -424,10 +842,123 @@
|
||||
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '">'
|
||||
: '<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>';
|
||||
|
||||
return '<article class="part-card" role="listitem" data-part-id="' + p.id_part + '">' +
|
||||
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">' +
|
||||
'<div class="part-card__oem">' + esc(p.oem_part_number) + '</div>' +
|
||||
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 () {
|
||||
openPartDetail(parseInt(this.dataset.partId));
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
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>';
|
||||
}
|
||||
|
||||
var imgHtml = p.image_url
|
||||
? '<img src="' + esc(p.image_url) + '" alt="' + esc(p.name) + '">'
|
||||
: '<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 + '">' +
|
||||
'<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">' +
|
||||
@@ -618,11 +1149,148 @@
|
||||
// ─── 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; }
|
||||
searchTimeout = setTimeout(function () { runSearch(q); }, 350);
|
||||
// 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) {
|
||||
@@ -630,10 +1298,11 @@
|
||||
e.preventDefault();
|
||||
clearTimeout(searchTimeout);
|
||||
var q = this.value.trim();
|
||||
if (q.length >= 2) runSearch(q);
|
||||
if (q.length >= 2) runSmartSearch(q);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
searchDropdown.classList.remove('is-visible');
|
||||
updateSearchHint(null);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -906,7 +1575,7 @@
|
||||
|
||||
// Load brands filtered by year
|
||||
vsBrand.disabled = false;
|
||||
apiFetch(API + '/brands?year_id=' + yearId).then(function (data) {
|
||||
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>';
|
||||
@@ -980,7 +1649,7 @@
|
||||
nav.level = 'categories';
|
||||
pushNavState();
|
||||
|
||||
loadCategories();
|
||||
loadCategoriesForMode();
|
||||
|
||||
// Scroll to catalog content
|
||||
setTimeout(function () {
|
||||
@@ -999,7 +1668,9 @@
|
||||
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; currentPage = 1;
|
||||
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();
|
||||
}
|
||||
@@ -1231,10 +1902,12 @@
|
||||
decodeVin: decodeVin,
|
||||
togglePlate: togglePlate,
|
||||
lookupPlate: lookupPlate,
|
||||
setMode: setCatalogMode,
|
||||
};
|
||||
|
||||
// ─── INIT ───
|
||||
renderCart();
|
||||
updateModeToggleUI();
|
||||
vsLoadYears();
|
||||
loadBrands();
|
||||
|
||||
|
||||
@@ -256,7 +256,7 @@ const Config = (() => {
|
||||
+ '<td>' + escHtml(emp.branch_name || 'Todas') + '</td>'
|
||||
+ '<td>' + statusBadge + '</td>'
|
||||
+ '<td>' + (emp.max_discount_pct || 0) + '%</td>'
|
||||
+ '<td><button class="btn btn--ghost btn--sm" disabled>Editar</button></td>'
|
||||
+ '<td><button class="btn btn--ghost btn--sm" onclick="Config.editEmployee(' + emp.id + ')">Editar</button></td>'
|
||||
+ '</tr>';
|
||||
});
|
||||
|
||||
@@ -265,8 +265,21 @@ const Config = (() => {
|
||||
}
|
||||
|
||||
async function saveEmployee(data) {
|
||||
var res = await fetch(API + '/employees', {
|
||||
method: 'POST',
|
||||
// Check if we're editing (modal has editId) or creating
|
||||
var modal = document.getElementById('employee-modal');
|
||||
var editId = modal ? modal.dataset.editId : null;
|
||||
var url = API + '/employees';
|
||||
var method = 'POST';
|
||||
|
||||
if (editId) {
|
||||
url = API + '/employees/' + editId;
|
||||
method = 'PUT';
|
||||
// Clear the edit marker so next use is a fresh create
|
||||
delete modal.dataset.editId;
|
||||
}
|
||||
|
||||
var res = await fetch(url, {
|
||||
method: method,
|
||||
headers: headers(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
@@ -302,6 +315,95 @@ const Config = (() => {
|
||||
if (el) el.value = v || '';
|
||||
}
|
||||
|
||||
function getVal(id) {
|
||||
var el = document.getElementById(id);
|
||||
return el ? el.value.trim() : '';
|
||||
}
|
||||
|
||||
async function editEmployee(empId) {
|
||||
if (!checkAuth()) return;
|
||||
// Find the employee in the loaded data by re-fetching
|
||||
try {
|
||||
var res = await fetch(API + '/employees', { headers: headers() });
|
||||
if (!res.ok) throw new Error('Failed to load employees');
|
||||
var json = await res.json();
|
||||
var emp = (json.data || []).find(function(e) { return e.id === empId; });
|
||||
if (!emp) { toast('Empleado no encontrado', 'error'); return; }
|
||||
|
||||
// Pre-fill the "new employee" modal with existing data for editing
|
||||
setVal('new-emp-name', emp.name);
|
||||
setVal('new-emp-email', emp.email || '');
|
||||
var roleSelect = document.getElementById('new-emp-role');
|
||||
if (roleSelect) roleSelect.value = emp.role || 'cashier';
|
||||
var branchSelect = document.getElementById('new-emp-branch');
|
||||
if (branchSelect) branchSelect.value = emp.branch_id || '';
|
||||
setVal('new-emp-discount', emp.max_discount_pct || '');
|
||||
setVal('new-emp-pin', ''); // Don't pre-fill PIN for security
|
||||
|
||||
// Store the ID so saveEmployee knows it's an update
|
||||
var modal = document.getElementById('employee-modal');
|
||||
if (modal) {
|
||||
modal.dataset.editId = empId;
|
||||
var title = modal.querySelector('.modal-title, h3');
|
||||
if (title) title.textContent = 'Editar Empleado';
|
||||
}
|
||||
openModal('employee-modal');
|
||||
} catch (e) {
|
||||
toast('Error: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTaxParams() {
|
||||
if (!checkAuth()) return;
|
||||
var data = {
|
||||
tax_iva: getVal('tax-iva') || '16',
|
||||
tax_ieps: getVal('tax-ieps') || '0',
|
||||
invoice_serie: getVal('tax-serie') || 'FA',
|
||||
invoice_folio: getVal('tax-folio') || '1',
|
||||
default_currency: document.getElementById('tax-moneda') ? document.getElementById('tax-moneda').value : 'MXN',
|
||||
default_payment_method: document.getElementById('tax-forma-pago') ? document.getElementById('tax-forma-pago').value : '01',
|
||||
};
|
||||
try {
|
||||
// Use the business PUT endpoint with tax_ prefixed keys
|
||||
var res = await fetch(API + '/business', {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Error al guardar');
|
||||
toast('Parámetros de impuestos guardados', 'ok');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBusiness() {
|
||||
if (!checkAuth()) return;
|
||||
var data = {
|
||||
razon_social: getVal('biz-razon-social'),
|
||||
nombre: getVal('biz-nombre'),
|
||||
rfc: getVal('biz-rfc'),
|
||||
regimen_fiscal: getVal('biz-regimen'),
|
||||
direccion: getVal('biz-direccion'),
|
||||
telefono: getVal('biz-telefono'),
|
||||
email: getVal('biz-email'),
|
||||
};
|
||||
try {
|
||||
var res = await fetch(API + '/business', {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
var err = await res.json().catch(function() { return { error: res.statusText }; });
|
||||
throw new Error(err.error || 'Error al guardar');
|
||||
}
|
||||
toast('Datos de empresa guardados', 'ok');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Event bindings
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -525,7 +627,8 @@ const Config = (() => {
|
||||
|
||||
return {
|
||||
init, setTheme, selectThemeOption,
|
||||
loadBranches, loadEmployees, saveBranch, saveEmployee,
|
||||
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
|
||||
loadBusiness, saveBusiness, saveTaxParams,
|
||||
loadCurrency, saveCurrency,
|
||||
openModal, closeModal
|
||||
};
|
||||
|
||||
404
pos/static/js/pos-utils.js
Normal file
404
pos/static/js/pos-utils.js
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* pos-utils.js — Shared utility functions for all POS pages.
|
||||
*
|
||||
* Provides common operations that multiple pages need:
|
||||
* - CSV export of any visible table
|
||||
* - Print page (PDF via browser print dialog)
|
||||
* - Toast notifications (if page doesn't have its own)
|
||||
*
|
||||
* Load this script in every POS template BEFORE page-specific JS.
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ── CSV Export ──────────────────────────────────────────────────
|
||||
// Finds the first visible <table> on the page and downloads it as CSV.
|
||||
// Works on inventory, customers, invoicing, reports, accounting.
|
||||
|
||||
window.exportVisibleTableCSV = function(prefix) {
|
||||
prefix = prefix || 'datos';
|
||||
var tables = document.querySelectorAll('table');
|
||||
var table = null;
|
||||
|
||||
// Find first visible table with data rows
|
||||
for (var i = 0; i < tables.length; i++) {
|
||||
if (tables[i].offsetParent !== null && tables[i].querySelector('tbody tr')) {
|
||||
table = tables[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!table) {
|
||||
showToast('No hay tabla de datos para exportar en esta vista.', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = [];
|
||||
|
||||
// Header row
|
||||
var ths = table.querySelectorAll('thead th');
|
||||
if (ths.length) {
|
||||
rows.push(Array.from(ths).map(function(th) {
|
||||
return '"' + th.textContent.trim().replace(/"/g, '""') + '"';
|
||||
}).join(','));
|
||||
}
|
||||
|
||||
// Data rows
|
||||
table.querySelectorAll('tbody tr').forEach(function(tr) {
|
||||
var cells = tr.querySelectorAll('td');
|
||||
rows.push(Array.from(cells).map(function(td) {
|
||||
return '"' + td.textContent.trim().replace(/"/g, '""') + '"';
|
||||
}).join(','));
|
||||
});
|
||||
|
||||
if (rows.length <= 1) {
|
||||
showToast('La tabla está vacía — no hay datos para exportar.', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
var csv = rows.join('\n');
|
||||
// BOM prefix so Excel opens UTF-8 correctly
|
||||
var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = prefix + '_nexus_' + new Date().toISOString().slice(0, 10) + '.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showToast('CSV descargado: ' + a.download, 'ok');
|
||||
};
|
||||
|
||||
// ── Print (PDF) ────────────────────────────────────────────────
|
||||
window.printPage = function() {
|
||||
window.print();
|
||||
};
|
||||
|
||||
// ── Toast (simple, non-blocking notification) ──────────────────
|
||||
// Only creates its own toast if the page doesn't already have one.
|
||||
window.showToast = function(msg, type) {
|
||||
type = type || 'info';
|
||||
var container = document.getElementById('toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
container.style.cssText = 'position:fixed;top:16px;right:16px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
var colors = {
|
||||
ok: 'background:#1a7a3a;color:#fff;',
|
||||
error: 'background:#c0392b;color:#fff;',
|
||||
warn: 'background:#d4a017;color:#000;',
|
||||
info: 'background:var(--color-surface-3,#333);color:var(--color-text-primary,#fff);',
|
||||
};
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.style.cssText = (colors[type] || colors.info) +
|
||||
'padding:10px 20px;border-radius:8px;font-size:14px;font-weight:500;' +
|
||||
'box-shadow:0 4px 12px rgba(0,0,0,0.3);pointer-events:auto;' +
|
||||
'animation:slideInRight 0.3s ease;max-width:400px;';
|
||||
toast.textContent = msg;
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(function() {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transition = 'opacity 0.3s';
|
||||
setTimeout(function() { toast.remove(); }, 300);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// ── "Próximamente" placeholder for features not yet built ──────
|
||||
window.featureProximamente = function(nombre) {
|
||||
showToast((nombre || 'Esta función') + ' estará disponible próximamente.', 'info');
|
||||
};
|
||||
|
||||
// ── Table Filter Panel ────────────────────────────────────────
|
||||
// Creates a dropdown filter panel that filters visible table rows
|
||||
// client-side. Call toggleFilterPanel(buttonEl, config) where config
|
||||
// is an array of {label, column, values} describing each filter.
|
||||
//
|
||||
// Usage (from onclick):
|
||||
// toggleFilterPanel(this, [
|
||||
// {label: 'Marca', column: 2, values: ['BOSCH','MONROE','Todas']},
|
||||
// {label: 'Status', column: 4, values: ['Activo','Inactivo','Todos']},
|
||||
// ])
|
||||
|
||||
var _activeFilterPanel = null;
|
||||
|
||||
window.toggleFilterPanel = function(btnEl, filters) {
|
||||
// Close existing panel if open
|
||||
if (_activeFilterPanel) {
|
||||
_activeFilterPanel.remove();
|
||||
_activeFilterPanel = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var panel = document.createElement('div');
|
||||
panel.className = 'filter-panel';
|
||||
panel.style.cssText = 'position:absolute;top:100%;right:0;z-index:1000;' +
|
||||
'background:var(--glass-bg-strong,#1a1a1a);backdrop-filter:blur(16px);' +
|
||||
'border:1px solid var(--glass-border,#333);border-radius:var(--radius-lg,12px);' +
|
||||
'padding:16px;min-width:260px;box-shadow:0 8px 32px rgba(0,0,0,0.3);' +
|
||||
'display:flex;flex-direction:column;gap:12px;';
|
||||
|
||||
var title = document.createElement('div');
|
||||
title.style.cssText = 'font-weight:700;font-size:14px;display:flex;justify-content:space-between;align-items:center;';
|
||||
title.innerHTML = 'Filtros <button onclick="closeFilterPanel()" style="background:none;border:none;color:var(--color-text-muted);cursor:pointer;font-size:18px;">✕</button>';
|
||||
panel.appendChild(title);
|
||||
|
||||
filters.forEach(function(f) {
|
||||
var group = document.createElement('div');
|
||||
var label = document.createElement('label');
|
||||
label.style.cssText = 'display:block;font-size:12px;color:var(--color-text-muted);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.05em;';
|
||||
label.textContent = f.label;
|
||||
group.appendChild(label);
|
||||
|
||||
var select = document.createElement('select');
|
||||
select.style.cssText = 'width:100%;padding:8px 10px;background:var(--glass-bg,#222);' +
|
||||
'border:1px solid var(--glass-border,#444);border-radius:6px;' +
|
||||
'color:var(--color-text-primary,#fff);font-size:13px;';
|
||||
select.dataset.filterColumn = f.column;
|
||||
|
||||
// "Todos" option always first
|
||||
var allOpt = document.createElement('option');
|
||||
allOpt.value = '';
|
||||
allOpt.textContent = f.allLabel || 'Todos';
|
||||
select.appendChild(allOpt);
|
||||
|
||||
(f.values || []).forEach(function(v) {
|
||||
if (!v) return;
|
||||
var opt = document.createElement('option');
|
||||
opt.value = v;
|
||||
opt.textContent = v;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
|
||||
select.addEventListener('change', function() { applyFilters(panel); });
|
||||
group.appendChild(select);
|
||||
panel.appendChild(group);
|
||||
});
|
||||
|
||||
// Clear all button
|
||||
var clearBtn = document.createElement('button');
|
||||
clearBtn.style.cssText = 'padding:8px;background:transparent;border:1px dashed var(--glass-border,#444);' +
|
||||
'border-radius:6px;color:var(--color-text-muted);cursor:pointer;font-size:12px;';
|
||||
clearBtn.textContent = 'Limpiar filtros';
|
||||
clearBtn.addEventListener('click', function() {
|
||||
panel.querySelectorAll('select').forEach(function(s) { s.value = ''; });
|
||||
applyFilters(panel);
|
||||
});
|
||||
panel.appendChild(clearBtn);
|
||||
|
||||
// Position relative to the button
|
||||
var wrapper = btnEl.parentElement;
|
||||
if (wrapper) wrapper.style.position = 'relative';
|
||||
(wrapper || document.body).appendChild(panel);
|
||||
_activeFilterPanel = panel;
|
||||
|
||||
// Close on outside click
|
||||
setTimeout(function() {
|
||||
document.addEventListener('click', function handler(e) {
|
||||
if (!panel.contains(e.target) && e.target !== btnEl) {
|
||||
closeFilterPanel();
|
||||
document.removeEventListener('click', handler);
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
window.closeFilterPanel = function() {
|
||||
if (_activeFilterPanel) {
|
||||
_activeFilterPanel.remove();
|
||||
_activeFilterPanel = null;
|
||||
}
|
||||
};
|
||||
|
||||
function applyFilters(panel) {
|
||||
var selects = panel.querySelectorAll('select[data-filter-column]');
|
||||
// Find the nearest visible table
|
||||
var tables = document.querySelectorAll('table');
|
||||
var table = null;
|
||||
for (var i = 0; i < tables.length; i++) {
|
||||
if (tables[i].offsetParent !== null) { table = tables[i]; break; }
|
||||
}
|
||||
if (!table) return;
|
||||
|
||||
var rows = table.querySelectorAll('tbody tr');
|
||||
rows.forEach(function(tr) {
|
||||
var show = true;
|
||||
selects.forEach(function(sel) {
|
||||
var col = parseInt(sel.dataset.filterColumn);
|
||||
var val = sel.value.toLowerCase();
|
||||
if (!val) return; // "Todos" — no filter
|
||||
var cells = tr.querySelectorAll('td');
|
||||
if (cells[col]) {
|
||||
var cellText = cells[col].textContent.trim().toLowerCase();
|
||||
if (cellText.indexOf(val.toLowerCase()) === -1) show = false;
|
||||
}
|
||||
});
|
||||
tr.style.display = show ? '' : 'none';
|
||||
});
|
||||
|
||||
// Update count badge if exists
|
||||
var visibleCount = 0;
|
||||
rows.forEach(function(tr) { if (tr.style.display !== 'none') visibleCount++; });
|
||||
var badge = document.querySelector('.filter-count-badge');
|
||||
if (badge) badge.textContent = visibleCount + ' resultados';
|
||||
}
|
||||
|
||||
// ── Auto-extract unique values from a table column ──────────
|
||||
// Useful for building filter options dynamically from data.
|
||||
window.getUniqueColumnValues = function(tableEl, colIndex, maxValues) {
|
||||
maxValues = maxValues || 30;
|
||||
var values = {};
|
||||
if (!tableEl) return [];
|
||||
tableEl.querySelectorAll('tbody tr').forEach(function(tr) {
|
||||
var cells = tr.querySelectorAll('td');
|
||||
if (cells[colIndex]) {
|
||||
var v = cells[colIndex].textContent.trim();
|
||||
if (v && v !== '-' && v !== '') values[v] = (values[v] || 0) + 1;
|
||||
}
|
||||
});
|
||||
// Sort by frequency (most common first)
|
||||
return Object.keys(values)
|
||||
.sort(function(a, b) { return values[b] - values[a]; })
|
||||
.slice(0, maxValues);
|
||||
};
|
||||
|
||||
// ── Auto-print polling for WhatsApp quotations ───────────────
|
||||
// Polls /quotations/print-queue every 15s. When a confirmed WA quote
|
||||
// is found, it fetches the ESC/POS bytes and sends to the connected
|
||||
// thermal printer. Falls back to browser print if no thermal is connected.
|
||||
|
||||
var _autoPrintTimer = null;
|
||||
var _autoPrintEnabled = false;
|
||||
|
||||
window.startAutoPrint = function() {
|
||||
if (_autoPrintTimer) return;
|
||||
_autoPrintEnabled = true;
|
||||
var token = localStorage.getItem('pos_token');
|
||||
if (!token) return;
|
||||
|
||||
_autoPrintTimer = setInterval(function() {
|
||||
fetch('/pos/api/quotations/print-queue', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (!d.data || !d.data.length) return;
|
||||
d.data.forEach(function(q) {
|
||||
console.log('[auto-print] Cotización #' + q.id + ' confirmada por WhatsApp — imprimiendo...');
|
||||
showToast('🖨️ Imprimiendo cotización #' + q.id + ' (WhatsApp)', 'ok');
|
||||
autoPrintQuote(q.id, token);
|
||||
});
|
||||
})
|
||||
.catch(function() {}); // silent on errors
|
||||
}, 15000); // every 15 seconds
|
||||
|
||||
console.log('[auto-print] Enabled — polling every 15s');
|
||||
};
|
||||
|
||||
window.stopAutoPrint = function() {
|
||||
if (_autoPrintTimer) {
|
||||
clearInterval(_autoPrintTimer);
|
||||
_autoPrintTimer = null;
|
||||
}
|
||||
_autoPrintEnabled = false;
|
||||
};
|
||||
|
||||
function autoPrintQuote(quoteId, token) {
|
||||
// Try thermal printer first (via NexusPrinter if loaded)
|
||||
if (typeof NexusPrinter !== 'undefined' && NexusPrinter.isConnected && NexusPrinter.isConnected()) {
|
||||
fetch('/pos/api/quotations/' + quoteId + '/print', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ printer_type: 'escpos_raw', width: 80 }),
|
||||
})
|
||||
.then(function(r) { return r.arrayBuffer(); })
|
||||
.then(function(buf) {
|
||||
NexusPrinter.sendRaw(new Uint8Array(buf));
|
||||
markPrinted(quoteId, token);
|
||||
})
|
||||
.catch(function(e) {
|
||||
console.error('[auto-print] Thermal print failed:', e);
|
||||
browserPrintQuote(quoteId, token);
|
||||
});
|
||||
} else {
|
||||
browserPrintQuote(quoteId, token);
|
||||
}
|
||||
}
|
||||
|
||||
function browserPrintQuote(quoteId, token) {
|
||||
// Fallback: open a print-friendly window
|
||||
fetch('/pos/api/quotations/' + quoteId + '/print', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ printer_type: 'browser' }),
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(q) {
|
||||
var html = '<html><head><title>Cotización #' + q.id + '</title>';
|
||||
html += '<style>body{font-family:monospace;font-size:12px;width:80mm;margin:0 auto;padding:10px;}';
|
||||
html += 'h1{font-size:18px;text-align:center;margin:0;}';
|
||||
html += '.center{text-align:center;}.right{text-align:right;}';
|
||||
html += 'hr{border:none;border-top:1px dashed #000;}';
|
||||
html += 'table{width:100%;border-collapse:collapse;}td{padding:2px 4px;}</style></head><body>';
|
||||
html += '<h1>COTIZACIÓN</h1>';
|
||||
html += '<p class="center">COT-' + q.id + '</p>';
|
||||
html += '<p>Fecha: ' + (q.created_at || '').substring(0, 10) + '</p>';
|
||||
if (q.customer_name) html += '<p>Cliente: ' + q.customer_name + '</p>';
|
||||
if (q.wa_phone) html += '<p>WhatsApp: ' + q.wa_phone + '</p>';
|
||||
html += '<hr><table>';
|
||||
(q.items || []).forEach(function(it) {
|
||||
html += '<tr><td>' + it.quantity + 'x ' + it.name + '</td><td class="right">$' + it.subtotal.toFixed(2) + '</td></tr>';
|
||||
if (it.part_number) html += '<tr><td colspan="2" style="font-size:10px;color:#666;"> #' + it.part_number + '</td></tr>';
|
||||
});
|
||||
html += '</table><hr>';
|
||||
html += '<p class="right">Subtotal: $' + q.subtotal.toFixed(2) + '</p>';
|
||||
html += '<p class="right">IVA: $' + q.tax_total.toFixed(2) + '</p>';
|
||||
html += '<p class="right" style="font-size:16px;font-weight:bold;">TOTAL: $' + q.total.toFixed(2) + '</p>';
|
||||
html += '<hr><p class="center" style="font-size:10px;">Esta cotización no es comprobante fiscal<br>Precios sujetos a disponibilidad</p>';
|
||||
html += '</body></html>';
|
||||
|
||||
var w = window.open('', '_blank', 'width=400,height=600');
|
||||
w.document.write(html);
|
||||
w.document.close();
|
||||
setTimeout(function() { w.print(); }, 500);
|
||||
markPrinted(quoteId, token);
|
||||
})
|
||||
.catch(function(e) {
|
||||
console.error('[auto-print] Browser print failed:', e);
|
||||
});
|
||||
}
|
||||
|
||||
function markPrinted(quoteId, token) {
|
||||
fetch('/pos/api/quotations/' + quoteId + '/mark-printed', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token },
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
// Auto-start polling on pages that are likely to have a printer
|
||||
// (POS sale page and quotations page)
|
||||
if (window.location.pathname.indexOf('/pos/sale') !== -1 ||
|
||||
window.location.pathname.indexOf('/pos/quotation') !== -1 ||
|
||||
window.location.pathname.indexOf('/pos/dashboard') !== -1) {
|
||||
var _initToken = localStorage.getItem('pos_token');
|
||||
if (_initToken) {
|
||||
setTimeout(function() { startAutoPrint(); }, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Inject styles
|
||||
if (!document.getElementById('pos-utils-styles')) {
|
||||
var style = document.createElement('style');
|
||||
style.id = 'pos-utils-styles';
|
||||
style.textContent = '@keyframes slideInRight{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}' +
|
||||
'.filter-panel select:focus{outline:none;border-color:var(--color-primary,#F5A623);box-shadow:0 0 0 2px var(--glow-color-soft,rgba(245,166,35,0.15));}';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -715,3 +715,39 @@ const Reports = (() => {
|
||||
loadVentas, loadInventario, loadClientes, loadFinancieros, fmt
|
||||
};
|
||||
})();
|
||||
|
||||
// ── Global: Export visible table as CSV (Excel-compatible) ──
|
||||
function exportReportCSV() {
|
||||
var tables = document.querySelectorAll('table');
|
||||
// Find the first visible table
|
||||
var table = null;
|
||||
for (var i = 0; i < tables.length; i++) {
|
||||
var t = tables[i];
|
||||
if (t.offsetParent !== null && t.querySelector('tbody tr')) {
|
||||
table = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!table) {
|
||||
alert('No hay tabla de datos para exportar en esta vista.');
|
||||
return;
|
||||
}
|
||||
var rows = [];
|
||||
var ths = table.querySelectorAll('thead th');
|
||||
if (ths.length) {
|
||||
rows.push(Array.from(ths).map(function(th) { return '"' + th.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
|
||||
}
|
||||
table.querySelectorAll('tbody tr').forEach(function(tr) {
|
||||
var cells = tr.querySelectorAll('td');
|
||||
rows.push(Array.from(cells).map(function(td) { return '"' + td.textContent.trim().replace(/"/g, '""') + '"'; }).join(','));
|
||||
});
|
||||
if (rows.length <= 1) { alert('La tabla esta vacia.'); return; }
|
||||
var csv = rows.join('\n');
|
||||
var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'reporte_nexus_' + new Date().toISOString().slice(0, 10) + '.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
]},
|
||||
{ label: _t('nav_management'), items: [
|
||||
{ name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
|
||||
{ name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
|
||||
{ name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' },
|
||||
{ name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
|
||||
{ name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
|
||||
{ name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
|
||||
@@ -163,4 +165,61 @@
|
||||
var main = document.querySelector('main, .main-content, #mainContent, .main, .page-content');
|
||||
if (main) main.classList.add('pos-main-offset');
|
||||
|
||||
// ── Tablet/mobile: sidebar toggle + overlay ─────────────────────
|
||||
// Creates a hamburger button + overlay for screens < 1024px.
|
||||
// The CSS in pos-glass.css hides the sidebar by default on tablets
|
||||
// and shows it as a slide-in drawer when .open is added.
|
||||
|
||||
var sidebar = document.querySelector('.pos-sidebar, .sidebar, #sidebar');
|
||||
var overlay = document.getElementById('sidebar-overlay');
|
||||
|
||||
// Create overlay if it doesn't exist
|
||||
if (!overlay && sidebar) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'sidebar-overlay';
|
||||
overlay.className = 'sidebar-overlay';
|
||||
overlay.addEventListener('click', function () { closeSidebar(); });
|
||||
sidebar.parentNode.insertBefore(overlay, sidebar);
|
||||
}
|
||||
|
||||
// Create hamburger button if it doesn't exist
|
||||
var hamburger = document.getElementById('hamburger-btn');
|
||||
if (!hamburger) {
|
||||
hamburger = document.createElement('button');
|
||||
hamburger.id = 'hamburger-btn';
|
||||
hamburger.className = 'hamburger-btn';
|
||||
hamburger.setAttribute('aria-label', 'Menú');
|
||||
hamburger.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>';
|
||||
hamburger.style.cssText = 'display:none;position:fixed;top:10px;left:10px;z-index:' +
|
||||
(parseInt(getComputedStyle(document.documentElement).getPropertyValue('--z-modal') || 1050) + 2) +
|
||||
';background:var(--glass-bg-strong);backdrop-filter:blur(12px);border:1px solid var(--glass-border);' +
|
||||
'border-radius:var(--radius-md);padding:8px;cursor:pointer;color:var(--color-text-primary);' +
|
||||
'box-shadow:0 2px 8px rgba(0,0,0,0.2);';
|
||||
hamburger.addEventListener('click', function () { toggleSidebar(); });
|
||||
document.body.appendChild(hamburger);
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
if (!sidebar) return;
|
||||
var isOpen = sidebar.classList.contains('open');
|
||||
sidebar.classList.toggle('open', !isOpen);
|
||||
if (overlay) overlay.classList.toggle('open', !isOpen);
|
||||
document.body.style.overflow = isOpen ? '' : 'hidden';
|
||||
}
|
||||
|
||||
function closeSidebar() {
|
||||
if (sidebar) sidebar.classList.remove('open');
|
||||
if (overlay) overlay.classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// Auto-close sidebar on window resize to desktop
|
||||
window.addEventListener('resize', function () {
|
||||
if (window.innerWidth >= 1024) closeSidebar();
|
||||
});
|
||||
|
||||
// Expose globally so inline onclick handlers and page-specific JS can call them
|
||||
window.toggleSidebar = toggleSidebar;
|
||||
window.closeSidebar = closeSidebar;
|
||||
|
||||
})();
|
||||
|
||||
@@ -103,6 +103,9 @@
|
||||
messengerArea.style.display = 'flex';
|
||||
disconnectBtn.style.display = '';
|
||||
connectBtn.style.display = 'none';
|
||||
// Load conversations + start polling on page load / reconnect
|
||||
loadConversations();
|
||||
startPolling();
|
||||
} else if (state === 'connecting') {
|
||||
statusDot.className = 'status-dot status-dot--warn';
|
||||
statusText.textContent = 'Escaneando QR...';
|
||||
@@ -221,18 +224,43 @@
|
||||
var html = '';
|
||||
convs.forEach(function (c) {
|
||||
var isActive = c.phone === activePhone;
|
||||
var dirIcon = c.last_direction === 'outgoing' ? '→ ' : '';
|
||||
var dirIcon = c.last_direction === 'outgoing' ? '↗ ' : '↙ ';
|
||||
// Show contact name if available, otherwise try to format the phone.
|
||||
// LID numbers (15+ digits, no country code pattern) show as "Contacto"
|
||||
var displayName = c.contact_name || '';
|
||||
if (!displayName) {
|
||||
var isLid = c.phone.length > 13 || !/^(52|1|44|34)/.test(c.phone);
|
||||
displayName = isLid ? 'Contacto WhatsApp' : fmtPhone(c.phone);
|
||||
}
|
||||
html += '<div class="conv-item' + (isActive ? ' is-active' : '') + '" data-phone="' + escHtml(c.phone) + '">'
|
||||
+ '<div class="conv-item__phone">' + escHtml(fmtPhone(c.phone)) + '</div>'
|
||||
+ '<div class="conv-item__preview">' + dirIcon + escHtml(c.last_message) + '</div>'
|
||||
+ '<div class="conv-item__phone">' + escHtml(displayName) + '</div>'
|
||||
+ '<div class="conv-item__preview">' + dirIcon + escHtml(c.last_message || '(sin texto)') + '</div>'
|
||||
+ '<div class="conv-item__time">' + fmtTime(c.last_at) + '</div>'
|
||||
+ '<button class="conv-item__delete" data-del-phone="' + escHtml(c.phone) + '" title="Borrar conversacion">×</button>'
|
||||
+ '</div>';
|
||||
});
|
||||
// "Borrar todo" button at the bottom
|
||||
html += '<div style="padding:8px;text-align:center;">'
|
||||
+ '<button class="conv-delete-all" style="background:none;border:1px dashed var(--color-border,#444);color:var(--color-text-muted);padding:6px 12px;border-radius:6px;cursor:pointer;font-size:11px;" onclick="deleteAllConversations()">Borrar todas las conversaciones</button>'
|
||||
+ '</div>';
|
||||
convList.innerHTML = html;
|
||||
|
||||
convList.querySelectorAll('.conv-item').forEach(function (el) {
|
||||
el.addEventListener('click', function () {
|
||||
openConversation(el.getAttribute('data-phone'));
|
||||
el.addEventListener('click', function (e) {
|
||||
if (e.target.classList.contains('conv-item__delete')) return;
|
||||
var name = el.querySelector('.conv-item__phone') ? el.querySelector('.conv-item__phone').textContent : '';
|
||||
openConversation(el.getAttribute('data-phone'), name);
|
||||
});
|
||||
});
|
||||
|
||||
// Wire delete buttons
|
||||
convList.querySelectorAll('.conv-item__delete').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var phone = btn.getAttribute('data-del-phone');
|
||||
if (confirm('Borrar conversacion con ' + fmtPhone(phone) + '?')) {
|
||||
deleteConversation(phone);
|
||||
}
|
||||
});
|
||||
});
|
||||
}).catch(function () {
|
||||
@@ -240,11 +268,43 @@
|
||||
});
|
||||
}
|
||||
|
||||
function deleteConversation(phone) {
|
||||
api('DELETE', '/conversations/' + encodeURIComponent(phone)).then(function (res) {
|
||||
if (res.ok) {
|
||||
if (activePhone === phone) {
|
||||
activePhone = null;
|
||||
chatPanel.style.display = 'none';
|
||||
emptyState.style.display = '';
|
||||
}
|
||||
loadConversations();
|
||||
} else {
|
||||
alert('Error: ' + (res.error || 'unknown'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.deleteAllConversations = function () {
|
||||
if (!confirm('Borrar TODAS las conversaciones? Esta accion no se puede deshacer.')) return;
|
||||
api('DELETE', '/conversations').then(function (res) {
|
||||
if (res.ok) {
|
||||
activePhone = null;
|
||||
chatPanel.style.display = 'none';
|
||||
emptyState.style.display = '';
|
||||
loadConversations();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// -- Open a conversation ---------------------------------------------------
|
||||
|
||||
function openConversation(phone) {
|
||||
var activeContactName = '';
|
||||
|
||||
function openConversation(phone, contactName) {
|
||||
activePhone = phone;
|
||||
chatHeader.textContent = fmtPhone(phone);
|
||||
// Use contact name if available; fall back to formatted phone
|
||||
var isLid = phone.length > 13 || !/^(52|1|44|34)/.test(phone);
|
||||
activeContactName = contactName || '';
|
||||
chatHeader.textContent = activeContactName || (isLid ? 'Contacto WhatsApp' : fmtPhone(phone));
|
||||
emptyState.style.display = 'none';
|
||||
chatPanel.style.display = 'flex';
|
||||
|
||||
@@ -267,13 +327,13 @@
|
||||
var html = '';
|
||||
msgs.forEach(function (m) {
|
||||
var cls = m.direction === 'outgoing' ? 'msg-bubble--out' : 'msg-bubble--in';
|
||||
var statusBadge = '';
|
||||
if (m.direction === 'outgoing' && m.status) {
|
||||
statusBadge = '<span class="msg-status">' + escHtml(m.status) + '</span>';
|
||||
}
|
||||
// Support both 'text' and 'message_text' keys (backend changed)
|
||||
var text = m.message_text || m.text || '';
|
||||
// Support both 'created_at' and 'date' keys
|
||||
var time = m.created_at || m.date || '';
|
||||
html += '<div class="msg-bubble ' + cls + '">'
|
||||
+ '<div class="msg-bubble__text">' + escHtml(m.message_text).replace(/\n/g, '<br>') + '</div>'
|
||||
+ '<div class="msg-bubble__meta">' + fmtTime(m.created_at) + ' ' + statusBadge + '</div>'
|
||||
+ '<div class="msg-bubble__text">' + escHtml(text).replace(/\n/g, '<br>') + '</div>'
|
||||
+ '<div class="msg-bubble__meta">' + fmtTime(time) + '</div>'
|
||||
+ '</div>';
|
||||
});
|
||||
chatMessages.innerHTML = html || '<div class="chat-empty">Sin mensajes</div>';
|
||||
@@ -328,16 +388,50 @@
|
||||
if (quoteBtn) {
|
||||
quoteBtn.addEventListener('click', function () {
|
||||
if (!activePhone) { alert('Selecciona una conversacion primero'); return; }
|
||||
var quoteId = prompt('ID de la cotizacion a enviar:');
|
||||
if (!quoteId) return;
|
||||
api('POST', '/send-quote/' + quoteId, { phone: activePhone }).then(function (res) {
|
||||
if (res.error) {
|
||||
alert('Error: ' + res.error);
|
||||
} else {
|
||||
loadMessages(activePhone);
|
||||
loadConversations();
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch available quotations and let user pick one
|
||||
fetch('/pos/api/quotations?per_page=20', { headers: authHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
var quotes = (d.data || []).filter(function (q) { return q.status === 'active'; });
|
||||
if (quotes.length === 0) {
|
||||
alert('No hay cotizaciones activas. Crea una desde el POS (F4) o via WhatsApp.');
|
||||
return;
|
||||
}
|
||||
var msg = 'Cotizaciones activas:\n';
|
||||
quotes.forEach(function (q) {
|
||||
msg += '#' + q.id + ' — $' + q.total.toFixed(2) + ' (' + (q.customer_name || q.source || 'sin cliente') + ')\n';
|
||||
});
|
||||
var quoteId = prompt(msg + '\nEscribe el ID de la cotizacion a enviar:');
|
||||
if (!quoteId) return;
|
||||
|
||||
// Fetch the quotation detail and send it formatted
|
||||
fetch('/pos/api/quotations/' + quoteId, { headers: authHeaders() })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (q) {
|
||||
if (q.error) { alert('Error: ' + q.error); return; }
|
||||
// Format the quotation as a WhatsApp message
|
||||
var lines = ['📄 *COTIZACIÓN #' + q.id + '*', ''];
|
||||
(q.items || []).forEach(function (it, i) {
|
||||
lines.push((i + 1) + '. ' + it.name);
|
||||
lines.push(' #' + it.part_number + ' × ' + it.quantity + ' = $' + it.subtotal.toFixed(2));
|
||||
});
|
||||
lines.push('─────────────');
|
||||
lines.push('Subtotal: $' + q.subtotal.toFixed(2));
|
||||
lines.push('IVA: $' + q.tax_total.toFixed(2));
|
||||
lines.push('*TOTAL: $' + q.total.toFixed(2) + '*');
|
||||
|
||||
var text = lines.join('\n');
|
||||
api('POST', '/send', { phone: activePhone, message: text }).then(function (res) {
|
||||
if (res.error) {
|
||||
alert('Error enviando: ' + res.error);
|
||||
} else {
|
||||
loadMessages(activePhone);
|
||||
loadConversations();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user