Merge branch 'main' into desarrollo_hector

This commit is contained in:
2026-06-15 12:56:54 -06:00
168 changed files with 22773 additions and 1730 deletions

View File

@@ -506,4 +506,12 @@ const Accounting = (() => {
loadIncomeStatement, loadCashFlow, loadReconciliation, loadPeriodClose,
exportarContabilidad, showNewEntryModal, closeNewEntryModal, addEntryLine, submitNewEntry,
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
})();

View File

@@ -167,12 +167,6 @@
});
}
// Also prevent any DOMContentLoaded theme switchers from overriding
// by re-applying our saved theme after a tick
setTimeout(function() {
document.documentElement.setAttribute('data-theme', savedTheme);
}, 100);
// ─── Expose globally ───
window.POS_USER = {
name: name,
@@ -186,4 +180,31 @@
permissions: payload.permissions || []
};
// ─── Preload enabled modules for sidebar filtering ───
try {
fetch('/pos/api/config/modules', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(function(r) {
if (r.ok) return r.json();
}).then(function(data) {
if (data) {
localStorage.setItem('pos_modules', JSON.stringify(data));
window.POS_USER.modules = data;
if (typeof window.renderSidebar === 'function') {
window.renderSidebar(data);
}
}
}).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

@@ -1 +0,0 @@
!function(){"use strict";var e=localStorage.getItem("pos_token");if(e){try{var t=JSON.parse(atob(e.split(".")[1]));if(1e3*t.exp<Date.now())return localStorage.removeItem("pos_token"),localStorage.removeItem("pos_employee"),void(window.location.href="/pos/login")}catch(e){return localStorage.removeItem("pos_token"),void(window.location.href="/pos/login")}var o={};try{o=JSON.parse(localStorage.getItem("pos_employee")||"{}")}catch(e){}var n=o.name||t.name||"Usuario",a=o.role||t.role||"",r="function"==typeof window.t?window.t:function(e){return e},i={owner:r("role_owner"),admin:r("role_admin"),cashier:r("role_cashier"),warehouse:r("role_warehouse"),accountant:r("role_accountant")}[a]||a,c=n.split(" ").map((function(e){return e[0]})).join("").toUpperCase().substring(0,2);document.querySelectorAll(".sidebar__user-name").forEach((function(e){e.textContent=n})),document.querySelectorAll(".sidebar__user-role").forEach((function(e){e.textContent=i})),document.querySelectorAll(".sidebar__user-avatar, .sidebar__avatar").forEach((function(e){e.textContent=c})),document.querySelectorAll(".profile-info__name").forEach((function(e){e.textContent=n})),document.querySelectorAll(".profile-info__role").forEach((function(e){e.textContent=i})),document.querySelectorAll(".theme-bar__label").forEach((function(e){-1===e.textContent.indexOf("Usuario:")&&-1===e.textContent.indexOf("Sucursal")||(e.textContent="Sucursal Principal — "+n)})),document.querySelectorAll(".status-bar .user-name, .status-info span").forEach((function(e){var t=e.textContent;["Hugo M.","Hugo García","J. Ramírez","José Ramírez","Carlos M.","Admin"].forEach((function(o){-1!==t.indexOf(o)&&(e.textContent=t.replace(o,n))}))}));var l=window.location.pathname;document.querySelectorAll(".nav-item, .nav-link").forEach((function(e){e.classList.remove("is-active","active"),(e.getAttribute("href")||"")===l&&(e.classList.add("is-active"),e.classList.add("active"))})),window.posLogout=function(){localStorage.removeItem("pos_token"),localStorage.removeItem("pos_employee"),localStorage.removeItem("pos_tenant_id"),localStorage.removeItem("pos_cart"),window.location.href="/pos/login"},document.querySelectorAll('[data-action="logout"], .btn-logout, .logout-btn').forEach((function(e){e.addEventListener("click",(function(e){e.preventDefault(),posLogout()}))}));var s=localStorage.getItem("pos_theme");s||(s=window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"industrial":"modern"),document.documentElement.setAttribute("data-theme",s),document.querySelectorAll(".theme-bar").forEach((function(e){e.style.display="none"})),window.posSetTheme=function(e){document.documentElement.setAttribute("data-theme",e),localStorage.setItem("pos_theme",e)},window.setTheme=window.posSetTheme,window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",(function(e){if(!localStorage.getItem("pos_theme")){var t=e.matches?"industrial":"modern";document.documentElement.setAttribute("data-theme",t)}})),setTimeout((function(){document.documentElement.setAttribute("data-theme",s)}),100),window.POS_USER={name:n,role:a,roleLabel:i,initials:c,token:e,tenantId:t.tenant_id,employeeId:t.employee_id,branchId:t.branch_id,permissions:t.permissions||[]}}else window.location.href="/pos/login"}();

View File

@@ -6,6 +6,7 @@
_offset: 0,
_limit: 50,
_total: 0,
_allowedBrands: [],
// Navigation state
nav: {
@@ -71,7 +72,9 @@
},
loading: function(on) {
this.el('brandCatalogLoading').style.display = on ? 'block' : 'none';
var el = this.el('brandCatalogLoading');
if (on) el.classList.add('is-visible');
else el.classList.remove('is-visible');
},
setContent: function(html) {
@@ -88,23 +91,23 @@
buildBreadcrumb: function() {
var parts = [];
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.loadBrands()" style="color:var(--color-primary);text-decoration:none;">Marcas</a>');
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick="BrandCatalog.loadBrands()">Marcas</a>');
if (this.nav.brand) {
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectBrand(' + JSON.stringify(this.nav.brand) + ',' + this.nav.brandId + ')" style="color:var(--color-primary);text-decoration:none;">' + escapeHtml(this.nav.brand) + '</a>');
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectBrand(' + JSON.stringify(this.nav.brand) + ',' + this.nav.brandId + ')\'>' + escapeHtml(this.nav.brand) + '</a>');
}
if (this.nav.model) {
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectModel(' + this.nav.modelId + ',' + JSON.stringify(this.nav.model) + ')" style="color:var(--color-primary);text-decoration:none;">' + escapeHtml(this.nav.model) + '</a>');
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectModel(' + this.nav.modelId + ',' + JSON.stringify(this.nav.model) + ')\'>' + escapeHtml(this.nav.model) + '</a>');
}
if (this.nav.year) {
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectYear(' + this.nav.yearId + ',' + this.nav.year + ')" style="color:var(--color-primary);text-decoration:none;">' + this.nav.year + '</a>');
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectYear(' + this.nav.yearId + ',' + this.nav.year + ')\'>' + this.nav.year + '</a>');
}
if (this.nav.engine) {
parts.push('<a href="javascript:void(0)" onclick="BrandCatalog.selectEngine(' + this.nav.myeId + ',' + JSON.stringify(this.nav.engine) + ')" style="color:var(--color-primary);text-decoration:none;">' + escapeHtml(this.nav.engine) + '</a>');
parts.push('<a href="javascript:void(0)" class="breadcrumb__link" onclick=\'BrandCatalog.selectEngine(' + this.nav.myeId + ',' + JSON.stringify(this.nav.engine) + ')\'>' + escapeHtml(this.nav.engine) + '</a>');
}
if (this.nav.category) {
parts.push('<strong>' + escapeHtml(this.nav.category) + '</strong>');
parts.push('<span class="breadcrumb__current">' + escapeHtml(this.nav.category) + '</span>');
}
this.setBreadcrumb(parts.join(' &rsaquo; '));
this.setBreadcrumb('<nav class="breadcrumb">' + parts.join('<span class="breadcrumb__sep">&rsaquo;</span>') + '</nav>');
},
// ---------- BRANDS ----------
@@ -112,12 +115,10 @@
this.loading(true);
this.state = 'brands';
this.reset();
this.setBreadcrumb('<strong>Marcas de vehiculo</strong>');
this.setBreadcrumb('<nav class="breadcrumb"><span class="breadcrumb__current">Marcas de vehiculo</span></nav>');
this.setSearch(
'<input type="text" id="brandSearchInput" placeholder="Buscar marca..." ' +
'style="width:100%;padding:10px 14px;border:1px solid var(--color-border);border-radius:var(--radius-md);' +
'font-size:var(--text-body);background:var(--color-surface);color:var(--color-text-primary);' +
'outline:none;" oninput="BrandCatalog.filterBrands(this.value)">'
'class="level-filter" oninput="BrandCatalog.filterBrands(this.value)">'
);
var self = this;
fetch('/pos/api/catalog/vehicle-brands', { headers: this._headers() })
@@ -130,24 +131,25 @@
self.loading(false);
self._allBrands = data.brands || [];
if (!self._allBrands.length) {
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron marcas.</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">No se encontraron marcas.</div></div>');
return;
}
self.renderBrandList(self._allBrands);
})
.catch(function(err) {
self.loading(false);
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar marcas: ' + escapeHtml(err.message) + '</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar marcas</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
});
},
renderBrandList: function(brands) {
var html = '';
var html = '<div class="nav-grid">';
brands.forEach(function(b) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectBrand(' + JSON.stringify(b.name) + ',' + b.id + ')">' +
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(b.name) + '</div>' +
html += '<div class="nav-card" onclick=\'BrandCatalog.selectBrand(' + JSON.stringify(b.name) + ',' + b.id + ')\'>' +
'<div class="nav-card__name">' + escapeHtml(b.name) + '</div>' +
'</div>';
});
html += '</div>';
this.setContent(html);
},
@@ -186,23 +188,28 @@
self.loading(false);
var models = data.data || [];
if (!models.length) {
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron modelos.</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">No se encontraron modelos.</div></div>');
return;
}
var html = '';
models.forEach(function(m) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectModel(' + m.id_model + ',' + JSON.stringify(m.display_name || m.name_model) + ')">' +
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(m.display_name || m.name_model) + '</div>' +
'</div>';
});
self.setContent(html);
self.renderModelList(models);
})
.catch(function(err) {
self.loading(false);
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar modelos: ' + escapeHtml(err.message) + '</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar modelos</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
});
},
renderModelList: function(models) {
var html = '<div class="nav-grid">';
models.forEach(function(m) {
html += '<div class="nav-card" onclick=\'BrandCatalog.selectModel(' + m.id_model + ',' + JSON.stringify(m.display_name || m.name_model) + ')\'>' +
'<div class="nav-card__name">' + escapeHtml(m.display_name || m.name_model) + '</div>' +
'</div>';
});
html += '</div>';
this.setContent(html);
},
selectModel: function(modelId, modelName) {
this.nav.model = modelName;
this.nav.modelId = modelId;
@@ -226,23 +233,28 @@
self.loading(false);
var years = data.data || [];
if (!years.length) {
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron años.</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">No se encontraron años.</div></div>');
return;
}
var html = '';
years.forEach(function(y) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectYear(' + y.id_year + ',' + y.year_car + ')">' +
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + y.year_car + '</div>' +
'</div>';
});
self.setContent(html);
self.renderYearList(years);
})
.catch(function(err) {
self.loading(false);
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar años: ' + escapeHtml(err.message) + '</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar años</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
});
},
renderYearList: function(years) {
var html = '<div class="nav-grid nav-grid--years">';
years.forEach(function(y) {
html += '<div class="nav-card nav-card--year" onclick=\'BrandCatalog.selectYear(' + y.id_year + ',' + y.year_car + ')\'>' +
'<div class="nav-card__name">' + y.year_car + '</div>' +
'</div>';
});
html += '</div>';
this.setContent(html);
},
selectYear: function(yearId, yearCar) {
this.nav.year = yearCar;
this.nav.yearId = yearId;
@@ -266,24 +278,29 @@
self.loading(false);
var engines = data.data || [];
if (!engines.length) {
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron motores.</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">No se encontraron motores.</div></div>');
return;
}
var html = '';
engines.forEach(function(e) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectEngine(' + e.id_mye + ',' + JSON.stringify(e.name_engine) + ')">' +
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(e.name_engine) + '</div>' +
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + escapeHtml(e.trim_level || '') + '</div>' +
'</div>';
});
self.setContent(html);
self.renderEngineList(engines);
})
.catch(function(err) {
self.loading(false);
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar motores: ' + escapeHtml(err.message) + '</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar motores</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
});
},
renderEngineList: function(engines) {
var html = '<div class="nav-grid">';
engines.forEach(function(e) {
html += '<div class="nav-card" onclick=\'BrandCatalog.selectEngine(' + e.id_mye + ',' + JSON.stringify(e.name_engine) + ')\'>' +
'<div class="nav-card__name">' + escapeHtml(e.name_engine) + '</div>' +
'<div class="nav-card__sub">' + escapeHtml(e.trim_level || '') + '</div>' +
'</div>';
});
html += '</div>';
this.setContent(html);
},
selectEngine: function(myeId, engineName) {
this.nav.engine = engineName;
this.nav.myeId = myeId;
@@ -305,26 +322,36 @@
.then(function(data) {
if (!data) return;
self.loading(false);
self._allowedBrands = data.allowed_brands || [];
var categories = data.data || [];
if (!categories.length) {
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-text-muted);">No se encontraron categorias.</p>');
var msg = 'No se encontraron categorias.';
if (self._allowedBrands.length) {
msg = 'Este vehiculo no tiene cobertura de ' + self._allowedBrands.join(', ') + '.';
}
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">' + msg + '</div><div class="empty-state__subtitle">Prueba con otro vehiculo o contacta a soporte para ampliar el catalogo.</div></div>');
return;
}
var html = '';
categories.forEach(function(c) {
html += '<div class="catalog-category-card" onclick="BrandCatalog.selectCategory(' + c.id_part_category + ',' + JSON.stringify(c.name) + ')">' +
'<div style="font-size:var(--text-h4);font-family:var(--font-heading);margin-bottom:4px;">' + escapeHtml(c.name) + '</div>' +
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);">' + (c.part_count || 0) + ' refacciones</div>' +
'</div>';
});
self.setContent(html);
self.renderCategoryList(categories);
})
.catch(function(err) {
self.loading(false);
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar categorias: ' + escapeHtml(err.message) + '</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar categorias</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
});
},
renderCategoryList: function(categories) {
var html = '<div class="nav-grid">';
categories.forEach(function(c) {
html += '<div class="nav-card" onclick=\'BrandCatalog.selectCategory(' + c.id_part_category + ',' + JSON.stringify(c.name) + ')\'>' +
'<div class="nav-card__name">' + escapeHtml(c.name) + '</div>' +
'<div class="nav-card__sub">' + (c.part_count || 0) + ' refacciones</div>' +
'</div>';
});
html += '</div>';
this.setContent(html);
},
selectCategory: function(catId, catName) {
this.nav.category = catName;
this.nav.categoryId = catId;
@@ -340,11 +367,10 @@
this.setSearch(
'<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;align-items:center;">' +
'<input type="text" id="partsSearchInput" placeholder="Buscar refaccion..." value="' + escapeHtml(searchTerm || '') + '" ' +
'style="flex:1;min-width:200px;padding:10px 14px;border:1px solid var(--color-border);border-radius:var(--radius-md);' +
'font-size:var(--text-body);background:var(--color-surface);color:var(--color-text-primary);outline:none;" ' +
'class="level-filter" ' +
'onkeydown="if(event.key===\'Enter\')BrandCatalog.searchParts(this.value)">' +
'<button class="btn btn--primary btn--sm" onclick="BrandCatalog.searchParts(document.getElementById(\'partsSearchInput\').value)">Buscar</button>' +
'<button class="btn btn--secondary btn--sm" onclick="BrandCatalog.clearPartsSearch()">Limpiar</button>' +
'<button class="btn btn-primary" onclick="BrandCatalog.searchParts(document.getElementById(\'partsSearchInput\').value)">Buscar</button>' +
'<button class="btn btn-ghost" onclick="BrandCatalog.clearPartsSearch()">Limpiar</button>' +
'</div>'
);
var url = '/pos/api/catalog/mye-parts?mye_id=' + encodeURIComponent(myeId) + '&category_id=' + encodeURIComponent(categoryId) +
@@ -361,6 +387,7 @@
.then(function(data) {
if (!data) return;
self.loading(false);
self._allowedBrands = data.allowed_brands || [];
self._lastItems = data.items || [];
self._total = data.total || 0;
self._offset = data.offset || 0;
@@ -368,16 +395,20 @@
})
.catch(function(err) {
self.loading(false);
self.setContent('<p style="grid-column:1/-1;text-align:center;color:var(--color-error);">Error al cargar refacciones: ' + escapeHtml(err.message) + '</p>');
self.setContent('<div class="empty-state is-visible"><div class="empty-state__title">Error al cargar refacciones</div><div class="empty-state__subtitle">' + escapeHtml(err.message) + '</div></div>');
});
},
renderPartsList: function(items, searchTerm) {
var html = '';
if (!items.length) {
html += '<div style="grid-column:1/-1;text-align:center;padding:var(--space-8);">' +
'<p style="color:var(--color-text-muted);font-size:var(--text-body-lg);">No se encontraron refacciones.</p>' +
'<button class="btn btn--primary" style="margin-top:var(--space-3);" onclick="BrandCatalog.loadCategories(' + this.nav.myeId + ')">Volver a categorias</button>' +
var msg = 'No se encontraron refacciones.';
if (this._allowedBrands.length) {
msg = 'No hay refacciones de ' + this._allowedBrands.join(', ') + ' en esta categoria.';
}
html += '<div class="empty-state is-visible">' +
'<div class="empty-state__title">' + msg + '</div>' +
'<div class="empty-state__subtitle"><button class="btn btn-primary" onclick="BrandCatalog.loadCategories(' + this.nav.myeId + ')">Volver a categorias</button></div>' +
'</div>';
this.setContent(html);
return;
@@ -389,40 +420,55 @@
'Mostrando ' + startIdx + '-' + endIdx + ' de ' + this._total + ' refacciones' +
'</div>';
html += '<div style="grid-column:1/-1;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-3);">';
html += '<div class="nav-grid nav-grid--parts">';
items.forEach(function(p) {
var price = p.local_price ? '$' + Number(p.local_price).toFixed(2) : 'Consultar precio';
var img = '/pos/static/images/placeholder-part.png';
var hasAm = !!p.manufacturer;
var price = p.local_price
? '$' + Number(p.local_price).toFixed(2)
: (p.price_usd ? '$' + Number(p.price_usd).toFixed(2) : 'Consultar precio');
var stockBadge = p.local_stock > 0
? '<span style="display:inline-block;background:var(--color-success);color:#fff;font-size:11px;padding:2px 8px;border-radius:var(--radius-sm);margin-left:6px;">' + p.local_stock + ' en stock</span>'
: '<span style="display:inline-block;background:var(--color-text-muted);color:#fff;font-size:11px;padding:2px 8px;border-radius:var(--radius-sm);margin-left:6px;">Sin stock local</span>';
html += '<div class="catalog-category-card" style="padding:0;overflow:hidden;display:flex;flex-direction:column;">' +
'<div style="height:160px;background:#f5f5f5;display:flex;align-items:center;justify-content:center;">' +
'<img src="' + escapeHtml(img) + '" alt="" style="max-width:100%;max-height:100%;object-fit:contain;">' +
? '<span class="stock-badge stock-badge--local">En stock</span>'
: '<span class="stock-badge stock-badge--none">Sin stock local</span>';
var imgHtml = p.image_url
? '<img src="' + escapeHtml(p.image_url) + '" alt="">'
: '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>';
var brandLine = hasAm
? '<div style="font-size:var(--text-caption);color:var(--color-accent);font-weight:600;">' + escapeHtml(p.manufacturer) + '</div>'
: '';
html += '<div class="part-card">' +
'<div class="part-card__image">' + imgHtml + '</div>' +
'<div class="part-card__body">' +
brandLine +
'<div class="part-card__oem">' + escapeHtml(p.oem_part_number || 'N/A') + '</div>' +
'<div class="part-card__name">' + escapeHtml(p.name || '') + '</div>' +
'</div>' +
'<div style="padding:var(--space-3);flex:1;display:flex;flex-direction:column;">' +
'<div style="font-weight:600;font-size:var(--text-body);margin-bottom:4px;">' + escapeHtml(p.oem_part_number || 'N/A') + stockBadge + '</div>' +
'<div style="font-size:var(--text-body-sm);color:var(--color-text-muted);margin-bottom:8px;flex:1;">' + escapeHtml(p.name || '') + '</div>' +
'<div style="font-size:var(--text-h5);font-weight:700;color:var(--color-primary);margin-bottom:8px;">' + price + '</div>' +
'<button class="btn btn--primary btn--sm" style="width:100%;" onclick="BrandCatalog.addToCart(' + p.id + ', event)">Agregar</button>' +
'<div class="part-card__footer">' +
stockBadge +
'<span class="part-card__price">' + price + '</span>' +
'</div>' +
'<button class="btn btn-primary" onclick="BrandCatalog.addToCart(' + p.id + ', event)">Agregar</button>' +
'</div>';
});
html += '</div>';
html += this.renderPagination();
this.setContent(html);
},
renderPagination: function() {
var hasPrev = this._offset > 0;
var hasNext = (this._offset + this._limit) < this._total;
var pageNum = Math.floor(this._offset / this._limit) + 1;
var totalPages = Math.ceil(this._total / this._limit) || 1;
html += '<div style="grid-column:1/-1;display:flex;justify-content:center;align-items:center;gap:var(--space-3);padding:var(--space-4) 0;">' +
'<button class="btn btn--secondary" ' + (hasPrev ? '' : 'disabled style="opacity:0.5;cursor:not-allowed;"') +
var html = '<div class="pagination">' +
'<button class="page-item" ' + (hasPrev ? '' : 'disabled') +
' onclick="BrandCatalog.goToPage(' + (this._offset - this._limit) + ')">&larr; Anterior</button>' +
'<span style="font-size:var(--text-body-sm);color:var(--color-text-muted);">Pagina ' + pageNum + ' de ' + totalPages + '</span>' +
'<button class="btn btn--secondary" ' + (hasNext ? '' : 'disabled style="opacity:0.5;cursor:not-allowed;"') +
'<button class="page-item" ' + (hasNext ? '' : 'disabled') +
' onclick="BrandCatalog.goToPage(' + (this._offset + this._limit) + ')">Siguiente &rarr;</button>' +
'</div>';
this.setContent(html);
return html;
},
searchParts: function(term) {
@@ -451,16 +497,17 @@
return;
}
if (window.CatalogApp && CatalogApp.addToCart) {
var isAftermarket = !!part.manufacturer;
CatalogApp.addToCart({
id: part.id,
id: part.oem_id || part.id,
part_number: part.oem_part_number || 'N/A',
name: part.name || 'Refaccion',
brand: '',
price: part.local_price || 0,
brand: part.manufacturer || '',
price: part.local_price || part.price_usd || 0,
tax_rate: 0.16,
unit: 'PZA',
stock: part.local_stock || 0,
source: 'oem-brand',
source: isAftermarket ? 'aftermarket' : 'oem-brand',
inventory_id: null
}, 1);
var btn = event.target;
@@ -482,4 +529,12 @@
}
window.BrandCatalog = BrandCatalog;
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
})();

