Major features: - Pixel-Perfect glassmorphism design (landing + POS + public catalog) - OEM/Local catalog toggle with Nexpart taxonomy (14 groups, 108 subgroups, 558 part types) - Marketplace B2B Phase 1 (bodegas, POs, status machine, WA+email notifications) - Peer-to-peer inventory (multi-instance, LAN discovery) - WhatsApp: photo→Vision AI, voice→Whisper, conversational quotations - Smart unified search (VIN/plate/part_number/keyword auto-detect) - Shop Supplies tab (vehicle-independent parts) - Chatbot AI fallback chain (5 models) + response cache - CSV inventory import tool + setup_instance.sh installer - Tablet-responsive CSS + sidebar toggle - Filters, export CSV, employee edit, business data save - Quotation system (WA→POS) with auto-print on confirmation - Live stats on landing page Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
405 lines
18 KiB
JavaScript
405 lines
18 KiB
JavaScript
/**
|
|
* pos-utils.js — Shared utility functions for all POS pages.
|
|
*
|
|
* Provides common operations that multiple pages need:
|
|
* - CSV export of any visible table
|
|
* - Print page (PDF via browser print dialog)
|
|
* - Toast notifications (if page doesn't have its own)
|
|
*
|
|
* Load this script in every POS template BEFORE page-specific JS.
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ── CSV Export ──────────────────────────────────────────────────
|
|
// Finds the first visible <table> on the page and downloads it as CSV.
|
|
// Works on inventory, customers, invoicing, reports, accounting.
|
|
|
|
window.exportVisibleTableCSV = function(prefix) {
|
|
prefix = prefix || 'datos';
|
|
var tables = document.querySelectorAll('table');
|
|
var table = null;
|
|
|
|
// Find first visible table with data rows
|
|
for (var i = 0; i < tables.length; i++) {
|
|
if (tables[i].offsetParent !== null && tables[i].querySelector('tbody tr')) {
|
|
table = tables[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!table) {
|
|
showToast('No hay tabla de datos para exportar en esta vista.', 'warn');
|
|
return;
|
|
}
|
|
|
|
var rows = [];
|
|
|
|
// Header row
|
|
var ths = table.querySelectorAll('thead th');
|
|
if (ths.length) {
|
|
rows.push(Array.from(ths).map(function(th) {
|
|
return '"' + th.textContent.trim().replace(/"/g, '""') + '"';
|
|
}).join(','));
|
|
}
|
|
|
|
// Data rows
|
|
table.querySelectorAll('tbody tr').forEach(function(tr) {
|
|
var cells = tr.querySelectorAll('td');
|
|
rows.push(Array.from(cells).map(function(td) {
|
|
return '"' + td.textContent.trim().replace(/"/g, '""') + '"';
|
|
}).join(','));
|
|
});
|
|
|
|
if (rows.length <= 1) {
|
|
showToast('La tabla está vacía — no hay datos para exportar.', 'warn');
|
|
return;
|
|
}
|
|
|
|
var csv = rows.join('\n');
|
|
// BOM prefix so Excel opens UTF-8 correctly
|
|
var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = prefix + '_nexus_' + new Date().toISOString().slice(0, 10) + '.csv';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
showToast('CSV descargado: ' + a.download, 'ok');
|
|
};
|
|
|
|
// ── Print (PDF) ────────────────────────────────────────────────
|
|
window.printPage = function() {
|
|
window.print();
|
|
};
|
|
|
|
// ── Toast (simple, non-blocking notification) ──────────────────
|
|
// Only creates its own toast if the page doesn't already have one.
|
|
window.showToast = function(msg, type) {
|
|
type = type || 'info';
|
|
var container = document.getElementById('toast-container');
|
|
if (!container) {
|
|
container = document.createElement('div');
|
|
container.id = 'toast-container';
|
|
container.style.cssText = 'position:fixed;top:16px;right:16px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none;';
|
|
document.body.appendChild(container);
|
|
}
|
|
|
|
var colors = {
|
|
ok: 'background:#1a7a3a;color:#fff;',
|
|
error: 'background:#c0392b;color:#fff;',
|
|
warn: 'background:#d4a017;color:#000;',
|
|
info: 'background:var(--color-surface-3,#333);color:var(--color-text-primary,#fff);',
|
|
};
|
|
|
|
var toast = document.createElement('div');
|
|
toast.style.cssText = (colors[type] || colors.info) +
|
|
'padding:10px 20px;border-radius:8px;font-size:14px;font-weight:500;' +
|
|
'box-shadow:0 4px 12px rgba(0,0,0,0.3);pointer-events:auto;' +
|
|
'animation:slideInRight 0.3s ease;max-width:400px;';
|
|
toast.textContent = msg;
|
|
container.appendChild(toast);
|
|
|
|
setTimeout(function() {
|
|
toast.style.opacity = '0';
|
|
toast.style.transition = 'opacity 0.3s';
|
|
setTimeout(function() { toast.remove(); }, 300);
|
|
}, 3000);
|
|
};
|
|
|
|
// ── "Próximamente" placeholder for features not yet built ──────
|
|
window.featureProximamente = function(nombre) {
|
|
showToast((nombre || 'Esta función') + ' estará disponible próximamente.', 'info');
|
|
};
|
|
|
|
// ── Table Filter Panel ────────────────────────────────────────
|
|
// Creates a dropdown filter panel that filters visible table rows
|
|
// client-side. Call toggleFilterPanel(buttonEl, config) where config
|
|
// is an array of {label, column, values} describing each filter.
|
|
//
|
|
// Usage (from onclick):
|
|
// toggleFilterPanel(this, [
|
|
// {label: 'Marca', column: 2, values: ['BOSCH','MONROE','Todas']},
|
|
// {label: 'Status', column: 4, values: ['Activo','Inactivo','Todos']},
|
|
// ])
|
|
|
|
var _activeFilterPanel = null;
|
|
|
|
window.toggleFilterPanel = function(btnEl, filters) {
|
|
// Close existing panel if open
|
|
if (_activeFilterPanel) {
|
|
_activeFilterPanel.remove();
|
|
_activeFilterPanel = null;
|
|
return;
|
|
}
|
|
|
|
var panel = document.createElement('div');
|
|
panel.className = 'filter-panel';
|
|
panel.style.cssText = 'position:absolute;top:100%;right:0;z-index:1000;' +
|
|
'background:var(--glass-bg-strong,#1a1a1a);backdrop-filter:blur(16px);' +
|
|
'border:1px solid var(--glass-border,#333);border-radius:var(--radius-lg,12px);' +
|
|
'padding:16px;min-width:260px;box-shadow:0 8px 32px rgba(0,0,0,0.3);' +
|
|
'display:flex;flex-direction:column;gap:12px;';
|
|
|
|
var title = document.createElement('div');
|
|
title.style.cssText = 'font-weight:700;font-size:14px;display:flex;justify-content:space-between;align-items:center;';
|
|
title.innerHTML = 'Filtros <button onclick="closeFilterPanel()" style="background:none;border:none;color:var(--color-text-muted);cursor:pointer;font-size:18px;">✕</button>';
|
|
panel.appendChild(title);
|
|
|
|
filters.forEach(function(f) {
|
|
var group = document.createElement('div');
|
|
var label = document.createElement('label');
|
|
label.style.cssText = 'display:block;font-size:12px;color:var(--color-text-muted);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.05em;';
|
|
label.textContent = f.label;
|
|
group.appendChild(label);
|
|
|
|
var select = document.createElement('select');
|
|
select.style.cssText = 'width:100%;padding:8px 10px;background:var(--glass-bg,#222);' +
|
|
'border:1px solid var(--glass-border,#444);border-radius:6px;' +
|
|
'color:var(--color-text-primary,#fff);font-size:13px;';
|
|
select.dataset.filterColumn = f.column;
|
|
|
|
// "Todos" option always first
|
|
var allOpt = document.createElement('option');
|
|
allOpt.value = '';
|
|
allOpt.textContent = f.allLabel || 'Todos';
|
|
select.appendChild(allOpt);
|
|
|
|
(f.values || []).forEach(function(v) {
|
|
if (!v) return;
|
|
var opt = document.createElement('option');
|
|
opt.value = v;
|
|
opt.textContent = v;
|
|
select.appendChild(opt);
|
|
});
|
|
|
|
select.addEventListener('change', function() { applyFilters(panel); });
|
|
group.appendChild(select);
|
|
panel.appendChild(group);
|
|
});
|
|
|
|
// Clear all button
|
|
var clearBtn = document.createElement('button');
|
|
clearBtn.style.cssText = 'padding:8px;background:transparent;border:1px dashed var(--glass-border,#444);' +
|
|
'border-radius:6px;color:var(--color-text-muted);cursor:pointer;font-size:12px;';
|
|
clearBtn.textContent = 'Limpiar filtros';
|
|
clearBtn.addEventListener('click', function() {
|
|
panel.querySelectorAll('select').forEach(function(s) { s.value = ''; });
|
|
applyFilters(panel);
|
|
});
|
|
panel.appendChild(clearBtn);
|
|
|
|
// Position relative to the button
|
|
var wrapper = btnEl.parentElement;
|
|
if (wrapper) wrapper.style.position = 'relative';
|
|
(wrapper || document.body).appendChild(panel);
|
|
_activeFilterPanel = panel;
|
|
|
|
// Close on outside click
|
|
setTimeout(function() {
|
|
document.addEventListener('click', function handler(e) {
|
|
if (!panel.contains(e.target) && e.target !== btnEl) {
|
|
closeFilterPanel();
|
|
document.removeEventListener('click', handler);
|
|
}
|
|
});
|
|
}, 100);
|
|
};
|
|
|
|
window.closeFilterPanel = function() {
|
|
if (_activeFilterPanel) {
|
|
_activeFilterPanel.remove();
|
|
_activeFilterPanel = null;
|
|
}
|
|
};
|
|
|
|
function applyFilters(panel) {
|
|
var selects = panel.querySelectorAll('select[data-filter-column]');
|
|
// Find the nearest visible table
|
|
var tables = document.querySelectorAll('table');
|
|
var table = null;
|
|
for (var i = 0; i < tables.length; i++) {
|
|
if (tables[i].offsetParent !== null) { table = tables[i]; break; }
|
|
}
|
|
if (!table) return;
|
|
|
|
var rows = table.querySelectorAll('tbody tr');
|
|
rows.forEach(function(tr) {
|
|
var show = true;
|
|
selects.forEach(function(sel) {
|
|
var col = parseInt(sel.dataset.filterColumn);
|
|
var val = sel.value.toLowerCase();
|
|
if (!val) return; // "Todos" — no filter
|
|
var cells = tr.querySelectorAll('td');
|
|
if (cells[col]) {
|
|
var cellText = cells[col].textContent.trim().toLowerCase();
|
|
if (cellText.indexOf(val.toLowerCase()) === -1) show = false;
|
|
}
|
|
});
|
|
tr.style.display = show ? '' : 'none';
|
|
});
|
|
|
|
// Update count badge if exists
|
|
var visibleCount = 0;
|
|
rows.forEach(function(tr) { if (tr.style.display !== 'none') visibleCount++; });
|
|
var badge = document.querySelector('.filter-count-badge');
|
|
if (badge) badge.textContent = visibleCount + ' resultados';
|
|
}
|
|
|
|
// ── Auto-extract unique values from a table column ──────────
|
|
// Useful for building filter options dynamically from data.
|
|
window.getUniqueColumnValues = function(tableEl, colIndex, maxValues) {
|
|
maxValues = maxValues || 30;
|
|
var values = {};
|
|
if (!tableEl) return [];
|
|
tableEl.querySelectorAll('tbody tr').forEach(function(tr) {
|
|
var cells = tr.querySelectorAll('td');
|
|
if (cells[colIndex]) {
|
|
var v = cells[colIndex].textContent.trim();
|
|
if (v && v !== '-' && v !== '') values[v] = (values[v] || 0) + 1;
|
|
}
|
|
});
|
|
// Sort by frequency (most common first)
|
|
return Object.keys(values)
|
|
.sort(function(a, b) { return values[b] - values[a]; })
|
|
.slice(0, maxValues);
|
|
};
|
|
|
|
// ── Auto-print polling for WhatsApp quotations ───────────────
|
|
// Polls /quotations/print-queue every 15s. When a confirmed WA quote
|
|
// is found, it fetches the ESC/POS bytes and sends to the connected
|
|
// thermal printer. Falls back to browser print if no thermal is connected.
|
|
|
|
var _autoPrintTimer = null;
|
|
var _autoPrintEnabled = false;
|
|
|
|
window.startAutoPrint = function() {
|
|
if (_autoPrintTimer) return;
|
|
_autoPrintEnabled = true;
|
|
var token = localStorage.getItem('pos_token');
|
|
if (!token) return;
|
|
|
|
_autoPrintTimer = setInterval(function() {
|
|
fetch('/pos/api/quotations/print-queue', {
|
|
headers: { 'Authorization': 'Bearer ' + token }
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(d) {
|
|
if (!d.data || !d.data.length) return;
|
|
d.data.forEach(function(q) {
|
|
console.log('[auto-print] Cotización #' + q.id + ' confirmada por WhatsApp — imprimiendo...');
|
|
showToast('🖨️ Imprimiendo cotización #' + q.id + ' (WhatsApp)', 'ok');
|
|
autoPrintQuote(q.id, token);
|
|
});
|
|
})
|
|
.catch(function() {}); // silent on errors
|
|
}, 15000); // every 15 seconds
|
|
|
|
console.log('[auto-print] Enabled — polling every 15s');
|
|
};
|
|
|
|
window.stopAutoPrint = function() {
|
|
if (_autoPrintTimer) {
|
|
clearInterval(_autoPrintTimer);
|
|
_autoPrintTimer = null;
|
|
}
|
|
_autoPrintEnabled = false;
|
|
};
|
|
|
|
function autoPrintQuote(quoteId, token) {
|
|
// Try thermal printer first (via NexusPrinter if loaded)
|
|
if (typeof NexusPrinter !== 'undefined' && NexusPrinter.isConnected && NexusPrinter.isConnected()) {
|
|
fetch('/pos/api/quotations/' + quoteId + '/print', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ printer_type: 'escpos_raw', width: 80 }),
|
|
})
|
|
.then(function(r) { return r.arrayBuffer(); })
|
|
.then(function(buf) {
|
|
NexusPrinter.sendRaw(new Uint8Array(buf));
|
|
markPrinted(quoteId, token);
|
|
})
|
|
.catch(function(e) {
|
|
console.error('[auto-print] Thermal print failed:', e);
|
|
browserPrintQuote(quoteId, token);
|
|
});
|
|
} else {
|
|
browserPrintQuote(quoteId, token);
|
|
}
|
|
}
|
|
|
|
function browserPrintQuote(quoteId, token) {
|
|
// Fallback: open a print-friendly window
|
|
fetch('/pos/api/quotations/' + quoteId + '/print', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ printer_type: 'browser' }),
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(q) {
|
|
var html = '<html><head><title>Cotización #' + q.id + '</title>';
|
|
html += '<style>body{font-family:monospace;font-size:12px;width:80mm;margin:0 auto;padding:10px;}';
|
|
html += 'h1{font-size:18px;text-align:center;margin:0;}';
|
|
html += '.center{text-align:center;}.right{text-align:right;}';
|
|
html += 'hr{border:none;border-top:1px dashed #000;}';
|
|
html += 'table{width:100%;border-collapse:collapse;}td{padding:2px 4px;}</style></head><body>';
|
|
html += '<h1>COTIZACIÓN</h1>';
|
|
html += '<p class="center">COT-' + q.id + '</p>';
|
|
html += '<p>Fecha: ' + (q.created_at || '').substring(0, 10) + '</p>';
|
|
if (q.customer_name) html += '<p>Cliente: ' + q.customer_name + '</p>';
|
|
if (q.wa_phone) html += '<p>WhatsApp: ' + q.wa_phone + '</p>';
|
|
html += '<hr><table>';
|
|
(q.items || []).forEach(function(it) {
|
|
html += '<tr><td>' + it.quantity + 'x ' + it.name + '</td><td class="right">$' + it.subtotal.toFixed(2) + '</td></tr>';
|
|
if (it.part_number) html += '<tr><td colspan="2" style="font-size:10px;color:#666;"> #' + it.part_number + '</td></tr>';
|
|
});
|
|
html += '</table><hr>';
|
|
html += '<p class="right">Subtotal: $' + q.subtotal.toFixed(2) + '</p>';
|
|
html += '<p class="right">IVA: $' + q.tax_total.toFixed(2) + '</p>';
|
|
html += '<p class="right" style="font-size:16px;font-weight:bold;">TOTAL: $' + q.total.toFixed(2) + '</p>';
|
|
html += '<hr><p class="center" style="font-size:10px;">Esta cotización no es comprobante fiscal<br>Precios sujetos a disponibilidad</p>';
|
|
html += '</body></html>';
|
|
|
|
var w = window.open('', '_blank', 'width=400,height=600');
|
|
w.document.write(html);
|
|
w.document.close();
|
|
setTimeout(function() { w.print(); }, 500);
|
|
markPrinted(quoteId, token);
|
|
})
|
|
.catch(function(e) {
|
|
console.error('[auto-print] Browser print failed:', e);
|
|
});
|
|
}
|
|
|
|
function markPrinted(quoteId, token) {
|
|
fetch('/pos/api/quotations/' + quoteId + '/mark-printed', {
|
|
method: 'POST',
|
|
headers: { 'Authorization': 'Bearer ' + token },
|
|
}).catch(function() {});
|
|
}
|
|
|
|
// Auto-start polling on pages that are likely to have a printer
|
|
// (POS sale page and quotations page)
|
|
if (window.location.pathname.indexOf('/pos/sale') !== -1 ||
|
|
window.location.pathname.indexOf('/pos/quotation') !== -1 ||
|
|
window.location.pathname.indexOf('/pos/dashboard') !== -1) {
|
|
var _initToken = localStorage.getItem('pos_token');
|
|
if (_initToken) {
|
|
setTimeout(function() { startAutoPrint(); }, 3000);
|
|
}
|
|
}
|
|
|
|
// Inject styles
|
|
if (!document.getElementById('pos-utils-styles')) {
|
|
var style = document.createElement('style');
|
|
style.id = 'pos-utils-styles';
|
|
style.textContent = '@keyframes slideInRight{from{transform:translateX(100%);opacity:0}to{transform:translateX(0);opacity:1}}' +
|
|
'.filter-panel select:focus{outline:none;border-color:var(--color-primary,#F5A623);box-shadow:0 0 0 2px var(--glow-color-soft,rgba(245,166,35,0.15));}';
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
})();
|