Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
This commit is contained in:
@@ -39,6 +39,8 @@ const POS = (() => {
|
||||
let receiptConfig = {};
|
||||
let couriers = [];
|
||||
let selectedCourierId = null;
|
||||
let crossSellTimer = null;
|
||||
let crossSellItems = [];
|
||||
|
||||
// Currency-aware formatter: reads pos_currency from localStorage
|
||||
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
|
||||
@@ -46,13 +48,29 @@ const POS = (() => {
|
||||
const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX';
|
||||
const fmt = (n) => (_currSymbols[_posCurrency] || '$') + parseFloat(n || 0).toLocaleString(_currLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
// Price based on customer tier: base price (price_1) with tier discount.
|
||||
// Tier 2 = Taller (5% off), Tier 3 = Mayoreo (10% off), Tier 1 = base.
|
||||
function priceForTier(basePrice, tier) {
|
||||
function esc(s) {
|
||||
if (s == null) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// Price based on customer tier.
|
||||
// Prefers the tenant's real tier prices (price_2/price_3, materialized
|
||||
// from the tier discounts config) and falls back to the legacy formula.
|
||||
function priceForTier(basePrice, tier, item) {
|
||||
const p = parseFloat(basePrice) || 0;
|
||||
const t = parseInt(tier, 10) || 1;
|
||||
if (t === 2) return Math.round(p * 0.95 * 100) / 100;
|
||||
if (t === 3) return Math.round(p * 0.90 * 100) / 100;
|
||||
if (t === 2) {
|
||||
const p2 = item ? parseFloat(item.price_2) : 0;
|
||||
if (p2 > 0) return p2;
|
||||
return Math.round(p * 0.95 * 100) / 100;
|
||||
}
|
||||
if (t === 3) {
|
||||
const p3 = item ? parseFloat(item.price_3) : 0;
|
||||
if (p3 > 0) return p3;
|
||||
return Math.round(p * 0.90 * 100) / 100;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
@@ -209,6 +227,10 @@ const POS = (() => {
|
||||
await loadRegister();
|
||||
await loadReceiptConfig();
|
||||
await loadCouriers();
|
||||
await restoreCartState();
|
||||
replayOfflineSales();
|
||||
scheduleCrossSell();
|
||||
if (navigator.onLine === false && typeof showOfflineBanner === 'function') showOfflineBanner(true);
|
||||
|
||||
// Setup event listeners
|
||||
setupKeyboard();
|
||||
@@ -259,6 +281,11 @@ const POS = (() => {
|
||||
selectedCourierId = sel.value ? parseInt(sel.value, 10) : null;
|
||||
});
|
||||
}
|
||||
const saleSel = document.getElementById('saleCourier');
|
||||
if (saleSel) {
|
||||
saleSel.innerHTML = '<option value="">— Sin repartidor —</option>' +
|
||||
couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not load couriers:', e);
|
||||
couriers = [];
|
||||
@@ -402,18 +429,82 @@ const POS = (() => {
|
||||
|
||||
renderCart();
|
||||
showToast(`${item.name || 'Articulo'} agregado`);
|
||||
scheduleCrossSell();
|
||||
}
|
||||
|
||||
function removeFromCart(index) {
|
||||
cart.splice(index, 1);
|
||||
if (selectedRow >= cart.length) selectedRow = cart.length - 1;
|
||||
renderCart();
|
||||
scheduleCrossSell();
|
||||
}
|
||||
|
||||
function clearCart() {
|
||||
cart.length = 0;
|
||||
selectedRow = -1;
|
||||
renderCart();
|
||||
hideCrossSell();
|
||||
}
|
||||
|
||||
// ─── Cross-sell (A10) ────────────────
|
||||
// Debounced call to /pos/api/recommendations. The panel lives in
|
||||
// pos.html (#crossSellPanel / #crossSellList).
|
||||
function scheduleCrossSell() {
|
||||
if (crossSellTimer) clearTimeout(crossSellTimer);
|
||||
if (!cart.length) { hideCrossSell(); return; }
|
||||
crossSellTimer = setTimeout(refreshCrossSell, 350);
|
||||
}
|
||||
|
||||
async function refreshCrossSell() {
|
||||
const panel = document.getElementById('crossSellPanel');
|
||||
const list = document.getElementById('crossSellList');
|
||||
if (!panel || !list) return;
|
||||
const ids = cart.map(c => c.inventory_id).filter(Boolean);
|
||||
if (!ids.length) { hideCrossSell(); return; }
|
||||
try {
|
||||
const data = await api('/pos/api/recommendations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ inventory_ids: ids, limit: 4 })
|
||||
});
|
||||
crossSellItems = data.recommendations || [];
|
||||
} catch (e) {
|
||||
hideCrossSell();
|
||||
return;
|
||||
}
|
||||
const inCart = new Set(ids);
|
||||
const items = crossSellItems.filter(r => !inCart.has(r.inventory_id));
|
||||
if (!items.length) { hideCrossSell(); return; }
|
||||
list.innerHTML = items.map(r => `
|
||||
<button type="button" onclick="POS.addCrossSellItem(${r.inventory_id})"
|
||||
title="${esc(r.reason || '')}"
|
||||
style="display:flex;justify-content:space-between;align-items:center;gap:8px;text-align:left;width:100%;background:var(--color-bg-surface);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:5px 8px;cursor:pointer;color:var(--color-text-primary);">
|
||||
<span style="display:flex;flex-direction:column;overflow:hidden;">
|
||||
<span style="font-size:var(--text-body-sm);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(r.name || '')}</span>
|
||||
<span style="font-size:var(--text-caption);color:var(--color-text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${esc(r.reason || '')}</span>
|
||||
</span>
|
||||
<span style="font-family:var(--font-mono);font-size:var(--text-body-sm);white-space:nowrap;">${fmt(r.price)}</span>
|
||||
</button>`).join('');
|
||||
panel.style.display = '';
|
||||
}
|
||||
|
||||
function hideCrossSell() {
|
||||
if (crossSellTimer) { clearTimeout(crossSellTimer); crossSellTimer = null; }
|
||||
const panel = document.getElementById('crossSellPanel');
|
||||
if (panel) panel.style.display = 'none';
|
||||
}
|
||||
|
||||
function addCrossSellItem(inventoryId) {
|
||||
const rec = crossSellItems.find(r => r.inventory_id === inventoryId);
|
||||
if (!rec) return;
|
||||
addToCart({
|
||||
inventory_id: rec.inventory_id,
|
||||
name: rec.name,
|
||||
part_number: rec.part_number,
|
||||
quantity: 1,
|
||||
unit_price: rec.price || 0,
|
||||
price_1: rec.price || 0,
|
||||
stock: rec.stock || 0,
|
||||
});
|
||||
}
|
||||
|
||||
function openCancelModal() {
|
||||
@@ -504,6 +595,32 @@ const POS = (() => {
|
||||
}
|
||||
});
|
||||
|
||||
function saveCartState() {
|
||||
try {
|
||||
sessionStorage.setItem('pos_cart_state', JSON.stringify({
|
||||
cart, currentCustomerId: currentCustomer ? currentCustomer.id : null,
|
||||
}));
|
||||
} catch (e) { /* storage lleno o bloqueado */ }
|
||||
}
|
||||
|
||||
async function restoreCartState() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem('pos_cart_state');
|
||||
if (!raw) return;
|
||||
const state = JSON.parse(raw);
|
||||
if (!state || !Array.isArray(state.cart) || !state.cart.length) return;
|
||||
cart = state.cart;
|
||||
if (state.currentCustomerId) {
|
||||
try {
|
||||
const c = await api('/pos/api/customers/' + state.currentCustomerId);
|
||||
if (c && c.data) currentCustomer = c.data;
|
||||
} catch (e) { /* cliente ya no existe */ }
|
||||
}
|
||||
renderCart();
|
||||
showToast('Carrito restaurado (' + cart.length + ' partidas)', 'ok');
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function renderCart() {
|
||||
const tbody = document.getElementById('cartBody');
|
||||
const table = document.getElementById('cartTable');
|
||||
@@ -558,6 +675,7 @@ const POS = (() => {
|
||||
tbody.innerHTML = html;
|
||||
updateTotals();
|
||||
updateAvgMargin();
|
||||
saveCartState();
|
||||
}
|
||||
|
||||
function updateQty(index, val) {
|
||||
@@ -646,6 +764,17 @@ const POS = (() => {
|
||||
}
|
||||
|
||||
// ─── Search ──────────────────────────
|
||||
let lastSearchResults = [];
|
||||
let lastSearchTier = 1;
|
||||
let searchActiveIndex = -1;
|
||||
|
||||
function highlightSearchResult() {
|
||||
const rows = document.querySelectorAll('#searchResults > div[data-search-idx]');
|
||||
rows.forEach((el, i) => {
|
||||
el.style.background = i === searchActiveIndex ? 'var(--color-primary-muted)' : '';
|
||||
});
|
||||
}
|
||||
|
||||
function setupSearch() {
|
||||
const input = document.getElementById('itemSearch');
|
||||
if (!input) return;
|
||||
@@ -654,9 +783,35 @@ const POS = (() => {
|
||||
searchTimeout = setTimeout(() => searchItems(input.value.trim()), 300);
|
||||
});
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
if (!lastSearchResults.length) return;
|
||||
e.preventDefault();
|
||||
searchActiveIndex = e.key === 'ArrowDown'
|
||||
? Math.min(searchActiveIndex + 1, lastSearchResults.length - 1)
|
||||
: Math.max(searchActiveIndex - 1, 0);
|
||||
highlightSearchResult();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
searchItems(input.value.trim());
|
||||
const q = input.value.trim();
|
||||
if (!q) return;
|
||||
const norm = (s) => (s || '').toUpperCase().replace(/[\s\-\._]/g, '');
|
||||
// 0-click scanner: exact barcode/part_number match adds directly
|
||||
const exact = lastSearchResults.find(r =>
|
||||
norm(r.part_number) === norm(q) || norm(r.barcode || '') === norm(q));
|
||||
if (exact) {
|
||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : lastSearchTier;
|
||||
addFromSearch(exact, priceForTier(exact.price_1, tier, exact));
|
||||
return;
|
||||
}
|
||||
if (searchActiveIndex >= 0 && lastSearchResults[searchActiveIndex]) {
|
||||
const item = lastSearchResults[searchActiveIndex];
|
||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : lastSearchTier;
|
||||
addFromSearch(item, priceForTier(item.price_1, tier, item));
|
||||
return;
|
||||
}
|
||||
searchItems(q);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
input.value = '';
|
||||
@@ -665,23 +820,74 @@ const POS = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
function cacheSearch(q, items) {
|
||||
try {
|
||||
const cache = JSON.parse(localStorage.getItem('pos_search_cache') || '{}');
|
||||
cache[q.toLowerCase()] = { items: items.slice(0, 12), ts: Date.now() };
|
||||
const keys = Object.keys(cache);
|
||||
if (keys.length > 40) keys.slice(0, 5).forEach(k => delete cache[k]);
|
||||
localStorage.setItem('pos_search_cache', JSON.stringify(cache));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function searchOffline(q) {
|
||||
try {
|
||||
const cache = JSON.parse(localStorage.getItem('pos_search_cache') || '{}');
|
||||
const ql = q.toLowerCase();
|
||||
let hits = [];
|
||||
Object.keys(cache).forEach(function (k) {
|
||||
if (ql.indexOf(k) !== -1 || k.indexOf(ql) !== -1) hits = hits.concat(cache[k].items);
|
||||
});
|
||||
const seen = new Set();
|
||||
return hits.filter(function (i) {
|
||||
if (seen.has(i.id)) return false; seen.add(i.id); return true;
|
||||
}).slice(0, 12);
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
async function searchItems(q) {
|
||||
if (!q || q.length < 2) { hideSearchResults(); return; }
|
||||
|
||||
if (navigator.onLine === false) {
|
||||
const cached = searchOffline(q);
|
||||
const container = document.getElementById('searchResults');
|
||||
const totals = document.getElementById('totalsPanel');
|
||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
||||
lastSearchResults = cached;
|
||||
lastSearchTier = tier;
|
||||
searchActiveIndex = -1;
|
||||
container.innerHTML = '<div style="padding:8px var(--space-4);background:var(--color-warning-muted,#fef3c7);font-size:var(--text-caption);">Sin conexión — resultados guardados</div>'
|
||||
+ (cached.length ? cached.map(function (item, idx) {
|
||||
const price = priceForTier(item.price_1, tier, item);
|
||||
return `<div data-search-idx="${idx}" style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
||||
<div><div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
|
||||
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${item.part_number} | Stock: ${item.stock}</div></div>
|
||||
<div style="font-family:var(--font-mono);font-weight:var(--font-weight-bold);color:var(--color-primary);">${fmt(price)}</div></div>`;
|
||||
}).join('') : '<div style="padding:16px;text-align:center;color:var(--color-text-muted);">Sin resultados guardados para esta búsqueda</div>');
|
||||
container.style.display = '';
|
||||
totals.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api(`/pos/api/inventory/items?q=${encodeURIComponent(q)}&per_page=20`);
|
||||
const container = document.getElementById('searchResults');
|
||||
const totals = document.getElementById('totalsPanel');
|
||||
|
||||
if (data.data.length === 0) {
|
||||
searchActiveIndex = -1;
|
||||
lastSearchResults = data.data || [];
|
||||
lastSearchTier = 1;
|
||||
if (lastSearchResults.length) cacheSearch(q, lastSearchResults);
|
||||
|
||||
if (lastSearchResults.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 {
|
||||
let html = '';
|
||||
data.data.forEach(item => {
|
||||
lastSearchResults.forEach((item, idx) => {
|
||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
||||
const price = priceForTier(item.price_1, tier);
|
||||
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
||||
const price = priceForTier(item.price_1, tier, item);
|
||||
html += `<div data-search-idx="${idx}" style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="POS_searchHover(${idx})" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
||||
<div>
|
||||
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
|
||||
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${item.part_number} | ${item.brand || ''}</div>
|
||||
@@ -719,6 +925,290 @@ const POS = (() => {
|
||||
document.getElementById('itemSearch').focus();
|
||||
}
|
||||
|
||||
window.highlightSearchResult = highlightSearchResult;
|
||||
window.POS_searchHover = function (idx) { searchActiveIndex = idx; highlightSearchResult(); };
|
||||
|
||||
|
||||
// ─── Venta offline (cola + replay) ─────────────────────
|
||||
function offlineQueue() {
|
||||
try { return JSON.parse(localStorage.getItem('pos_offline_sales') || '[]'); }
|
||||
catch (e) { return []; }
|
||||
}
|
||||
function offlineSave(queue) {
|
||||
try { localStorage.setItem('pos_offline_sales', JSON.stringify(queue)); } catch (e) {}
|
||||
const el = document.getElementById('offlineQueueBadge');
|
||||
if (el) el.textContent = queue.length;
|
||||
}
|
||||
|
||||
function queueOfflineSale(saleData) {
|
||||
const queue = offlineQueue();
|
||||
saleData.client_uuid = (crypto.randomUUID ? crypto.randomUUID() : 'off-' + Date.now() + '-' + Math.random().toString(36).slice(2));
|
||||
saleData._queued_at = new Date().toISOString();
|
||||
queue.push(saleData);
|
||||
offlineSave(queue);
|
||||
return saleData.client_uuid;
|
||||
}
|
||||
|
||||
async function replayOfflineSales() {
|
||||
const queue = offlineQueue();
|
||||
if (!queue.length) return 0;
|
||||
const remaining = [];
|
||||
let synced = 0;
|
||||
for (const saleData of queue) {
|
||||
try {
|
||||
await api('/pos/api/sales', { method: 'POST', body: JSON.stringify(saleData) });
|
||||
synced++;
|
||||
} catch (e) {
|
||||
remaining.push(saleData); // aún sin conexión: se reintenta luego
|
||||
}
|
||||
}
|
||||
offlineSave(remaining);
|
||||
if (synced) showToast(synced + ' venta(s) offline sincronizada(s)', 'ok');
|
||||
return synced;
|
||||
}
|
||||
|
||||
window.addEventListener('online', function () {
|
||||
if (typeof showOfflineBanner === 'function') showOfflineBanner(false);
|
||||
replayOfflineSales();
|
||||
});
|
||||
window.addEventListener('offline', function () {
|
||||
if (typeof showOfflineBanner === 'function') showOfflineBanner(true);
|
||||
});
|
||||
|
||||
// ─── Devoluciones UI ──────────────────
|
||||
let returnSale = null;
|
||||
|
||||
function openReturnModal() {
|
||||
if (!currentPerms.includes('pos.sell')) { showToast('Sin permiso para devoluciones'); return; }
|
||||
returnSale = null;
|
||||
document.getElementById('returnSaleId').value = '';
|
||||
document.getElementById('returnSaleInfo').style.display = 'none';
|
||||
document.getElementById('returnItemsList').style.display = 'none';
|
||||
document.getElementById('returnItemsList').innerHTML = '';
|
||||
document.getElementById('returnFooter').style.display = 'none';
|
||||
document.getElementById('returnResult').innerHTML = '';
|
||||
document.getElementById('returnModal').classList.add('open');
|
||||
setTimeout(() => document.getElementById('returnSaleId').focus(), 100);
|
||||
}
|
||||
function closeReturnModal() {
|
||||
document.getElementById('returnModal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function searchReturnSale() {
|
||||
const id = parseInt(document.getElementById('returnSaleId').value, 10);
|
||||
if (!id) return;
|
||||
try {
|
||||
const sale = await api('/pos/api/sales/' + id);
|
||||
if (!sale || !sale.items || !sale.items.length) throw new Error('Venta sin partidas');
|
||||
if (sale.status === 'cancelled') throw new Error('La venta está cancelada');
|
||||
returnSale = sale;
|
||||
document.getElementById('returnSaleInfo').style.display = 'block';
|
||||
document.getElementById('returnSaleInfo').innerHTML =
|
||||
`Venta #${sale.id} — ${sale.customer_name || 'Público General'} — Total: ${fmt(sale.total)} — Estado: ${sale.status}`;
|
||||
const list = document.getElementById('returnItemsList');
|
||||
list.style.display = 'block';
|
||||
list.innerHTML = sale.items.map(it => `
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:8px var(--space-3);border-bottom:1px solid var(--color-border);">
|
||||
<input type="checkbox" id="ret-chk-${it.id}" checked style="width:auto;" />
|
||||
<span style="flex:1;font-size:var(--text-body-sm);">${it.name || it.part_number} (${it.part_number})</span>
|
||||
<span style="font-size:var(--text-caption);color:var(--color-text-muted);">cant: ${it.quantity}</span>
|
||||
<input type="number" id="ret-qty-${it.id}" class="form-input" min="1" max="${it.quantity}" value="${it.quantity}" style="width:70px;" />
|
||||
<input type="text" id="ret-reason-${it.id}" class="form-input" placeholder="Motivo" style="width:160px;" />
|
||||
</div>`).join('');
|
||||
document.getElementById('returnFooter').style.display = 'flex';
|
||||
} catch (e) {
|
||||
document.getElementById('returnResult').innerHTML = '<span style="color:var(--color-error);">' + esc(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitReturn() {
|
||||
if (!returnSale) return;
|
||||
const items = [];
|
||||
returnSale.items.forEach(it => {
|
||||
const chk = document.getElementById('ret-chk-' + it.id);
|
||||
if (chk && chk.checked) {
|
||||
const qty = parseInt(document.getElementById('ret-qty-' + it.id).value, 10) || 0;
|
||||
const reason = (document.getElementById('ret-reason-' + it.id).value || '').trim();
|
||||
if (qty > 0) items.push({ sale_item_id: it.id, quantity: qty, reason: reason || 'Devolución' });
|
||||
}
|
||||
});
|
||||
if (!items.length) {
|
||||
document.getElementById('returnResult').innerHTML = '<span style="color:var(--color-error);">Selecciona al menos una partida</span>';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api('/pos/api/returns', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sale_id: returnSale.id,
|
||||
items,
|
||||
notes: document.getElementById('returnNotes').value,
|
||||
register_id: currentRegister ? currentRegister.id : null,
|
||||
refund_method: returnSale.payment_method || 'efectivo',
|
||||
}),
|
||||
});
|
||||
document.getElementById('returnResult').innerHTML =
|
||||
'<span style="color:var(--color-success);">Devolución registrada: reembolso ' + fmt(res.total_refund || 0) + '</span>';
|
||||
renderCart();
|
||||
} catch (e) {
|
||||
document.getElementById('returnResult').innerHTML = '<span style="color:var(--color-error);">' + esc(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Apartados UI ─────────────────────
|
||||
let currentLayawayId = null;
|
||||
|
||||
function openLayawaysModal() {
|
||||
document.getElementById('layawaysModal').classList.add('open');
|
||||
document.getElementById('layawayActions').style.display = 'none';
|
||||
loadLayaways();
|
||||
}
|
||||
function closeLayawaysModal() {
|
||||
document.getElementById('layawaysModal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function loadLayaways() {
|
||||
const el = document.getElementById('layawaysList');
|
||||
try {
|
||||
const data = await api('/pos/api/layaways?status=active&per_page=50');
|
||||
const rows = data.data || data.layaways || [];
|
||||
if (!rows.length) {
|
||||
el.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-caption);">No hay apartados activos</span>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = rows.map(l => {
|
||||
const balance = (l.total || 0) - (l.amount_paid || 0);
|
||||
return `<div style="display:flex;align-items:center;gap:10px;padding:10px var(--space-3);border-bottom:1px solid var(--color-border);cursor:pointer;"
|
||||
onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''"
|
||||
onclick="POS.selectLayaway(${l.id}, ${l.total}, ${l.amount_paid || 0}, '${(l.customer_name || '').replace(/'/g, "\\'")}')">
|
||||
<div style="flex:1;">
|
||||
<div style="font-weight:var(--font-weight-semibold);">Apartado #${l.id} — ${l.customer_name || 'Sin cliente'}</div>
|
||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">Vence: ${l.expires_at || '—'}</div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div style="font-weight:var(--font-weight-bold);">${fmt(balance)}</div>
|
||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);">de ${fmt(l.total)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
el.innerHTML = '<span style="color:var(--color-error);">Error cargando apartados</span>';
|
||||
}
|
||||
}
|
||||
function selectLayaway(id, total, paid, customerName) {
|
||||
currentLayawayId = id;
|
||||
document.getElementById('layawayActions').style.display = 'block';
|
||||
document.getElementById('layawayDetail').innerHTML =
|
||||
`<strong>Apartado #${id}</strong> — ${customerName} — Saldo: <strong>${fmt(total - paid)}</strong>`;
|
||||
document.getElementById('layawayPayAmount').value = (Math.round((total - paid) * 100) / 100) || '';
|
||||
document.getElementById('layawayResult').innerHTML = '';
|
||||
}
|
||||
|
||||
async function layawayPay() {
|
||||
if (!currentLayawayId) return;
|
||||
const amount = parseFloat(document.getElementById('layawayPayAmount').value) || 0;
|
||||
if (amount <= 0) return;
|
||||
try {
|
||||
await api(`/pos/api/layaways/${currentLayawayId}/payment`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ amount, payment_method: document.getElementById('layawayPayMethod').value }),
|
||||
});
|
||||
document.getElementById('layawayResult').innerHTML = '<span style="color:var(--color-success);">Abono registrado</span>';
|
||||
loadLayaways();
|
||||
} catch (e) {
|
||||
document.getElementById('layawayResult').innerHTML = '<span style="color:var(--color-error);">' + esc(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function layawayComplete() {
|
||||
if (!currentLayawayId) return;
|
||||
try {
|
||||
const res = await api(`/pos/api/layaways/${currentLayawayId}/complete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
payment_method: document.getElementById('layawayPayMethod').value,
|
||||
register_id: currentRegister ? currentRegister.id : null,
|
||||
}),
|
||||
});
|
||||
document.getElementById('layawayResult').innerHTML = '<span style="color:var(--color-success);">Apartado liquidado' + (res.id ? ' — Venta #' + res.id : '') + '</span>';
|
||||
loadLayaways();
|
||||
} catch (e) {
|
||||
document.getElementById('layawayResult').innerHTML = '<span style="color:var(--color-error);">' + esc(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function layawayCancel() {
|
||||
if (!currentLayawayId) return;
|
||||
if (!confirm('¿Cancelar el apartado? El stock regresará a inventario.')) return;
|
||||
try {
|
||||
await api(`/pos/api/layaways/${currentLayawayId}/cancel`, { method: 'PUT', body: '{}' });
|
||||
document.getElementById('layawayResult').innerHTML = '<span style="color:var(--color-success);">Apartado cancelado</span>';
|
||||
loadLayaways();
|
||||
} catch (e) {
|
||||
document.getElementById('layawayResult').innerHTML = '<span style="color:var(--color-error);">' + esc(e.message) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── Vehículo: placa/VIN → piezas compatibles ─────────────────
|
||||
async function vehicleLookup() {
|
||||
const input = document.getElementById('vehicleLookup');
|
||||
const raw = (input.value || '').trim();
|
||||
if (!raw) return;
|
||||
const compact = raw.replace(/[^A-Za-z0-9]/g, '').toUpperCase();
|
||||
const container = document.getElementById('searchResults');
|
||||
const totals = document.getElementById('totalsPanel');
|
||||
container.innerHTML = '<div style="padding:14px;text-align:center;color:var(--color-text-muted);">Buscando vehículo...</div>';
|
||||
container.style.display = '';
|
||||
totals.style.display = 'none';
|
||||
|
||||
let myeId = null, label = '';
|
||||
try {
|
||||
if (compact.length === 17) {
|
||||
const d = await api('/pos/api/catalog/vin/' + encodeURIComponent(compact));
|
||||
const m = d.match || d.catalog_match || d;
|
||||
myeId = d.mye_id || m.mye_id || m.id_mye || null;
|
||||
label = [m.make || d.make, m.model || d.model, m.year || d.year].filter(Boolean).join(' ');
|
||||
} else {
|
||||
const d = await api('/pos/api/catalog/plate/' + encodeURIComponent(raw));
|
||||
if (!d.found) throw new Error('Placa no registrada — regístrala en el cliente');
|
||||
const m = d.catalog_match || {};
|
||||
myeId = m.mye_id || m.id_mye || null;
|
||||
label = [d.make, d.model, d.year].filter(Boolean).join(' ');
|
||||
if (d.customer_id) {
|
||||
try {
|
||||
const cust = await api('/pos/api/customers/' + d.customer_id);
|
||||
if (cust && cust.data && !currentCustomer) currentCustomer = cust.data;
|
||||
} catch (e) { /* opcional */ }
|
||||
}
|
||||
}
|
||||
if (!myeId) {
|
||||
throw new Error('Vehículo encontrado pero sin motor en catálogo; usa el catálogo por modelo/año');
|
||||
}
|
||||
const data = await api('/pos/api/catalog/vehicle-inventory?mye_id=' + myeId);
|
||||
const items = data.data || [];
|
||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
||||
let html = '<div style="padding:10px var(--space-4);background:var(--color-primary-muted);font-weight:var(--font-weight-semibold);">🚗 Piezas compatibles: ' + esc(label || 'vehículo') + ' (' + items.length + ')</div>';
|
||||
if (!items.length) {
|
||||
html += '<div style="padding:16px;text-align:center;color:var(--color-text-muted);">Sin piezas compatibles registradas aún</div>';
|
||||
} else {
|
||||
items.forEach(item => {
|
||||
const price = priceForTier(item.price_1, tier, item);
|
||||
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
||||
<div>
|
||||
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
|
||||
<div style="font-family:var(--font-mono);font-size:var(--text-caption);color:var(--color-text-muted);">${item.part_number} | ${item.brand || ''}</div>
|
||||
<div style="font-size:var(--text-caption);color:${item.stock <= 0 ? 'var(--color-error)' : 'var(--color-text-muted)'};">Stock: ${item.stock}</div>
|
||||
</div>
|
||||
<div style="font-family:var(--font-mono);font-weight:var(--font-weight-bold);color:var(--color-primary);">${fmt(price)}</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
container.innerHTML = html;
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div style="padding:14px;text-align:center;color:var(--color-error);">' + esc(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
function hideSearchResults() {
|
||||
const sr = document.getElementById('searchResults');
|
||||
const tp = document.getElementById('totalsPanel');
|
||||
@@ -923,6 +1413,8 @@ const POS = (() => {
|
||||
document.getElementById('changeDisplay').className = 'cambio-amount positive';
|
||||
const payRef = document.getElementById('paymentRef');
|
||||
if (payRef) payRef.value = '';
|
||||
const saleCourierSel = document.getElementById('saleCourier');
|
||||
if (saleCourierSel) saleCourierSel.value = '';
|
||||
|
||||
// Populate quick amounts
|
||||
const quickContainer = document.getElementById('quickAmounts');
|
||||
@@ -998,9 +1490,22 @@ const POS = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
function getPayCurrency() {
|
||||
const sel = document.getElementById('payCurrency');
|
||||
return sel ? sel.value : 'MXN';
|
||||
}
|
||||
|
||||
function getUsdRate() {
|
||||
const r = parseFloat(localStorage.getItem('pos_exchange_rate')) || 17.5;
|
||||
return r > 0 ? r : 17.5;
|
||||
}
|
||||
|
||||
function updateChange() {
|
||||
const total = getTotal();
|
||||
const received = parseFloat(document.getElementById('cashReceived').value) || 0;
|
||||
let received = parseFloat(document.getElementById('cashReceived').value) || 0;
|
||||
const hint = document.getElementById('usdRateHint');
|
||||
if (hint) hint.textContent = getPayCurrency() === 'USD' ? ('TC: ' + getUsdRate()) : '';
|
||||
if (getPayCurrency() === 'USD') received = received * getUsdRate();
|
||||
const change = received - total;
|
||||
const el = document.getElementById('changeDisplay');
|
||||
if (change >= 0) {
|
||||
@@ -1035,8 +1540,14 @@ const POS = (() => {
|
||||
let reference = '';
|
||||
let saleType = 'cash';
|
||||
|
||||
let payCurrencyNote = '';
|
||||
if (paymentMethod === 'efectivo') {
|
||||
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
|
||||
if (getPayCurrency() === 'USD') {
|
||||
const usd = amountPaid;
|
||||
amountPaid = Math.round(usd * getUsdRate() * 100) / 100;
|
||||
payCurrencyNote = `Pago en USD: $${usd.toFixed(2)} @ TC ${getUsdRate()}`;
|
||||
}
|
||||
if (amountPaid < total) { alert(`Monto insuficiente. Total: ${fmt(total)}`); return; }
|
||||
} else if (paymentMethod === 'transferencia' || paymentMethod === 'tarjeta') {
|
||||
amountPaid = total;
|
||||
@@ -1083,8 +1594,10 @@ const POS = (() => {
|
||||
register_id: currentRegister ? currentRegister.id : null,
|
||||
amount_paid: amountPaid,
|
||||
payment_details: paymentDetails,
|
||||
reference: reference,
|
||||
reference: [reference, payCurrencyNote].filter(Boolean).join(' | '),
|
||||
generate_cfdi: document.getElementById('cfdiCheck').checked,
|
||||
courier_id: (document.getElementById('saleCourier') || {}).value
|
||||
? parseInt(document.getElementById('saleCourier').value, 10) : null,
|
||||
};
|
||||
|
||||
const zeroItem = findZeroPriceItem();
|
||||
@@ -1126,16 +1639,42 @@ const POS = (() => {
|
||||
lastSaleId = sale.id;
|
||||
lastSaleData = sale;
|
||||
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
|
||||
if (saleData.courier_id) {
|
||||
const courier = couriers.find(c => c.id === saleData.courier_id);
|
||||
sale.courier_name = courier ? courier.name : '';
|
||||
}
|
||||
closePaymentModal();
|
||||
showTicket(sale);
|
||||
if (rc('auto_print', '') === true || rc('auto_print', '') === 'true') {
|
||||
try { printTicket(); } catch (e) { /* impresión no bloquea */ }
|
||||
}
|
||||
|
||||
// Clear cart
|
||||
cart = [];
|
||||
selectedRow = -1;
|
||||
clearCustomer();
|
||||
const saleCourierSel2 = document.getElementById('saleCourier');
|
||||
if (saleCourierSel2) saleCourierSel2.value = '';
|
||||
renderCart();
|
||||
} catch (e) {
|
||||
alert('Error al procesar venta: ' + e.message);
|
||||
// Sin conexión: encolar la venta para sincronizar después
|
||||
if (navigator.onLine === false || /network|failed to fetch/i.test(e.message)) {
|
||||
const uuid = queueOfflineSale(saleData);
|
||||
closePaymentModal();
|
||||
showTicket({
|
||||
id: 'OFF-' + uuid.slice(0, 8),
|
||||
total: total,
|
||||
items: saleData.items.map(it => ({ name: it.part_number || it.inventory_id, quantity: it.quantity, unit_price: it.unit_price, subtotal: it.unit_price * it.quantity })),
|
||||
customer_name: currentCustomer ? currentCustomer.name : 'Público General',
|
||||
payment_method: paymentMethod,
|
||||
offline: true,
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
cart = []; selectedRow = -1; renderCart();
|
||||
showToast('Sin conexión — venta en cola (' + offlineQueue().length + ' pendientes). Se sincronizará automáticamente.', 'ok');
|
||||
} else {
|
||||
alert('Error al procesar venta: ' + e.message);
|
||||
}
|
||||
} finally {
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.textContent = 'Confirmar Pago';
|
||||
@@ -1593,7 +2132,7 @@ const POS = (() => {
|
||||
<span>Cliente: ${customerName}</span>
|
||||
${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''}
|
||||
</div>
|
||||
${isRemission && sale.courier_name ? `<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;"><span>Repartidor: ${sale.courier_name}</span></div>` : ''}
|
||||
${sale.courier_name ? `<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;"><span>Repartidor: ${sale.courier_name}</span></div>` : ''}
|
||||
<hr class="divider">
|
||||
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
|
||||
<span class="qty">Cant</span>
|
||||
@@ -1792,7 +2331,15 @@ const POS = (() => {
|
||||
|
||||
// ─── Drawer ──────────────────────────
|
||||
function openDrawer() {
|
||||
showToast('Comando enviado al cajon de efectivo.');
|
||||
// Kick ESC p 0 25 250 via connected thermal printer (USB/Serial)
|
||||
try {
|
||||
if (window.NexusPrinter && typeof window.NexusPrinter.sendRaw === 'function') {
|
||||
window.NexusPrinter.sendRaw(new Uint8Array([27, 112, 48, 25, 250]))
|
||||
.then(ok => showToast(ok ? 'Cajón abierto.' : 'Sin impresora conectada — no se pudo abrir el cajón.', ok ? 'ok' : 'error'));
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* fallthrough */ }
|
||||
showToast('Conecta una impresora térmica (Config → Impresora) para abrir el cajón.', 'error');
|
||||
}
|
||||
|
||||
// ─── Keyboard Shortcuts ──────────────
|
||||
@@ -1924,6 +2471,9 @@ const POS = (() => {
|
||||
addToCart, removeFromCart, clearCart, selectRow,
|
||||
updateQty, updateDiscount,
|
||||
addFromSearch, hideSearchResults,
|
||||
openReturnModal, closeReturnModal, searchReturnSale, submitReturn, vehicleLookup,
|
||||
openLayawaysModal, closeLayawaysModal, loadLayaways, selectLayaway,
|
||||
layawayPay, layawayComplete, layawayCancel,
|
||||
selectCustomer, clearCustomer,
|
||||
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
|
||||
checkout, confirmPayment, closePaymentModal,
|
||||
@@ -1938,6 +2488,7 @@ const POS = (() => {
|
||||
showOpenRegisterModal, closeOpenRegisterModal, openRegister,
|
||||
showCutZModal, closeCutZModal, loadCutX, confirmCutZ,
|
||||
openCancelModal, closeCancelModal, changeQuantity, applyDiscount, modifyPrice,
|
||||
refreshCrossSell, hideCrossSell, addCrossSellItem,
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user