feat(pos): add real thermal ticket printing via ESC/POS (#21)

- Add thermal_printer.py service generating raw ESC/POS bytes for 58mm/80mm printers
- Add /pos/api/sales/<id>/print endpoint (escpos_raw or browser mode)
- Add printer.js with WebUSB and Web Serial support for direct browser-to-printer
- Add thermal print button in ticket modal with connect/print workflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 08:16:44 +00:00
parent f9589f4a4e
commit ecdc3526a6
5 changed files with 413 additions and 1 deletions

View File

@@ -26,7 +26,11 @@ const POS = (() => {
let searchTimeout = null;
let customerSearchTimeout = null;
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
// Currency-aware formatter: reads pos_currency from localStorage
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
const _currSymbols = { MXN: '$', USD: 'US$' };
const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX';
const fmt = (n) => (_currSymbols[_posCurrency] || '$') + parseFloat(n || 0).toLocaleString(_currLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
@@ -940,6 +944,45 @@ const POS = (() => {
setTimeout(() => { if (area) area.style.display = 'none'; }, 500);
}
// ─── Thermal Printing ─────────────────
async function connectThermal() {
if (!window.NexusPrinter) { showToast('Printer module not loaded'); return; }
const result = await NexusPrinter.connect();
if (result.ok) {
showToast('Impresora conectada: ' + (result.name || result.type));
_updatePrinterButtons();
} else {
showToast(result.error || 'No se pudo conectar la impresora');
}
}
async function thermalPrint() {
if (!window.NexusPrinter || !NexusPrinter.isConnected()) {
showToast('Conecte una impresora termica primero');
return;
}
if (!lastSaleId) { showToast('No hay venta para imprimir'); return; }
const ok = await NexusPrinter.printSale(lastSaleId);
if (ok) {
showToast('Ticket enviado a impresora termica');
} else {
showToast('Error al imprimir. Reconecte la impresora.');
}
}
function _updatePrinterButtons() {
const connectBtn = document.getElementById('btnConnectPrinter');
const thermalBtn = document.getElementById('btnThermalPrint');
if (window.NexusPrinter && NexusPrinter.isConnected()) {
if (connectBtn) connectBtn.style.display = 'none';
if (thermalBtn) thermalBtn.style.display = '';
} else {
if (connectBtn) connectBtn.style.display = '';
if (thermalBtn) thermalBtn.style.display = 'none';
}
}
// ─── Last Sale ───────────────────────
async function showLastSale() {
if (!lastSaleId) { showToast('No hay venta reciente'); return; }
@@ -1078,5 +1121,6 @@ const POS = (() => {
creditSale, saveQuotation, createLayaway,
showLastSale, openDrawer,
showTicket, closeTicketModal, printTicket,
connectThermal, thermalPrint,
};
})();