View File

@@ -46,6 +46,11 @@
var checkoutBtn = document.getElementById('checkoutBtn');
var cartFab = document.getElementById('cartFab');
var cartCloseBtn = document.getElementById('cartCloseBtn');
// Supplier prices upload
var uploadPricesBtn = document.getElementById('uploadPricesBtn');
var uploadPricesModal= document.getElementById('uploadPricesModal');
var uploadPricesFile = document.getElementById('uploadPricesFile');
var uploadPricesStatus=document.getElementById('uploadPricesStatus');
// ─── Navigation State ───
var nav = {
@@ -195,7 +200,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 +250,7 @@
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
return d.innerHTML.replace(/"/g, '&quot;');
}
// ─── Breadcrumb ───
@@ -304,9 +321,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) {
@@ -363,10 +380,14 @@
var cacheKey = 'nexus:brands:' + catalogMode;
var cached = sessionStorage.getItem(cacheKey);
if (cached) {
hideLoading();
var data = JSON.parse(cached);
renderBrands(data);
return;
try {
hideLoading();
var data = JSON.parse(cached);
renderBrands(data);
return;
} catch (e) {
sessionStorage.removeItem(cacheKey);
}
}
apiFetch(API + '/brands?mode=' + catalogMode).then(function (data) {
@@ -937,9 +958,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));
});
});
@@ -998,17 +1024,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">'
@@ -1040,6 +1072,7 @@
'</div>' +
'<div class="part-card__footer">' +
(p.local_price ? '<span class="part-card__price">$' + fmt(p.local_price) + '</span>' : '<span class="part-card__price" style="color:var(--color-text-muted);">Sin precio</span>') +
(p.supplier_price ? '<span class="part-card__price" style="color:#2d7d46;font-size:0.85em;">Prov: $' + fmt(p.supplier_price) + '</span>' : '') +
stockBadge +
'</div>' +
'</article>';
@@ -1049,10 +1082,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));
});
});
@@ -1195,6 +1233,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');
@@ -1408,17 +1513,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 +
@@ -1430,6 +1540,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' +
@@ -1439,6 +1550,10 @@
alert(info);
return;
}
if (src === 'supplier_catalog' || (typeof pid === 'string' && pid.indexOf('sc:') === 0)) {
openSupplierDetail(pid.replace('sc:', ''));
return;
}
openPartDetail(parseInt(pid));
});
});
@@ -1645,8 +1760,13 @@
var cacheKey = 'nexus:years-all';
var cached = sessionStorage.getItem(cacheKey);
if (cached) {
var data = JSON.parse(cached);
var years = data.data || data || [];
try {
var data = JSON.parse(cached);
var years = data.data || data || [];
} catch (e) {
sessionStorage.removeItem(cacheKey);
var years = [];
}
if (!years.length) {
years = [];
for (var y = 2026; y >= 1990; y--) years.push({ id_year: y, year_car: y });
@@ -2046,6 +2166,53 @@
});
}
// ─── Supplier prices upload ─────────────────────────────────────────────
function openUploadPricesModal() {
if (uploadPricesModal) uploadPricesModal.style.display = 'flex';
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '';
if (uploadPricesFile) uploadPricesFile.value = '';
}
function closeUploadPricesModal() {
if (uploadPricesModal) uploadPricesModal.style.display = 'none';
}
async function submitUploadPrices() {
if (!uploadPricesFile || !uploadPricesFile.files || !uploadPricesFile.files[0]) {
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-error);">Selecciona un archivo primero.</span>';
return;
}
var form = new FormData();
form.append('file', uploadPricesFile.files[0]);
if (uploadPricesStatus) uploadPricesStatus.innerHTML = 'Subiendo...';
try {
var res = await fetch('/pos/api/supplier-catalog/prices/upload', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: form
});
var data = await res.json();
if (res.ok && data.success) {
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-success);">✓ Precios actualizados: ' + data.processed + ' (insertados: ' + data.inserted + ', actualizados: ' + data.updated + ')</span>';
uploadPricesFile.value = '';
} else {
var msg = data.error || 'Error al subir precios';
var details = (data.details || []).join('<br>');
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-error);">' + esc(msg) + '</span>' + (details ? '<div style="margin-top:4px;font-size:0.9em;">' + details + '</div>' : '');
}
} catch (e) {
if (uploadPricesStatus) uploadPricesStatus.innerHTML = '<span style="color:var(--color-error);">Error de red: ' + esc(e.message) + '</span>';
}
}
function shouldShowUploadPricesButton() {
try {
var user = JSON.parse(localStorage.getItem('pos_employee') || '{}');
return user.role === 'owner' || user.role === 'admin';
} catch (e) { return false; }
}
if (uploadPricesBtn && shouldShowUploadPricesButton()) {
uploadPricesBtn.style.display = 'inline-flex';
}
window.CatalogApp = {
toggleCart: toggleCart,
goToCheckout: goToCheckout,
@@ -2065,6 +2232,9 @@
togglePlate: togglePlate,
lookupPlate: lookupPlate,
setMode: setCatalogMode,
openUploadPricesModal: openUploadPricesModal,
closeUploadPricesModal: closeUploadPricesModal,
submitUploadPrices: submitUploadPrices,
};
// ─── INIT ───

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -19,6 +19,11 @@ const Config = (() => {
return true;
}
function escapeHtml(text) {
if (!text) return '';
return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function headers() {
return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' };
}
@@ -156,7 +161,7 @@ const Config = (() => {
_branches.forEach(function(b, idx) {
var statusBadge = b.is_active
? '<span class="badge badge--ok" style="padding:0 4px;font-size:0.625rem;">' + (idx === 0 ? 'Principal' : 'Activa') + '</span>'
? '<span class="badge badge--ok" style="padding:0 4px;font-size:0.625rem;">' + (b.is_main ? 'Principal' : 'Activa') + '</span>'
: '<span class="badge badge--inactive" style="padding:0 4px;font-size:0.625rem;">Inactiva</span>';
html += '<div class="device-card">'
@@ -165,14 +170,20 @@ const Config = (() => {
+ '</div>'
+ '<div class="device-card__body">'
+ '<div class="device-card__name">' + escHtml(b.name) + '</div>'
+ '<div class="device-card__detail">' + statusBadge + '</div>'
+ '<div class="device-card__detail">' + statusBadge
+ (b.rfc ? ' · RFC: ' + escHtml(b.rfc) : '')
+ (b.cp ? ' · CP: ' + escHtml(b.cp) : '')
+ '</div>'
+ (b.address ? '<div class="device-card__detail">' + escHtml(b.address) + '</div>' : '')
+ (b.phone ? '<div class="device-card__detail">' + escHtml(b.phone) + '</div>' : '')
+ '</div>'
+ '<div class="device-card__actions">'
+ '<button class="btn btn--ghost btn--sm" onclick="Config.editBranch(' + b.id + ')">Editar</button>'
+ '</div></div>';
});
// "Agregar Sucursal" card
html += '<div class="device-card" style="border-style:dashed;cursor:pointer;" onclick="Config.openModal(\'modal-branch\')">'
html += '<div class="device-card" style="border-style:dashed;cursor:pointer;" onclick="Config.openBranchModal()">'
+ '<div class="device-card__icon" style="background:transparent;border:2px dashed var(--color-border);">'
+ '<svg viewBox="0 0 24 24" style="stroke:var(--color-text-muted);"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>'
+ '</div>'
@@ -198,9 +209,36 @@ const Config = (() => {
});
}
function openBranchModal(branch) {
document.getElementById('branch-modal-title').textContent = branch ? 'Editar Sucursal' : 'Nueva Sucursal';
document.getElementById('branch-id').value = branch ? branch.id : '';
document.getElementById('branch-name').value = branch ? branch.name : '';
document.getElementById('branch-rfc').value = branch ? (branch.rfc || '') : '';
document.getElementById('branch-razon').value = branch ? (branch.razon_social || '') : '';
document.getElementById('branch-regimen').value = branch ? (branch.regimen_fiscal || '') : '';
document.getElementById('branch-cp').value = branch ? (branch.cp || '') : '';
document.getElementById('branch-direccion-fiscal').value = branch ? (branch.direccion_fiscal || '') : '';
document.getElementById('branch-serie').value = branch ? (branch.serie_cfdi || '') : '';
document.getElementById('branch-folio-inicio').value = branch ? (branch.folio_inicio || '') : '';
document.getElementById('branch-folio-actual').value = branch ? (branch.folio_actual || '') : '';
document.getElementById('branch-email').value = branch ? (branch.email || '') : '';
document.getElementById('branch-address').value = branch ? (branch.address || '') : '';
document.getElementById('branch-phone').value = branch ? (branch.phone || '') : '';
document.getElementById('branch-main').checked = branch ? !!branch.is_main : false;
openModal('modal-branch');
}
function editBranch(branchId) {
var b = _branches.find(function(x) { return x.id === branchId; });
if (!b) { toast('Sucursal no encontrada', 'error'); return; }
openBranchModal(b);
}
async function saveBranch(data) {
var res = await fetch(API + '/branches', {
method: 'POST',
var branchId = document.getElementById('branch-id').value;
var url = API + '/branches' + (branchId ? '/' + branchId : '');
var res = await fetch(url, {
method: branchId ? 'PUT' : 'POST',
headers: headers(),
body: JSON.stringify(data)
});
@@ -424,14 +462,36 @@ const Config = (() => {
try {
await saveBranch({
name: name,
address: document.getElementById('branch-address').value.trim(),
phone: document.getElementById('branch-phone').value.trim()
rfc: document.getElementById('branch-rfc').value.trim() || null,
razon_social: document.getElementById('branch-razon').value.trim() || null,
regimen_fiscal: document.getElementById('branch-regimen').value.trim() || null,
cp: document.getElementById('branch-cp').value.trim() || null,
direccion_fiscal: document.getElementById('branch-direccion-fiscal').value.trim() || null,
serie_cfdi: document.getElementById('branch-serie').value.trim() || null,
folio_inicio: document.getElementById('branch-folio-inicio').value ? parseInt(document.getElementById('branch-folio-inicio').value, 10) : null,
folio_actual: document.getElementById('branch-folio-actual').value ? parseInt(document.getElementById('branch-folio-actual').value, 10) : null,
email: document.getElementById('branch-email').value.trim() || null,
address: document.getElementById('branch-address').value.trim() || null,
phone: document.getElementById('branch-phone').value.trim() || null,
is_main: document.getElementById('branch-main').checked,
});
toast('Sucursal creada');
toast('Sucursal guardada');
closeModal('modal-branch');
// Reset form
document.getElementById('branch-id').value = '';
document.getElementById('branch-name').value = '';
document.getElementById('branch-rfc').value = '';
document.getElementById('branch-razon').value = '';
document.getElementById('branch-regimen').value = '';
document.getElementById('branch-cp').value = '';
document.getElementById('branch-direccion-fiscal').value = '';
document.getElementById('branch-serie').value = '';
document.getElementById('branch-folio-inicio').value = '';
document.getElementById('branch-folio-actual').value = '';
document.getElementById('branch-email').value = '';
document.getElementById('branch-address').value = '';
document.getElementById('branch-phone').value = '';
document.getElementById('branch-main').checked = false;
await loadBranches();
} catch (e) {
toast(e.message, 'error');
@@ -623,6 +683,117 @@ const Config = (() => {
}
}
// -------------------------------------------------------------------------
// Allowed Part Brands
// -------------------------------------------------------------------------
async function loadAllowedBrands() {
var container = document.getElementById('allowed-brands-container');
if (!container) return;
try {
var res = await fetch(API + '/available-brands', { headers: headers() });
if (!res.ok) throw new Error('Failed to load brands');
var d = await res.json();
var allBrands = d.brands || [];
var res2 = await fetch(API + '/allowed-brands', { headers: headers() });
if (!res2.ok) throw new Error('Failed to load allowed brands');
var d2 = await res2.json();
var allowed = d2.brands || [];
if (!allBrands.length) {
container.innerHTML = '<p style="color:var(--color-text-muted);font-size:var(--text-body-sm);">No hay marcas disponibles.</p>';
return;
}
var html = '';
allBrands.forEach(function(b) {
var checked = allowed.indexOf(b) !== -1 ? 'checked' : '';
html += '<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer;font-size:var(--text-body-sm);color:var(--color-text-primary);padding:var(--space-1);">' +
'<input type="checkbox" value="' + escapeHtml(b) + '" data-brand-checkbox ' + checked + ' style="width:16px;height:16px;cursor:pointer;">' +
escapeHtml(b) + '</label>';
});
container.innerHTML = html;
} catch (e) {
console.error('Config.loadAllowedBrands:', e);
if (container) container.innerHTML = '<p style="color:var(--color-error);font-size:var(--text-body-sm);">Error al cargar marcas.</p>';
}
}
async function saveAllowedBrands() {
var btn = document.getElementById('btn-save-allowed-brands');
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
try {
var checked = [];
document.querySelectorAll('[data-brand-checkbox]').forEach(function(cb) {
if (cb.checked) checked.push(cb.value);
});
var res = await fetch(API + '/allowed-brands', {
method: 'PUT',
headers: headers(),
body: JSON.stringify({ brands: checked })
});
if (!res.ok) {
var err = await res.json().catch(function() { return { error: res.statusText }; });
throw new Error(err.error || 'Save failed');
}
toast('Marcas permitidas actualizadas');
} catch (e) {
toast(e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Guardar Marcas'; }
}
}
// -------------------------------------------------------------------------
// Modules / Integrations
// -------------------------------------------------------------------------
async function loadModules() {
try {
var res = await fetch(API + '/modules', { headers: headers() });
if (!res.ok) return;
var data = await res.json();
var cbWa = document.getElementById('cfg-module-whatsapp');
var cbMp = document.getElementById('cfg-module-marketplace');
var cbMeli = document.getElementById('cfg-module-meli');
var cbCat = document.getElementById('cfg-module-catalog');
if (cbWa) cbWa.checked = data.whatsapp !== false;
if (cbMp) cbMp.checked = data.marketplace !== false;
if (cbMeli) cbMeli.checked = data.meli !== false;
if (cbCat) cbCat.checked = data.catalog !== false;
localStorage.setItem('pos_modules', JSON.stringify(data));
} catch (e) {
console.error('Config.loadModules:', e);
}
}
async function saveModules() {
var btn = event.target;
if (btn) { btn.disabled = true; btn.textContent = 'Guardando...'; }
try {
var data = {
whatsapp: document.getElementById('cfg-module-whatsapp').checked,
marketplace: document.getElementById('cfg-module-marketplace').checked,
meli: document.getElementById('cfg-module-meli').checked,
catalog: document.getElementById('cfg-module-catalog').checked,
};
var res = await fetch(API + '/modules', {
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 || 'Save failed');
}
localStorage.setItem('pos_modules', JSON.stringify(data));
toast('Módulos actualizados');
} catch (e) {
toast(e.message, 'error');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Guardar módulos'; }
}
}
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
@@ -650,6 +821,12 @@ const Config = (() => {
btnCompat.addEventListener('click', saveVehicleCompatSource);
}
// Allowed brands save button
var btnBrands = document.getElementById('btn-save-allowed-brands');
if (btnBrands) {
btnBrands.addEventListener('click', saveAllowedBrands);
}
// Kiosk mode toggle
var kioskToggle = document.getElementById('cfg-kiosk-mode');
if (kioskToggle && window.NexusKiosk) {
@@ -671,15 +848,26 @@ const Config = (() => {
loadBusiness();
loadCurrency();
loadVehicleCompatSource();
loadAllowedBrands();
loadModules();
}
document.addEventListener('DOMContentLoaded', init);
return {
init, setTheme, selectThemeOption,
init, setTheme, selectThemeOption, loadAllowedBrands, saveAllowedBrands,
loadBranches, loadEmployees, saveBranch, saveEmployee, editEmployee,
loadBusiness, saveBusiness, saveTaxParams,
loadCurrency, saveCurrency,
openModal, closeModal
loadModules, saveModules,
openModal, closeModal, openBranchModal, editBranch
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
})();

File diff suppressed because one or more lines are too long

View File

@@ -94,6 +94,8 @@ const Customers = (() => {
}
}
var selectedCustomers = new Set();
function renderCustomerRow(c) {
const tier = tierMap[c.price_tier] || 'Mostrador';
const tClass = tierClass[c.price_tier] || 'mostrador';
@@ -104,11 +106,13 @@ const Customers = (() => {
const creditClass = usedPct >= 80 ? 'none' : usedPct >= 60 ? 'low' : '';
const num = String(c.id).padStart(5, '0');
const selClass = (currentCustomer && currentCustomer.id === c.id) ? 'selected' : '';
return '<tr class="' + selClass + '" onclick="selectCustomer(' + c.id + ')">' +
const isChecked = selectedCustomers.has(c.id) ? 'checked' : '';
return '<tr class="' + selClass + '">' +
'<td onclick="event.stopPropagation();"><input type="checkbox" ' + isChecked + ' onchange="Customers.toggleCustomerSelection(' + c.id + ')"></td>' +
'<td class="cell-num">' + num + '</td>' +
'<td>' +
'<div class="cell-name">' + (c.name || '') + '</div>' +
'<div class="cell-name-sub hide-mobile">' + (c.email || '') + '</div>' +
'<div class="cell-name-sub hide-mobile">' + (c.razon_social || c.email || '') + '</div>' +
'</td>' +
'<td class="cell-rfc hide-mobile">' + (c.rfc || '-') + '</td>' +
'<td class="hide-mobile">' + (c.phone || '-') + '</td>' +
@@ -127,7 +131,12 @@ const Customers = (() => {
if (!tbody) return;
if (!customers || customers.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center;padding:var(--space-8);color:var(--color-text-muted);">Sin resultados.</td></tr>';
tbody.innerHTML = '<tr><td colspan="9">' + renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>',
title: 'Sin clientes',
subtitle: 'No se encontraron clientes registrados.',
action: '<button class="btn btn--primary btn--sm" onclick="Customers.openCreateModal()">Nuevo cliente</button>'
}) + '</td></tr>';
return;
}
@@ -137,7 +146,7 @@ const Customers = (() => {
rowHeight: 52,
buffer: 3,
renderRow: renderCustomerRow,
emptyHtml: '<tr><td colspan="9" style="text-align:center;padding:var(--space-8);color:var(--color-text-muted);">Sin resultados.</td></tr>'
emptyHtml: '<tr><td colspan="9">' + renderEmptyState({ title: 'Sin clientes', subtitle: 'No hay clientes registrados.' }) + '</td></tr>'
});
}
customersVS.setData(customers);
@@ -240,7 +249,9 @@ const Customers = (() => {
// Contact
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val || '-'; };
set('detailRazonSocial', c.razon_social);
set('detailAddress', c.address);
set('detailCp', c.cp);
set('detailPhone', c.phone);
set('detailEmail', c.email);
set('detailSince', formatDate(c.created_at));
@@ -263,6 +274,13 @@ const Customers = (() => {
bar.className = `progress-bar__fill ${pct < 40 ? 'low' : pct > 75 ? 'high' : ''}`;
}
// Discount
const discountEl = document.getElementById('detailMaxDiscount');
if (discountEl) discountEl.textContent = (c.max_discount_pct || 0) + '%';
// Re-wire action buttons after detail panel is visible
wireActionButtons();
// Purchase History
const hbody = document.getElementById('historyBody');
if (hbody) {
@@ -363,7 +381,7 @@ const Customers = (() => {
const btns = document.querySelectorAll('.quick-actions .action-btn');
// Order: Nueva Venta, Editar, Estado de Cuenta, Historial
if (btns.length >= 1) btns[0].onclick = () => {
if (currentCustomer) window.location.href = '/pos/?customer=' + currentCustomer.id;
if (currentCustomer) window.location.href = '/pos/sale?customer=' + currentCustomer.id;
};
if (btns.length >= 2) btns[1].onclick = () => editCurrent();
if (btns.length >= 3) btns[2].onclick = () => showStatement();
@@ -378,17 +396,19 @@ const Customers = (() => {
if (!modal) return;
document.getElementById('modalTitle').textContent = 'Nuevo Cliente';
document.getElementById('editId').value = '';
document.getElementById('fName').value = '';
document.getElementById('fRfc').value = '';
document.getElementById('fRazonSocial').value = '';
document.getElementById('fRegimenFiscal').value = '';
document.getElementById('fUsoCfdi').value = 'G03';
document.getElementById('fCp').value = '';
document.getElementById('fPhone').value = '';
document.getElementById('fEmail').value = '';
document.getElementById('fAddress').value = '';
document.getElementById('fPriceTier').value = '1';
document.getElementById('fCreditLimit').value = '0';
const safeSet = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
safeSet('fName', '');
safeSet('fRfc', '');
safeSet('fRazonSocial', '');
safeSet('fRegimenFiscal', '');
safeSet('fUsoCfdi', 'G03');
safeSet('fCp', '');
safeSet('fPhone', '');
safeSet('fEmail', '');
safeSet('fAddress', '');
safeSet('fPriceTier', '1');
safeSet('fCreditLimit', '0');
safeSet('fMaxDiscountPct', '0');
modal.classList.add('active');
document.getElementById('fName').focus();
}
@@ -400,17 +420,19 @@ const Customers = (() => {
if (!modal) return;
document.getElementById('modalTitle').textContent = 'Editar Cliente';
document.getElementById('editId').value = c.id;
document.getElementById('fName').value = c.name || '';
document.getElementById('fRfc').value = c.rfc || '';
document.getElementById('fRazonSocial').value = c.razon_social || '';
document.getElementById('fRegimenFiscal').value = c.regimen_fiscal || '';
document.getElementById('fUsoCfdi').value = c.uso_cfdi || 'G03';
document.getElementById('fCp').value = c.cp || '';
document.getElementById('fPhone').value = c.phone || '';
document.getElementById('fEmail').value = c.email || '';
document.getElementById('fAddress').value = c.address || '';
document.getElementById('fPriceTier').value = c.price_tier || '1';
document.getElementById('fCreditLimit').value = c.credit_limit || 0;
const safeSet = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
safeSet('fName', c.name || '');
safeSet('fRfc', c.rfc || '');
safeSet('fRazonSocial', c.razon_social || '');
safeSet('fRegimenFiscal', c.regimen_fiscal || '');
safeSet('fUsoCfdi', c.uso_cfdi || 'G03');
safeSet('fCp', c.cp || '');
safeSet('fPhone', c.phone || '');
safeSet('fEmail', c.email || '');
safeSet('fAddress', c.address || '');
safeSet('fPriceTier', c.price_tier || '1');
safeSet('fCreditLimit', c.credit_limit || 0);
safeSet('fMaxDiscountPct', c.max_discount_pct || 0);
modal.classList.add('active');
}
@@ -438,6 +460,7 @@ const Customers = (() => {
address: val('fAddress') || null,
price_tier: parseInt(val('fPriceTier')) || 1,
credit_limit: parseFloat(val('fCreditLimit')) || 0,
max_discount_pct: parseFloat(val('fMaxDiscountPct')) || 0,
};
const editId = val('editId');
@@ -474,7 +497,7 @@ const Customers = (() => {
if (nameEl) nameEl.textContent = currentCustomer.name;
const content = document.getElementById('statementContent');
if (content) content.innerHTML = '<div style="text-align:center;padding:20px;color:var(--color-text-muted);">Cargando...</div>';
if (content) content.innerHTML = '<div style="padding:20px;"><div class="skeleton skeleton--text"></div><div class="skeleton skeleton--text-sm" style="width:70%;"></div><div class="skeleton skeleton--text" style="width:50%;"></div></div>';
modal.classList.add('active');
try {
@@ -586,7 +609,9 @@ const Customers = (() => {
function injectModals() {
// Customer Create/Edit Modal
if (!document.getElementById('customerModal')) {
// Always remove and re-inject to ensure latest fields are present
const existingModal = document.getElementById('customerModal');
if (existingModal) existingModal.remove();
const div = document.createElement('div');
div.innerHTML = `
<div id="customerModal" class="modal-overlay" style="display:none;">
@@ -646,6 +671,7 @@ const Customers = (() => {
</select>
</div>
<div class="form-group"><label>Limite de Credito</label><input type="number" id="fCreditLimit" class="form-input" value="0" min="0" step="1000" /></div>
<div class="form-group"><label>Descuento Max (%)</label><input type="number" id="fMaxDiscountPct" class="form-input" value="0" min="0" max="100" step="0.5" /></div>
</div>
</div>
<div class="modal-footer">
@@ -655,9 +681,10 @@ const Customers = (() => {
</div>
</div>`;
document.body.appendChild(div);
}
// Statement Modal
const existingStatement = document.getElementById('statementModal');
if (existingStatement) existingStatement.remove();
if (!document.getElementById('statementModal')) {
const div = document.createElement('div');
div.innerHTML = `
@@ -772,11 +799,58 @@ const Customers = (() => {
// Run init
init();
return {
const publicApi = {
search, goToPage, loadCustomers,
showDetail, selectCustomer, closeDetail,
showCreateModal, editCurrent, closeModal, save,
showStatement, closeStatement,
showPaymentModal, closePayment, recordPayment,
};
// Bulk selection
publicApi.toggleCustomerSelection = function(id) {
if (selectedCustomers.has(id)) selectedCustomers.delete(id);
else selectedCustomers.add(id);
updateBulkToolbar();
};
publicApi.toggleSelectAll = function() {
var cb = document.getElementById('selectAllCustomers');
var allChecked = cb.checked;
if (customersVS && customersVS.data) {
customersVS.data.forEach(function(c) {
if (allChecked) selectedCustomers.add(c.id);
else selectedCustomers.delete(c.id);
});
customersVS.refresh();
}
updateBulkToolbar();
};
function updateBulkToolbar() {
var container = document.getElementById('customersBulkToolbar');
if (!container) return;
var count = selectedCustomers.size;
if (count === 0) { container.innerHTML = ''; return; }
container.innerHTML = renderBulkToolbar(count,
'<button class="btn btn--primary btn--sm" onclick="Customers.featureProximamente(\'Exportar seleccionados\')">📥 Exportar</button>' +
'<button class="btn btn--ghost btn--sm" onclick="Customers.clearSelection()">Limpiar</button>'
);
}
publicApi.clearSelection = function() {
selectedCustomers.clear();
document.getElementById('selectAllCustomers').checked = false;
if (customersVS) customersVS.refresh();
updateBulkToolbar();
};
// Expose globally for inline HTML onclick handlers
window.Customers = publicApi;
return publicApi;
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
})();

File diff suppressed because one or more lines are too long

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

@@ -208,7 +208,7 @@ const Dashboard = (() => {
function setKpiError(valueId, metaId) {
const v = document.getElementById(valueId);
const m = document.getElementById(metaId);
if (v) v.textContent = '--';
if (v) v.innerHTML = '<span style="color:var(--color-error)">--</span>';
if (m) m.innerHTML = '<span class="kpi-meta-text" style="color:var(--color-error)">Error al cargar</span>';
}
@@ -225,7 +225,12 @@ const Dashboard = (() => {
if (!container) return;
if (!registers || registers.length === 0) {
container.innerHTML = '<div class="rank-item" style="justify-content:center;color:var(--color-text-muted);font-size:var(--text-caption);">Sin cajas registradas hoy</div>';
container.innerHTML = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Sin cajas hoy',
subtitle: 'Ninguna caja ha sido abierta el día de hoy.',
action: '<a href="/pos/sale" class="btn btn--primary btn--sm">Abrir POS</a>'
});
return;
}
@@ -326,7 +331,12 @@ const Dashboard = (() => {
if (!container) return;
if (!data || !data.data || data.data.length === 0) {
container.innerHTML = '<div class="rank-item" style="justify-content:center;color:var(--color-text-muted);font-size:var(--text-caption);">Sin ventas hoy</div>';
container.innerHTML = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Sin ventas hoy',
subtitle: 'Aún no hay transacciones registradas el día de hoy.',
action: '<a href="/pos/sale" class="btn btn--primary btn--sm">Nueva venta</a>'
});
return;
}
@@ -353,7 +363,11 @@ const Dashboard = (() => {
const sorted = Object.values(productMap).sort((a, b) => b.revenue - a.revenue).slice(0, 5);
if (sorted.length === 0) {
container.innerHTML = '<div class="rank-item" style="justify-content:center;color:var(--color-text-muted);font-size:var(--text-caption);">Sin productos vendidos</div>';
container.innerHTML = renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>',
title: 'Sin productos vendidos',
subtitle: 'No hay suficiente información para mostrar el ranking.'
});
return;
}
@@ -444,7 +458,12 @@ const Dashboard = (() => {
if (!tbody) return;
if (!data || !data.data || data.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--color-text-muted);font-size:var(--text-caption);">Sin ventas hoy</td></tr>';
tbody.innerHTML = '<tr><td colspan="5">' + renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Sin ventas hoy',
subtitle: 'Aún no hay transacciones registradas.',
action: '<a href="/pos/sale" class="btn btn--primary btn--sm">Nueva venta</a>'
}) + '</td></tr>';
return;
}
@@ -526,6 +545,17 @@ const Dashboard = (() => {
}, 120000);
}
// Register Cmd+K items
if (typeof registerCmdKItem === 'function') {
registerCmdKItem({ group: 'Principal', label: 'Dashboard', href: '/pos/dashboard', icon: '📊' });
registerCmdKItem({ group: 'Principal', label: 'POS Ventas', href: '/pos/sale', icon: '🛒' });
registerCmdKItem({ group: 'Principal', label: 'Catálogo', href: '/pos/catalog', icon: '📁' });
registerCmdKItem({ group: 'Principal', label: 'Clientes', href: '/pos/customers', icon: '👤' });
registerCmdKItem({ group: 'Principal', label: 'Facturación', href: '/pos/invoicing', icon: '📄' });
registerCmdKItem({ group: 'Principal', label: 'Reportes', href: '/pos/reports', icon: '📈' });
registerCmdKItem({ group: 'Principal', label: 'Configuración', href: '/pos/config', icon: '⚙️' });
}
document.addEventListener('DOMContentLoaded', init);
return { init, setTheme };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -478,6 +478,51 @@ const Invoicing = (() => {
alert('Nota de credito: proximamente');
}
// ---- Global Invoice ----
function openGlobalInvoiceModal() {
const now = new Date();
document.getElementById('global-year').value = now.getFullYear();
document.getElementById('global-month').value = now.getMonth() + 1;
document.getElementById('global-preview').innerHTML = 'Presiona "Vista previa" para ver ventas elegibles.';
document.getElementById('modalGlobalInvoice').style.display = 'flex';
}
async function previewGlobalInvoice() {
const year = document.getElementById('global-year').value;
const month = document.getElementById('global-month').value;
const preview = document.getElementById('global-preview');
preview.innerHTML = 'Cargando...';
try {
const res = await api(`/global-invoice/eligible-sales?year=${year}&month=${month}`);
preview.innerHTML = `<strong>${res.count} ventas elegibles</strong> — Total: $${fmt(res.total)}<br><small>${res.sales.map(s => '#' + s.id).join(', ')}</small>`;
} catch (e) {
preview.innerHTML = '<span style="color:var(--color-error);">Error: ' + e.message + '</span>';
}
}
async function generateGlobalInvoice() {
const year = parseInt(document.getElementById('global-year').value, 10);
const month = parseInt(document.getElementById('global-month').value, 10);
const btn = document.querySelector('#modalGlobalInvoice .btn--primary');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Generando...';
try {
const res = await api('/global-invoice', {
method: 'POST',
body: JSON.stringify({ year, month })
});
alert(`Factura global generada: ${res.provisional_folio} (${res.sales_count} ventas, $${fmt(res.total)})`);
document.getElementById('modalGlobalInvoice').style.display = 'none';
loadFacturas();
} catch (e) {
alert('Error: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
// Expose switchTab globally for onclick handlers in HTML
window.switchTab = switchTab;
window.showNewInvoiceModal = showNewInvoiceModal;
@@ -489,5 +534,14 @@ const Invoicing = (() => {
switchTab, loadFacturas, loadNotas, loadComplementos, loadCancelaciones,
showDetail, showCancelModal, confirmCancel, processQueue,
showNewInvoiceModal, closeNewInvoiceModal, submitNewInvoice, notaCreditoPlaceholder,
openGlobalInvoiceModal, previewGlobalInvoice, generateGlobalInvoice,
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
})();

View File

@@ -154,6 +154,9 @@
}
};
// Expose for other scripts
window.isKioskEnabled = isKioskEnabled;
// ─── Init ───
if (isKioskEnabled()) {
activate();

View File

@@ -0,0 +1,608 @@
/**
* marketplace_external.js — MercadoLibre integration UI
*/
(function() {
'use strict';
var API = '/pos/api/marketplace-ext';
var TOKEN = localStorage.getItem('pos_token') || '';
function headers() {
return {
'Authorization': 'Bearer ' + TOKEN,
'Content-Type': 'application/json',
'X-Device-Id': localStorage.getItem('pos_device_id') || 'web',
};
}
// ─── Tabs ──────────────────────────────────────────────────────────────
window.switchTab = function(tab) {
document.querySelectorAll('.tab-btn').forEach(function(b) {
b.classList.toggle('is-active', b.dataset.tab === tab);
b.setAttribute('aria-selected', b.dataset.tab === tab ? 'true' : 'false');
});
document.querySelectorAll('.tab-panel').forEach(function(p) {
p.classList.toggle('is-active', p.id === 'panel-' + tab);
});
if (tab === 'listings') loadListings();
if (tab === 'orders') loadOrders();
if (tab === 'questions') loadQuestions();
};
function closeModal(id) {
document.getElementById(id).classList.remove('is-open');
}
window.closeModal = closeModal;
// ─── Config / Connection ───────────────────────────────────────────────
async function loadConfig() {
try {
var res = await fetch(API + '/config', { headers: headers() });
if (!res.ok) throw new Error('Failed to load config');
var cfg = await res.json();
var statusDiv = document.getElementById('configStatus');
var formDiv = document.getElementById('configForm');
var connectedDiv = document.getElementById('configConnected');
if (cfg.connected) {
statusDiv.innerHTML = '<span class="meli-status meli-status--active">● Conectado</span>';
formDiv.style.display = 'none';
connectedDiv.style.display = 'block';
document.getElementById('connectedNickname').textContent = cfg.meli_user_id || 'Usuario ML';
document.getElementById('connectedSite').textContent = cfg.meli_site_id || 'MLM';
} else {
statusDiv.innerHTML = '<span class="meli-status meli-status--pending">● No conectado</span>';
formDiv.style.display = 'block';
connectedDiv.style.display = 'none';
}
} catch (e) {
console.error(e);
document.getElementById('configStatus').innerHTML = '<p style="color:var(--color-danger);">Error cargando configuración</p>';
}
}
window.startOAuth = function() {
var clientId = document.getElementById('cfgClientId').value.trim();
var clientSecret = document.getElementById('cfgClientSecret').value.trim();
var category = document.getElementById('cfgCategory').value.trim();
var shipping = document.getElementById('cfgShipping').value;
if (!clientId || !clientSecret) {
alert('Client ID y Client Secret son requeridos');
return;
}
// Save config locally for the callback
localStorage.setItem('meli_client_id', clientId);
localStorage.setItem('meli_client_secret', clientSecret);
localStorage.setItem('meli_category', category);
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) + '&scope=read+write+offline_access';
window.location.href = authUrl;
};
window.disconnectMeli = async function() {
if (!confirm('¿Desconectar MercadoLibre? Las publicaciones existentes no se eliminarán de ML.')) return;
try {
var res = await fetch(API + '/connect', { method: 'DELETE', headers: headers() });
if (res.ok) {
loadConfig();
} else {
alert('Error desconectando');
}
} catch (e) {
alert('Error: ' + e.message);
}
};
// ─── Listings ──────────────────────────────────────────────────────────
var listingsData = [];
window.loadListings = async function() {
var container = document.getElementById('listingsContainer');
container.innerHTML = '<div class="meli-card"><div class="skeleton skeleton--text"></div><div class="skeleton skeleton--text-sm" style="width:70%;"></div></div>'
+ '<div class="meli-card"><div class="skeleton skeleton--text"></div><div class="skeleton skeleton--text-sm" style="width:60%;"></div></div>'
+ '<div class="meli-card"><div class="skeleton skeleton--text"></div><div class="skeleton skeleton--text-sm" style="width:80%;"></div></div>';
try {
var res = await fetch(API + '/listings?page=1&per_page=50', { headers: headers() });
if (!res.ok) throw new Error('Failed to load listings');
var data = await res.json();
listingsData = data.items || [];
renderListings();
} 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"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Error cargando publicaciones',
subtitle: 'No se pudieron obtener las publicaciones de MercadoLibre. Intenta de nuevo.'
});
}
};
function renderListings() {
var container = document.getElementById('listingsContainer');
var statusFilter = document.getElementById('listingStatusFilter').value;
var search = document.getElementById('listingSearch').value.toLowerCase();
var filtered = listingsData.filter(function(l) {
if (statusFilter && l.external_status !== statusFilter) return false;
if (search && !((l.title || '').toLowerCase().includes(search))) return false;
return true;
});
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"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Sin publicaciones',
subtitle: 'Aún no hay publicaciones en MercadoLibre. Ve a Inventario y publica un producto.',
action: '<a href="/pos/inventory" class="btn btn--meli btn--sm">Ir a Inventario</a>'
});
return;
}
container.innerHTML = filtered.map(function(l) {
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);">'
+ '<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: <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>')
+ (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('');
}
window.filterListings = renderListings;
window.syncListing = async function(id) {
try {
var res = await fetch(API + '/listings/' + id + '/sync', { method: 'POST', headers: headers() });
var data = await res.json();
if (res.ok) {
showToast('Sincronizado: $' + data.price + ' · Stock: ' + data.stock, 'ok', { title: 'Publicación actualizada' });
loadListings();
} else {
showToast(data.error || 'Error desconocido', 'error', { title: 'Error de sincronización' });
}
} catch (e) {
showToast(e.message, 'error', { title: 'Error de red' });
}
};
window.pauseListing = async function(id) {
try {
var res = await fetch(API + '/listings/' + id + '/pause', { method: 'POST', headers: headers() });
if (res.ok) { loadListings(); } else { alert('Error'); }
} catch (e) { alert('Error: ' + e.message); }
};
window.activateListing = async function(id) {
try {
var res = await fetch(API + '/listings/' + id + '/activate', { method: 'POST', headers: headers() });
if (res.ok) { loadListings(); } else { alert('Error'); }
} catch (e) { alert('Error: ' + e.message); }
};
window.deleteListing = async function(id) {
if (!confirm('¿Cerrar esta publicación en MercadoLibre?')) return;
try {
var res = await fetch(API + '/listings/' + id, { method: 'DELETE', headers: headers() });
if (res.ok) { loadListings(); } else { alert('Error'); }
} 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 = [];
window.loadOrders = async function() {
var tbody = document.getElementById('ordersTableBody');
tbody.innerHTML = renderSkeletonRows(6, 5);
try {
var res = await fetch(API + '/orders?page=1&per_page=50', { headers: headers() });
if (!res.ok) throw new Error('Failed to load orders');
var data = await res.json();
ordersData = data.items || [];
renderOrders();
} catch (e) {
tbody.innerHTML = '<tr><td colspan="6">' + renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Error cargando órdenes',
subtitle: 'No se pudieron obtener las órdenes de MercadoLibre.'
}) + '</td></tr>';
}
};
function renderOrders() {
var tbody = document.getElementById('ordersTableBody');
var statusFilter = document.getElementById('orderStatusFilter').value;
var search = document.getElementById('orderSearch').value.toLowerCase();
var filtered = ordersData.filter(function(o) {
if (statusFilter && o.status !== statusFilter) return false;
if (search && !((o.buyer_name || '').toLowerCase().includes(search) || (o.external_order_id || '').includes(search))) return false;
return true;
});
if (!filtered.length) {
tbody.innerHTML = '<tr><td colspan="6">' + renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Sin órdenes',
subtitle: 'No hay órdenes de MercadoLibre en este momento.'
}) + '</td></tr>';
return;
}
tbody.innerHTML = filtered.map(function(o) {
var statusClass = 'meli-status--' + (o.status || 'pending');
return '<tr>'
+ '<td><a href="#" onclick="showOrderDetail(' + o.id + ');return false;">' + escapeHtml(o.external_order_id) + '</a></td>'
+ '<td>' + escapeHtml(o.buyer_name || o.buyer_nickname || '—') + '</td>'
+ '<td style="text-align:right">$' + (o.total_amount || 0).toFixed(2) + '</td>'
+ '<td><span class="meli-status ' + statusClass + '">' + (o.status || '—') + '</span></td>'
+ '<td>' + (o.created_at ? o.created_at.split('T')[0] : '—') + '</td>'
+ '<td>'
+ (o.status === 'pending' ? '<button class="btn btn--primary btn--xs" onclick="convertOrder(' + o.id + ')">Convertir a Venta</button> ' : '')
+ '<button class="btn btn--ghost btn--xs" onclick="showOrderDetail(' + o.id + ')">Ver</button>'
+ '</td>'
+ '</tr>';
}).join('');
}
window.filterOrders = renderOrders;
window.showOrderDetail = async function(id) {
var modal = document.getElementById('orderModal');
var body = document.getElementById('orderModalBody');
var footer = document.getElementById('orderModalFooter');
body.innerHTML = 'Cargando...';
footer.innerHTML = '';
modal.classList.add('is-open');
try {
var res = await fetch(API + '/orders/' + id, { headers: headers() });
var o = await res.json();
if (!res.ok) throw new Error(o.error || 'Error');
var itemsHtml = (o.items || []).map(function(it) {
return '<tr><td>' + escapeHtml(it.title || '—') + '</td><td>' + it.quantity + '</td><td style="text-align:right">$' + (it.unit_price || 0).toFixed(2) + '</td></tr>';
}).join('');
body.innerHTML = '<div style="margin-bottom:var(--space-4);">'
+ '<p><strong>Comprador:</strong> ' + escapeHtml(o.buyer_name || '—') + ' (' + escapeHtml(o.buyer_nickname || '—') + ')</p>'
+ '<p><strong>Email:</strong> ' + escapeHtml(o.buyer_email || '—') + '</p>'
+ '<p><strong>Teléfono:</strong> ' + escapeHtml(o.buyer_phone || '—') + '</p>'
+ '<p><strong>Total:</strong> $' + (o.total_amount || 0).toFixed(2) + '</p>'
+ '<p><strong>Estado ML:</strong> ' + escapeHtml(o.external_status || '—') + '</p>'
+ '<p><strong>Estado Nexus:</strong> ' + escapeHtml(o.status || '—') + '</p>'
+ '</div>'
+ '<h4 style="margin:var(--space-3) 0;">Items</h4>'
+ '<table class="data-table"><thead><tr><th>Producto</th><th>Cantidad</th><th style="text-align:right">Precio</th></tr></thead><tbody>' + itemsHtml + '</tbody></table>';
footer.innerHTML = '';
if (o.status === 'pending') {
footer.innerHTML += '<button class="btn btn--primary" onclick="convertOrder(' + o.id + ');closeModal(\'orderModal\')">Convertir a Venta</button> ';
}
if (o.status === 'confirmed') {
footer.innerHTML += '<button class="btn btn--primary" onclick="updateOrderStatus(' + o.id + ', \'packed\')">Marcar Empacada</button> ';
}
if (o.status === 'packed') {
footer.innerHTML += '<button class="btn btn--primary" onclick="updateOrderStatus(' + o.id + ', \'shipped\')">Marcar Enviada</button> ';
}
footer.innerHTML += '<button class="btn btn--ghost" onclick="closeModal(\'orderModal\')">Cerrar</button>';
} catch (e) {
body.innerHTML = '<p style="color:var(--color-danger)">Error: ' + escapeHtml(e.message) + '</p>';
}
};
window.convertOrder = async function(id) {
try {
var res = await fetch(API + '/orders/' + id + '/convert', {
method: 'POST',
headers: headers(),
body: JSON.stringify({})
});
var data = await res.json();
if (res.ok) {
showToast('Orden convertida a venta #' + data.sale_id, 'ok', { title: 'Venta creada' });
loadOrders();
} else {
showToast(data.error || 'Error desconocido', 'error', { title: 'Error al convertir' });
}
} catch (e) {
showToast(e.message, 'error', { title: 'Error de red' });
}
};
window.updateOrderStatus = async function(id, status) {
try {
var res = await fetch(API + '/orders/' + id + '/status', {
method: 'POST',
headers: headers(),
body: JSON.stringify({ status: status })
});
if (res.ok) { loadOrders(); } else { alert('Error'); }
} catch (e) { alert('Error: ' + e.message); }
};
// ─── Utils ─────────────────────────────────────────────────────────────
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ─── Init ──────────────────────────────────────────────────────────────
// Handle OAuth callback
var urlParams = new URLSearchParams(window.location.search);
var authCode = urlParams.get('code');
if (authCode && window.location.pathname.includes('marketplace-external')) {
(async function() {
var clientId = localStorage.getItem('meli_client_id');
var clientSecret = localStorage.getItem('meli_client_secret');
var redirectUri = window.location.origin + '/pos/marketplace-external/callback';
try {
var res = await fetch(API + '/connect', {
method: 'POST',
headers: headers(),
body: JSON.stringify({
code: authCode,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
})
});
var data = await res.json();
if (res.ok) {
alert('¡Conectado exitosamente con MercadoLibre!');
window.history.replaceState({}, document.title, '/pos/marketplace-external');
loadConfig();
} else {
alert('Error conectando: ' + (data.error || 'Unknown'));
}
} catch (e) {
alert('Error: ' + e.message);
}
})();
}
// ML Status Cards (sparkline simulation)
window.loadMeliStats = async function() {
var container = document.getElementById('meliStatsBar');
if (!container) return;
try {
var res = await fetch(API + '/listings?page=1&per_page=200', { headers: headers() });
if (!res.ok) throw new Error('Failed');
var data = await res.json();
var items = data.items || [];
var active = items.filter(function(l) { return l.external_status === 'active'; }).length;
var paused = items.filter(function(l) { return l.external_status === 'paused'; }).length;
var closed = items.filter(function(l) { return l.external_status === 'closed'; }).length;
var total = items.length;
var html = '<div style="display:flex;gap:var(--space-4);flex-wrap:wrap;margin-bottom:var(--space-4);">' +
'<div class="kpi-card" style="flex:1;min-width:160px;"><div class="kpi-card__label">Activas</div><div class="kpi-card__value" style="color:var(--color-success);">' + active + '</div></div>' +
'<div class="kpi-card" style="flex:1;min-width:160px;"><div class="kpi-card__label">Pausadas</div><div class="kpi-card__value" style="color:var(--color-warning);">' + paused + '</div></div>' +
'<div class="kpi-card" style="flex:1;min-width:160px;"><div class="kpi-card__label">Cerradas</div><div class="kpi-card__value" style="color:var(--color-error);">' + closed + '</div></div>' +
'<div class="kpi-card" style="flex:1;min-width:160px;"><div class="kpi-card__label">Total</div><div class="kpi-card__value">' + total + '</div></div>';
// Sparkline simulation
html += '<div class="kpi-card" style="flex:1;min-width:200px;"><div class="kpi-card__label">Tendencia</div><div id="meliSparkline"></div></div>';
html += '</div>';
container.innerHTML = html;
if (typeof renderSparkline === 'function') {
renderSparkline('#meliSparkline', [active, paused, closed, total % 50, active - 2, paused + 1, closed, active], { prefix: '' });
}
} catch(e) {
container.innerHTML = '';
}
};
// Kanban Order View
var _orderViewMode = 'table';
window.toggleOrderView = function() {
_orderViewMode = _orderViewMode === 'table' ? 'kanban' : 'table';
document.getElementById('ordersTableView').style.display = _orderViewMode === 'table' ? '' : 'none';
document.getElementById('ordersKanbanView').style.display = _orderViewMode === 'kanban' ? '' : 'none';
document.getElementById('btnKanbanView').textContent = _orderViewMode === 'table' ? '📋 Kanban' : '📄 Tabla';
if (_orderViewMode === 'kanban') renderKanbanOrders();
};
function renderKanbanOrders() {
var container = document.getElementById('ordersKanbanView');
if (!container) return;
var columns = [
{ key: 'pending', label: 'Pendientes', badge: 'badge--pending' },
{ key: 'confirmed', label: 'Confirmadas', badge: 'badge--ok' },
{ key: 'packed', label: 'Empacadas', badge: 'badge--transit' },
{ key: 'shipped', label: 'Enviadas', badge: 'badge--transit' },
{ key: 'delivered', label: 'Entregadas', badge: 'badge--complete' },
{ key: 'cancelled', label: 'Canceladas', badge: 'badge--cancelled' },
];
var html = '<div class="kanban">';
columns.forEach(function(col) {
var items = ordersData.filter(function(o) { return o.status === col.key; });
html += '<div class="kanban__col" data-status="' + col.key + '">';
html += '<div class="kanban__col-header">' + col.label + '<span class="kanban__col-count ' + col.badge + '">' + items.length + '</span></div>';
html += '<div class="kanban__cards">';
items.slice(0, 20).forEach(function(o) {
html += '<div class="kanban__card" draggable="true" data-id="' + o.id + '">' +
'<div class="kanban__card-title">' + escapeHtml(o.buyer_name || o.buyer_nickname || '—') + '</div>' +
'<div class="kanban__card-meta">$' + (o.total_amount || 0).toFixed(2) + ' · ' + escapeHtml(o.external_order_id || '') + '</div>' +
'</div>';
});
if (items.length > 20) html += '<div style="text-align:center;font-size:11px;color:var(--color-text-muted);padding:8px;">+' + (items.length - 20) + ' más</div>';
html += '</div></div>';
});
html += '</div>';
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() {
loadConfig();
});
})();

View File

@@ -76,38 +76,245 @@
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) {
// ── Toast (enhanced with icons, progress bar, close button, actions) ──
var _toastIcons = {
ok: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>',
error: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
warn: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
info: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>'
};
var _toastTitles = { ok: 'Éxito', error: 'Error', warn: 'Advertencia', info: 'Información' };
window.showToast = function(msg, type, opts) {
type = type || 'info';
opts = opts || {};
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;
toast.className = 'toast toast--' + type;
var iconHtml = '<div class="toast__icon">' + (_toastIcons[type] || _toastIcons.info) + '</div>';
var titleHtml = opts.title ? '<div class="toast__title">' + opts.title + '</div>' : '';
var actionHtml = '';
if (opts.action && opts.action.text) {
actionHtml = '<div class="toast__action"><button onclick="this.closest(\'.toast\').__toastAction()">' + opts.action.text + '</button></div>';
toast.__toastAction = function() {
if (opts.action.callback) opts.action.callback();
_removeToast(toast);
};
}
var progressHtml = '<div class="toast__progress" style="animation-duration:' + (opts.duration || 4000) + 'ms;"></div>';
toast.innerHTML = iconHtml +
'<div class="toast__content">' + titleHtml + '<div class="toast__msg">' + msg + '</div>' + actionHtml + '</div>' +
'<button class="toast__close" onclick="_removeToast(this.closest(\'.toast\'))">✕</button>' +
progressHtml;
container.appendChild(toast);
setTimeout(function() {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(function() { toast.remove(); }, 300);
}, 3000);
var timer = setTimeout(function() { _removeToast(toast); }, opts.duration || 4000);
toast.__toastTimer = timer;
toast.addEventListener('mouseenter', function() { clearTimeout(timer); var p = toast.querySelector('.toast__progress'); if (p) p.style.animationPlayState = 'paused'; });
toast.addEventListener('mouseleave', function() { var p = toast.querySelector('.toast__progress'); if (p) p.style.animationPlayState = 'running'; timer = setTimeout(function() { _removeToast(toast); }, 2000); toast.__toastTimer = timer; });
};
window._removeToast = function(toast) {
if (!toast || toast.__toastRemoved) return;
toast.__toastRemoved = true;
if (toast.__toastTimer) clearTimeout(toast.__toastTimer);
toast.style.animation = 'toastSlideOut 0.25s ease forwards';
setTimeout(function() { toast.remove(); }, 260);
};
// ── Skeleton helpers ──────────────────────────────────────────
window.renderSkeletonRows = function(cols, rows) {
rows = rows || 6;
var html = '';
for (var i = 0; i < rows; i++) {
html += '<tr class="skeleton--table-row">';
for (var j = 0; j < cols; j++) {
html += '<td><div class="skeleton"></div></td>';
}
html += '</tr>';
}
return html;
};
window.showSkeleton = function(containerSelector, cols, rows) {
var el = typeof containerSelector === 'string' ? document.querySelector(containerSelector) : containerSelector;
if (!el) return;
el.dataset.originalContent = el.innerHTML;
el.innerHTML = renderSkeletonRows(cols || 6, rows || 6);
};
window.hideSkeleton = function(containerSelector) {
var el = typeof containerSelector === 'string' ? document.querySelector(containerSelector) : containerSelector;
if (!el || el.dataset.originalContent === undefined) return;
el.innerHTML = el.dataset.originalContent;
delete el.dataset.originalContent;
};
// ── Empty state helper ────────────────────────────────────────
window.renderEmptyState = function(opts) {
opts = opts || {};
var icon = opts.icon || '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="9" y1="9" x2="15" y2="15"/><line x1="15" y1="9" x2="9" y2="15"/></svg>';
var title = opts.title || 'Sin datos';
var subtitle = opts.subtitle || 'No hay información disponible en este momento.';
var action = opts.action ? '<div class="empty-state__action">' + opts.action + '</div>' : '';
return '<div class="empty-state">' +
'<div class="empty-state__icon">' + icon + '</div>' +
'<div class="empty-state__title">' + title + '</div>' +
'<div class="empty-state__subtitle">' + subtitle + '</div>' +
action + '</div>';
};
// ── Cmd+K Global Search ───────────────────────────────────────
(function() {
var cmdkOverlay = null, cmdkInput = null, cmdkResults = null, cmdkSelected = -1;
var cmdkItems = [];
function buildCmdK() {
if (cmdkOverlay) return;
cmdkOverlay = document.createElement('div');
cmdkOverlay.className = 'cmdk-overlay';
cmdkOverlay.innerHTML =
'<div class="cmdk-modal" role="dialog" aria-label="Búsqueda global">' +
' <div class="cmdk-input-wrap">' +
' <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--color-text-muted);flex-shrink:0;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>' +
' <input type="text" class="cmdk-input" placeholder="Buscar módulos, productos, clientes..." autocomplete="off">' +
' <span class="cmdk-shortcut">ESC</span>' +
' </div>' +
' <div class="cmdk-results"></div>' +
' <div class="cmdk-footer"><span>↑↓ navegar · ↵ seleccionar</span><span>' + cmdkItems.length + ' resultados</span></div>' +
'</div>';
document.body.appendChild(cmdkOverlay);
cmdkInput = cmdkOverlay.querySelector('.cmdk-input');
cmdkResults = cmdkOverlay.querySelector('.cmdk-results');
cmdkOverlay.addEventListener('click', function(e) { if (e.target === cmdkOverlay) closeCmdK(); });
cmdkInput.addEventListener('input', function() { filterCmdK(this.value); });
cmdkInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { closeCmdK(); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); moveCmdK(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); moveCmdK(-1); }
if (e.key === 'Enter') { e.preventDefault(); activateCmdK(); }
});
}
function openCmdK() {
buildCmdK();
cmdkOverlay.classList.add('is-open');
cmdkInput.value = '';
cmdkInput.focus();
filterCmdK('');
}
function closeCmdK() {
if (cmdkOverlay) cmdkOverlay.classList.remove('is-open');
}
function moveCmdK(dir) {
var items = cmdkResults.querySelectorAll('.cmdk-item');
if (!items.length) return;
cmdkSelected += dir;
if (cmdkSelected < 0) cmdkSelected = items.length - 1;
if (cmdkSelected >= items.length) cmdkSelected = 0;
items.forEach(function(it, i) { it.classList.toggle('is-selected', i === cmdkSelected); });
var sel = items[cmdkSelected];
if (sel) sel.scrollIntoView({ block: 'nearest' });
}
function activateCmdK() {
var items = cmdkResults.querySelectorAll('.cmdk-item');
var sel = items[cmdkSelected];
if (sel && sel.dataset.href) { closeCmdK(); window.location.href = sel.dataset.href; }
}
function filterCmdK(q) {
q = (q || '').toLowerCase().trim();
var groups = {};
cmdkItems.forEach(function(item) {
if (!q || item.label.toLowerCase().indexOf(q) !== -1 || (item.keywords || '').toLowerCase().indexOf(q) !== -1) {
groups[item.group] = groups[item.group] || [];
groups[item.group].push(item);
}
});
var html = '';
var total = 0;
Object.keys(groups).forEach(function(g) {
html += '<div class="cmdk-group"><div class="cmdk-group__label">' + g + '</div>';
groups[g].forEach(function(item) {
total++;
html += '<div class="cmdk-item" data-href="' + (item.href || '') + '">' +
'<div class="cmdk-item__icon">' + (item.icon || '→') + '</div>' +
'<div>' + item.label + '</div>' +
(item.meta ? '<div class="cmdk-item__meta">' + item.meta + '</div>' : '') +
'</div>';
});
html += '</div>';
});
if (!total) html = '<div style="padding:24px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
cmdkResults.innerHTML = html;
cmdkSelected = 0;
var first = cmdkResults.querySelector('.cmdk-item');
if (first) first.classList.add('is-selected');
var footer = cmdkOverlay.querySelector('.cmdk-footer span:last-child');
if (footer) footer.textContent = total + ' resultados';
}
document.addEventListener('keydown', function(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); openCmdK(); }
});
window.registerCmdKItem = function(item) {
if (!item || !item.label) return;
cmdkItems.push(item);
};
window.openCmdK = openCmdK;
window.closeCmdK = closeCmdK;
})();
// ── Connection indicator helper ───────────────────────────────
window.ConnectionStatus = {
online: function() { return navigator.onLine; },
render: function(containerId) {
var el = document.getElementById(containerId);
if (!el) return;
function update() {
var isOnline = navigator.onLine;
el.className = 'connection-indicator' + (isOnline ? '' : ' connection-indicator--offline');
el.innerHTML = '<span></span>' + (isOnline ? 'En línea' : 'Sin conexión');
}
update();
window.addEventListener('online', update);
window.addEventListener('offline', update);
}
};
// ── Bulk toolbar helper ───────────────────────────────────────
window.renderBulkToolbar = function(count, actionsHtml) {
return '<div class="bulk-toolbar">' +
'<div class="bulk-toolbar__count">' + count + ' seleccionado' + (count !== 1 ? 's' : '') + '</div>' +
'<div class="bulk-toolbar__actions">' + actionsHtml + '</div>' +
'</div>';
};
// ── Entrance animation helper ─────────────────────────────────
window.animateEntrance = function(selector, animClass, stagger) {
animClass = animClass || 'animate-fade-in-up';
stagger = stagger || 0.05;
var els = document.querySelectorAll(selector);
els.forEach(function(el, i) {
el.style.animationDelay = (i * stagger) + 's';
el.classList.add(animClass);
});
};
// ── "Próximamente" placeholder for features not yet built ──────
@@ -392,4 +599,275 @@
}
}
// ── Barcode Scanner Feedback ──────────────────────────────────
window.BarcodeFeedback = {
_audioCtx: null,
success: function() {
this._beep(800, 0.1, 'sine');
this._flash('#22c55e');
if (navigator.vibrate) navigator.vibrate(50);
},
error: function() {
this._beep(200, 0.15, 'square');
this._flash('#ef4444');
if (navigator.vibrate) navigator.vibrate([80, 50, 80]);
},
_beep: function(freq, duration, type) {
try {
if (!this._audioCtx) this._audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var osc = this._audioCtx.createOscillator();
var gain = this._audioCtx.createGain();
osc.type = type || 'sine';
osc.frequency.value = freq;
gain.gain.value = 0.1;
osc.connect(gain);
gain.connect(this._audioCtx.destination);
osc.start();
osc.stop(this._audioCtx.currentTime + duration);
} catch(e) {}
},
_flash: function(color) {
var el = document.createElement('div');
el.style.cssText = 'position:fixed;inset:0;z-index:99999;opacity:0.3;background:' + color + ';pointer-events:none;transition:opacity 0.2s;';
document.body.appendChild(el);
setTimeout(function() { el.style.opacity = '0'; }, 50);
setTimeout(function() { el.remove(); }, 300);
}
};
// ── Saved Filters ─────────────────────────────────────────────
window.SavedFilters = {
_key: function(page) { return 'pos_filters_' + (page || window.location.pathname); },
save: function(name, filters) {
var key = this._key();
var saved = this.list();
saved.push({ name: name, filters: filters, created: Date.now() });
localStorage.setItem(key, JSON.stringify(saved));
},
list: function() {
try { return JSON.parse(localStorage.getItem(this._key()) || '[]'); } catch(e) { return []; }
},
remove: function(name) {
var saved = this.list().filter(function(f) { return f.name !== name; });
localStorage.setItem(this._key(), JSON.stringify(saved));
},
renderChips: function(containerId, onApply) {
var container = document.getElementById(containerId);
if (!container) return;
var saved = this.list();
if (!saved.length) { container.innerHTML = ''; return; }
var html = '';
saved.forEach(function(f) {
html += '<span class="filter-chip">' + esc(f.name) +
'<button class="filter-chip__remove" onclick="SavedFilters.remove(\'' + esc(f.name) + '\');SavedFilters.renderChips(\'' + containerId + '\');">&times;</button></span>';
});
container.innerHTML = html;
container.querySelectorAll('.filter-chip').forEach(function(chip, i) {
chip.addEventListener('click', function(e) {
if (e.target.classList.contains('filter-chip__remove')) return;
if (onApply) onApply(saved[i].filters);
});
});
}
};
// ── Resizable Columns ─────────────────────────────────────────
window.makeTableResizable = function(tableSelector) {
var table = document.querySelector(tableSelector);
if (!table) return;
var ths = table.querySelectorAll('thead th');
ths.forEach(function(th, i) {
if (i >= ths.length - 1) return; // skip last column
var handle = document.createElement('div');
handle.className = 'resize-handle';
th.appendChild(handle);
handle.addEventListener('mousedown', function(e) {
e.preventDefault();
var startX = e.pageX;
var startW = th.offsetWidth;
th.classList.add('is-resizing');
function onMove(ev) {
var newW = Math.max(60, startW + (ev.pageX - startX));
th.style.width = newW + 'px';
th.style.minWidth = newW + 'px';
}
function onUp() {
th.classList.remove('is-resizing');
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
};
// ── Density / Touch Mode Toggles ──────────────────────────────
window.DensityToggle = {
set: function(density) {
document.documentElement.setAttribute('data-density', density);
localStorage.setItem('pos_density', density);
},
init: function() {
var saved = localStorage.getItem('pos_density') || 'normal';
document.documentElement.setAttribute('data-density', saved);
}
};
window.TouchModeToggle = {
set: function(enabled) {
document.documentElement.setAttribute('data-touch', enabled ? 'true' : 'false');
localStorage.setItem('pos_touch_mode', enabled ? 'true' : 'false');
},
init: function() {
var saved = localStorage.getItem('pos_touch_mode') === 'true';
document.documentElement.setAttribute('data-touch', saved ? 'true' : 'false');
}
};
DensityToggle.init();
TouchModeToggle.init();
// ── Notifications Dropdown (functional) ───────────────────────
window.NotificationsDropdown = {
_visible: false,
_el: null,
toggle: function() {
if (this._visible) { this.hide(); return; }
this.show();
},
show: function() {
if (this._el) this._el.remove();
var btn = document.getElementById('notifDropdownBtn');
var el = document.createElement('div');
el.className = 'notif-dropdown';
el.innerHTML = '<div class="notif-dropdown__header">Notificaciones <button onclick="NotificationsDropdown.hide()" style="background:none;border:none;color:var(--color-text-muted);cursor:pointer;font-size:16px;">✕</button></div>' +
'<div class="notif-dropdown__list" id="notifDropdownList"><div class="notif-dropdown__empty">Sin notificaciones nuevas</div></div>';
if (btn) {
btn.parentElement.style.position = 'relative';
btn.parentElement.appendChild(el);
} else {
document.body.appendChild(el);
}
this._el = el;
this._visible = true;
this._load();
setTimeout(function() {
document.addEventListener('click', function handler(e) {
if (!el.contains(e.target) && e.target !== btn) {
NotificationsDropdown.hide();
document.removeEventListener('click', handler);
}
});
}, 100);
},
hide: function() { if (this._el) { this._el.remove(); this._el = null; } this._visible = false; },
_load: function() {
// Stub: can be wired to /api/notifications endpoint
var list = document.getElementById('notifDropdownList');
if (!list) return;
// Example: show inventory alerts count if available
var token = localStorage.getItem('pos_token');
if (!token) return;
fetch('/pos/api/inventory/alerts', { headers: { 'Authorization': 'Bearer ' + token } })
.then(function(r) { return r.json(); })
.then(function(d) {
var count = (d.counts || {}).critical || 0;
if (count > 0) {
list.innerHTML = '<div class="notif-dropdown__item notif-dropdown__item--unread" onclick="window.location.href=\'/pos/inventory#alertas\'">' +
'<div class="notif-dropdown__icon">⚠️</div>' +
'<div class="notif-dropdown__content"><div class="notif-dropdown__title">' + count + ' producto' + (count > 1 ? 's' : '') + ' sin stock</div>' +
'<div class="notif-dropdown__time">Stock crítico</div></div></div>';
}
}).catch(function() {});
}
};
// ── Ticket Preview Helper ─────────────────────────────────────
window.previewTicket = function(ticketData) {
var data = ticketData || {};
var items = (data.items || []).map(function(it) {
return '<div class="ticket-preview__row"><span>' + (it.quantity || 1) + 'x ' + esc(it.name) + '</span><span>$' + (it.subtotal || 0).toFixed(2) + '</span></div>';
}).join('');
var html = '<div class="ticket-preview">' +
'<div class="ticket-preview__header"><div class="ticket-preview__title">Nexus Autoparts</div><div class="ticket-preview__meta">' + (data.store || 'Sucursal Centro') + '</div></div>' +
'<div class="ticket-preview__meta" style="text-align:center;margin-bottom:8px;">' + new Date().toLocaleString('es-MX') + '</div>' +
'<div class="ticket-preview__row"><span>Ticket #' + (data.id || '---') + '</span></div>' +
'<hr style="border:none;border-top:1px dashed #ccc;margin:8px 0;">' +
items +
'<hr style="border:none;border-top:1px dashed #ccc;margin:8px 0;">' +
'<div class="ticket-preview__row"><span>Subtotal</span><span>$' + (data.subtotal || 0).toFixed(2) + '</span></div>' +
'<div class="ticket-preview__row"><span>IVA</span><span>$' + (data.tax || 0).toFixed(2) + '</span></div>' +
'<div class="ticket-preview__total"><span>TOTAL</span><span>$' + (data.total || 0).toFixed(2) + '</span></div>' +
'<div style="text-align:center;font-size:10px;color:#666;margin-top:12px;">Gracias por su compra</div>' +
'</div>';
return html;
};
// ── Image Comparator Helper ───────────────────────────────────
window.initImageComparator = function(containerSelector) {
var container = document.querySelector(containerSelector);
if (!container) return;
var overlay = container.querySelector('.img-compare__overlay');
var handle = container.querySelector('.img-compare__handle');
if (!overlay || !handle) return;
function move(x) {
var rect = container.getBoundingClientRect();
var pct = Math.max(0, Math.min(100, ((x - rect.left) / rect.width) * 100));
overlay.style.width = pct + '%';
handle.style.left = pct + '%';
}
handle.addEventListener('mousedown', function(e) {
e.preventDefault();
function onMove(ev) { move(ev.pageX); }
function onUp() { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
handle.addEventListener('touchstart', function(e) {
function onMove(ev) { move(ev.touches[0].pageX); }
function onUp() { document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onUp); }
document.addEventListener('touchmove', onMove);
document.addEventListener('touchend', onUp);
});
};
// ── Infinite Scroll Helper ────────────────────────────────────
window.InfiniteScroll = function(opts) {
opts = opts || {};
var container = opts.container || window;
var threshold = opts.threshold || 200;
var loading = false;
var observer = new IntersectionObserver(function(entries) {
if (entries[0].isIntersecting && !loading && opts.onLoad) {
loading = true;
opts.onLoad(function() { loading = false; });
}
}, { root: container === window ? null : container, rootMargin: threshold + 'px' });
var sentinel = document.createElement('div');
sentinel.style.cssText = 'height:1px;';
(opts.sentinelParent || document.body).appendChild(sentinel);
observer.observe(sentinel);
return { disconnect: function() { observer.disconnect(); sentinel.remove(); } };
};
// ── Sparkline Renderer ────────────────────────────────────────
window.renderSparkline = function(containerSelector, values, opts) {
opts = opts || {};
var container = typeof containerSelector === 'string' ? document.querySelector(containerSelector) : containerSelector;
if (!container || !values || !values.length) return;
var max = Math.max.apply(null, values) || 1;
var min = Math.min.apply(null, values);
var range = max - min || 1;
var cls = opts.trend === 'up' ? 'sparkline--up' : (opts.trend === 'down' ? 'sparkline--down' : '');
var html = '<div class="sparkline ' + cls + '">';
values.forEach(function(v) {
var h = Math.max(4, Math.round(((v - min) / range) * 100));
html += '<div class="sparkline__bar" style="height:' + h + '%" title="' + (opts.prefix || '') + v + '"></div>';
});
html += '</div>';
container.innerHTML = html;
};
})();

View File

@@ -123,6 +123,8 @@ const POS = (() => {
currentRegister = null;
document.getElementById('registerInfo').innerHTML =
'<span style="color:var(--color-error);cursor:pointer;" onclick="POS.showOpenRegisterModal()" title="Clic para abrir caja">&#x26A0; Sin caja abierta — Clic para abrir</span>';
// Force open register modal on first load
showOpenRegisterModal();
}
} catch (e) {
console.warn('Register check failed:', e);
@@ -240,6 +242,7 @@ const POS = (() => {
if (existing) {
existing.quantity += (item.quantity || 1);
renderCart();
if (window.BarcodeFeedback) BarcodeFeedback.success();
return;
}
@@ -253,6 +256,9 @@ const POS = (() => {
discount_pct: parseFloat(item.discount_pct || 0),
tax_rate: parseFloat(item.tax_rate || 0.16),
stock: item.stock || 0,
price_1: parseFloat(item.price_1 || 0),
price_2: parseFloat(item.price_2 || 0),
price_3: parseFloat(item.price_3 || 0),
});
renderCart();
@@ -265,6 +271,82 @@ const POS = (() => {
renderCart();
}
function clearCart() {
cart.length = 0;
selectedRow = -1;
renderCart();
}
function openCancelModal() {
const overlay = document.getElementById('overlay-cancelar-venta');
const dialog = document.getElementById('modal-cancelar-venta');
if (overlay) overlay.classList.add('active');
if (dialog) dialog.classList.add('active');
}
function closeCancelModal() {
const overlay = document.getElementById('overlay-cancelar-venta');
const dialog = document.getElementById('modal-cancelar-venta');
if (overlay) overlay.classList.remove('active');
if (dialog) dialog.classList.remove('active');
}
function changeQuantity() {
if (selectedRow < 0 || selectedRow >= cart.length) {
showToast('Selecciona un articulo primero', 'warn');
return;
}
const q = prompt('Nueva cantidad:', cart[selectedRow].quantity);
if (q !== null) {
const n = parseInt(q);
if (n > 0) {
cart[selectedRow].quantity = n;
renderCart();
}
}
}
function applyDiscount() {
if (selectedRow < 0 || selectedRow >= cart.length) {
showToast('Selecciona un articulo primero', 'warn');
return;
}
const d = prompt('Descuento %:', cart[selectedRow].discount_pct);
if (d !== null) {
const n = parseFloat(d);
if (n >= 0 && n <= 100) {
cart[selectedRow].discount_pct = n;
renderCart();
}
}
}
function modifyPrice() {
if (selectedRow < 0 || selectedRow >= cart.length) {
showToast('Selecciona un articulo primero', 'warn');
return;
}
const p = prompt('Nuevo precio unitario:', cart[selectedRow].unit_price);
if (p !== null) {
const n = parseFloat(p);
if (n >= 0) {
cart[selectedRow].unit_price = n;
renderCart();
}
}
}
// Wire confirm-cancel button
document.addEventListener('DOMContentLoaded', function() {
var btn = document.getElementById('btnConfirmCancel');
if (btn) {
btn.addEventListener('click', function() {
clearCart();
closeCancelModal();
});
}
});
function renderCart() {
const tbody = document.getElementById('cartBody');
const table = document.getElementById('cartTable');
@@ -436,6 +518,19 @@ const POS = (() => {
if (data.data.length === 0) {
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
if (window.BarcodeFeedback) BarcodeFeedback.error();
} else if (data.data.length === 1 && q.length >= 8) {
// Auto-select single result on barcode scan (long codes)
const item = data.data[0];
let price = item.price_1;
if (currentCustomer) {
const tier = currentCustomer.price_tier || 1;
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
}
addFromSearch(item, price);
input.value = '';
hideSearchResults();
return;
} else {
let html = '';
data.data.forEach(item => {
@@ -472,7 +567,11 @@ const POS = (() => {
cost: item.cost,
tax_rate: item.tax_rate,
stock: item.stock,
price_1: item.price_1,
price_2: item.price_2,
price_3: item.price_3,
});
if (window.BarcodeFeedback) BarcodeFeedback.success();
hideSearchResults();
document.getElementById('itemSearch').value = '';
document.getElementById('itemSearch').focus();
@@ -530,11 +629,22 @@ const POS = (() => {
}
}
function recalcCartPrices() {
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
cart.forEach(item => {
if (item.price_1 > 0) {
item.unit_price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
}
});
}
async function selectCustomer(customer) {
currentCustomer = customer;
document.getElementById('customerAutocomplete').style.display = 'none';
document.getElementById('customerSearchWrap').querySelector('input').style.display = 'none';
recalcCartPrices();
const tiers = { 1: 'P1 Mostrador', 2: 'P2 Taller', 3: 'P3 Mayoreo' };
document.getElementById('customerName').textContent = customer.name;
document.getElementById('customerTier').textContent = tiers[customer.price_tier] || 'P1';
@@ -1255,7 +1365,7 @@ const POS = (() => {
init();
return {
addToCart, removeFromCart, selectRow,
addToCart, removeFromCart, clearCart, selectRow,
updateQty, updateDiscount,
addFromSearch, hideSearchResults,
selectCustomer, clearCustomer,
@@ -1268,5 +1378,14 @@ const POS = (() => {
connectThermal, thermalPrint,
showOpenRegisterModal, closeOpenRegisterModal, openRegister,
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
openCancelModal, closeCancelModal, changeQuantity, applyDiscount, modifyPrice,
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
})();

File diff suppressed because one or more lines are too long

View File

@@ -714,6 +714,13 @@ const Reports = (() => {
init, setTheme, switchTab,
loadVentas, loadInventario, loadClientes, loadFinancieros, fmt
};
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "Reportes", href: "/pos/reports", icon: "📈" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
}
})();
// ── Global: Export visible table as CSV (Excel-compatible) ──

View File

@@ -3,7 +3,7 @@
* Replaces existing sidebar in each page with a consistent, themed version.
* Uses i18n t() for all labels when available.
*/
(function() {
window.renderSidebar = function(modulesOverride) {
'use strict';
// i18n helper — falls back to raw string if i18n.js not loaded
@@ -17,24 +17,38 @@
var currentTheme = localStorage.getItem('pos_theme') || 'industrial';
var currentLang = localStorage.getItem('pos_lang') || 'es';
var modules = {};
if (modulesOverride && typeof modulesOverride === 'object') {
modules = modulesOverride;
} else {
try {
modules = JSON.parse(localStorage.getItem('pos_modules') || '{}');
} catch(e) { modules = {}; }
}
function moduleEnabled(key) {
// Default to true if not configured yet
return modules[key] !== false;
}
var navSections = [
{ label: _t('nav_main'), items: [
{ name: _t('dashboard'), href: '/pos/dashboard', icon: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>' },
{ name: _t('pos'), href: '/pos/sale', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' },
{ name: _t('catalog'), href: '/pos/catalog', icon: '<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>' },
moduleEnabled('catalog') ? { name: _t('catalog'), href: '/pos/catalog', icon: '<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>' } : null,
{ name: _t('inventory'), href: '/pos/inventory', icon: '<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>' },
]},
].filter(Boolean)},
{ 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"/>' },
moduleEnabled('marketplace') ? { 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"/>' } : null,
moduleEnabled('meli') ? { name: 'MercadoLibre', href: '/pos/marketplace-external', icon: '<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>' } : null,
{ 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"/>' },
{ name: _t('fleet'), href: '/pos/fleet', icon: '<path d="M1 13h22M1 13l2-6h6l2 6M9 7h6l2 6M15 13l2-6M5 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM19 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>' },
{ name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' },
]},
moduleEnabled('whatsapp') ? { name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' } : null,
].filter(Boolean)},
{ label: _t('nav_system'), items: [
{ name: _t('config'), href: '/pos/config', icon: '<circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>' },
]},
@@ -124,10 +138,6 @@
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');
@@ -180,4 +190,9 @@
window.toggleSidebar = toggleSidebar;
window.closeSidebar = closeSidebar;
})();
// Reveal sidebar smoothly after replacement to avoid flash/stun
document.body.classList.add('sidebar-ready');
};
// Initial render
window.renderSidebar();

View File

@@ -0,0 +1,131 @@
/**
* splash-loader.js — PWA splash screen + dynamic favicon
* Show an animated splash while the app loads, then fade out.
* Also provides dynamic favicon updates for notifications / offline states.
*/
(function() {
'use strict';
// ─── Create splash element ──────────────────────────────────────────────
var splash = document.createElement('div');
splash.id = 'nx-splash';
splash.style.cssText = 'position:fixed;inset:0;z-index:99999;' +
'background:linear-gradient(135deg,#0d0d0d 0%,#1a1205 100%);' +
'display:flex;flex-direction:column;align-items:center;justify-content:center;' +
'transition:opacity 0.5s ease,visibility 0.5s ease;';
// Logo SVG (animated)
var logoSvg = '<svg width="80" height="80" viewBox="0 0 80 80" style="margin-bottom:24px;">' +
'<defs><linearGradient id="nxg" x1="0%" y1="0%" x2="100%" y2="100%">' +
'<stop offset="0%" style="stop-color:#F5A623"/>' +
'<stop offset="100%" style="stop-color:#E08E00"/>' +
'</linearGradient></defs>' +
'<circle cx="40" cy="40" r="36" fill="none" stroke="url(#nxg)" stroke-width="3" stroke-dasharray="226" stroke-dashoffset="226" style="animation:nxDraw 1.2s ease forwards;">' +
'<animateTransform attributeName="transform" type="rotate" from="0 40 40" to="360 40 40" dur="8s" repeatCount="indefinite"/>' +
'</circle>' +
'<text x="40" y="48" text-anchor="middle" fill="#F5A623" font-family="system-ui,sans-serif" font-weight="800" font-size="28" style="animation:nxFadeIn 0.6s 0.4s ease both;">N</text>' +
'</svg>';
var title = '<div style="font-family:system-ui,sans-serif;font-size:20px;font-weight:700;color:#eee;letter-spacing:2px;text-transform:uppercase;margin-bottom:8px;">Nexus</div>';
var subtitle = '<div style="font-family:system-ui,sans-serif;font-size:13px;color:#888;letter-spacing:4px;text-transform:uppercase;">Autoparts POS</div>';
var spinner = '<div class="nx-loader" style="margin-top:32px;width:32px;height:32px;"><div class="nx-loader__ring"></div><div class="nx-loader__ring"></div></div>';
splash.innerHTML = logoSvg + title + subtitle + spinner;
// Inject keyframes if not present
if (!document.getElementById('nx-splash-styles')) {
var style = document.createElement('style');
style.id = 'nx-splash-styles';
style.textContent = '@keyframes nxDraw { to { stroke-dashoffset:0; } } @keyframes nxFadeIn { from { opacity:0;transform:translateY(8px); } to { opacity:1;transform:translateY(0); } }';
document.head.appendChild(style);
}
document.body.appendChild(splash);
// Hide splash when DOM is ready + minimum display time
var minDisplay = 800;
var startTime = Date.now();
function hideSplash() {
var elapsed = Date.now() - startTime;
var remaining = Math.max(0, minDisplay - elapsed);
setTimeout(function() {
splash.style.opacity = '0';
splash.style.visibility = 'hidden';
setTimeout(function() { splash.remove(); }, 500);
}, remaining);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', hideSplash);
} else {
hideSplash();
}
// ─── Dynamic Favicon ────────────────────────────────────────────────────
window.NexusFavicon = {
canvas: null,
ctx: null,
link: null,
baseColor: '#F5A623',
init: function() {
this.canvas = document.createElement('canvas');
this.canvas.width = 64;
this.canvas.height = 64;
this.ctx = this.canvas.getContext('2d');
this.link = document.querySelector('link[rel*="icon"]') || document.createElement('link');
this.link.rel = 'shortcut icon';
this.link.type = 'image/png';
document.head.appendChild(this.link);
this.setNormal();
},
draw: function(color, badge) {
var ctx = this.ctx;
var c = this.canvas;
ctx.clearRect(0, 0, 64, 64);
// Background circle
ctx.beginPath();
ctx.arc(32, 32, 30, 0, Math.PI * 2);
ctx.fillStyle = color || this.baseColor;
ctx.fill();
// Letter N
ctx.fillStyle = '#0d0d0d';
ctx.font = 'bold 36px system-ui,sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('N', 32, 33);
// Badge dot
if (badge) {
ctx.beginPath();
ctx.arc(52, 12, 10, 0, Math.PI * 2);
ctx.fillStyle = '#ef4444';
ctx.fill();
ctx.fillStyle = '#fff';
ctx.font = 'bold 11px system-ui,sans-serif';
ctx.fillText(badge > 9 ? '9+' : String(badge), 52, 13);
}
this.link.href = c.toDataURL('image/png');
},
setNormal: function() { this.draw(this.baseColor); },
setOffline: function() { this.draw('#666'); },
setNotify: function(count) { this.draw(this.baseColor, count); },
};
// Auto-init favicon
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() { NexusFavicon.init(); });
} else {
NexusFavicon.init();
}
// Update favicon on online/offline
window.addEventListener('online', function() { if (window.NexusFavicon) NexusFavicon.setNormal(); });
window.addEventListener('offline', function() { if (window.NexusFavicon) NexusFavicon.setOffline(); });
})();

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

@@ -24,6 +24,8 @@
this._scrollHandler = this._onScroll.bind(this);
this._resizeHandler = this._onResize.bind(this);
this._isTbody = this.container.tagName === 'TBODY';
this._rafId = null;
this._pendingRender = false;
this._init();
}
@@ -58,11 +60,22 @@
};
VirtualScroll.prototype._onScroll = function() {
this._render();
this._scheduleRender();
};
VirtualScroll.prototype._onResize = function() {
this._render();
this._scheduleRender();
};
VirtualScroll.prototype._scheduleRender = function() {
if (this._pendingRender) return;
this._pendingRender = true;
var self = this;
this._rafId = requestAnimationFrame(function() {
self._rafId = null;
self._pendingRender = false;
self._render();
});
};
VirtualScroll.prototype._getScrollTop = function() {
@@ -99,11 +112,7 @@
var buffer = this.buffer;
if (!data.length) {
if (this._isTbody) {
this.container.innerHTML = this.emptyHtml;
} else {
this.container.innerHTML = this.emptyHtml;
}
this.container.innerHTML = this.emptyHtml;
return;
}
@@ -112,20 +121,19 @@
var startIdx = Math.max(0, Math.floor(scrollTop / rowH) - buffer);
var endIdx = Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowH) + buffer);
// Build new HTML
var html = '';
if (this._isTbody) {
// Top spacer row
var topSpacerHeight = startIdx * rowH;
if (topSpacerHeight > 0) {
html += '<tr style="height:' + topSpacerHeight + 'px;"><td colspan="99" style="padding:0;border:0;"></td></tr>';
html += '<tr style="height:' + topSpacerHeight + 'px;" aria-hidden="true"><td colspan="99" style="padding:0;border:0;"></td></tr>';
}
for (var i = startIdx; i < endIdx; i++) {
html += this.renderRow(data[i], i);
}
// Bottom spacer row
var bottomSpacerHeight = (data.length - endIdx) * rowH;
if (bottomSpacerHeight > 0) {
html += '<tr style="height:' + bottomSpacerHeight + 'px;"><td colspan="99" style="padding:0;border:0;"></td></tr>';
html += '<tr style="height:' + bottomSpacerHeight + 'px;" aria-hidden="true"><td colspan="99" style="padding:0;border:0;"></td></tr>';
}
} else {
for (var j = startIdx; j < endIdx; j++) {
@@ -133,6 +141,10 @@
}
}
// Use a DocumentFragment approach via innerHTML to avoid flicker:
// Setting innerHTML on tbody is the fastest way, but we can reduce
// perceived flicker by ensuring the container has contain: paint
// and by batching via rAF (done in _scheduleRender).
this.container.innerHTML = html;
};

View File

@@ -16,6 +16,7 @@
var activePhone = null;
var pollTimer = null;
var statusPollTimer = null;
var qrPollTimer = null;
var connectionState = 'unknown'; // 'open', 'close', 'connecting', 'unknown'
// -- Helpers ---------------------------------------------------------------
@@ -88,6 +89,10 @@
api('GET', '/status').then(function (data) {
var state = (data.instance || data).state || data.state || 'close';
updateConnectionUI(state);
// If bridge already has a QR ready, show it immediately
if (state === 'qr' || state === 'connecting') {
fetchQR();
}
}).catch(function () {
updateConnectionUI('close');
});
@@ -106,7 +111,8 @@
// Load conversations + start polling on page load / reconnect
loadConversations();
startPolling();
} else if (state === 'connecting') {
stopQRPolling();
} else if (state === 'connecting' || state === 'qr') {
statusDot.className = 'status-dot status-dot--warn';
statusText.textContent = 'Escaneando QR...';
connectSection.style.display = 'flex';
@@ -125,6 +131,7 @@
refreshQrBtn.style.display = 'none';
qrImg.style.display = 'none';
qrPlaceholder.style.display = '';
stopQRPolling();
}
}
@@ -141,8 +148,15 @@
return;
}
// Instance created, now fetch QR
fetchQR();
// Switch UI to connecting state immediately
updateConnectionUI('connecting');
qrPlaceholder.textContent = 'Iniciando conexion con WhatsApp, generando QR...';
qrPlaceholder.style.display = '';
qrImg.style.display = 'none';
// Start polling for QR; the first fetchQR may not have QR ready yet
startStatusPolling();
startQRPolling();
}).catch(function () {
connectBtn.disabled = false;
connectBtn.textContent = 'Conectar WhatsApp';
@@ -151,7 +165,10 @@
}
function fetchQR() {
qrPlaceholder.textContent = 'Generando QR...';
// Only update placeholder text if we don't already have a QR image showing
if (qrImg.style.display !== 'block') {
qrPlaceholder.textContent = 'Generando codigo QR, espera unos segundos...';
}
api('GET', '/qr').then(function (data) {
var base64 = data.qr || data.base64 || data.qrcode || '';
@@ -164,14 +181,18 @@
// Start polling for connection state while QR is shown
startStatusPolling();
startQRPolling();
} else if ((data.instance && data.instance.state === 'open') || data.state === 'open') {
// Already connected
updateConnectionUI('open');
loadConversations();
} else {
qrPlaceholder.textContent = 'No se pudo generar el QR. Intenta de nuevo.';
qrPlaceholder.style.display = '';
qrImg.style.display = 'none';
// QR not ready yet — this is normal right after pressing Connect
if (qrImg.style.display !== 'block') {
qrPlaceholder.textContent = 'Generando codigo QR, por favor espera... (el codigo cambia cada pocos segundos, escanealo en cuanto aparezca)';
qrPlaceholder.style.display = '';
qrImg.style.display = 'none';
}
}
}).catch(function () {
qrPlaceholder.textContent = 'Error al obtener QR';
@@ -208,6 +229,24 @@
}
}
function startQRPolling() {
stopQRPolling();
qrPollTimer = setInterval(function () {
if (connectionState === 'connecting' || connectionState === 'qr') {
fetchQR();
} else {
stopQRPolling();
}
}, 5000);
}
function stopQRPolling() {
if (qrPollTimer) {
clearInterval(qrPollTimer);
qrPollTimer = null;
}
}
connectBtn.addEventListener('click', doConnect);
disconnectBtn.addEventListener('click', doDisconnect);
refreshQrBtn.addEventListener('click', fetchQR);
@@ -486,4 +525,12 @@
};
} catch(e) {}
// Register Cmd+K items
if (typeof registerCmdKItem === "function") {
registerCmdKItem({ group: "Principal", label: "POS Ventas", href: "/pos/sale", icon: "🛒" });
registerCmdKItem({ group: "Principal", label: "Catálogo", href: "/pos/catalog", icon: "📁" });
registerCmdKItem({ group: "Principal", label: "Clientes", href: "/pos/customers", icon: "👤" });
registerCmdKItem({ group: "Principal", label: "Dashboard", href: "/pos/dashboard", icon: "📊" });
}
})();