/**
* 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 = '' + opts.action.text + '
';
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;
};
// ── Loading / Empty state helpers ────────────────────────────────────────
window.renderLoadingState = function(opts) {
opts = opts || {};
var message = opts.message || 'Cargando...';
return '' +
'
' +
'
' + message + '
' +
'
';
};
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 =
'' +
'
' +
' ' +
' ' +
' ESC ' +
'
' +
'
' +
' ' +
'
';
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 '';
};
// ── 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 += '' + it.quantity + 'x ' + it.name + ' $' + it.subtotal.toFixed(2) + ' ';
if (it.part_number) html += ' #' + it.part_number + ' ';
});
html += '
';
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 = '' +
'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 = '' +
'' +
'
' + 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;
};
})();