/** * pos-utils.js — Shared utility functions for all POS pages. * * Provides common operations that multiple pages need: * - CSV export of any visible table * - Print page (PDF via browser print dialog) * - Toast notifications (if page doesn't have its own) * * Load this script in every POS template BEFORE page-specific JS. */ (function() { 'use strict'; // ── CSV Export ────────────────────────────────────────────────── // Finds the first visible on the page and downloads it as CSV. // Works on inventory, customers, invoicing, reports, accounting. window.exportVisibleTableCSV = function(prefix) { prefix = prefix || 'datos'; var tables = document.querySelectorAll('table'); var table = null; // Find first visible table with data rows for (var i = 0; i < tables.length; i++) { if (tables[i].offsetParent !== null && tables[i].querySelector('tbody tr')) { table = tables[i]; break; } } if (!table) { showToast('No hay tabla de datos para exportar en esta vista.', 'warn'); return; } var rows = []; // Header row var ths = table.querySelectorAll('thead th'); if (ths.length) { rows.push(Array.from(ths).map(function(th) { return '"' + th.textContent.trim().replace(/"/g, '""') + '"'; }).join(',')); } // Data rows table.querySelectorAll('tbody tr').forEach(function(tr) { var cells = tr.querySelectorAll('td'); rows.push(Array.from(cells).map(function(td) { return '"' + td.textContent.trim().replace(/"/g, '""') + '"'; }).join(',')); }); if (rows.length <= 1) { showToast('La tabla está vacía — no hay datos para exportar.', 'warn'); return; } var csv = rows.join('\n'); // BOM prefix so Excel opens UTF-8 correctly var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = prefix + '_nexus_' + new Date().toISOString().slice(0, 10) + '.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showToast('CSV descargado: ' + a.download, 'ok'); }; // ── Print (PDF) ──────────────────────────────────────────────── window.printPage = function() { window.print(); }; // ── Toast (enhanced with icons, progress bar, close button, actions) ── var _toastIcons = { ok: '', error: '', warn: '', info: '' }; 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'; document.body.appendChild(container); } var toast = document.createElement('div'); toast.className = 'toast toast--' + type; var iconHtml = '
' + (_toastIcons[type] || _toastIcons.info) + '
'; var titleHtml = opts.title ? '
' + opts.title + '
' : ''; var actionHtml = ''; if (opts.action && opts.action.text) { actionHtml = '
'; toast.__toastAction = function() { if (opts.action.callback) opts.action.callback(); _removeToast(toast); }; } var progressHtml = '
'; toast.innerHTML = iconHtml + '
' + titleHtml + '
' + msg + '
' + actionHtml + '
' + '' + progressHtml; container.appendChild(toast); 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 += ''; for (var j = 0; j < cols; j++) { html += ''; } html += ''; } 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 || ''; var title = opts.title || 'Sin datos'; var subtitle = opts.subtitle || 'No hay información disponible en este momento.'; var action = opts.action ? '
' + opts.action + '
' : ''; return '
' + '
' + icon + '
' + '
' + title + '
' + '
' + subtitle + '
' + action + '
'; }; // ── 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 = ''; 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 += '
' + g + '
'; groups[g].forEach(function(item) { total++; html += '
' + '
' + (item.icon || '→') + '
' + '
' + item.label + '
' + (item.meta ? '
' + item.meta + '
' : '') + '
'; }); html += '
'; }); if (!total) html = '
Sin resultados
'; 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 = '' + (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 '
' + '
' + count + ' seleccionado' + (count !== 1 ? 's' : '') + '
' + '
' + actionsHtml + '
' + '
'; }; // ── 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 ────── window.featureProximamente = function(nombre) { showToast((nombre || 'Esta función') + ' estará disponible próximamente.', 'info'); }; // ── Table Filter Panel ──────────────────────────────────────── // Creates a dropdown filter panel that filters visible table rows // client-side. Call toggleFilterPanel(buttonEl, config) where config // is an array of {label, column, values} describing each filter. // // Usage (from onclick): // toggleFilterPanel(this, [ // {label: 'Marca', column: 2, values: ['BOSCH','MONROE','Todas']}, // {label: 'Status', column: 4, values: ['Activo','Inactivo','Todos']}, // ]) var _activeFilterPanel = null; window.toggleFilterPanel = function(btnEl, filters) { // Close existing panel if open if (_activeFilterPanel) { _activeFilterPanel.remove(); _activeFilterPanel = null; return; } var panel = document.createElement('div'); panel.className = 'filter-panel'; panel.style.cssText = 'position:absolute;top:100%;right:0;z-index:1000;' + 'background:var(--glass-bg-strong,#1a1a1a);backdrop-filter:blur(16px);' + 'border:1px solid var(--glass-border,#333);border-radius:var(--radius-lg,12px);' + 'padding:16px;min-width:260px;box-shadow:0 8px 32px rgba(0,0,0,0.3);' + 'display:flex;flex-direction:column;gap:12px;'; var title = document.createElement('div'); title.style.cssText = 'font-weight:700;font-size:14px;display:flex;justify-content:space-between;align-items:center;'; title.innerHTML = 'Filtros '; panel.appendChild(title); filters.forEach(function(f) { var group = document.createElement('div'); var label = document.createElement('label'); label.style.cssText = 'display:block;font-size:12px;color:var(--color-text-muted);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.05em;'; label.textContent = f.label; group.appendChild(label); var select = document.createElement('select'); select.style.cssText = 'width:100%;padding:8px 10px;background:var(--glass-bg,#222);' + 'border:1px solid var(--glass-border,#444);border-radius:6px;' + 'color:var(--color-text-primary,#fff);font-size:13px;'; select.dataset.filterColumn = f.column; // "Todos" option always first var allOpt = document.createElement('option'); allOpt.value = ''; allOpt.textContent = f.allLabel || 'Todos'; select.appendChild(allOpt); (f.values || []).forEach(function(v) { if (!v) return; var opt = document.createElement('option'); opt.value = v; opt.textContent = v; select.appendChild(opt); }); select.addEventListener('change', function() { applyFilters(panel); }); group.appendChild(select); panel.appendChild(group); }); // Clear all button var clearBtn = document.createElement('button'); clearBtn.style.cssText = 'padding:8px;background:transparent;border:1px dashed var(--glass-border,#444);' + 'border-radius:6px;color:var(--color-text-muted);cursor:pointer;font-size:12px;'; clearBtn.textContent = 'Limpiar filtros'; clearBtn.addEventListener('click', function() { panel.querySelectorAll('select').forEach(function(s) { s.value = ''; }); applyFilters(panel); }); panel.appendChild(clearBtn); // Position relative to the button var wrapper = btnEl.parentElement; if (wrapper) wrapper.style.position = 'relative'; (wrapper || document.body).appendChild(panel); _activeFilterPanel = panel; // Close on outside click setTimeout(function() { document.addEventListener('click', function handler(e) { if (!panel.contains(e.target) && e.target !== btnEl) { closeFilterPanel(); document.removeEventListener('click', handler); } }); }, 100); }; window.closeFilterPanel = function() { if (_activeFilterPanel) { _activeFilterPanel.remove(); _activeFilterPanel = null; } }; function applyFilters(panel) { var selects = panel.querySelectorAll('select[data-filter-column]'); // Find the nearest visible table var tables = document.querySelectorAll('table'); var table = null; for (var i = 0; i < tables.length; i++) { if (tables[i].offsetParent !== null) { table = tables[i]; break; } } if (!table) return; var rows = table.querySelectorAll('tbody tr'); rows.forEach(function(tr) { var show = true; selects.forEach(function(sel) { var col = parseInt(sel.dataset.filterColumn); var val = sel.value.toLowerCase(); if (!val) return; // "Todos" — no filter var cells = tr.querySelectorAll('td'); if (cells[col]) { var cellText = cells[col].textContent.trim().toLowerCase(); if (cellText.indexOf(val.toLowerCase()) === -1) show = false; } }); tr.style.display = show ? '' : 'none'; }); // Update count badge if exists var visibleCount = 0; rows.forEach(function(tr) { if (tr.style.display !== 'none') visibleCount++; }); var badge = document.querySelector('.filter-count-badge'); if (badge) badge.textContent = visibleCount + ' resultados'; } // ── Auto-extract unique values from a table column ────────── // Useful for building filter options dynamically from data. window.getUniqueColumnValues = function(tableEl, colIndex, maxValues) { maxValues = maxValues || 30; var values = {}; if (!tableEl) return []; tableEl.querySelectorAll('tbody tr').forEach(function(tr) { var cells = tr.querySelectorAll('td'); if (cells[colIndex]) { var v = cells[colIndex].textContent.trim(); if (v && v !== '-' && v !== '') values[v] = (values[v] || 0) + 1; } }); // Sort by frequency (most common first) return Object.keys(values) .sort(function(a, b) { return values[b] - values[a]; }) .slice(0, maxValues); }; // ── Auto-print polling for WhatsApp quotations ─────────────── // Polls /quotations/print-queue every 15s. When a confirmed WA quote // is found, it fetches the ESC/POS bytes and sends to the connected // thermal printer. Falls back to browser print if no thermal is connected. var _autoPrintTimer = null; var _autoPrintEnabled = false; window.startAutoPrint = function() { if (_autoPrintTimer) return; _autoPrintEnabled = true; var token = localStorage.getItem('pos_token'); if (!token) return; _autoPrintTimer = setInterval(function() { fetch('/pos/api/quotations/print-queue', { headers: { 'Authorization': 'Bearer ' + token } }) .then(function(r) { return r.json(); }) .then(function(d) { if (!d.data || !d.data.length) return; d.data.forEach(function(q) { console.log('[auto-print] Cotización #' + q.id + ' confirmada por WhatsApp — imprimiendo...'); showToast('🖨️ Imprimiendo cotización #' + q.id + ' (WhatsApp)', 'ok'); autoPrintQuote(q.id, token); }); }) .catch(function() {}); // silent on errors }, 15000); // every 15 seconds console.log('[auto-print] Enabled — polling every 15s'); }; window.stopAutoPrint = function() { if (_autoPrintTimer) { clearInterval(_autoPrintTimer); _autoPrintTimer = null; } _autoPrintEnabled = false; }; function autoPrintQuote(quoteId, token) { // Try thermal printer first (via NexusPrinter if loaded) if (typeof NexusPrinter !== 'undefined' && NexusPrinter.isConnected && NexusPrinter.isConnected()) { fetch('/pos/api/quotations/' + quoteId + '/print', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify({ printer_type: 'escpos_raw', width: 80 }), }) .then(function(r) { return r.arrayBuffer(); }) .then(function(buf) { NexusPrinter.sendRaw(new Uint8Array(buf)); markPrinted(quoteId, token); }) .catch(function(e) { console.error('[auto-print] Thermal print failed:', e); browserPrintQuote(quoteId, token); }); } else { browserPrintQuote(quoteId, token); } } function browserPrintQuote(quoteId, token) { // Fallback: open a print-friendly window fetch('/pos/api/quotations/' + quoteId + '/print', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify({ printer_type: 'browser' }), }) .then(function(r) { return r.json(); }) .then(function(q) { var html = 'Cotización #' + q.id + ''; html += ''; html += '

COTIZACIÓN

'; html += '

COT-' + q.id + '

'; html += '

Fecha: ' + (q.created_at || '').substring(0, 10) + '

'; if (q.customer_name) html += '

Cliente: ' + q.customer_name + '

'; if (q.wa_phone) html += '

WhatsApp: ' + q.wa_phone + '

'; html += '
'; (q.items || []).forEach(function(it) { html += ''; if (it.part_number) html += ''; }); html += '
' + it.quantity + 'x ' + it.name + '$' + it.subtotal.toFixed(2) + '
#' + it.part_number + '

'; html += '

Subtotal: $' + q.subtotal.toFixed(2) + '

'; html += '

IVA: $' + q.tax_total.toFixed(2) + '

'; html += '

TOTAL: $' + q.total.toFixed(2) + '

'; html += '

Esta cotización no es comprobante fiscal
Precios sujetos a disponibilidad

'; html += ''; var w = window.open('', '_blank', 'width=400,height=600'); w.document.write(html); w.document.close(); setTimeout(function() { w.print(); }, 500); markPrinted(quoteId, token); }) .catch(function(e) { console.error('[auto-print] Browser print failed:', e); }); } function markPrinted(quoteId, token) { fetch('/pos/api/quotations/' + quoteId + '/mark-printed', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token }, }).catch(function() {}); } // Auto-start polling on pages that are likely to have a printer // (POS sale page and quotations page) if (window.location.pathname.indexOf('/pos/sale') !== -1 || window.location.pathname.indexOf('/pos/quotation') !== -1 || window.location.pathname.indexOf('/pos/dashboard') !== -1) { var _initToken = localStorage.getItem('pos_token'); if (_initToken) { setTimeout(function() { startAutoPrint(); }, 3000); } } // ── 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 += '' + esc(f.name) + ''; }); 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 = '
Notificaciones
' + '
Sin notificaciones nuevas
'; 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 = '
' + '
⚠️
' + '
' + count + ' producto' + (count > 1 ? 's' : '') + ' sin stock
' + '
Stock crítico
'; } }).catch(function() {}); } }; // ── Ticket Preview Helper ───────────────────────────────────── window.previewTicket = function(ticketData) { var data = ticketData || {}; var items = (data.items || []).map(function(it) { return '
' + (it.quantity || 1) + 'x ' + esc(it.name) + '$' + (it.subtotal || 0).toFixed(2) + '
'; }).join(''); var html = '
' + '
Nexus Autoparts
' + (data.store || 'Sucursal Centro') + '
' + '
' + new Date().toLocaleString('es-MX') + '
' + '
Ticket #' + (data.id || '---') + '
' + '
' + items + '
' + '
Subtotal$' + (data.subtotal || 0).toFixed(2) + '
' + '
IVA$' + (data.tax || 0).toFixed(2) + '
' + '
TOTAL$' + (data.total || 0).toFixed(2) + '
' + '
Gracias por su compra
' + '
'; 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 = '
'; values.forEach(function(v) { var h = Math.max(4, Math.round(((v - min) / range) * 100)); html += '
'; }); html += '
'; container.innerHTML = html; }; })();