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:
@@ -1698,3 +1698,96 @@ def push_test():
|
|||||||
if ok:
|
if ok:
|
||||||
return jsonify({'message': 'Test notification sent'})
|
return jsonify({'message': 'Test notification sent'})
|
||||||
return jsonify({'error': 'No subscription found or push failed'}), 400
|
return jsonify({'error': 'No subscription found or push failed'}), 400
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Thermal Printing ──────────────────────────────
|
||||||
|
|
||||||
|
@pos_bp.route('/sales/<int:sale_id>/print', methods=['POST'])
|
||||||
|
@require_auth('pos.sell')
|
||||||
|
def print_ticket(sale_id):
|
||||||
|
"""Generate a printable ticket for a sale.
|
||||||
|
|
||||||
|
Body (optional): {printer_type: 'escpos_raw' | 'browser', width: 58 | 80}
|
||||||
|
- escpos_raw: returns raw ESC/POS bytes (application/octet-stream)
|
||||||
|
- browser: returns printable HTML fragment (text/html)
|
||||||
|
"""
|
||||||
|
from flask import Response
|
||||||
|
from services.thermal_printer import generate_ticket
|
||||||
|
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
printer_type = body.get('printer_type', 'escpos_raw')
|
||||||
|
width = int(body.get('width', 80))
|
||||||
|
|
||||||
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Fetch sale
|
||||||
|
cur.execute("""
|
||||||
|
SELECT s.*, e.name as employee_name, c.name as customer_name
|
||||||
|
FROM sales s
|
||||||
|
LEFT JOIN employees e ON s.employee_id = e.id
|
||||||
|
LEFT JOIN customers c ON s.customer_id = c.id
|
||||||
|
WHERE s.id = %s
|
||||||
|
""", (sale_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
cur.close(); conn.close()
|
||||||
|
return jsonify({'error': 'Sale not found'}), 404
|
||||||
|
|
||||||
|
cols = [desc[0] for desc in cur.description]
|
||||||
|
sale = dict(zip(cols, row))
|
||||||
|
for k in ('subtotal', 'discount_total', 'tax_total', 'total', 'amount_paid', 'change_given'):
|
||||||
|
if sale.get(k) is not None:
|
||||||
|
sale[k] = float(sale[k])
|
||||||
|
|
||||||
|
# Fetch items
|
||||||
|
cur.execute("""
|
||||||
|
SELECT name, quantity, unit_price, subtotal
|
||||||
|
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||||
|
""", (sale_id,))
|
||||||
|
items = []
|
||||||
|
for r in cur.fetchall():
|
||||||
|
items.append({
|
||||||
|
'name': r[0], 'quantity': r[1],
|
||||||
|
'unit_price': float(r[2]) if r[2] else 0,
|
||||||
|
'subtotal': float(r[3]) if r[3] else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Fetch business info from config
|
||||||
|
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||||
|
try:
|
||||||
|
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')")
|
||||||
|
for rw in cur.fetchall():
|
||||||
|
if rw[0] == 'business_name':
|
||||||
|
business_info['name'] = rw[1]
|
||||||
|
else:
|
||||||
|
business_info[rw[0]] = rw[1]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
sale_data = {
|
||||||
|
'folio': f'V-{sale["id"]}',
|
||||||
|
'date': str(sale.get('created_at', '')),
|
||||||
|
'employee': sale.get('employee_name', ''),
|
||||||
|
'customer': sale.get('customer_name', ''),
|
||||||
|
'items': items,
|
||||||
|
'subtotal': sale.get('subtotal', 0),
|
||||||
|
'discount_total': sale.get('discount_total', 0),
|
||||||
|
'tax_total': sale.get('tax_total', 0),
|
||||||
|
'total': sale.get('total', 0),
|
||||||
|
'payment_method': sale.get('payment_method', 'efectivo'),
|
||||||
|
'amount_paid': sale.get('amount_paid'),
|
||||||
|
'change_given': sale.get('change_given'),
|
||||||
|
}
|
||||||
|
|
||||||
|
if printer_type == 'browser':
|
||||||
|
# Return the sale data as JSON for browser-side rendering
|
||||||
|
return jsonify(sale_data)
|
||||||
|
|
||||||
|
# Default: ESC/POS raw bytes
|
||||||
|
raw = generate_ticket(sale_data, business_info, width=width)
|
||||||
|
return Response(raw, mimetype='application/octet-stream',
|
||||||
|
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
|
||||||
|
|||||||
119
pos/services/thermal_printer.py
Normal file
119
pos/services/thermal_printer.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""ESC/POS thermal printer commands for 58mm and 80mm printers.
|
||||||
|
|
||||||
|
Generates raw ESC/POS byte commands that can be sent to a thermal printer
|
||||||
|
via USB, serial, or network connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ESC/POS command constants
|
||||||
|
ESC = b'\x1b'
|
||||||
|
GS = b'\x1d'
|
||||||
|
INIT = ESC + b'@' # Initialize printer
|
||||||
|
CUT = GS + b'V' + b'\x00' # Full cut
|
||||||
|
PARTIAL_CUT = GS + b'V' + b'\x01'
|
||||||
|
FEED = ESC + b'd' # Feed N lines
|
||||||
|
ALIGN_LEFT = ESC + b'a' + b'\x00'
|
||||||
|
ALIGN_CENTER = ESC + b'a' + b'\x01'
|
||||||
|
ALIGN_RIGHT = ESC + b'a' + b'\x02'
|
||||||
|
BOLD_ON = ESC + b'E' + b'\x01'
|
||||||
|
BOLD_OFF = ESC + b'E' + b'\x00'
|
||||||
|
DOUBLE_HEIGHT = ESC + b'!' + b'\x10'
|
||||||
|
NORMAL_SIZE = ESC + b'!' + b'\x00'
|
||||||
|
LARGE_SIZE = ESC + b'!' + b'\x30' # Double width + double height
|
||||||
|
|
||||||
|
|
||||||
|
def generate_ticket(sale_data, business_info, width=80):
|
||||||
|
"""Generate ESC/POS bytes for a sale ticket.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sale_data: dict with sale info (items, totals, payment, folio)
|
||||||
|
business_info: dict with business name, RFC, address
|
||||||
|
width: 58 or 80 (mm)
|
||||||
|
|
||||||
|
Returns: bytes ready to send to printer
|
||||||
|
"""
|
||||||
|
chars = 32 if width == 58 else 48 # characters per line
|
||||||
|
buf = bytearray()
|
||||||
|
buf += INIT
|
||||||
|
|
||||||
|
# Header: business name (centered, bold, large)
|
||||||
|
buf += ALIGN_CENTER
|
||||||
|
buf += LARGE_SIZE
|
||||||
|
buf += (business_info.get('name', 'NEXUS POS') + '\n').encode('cp437', errors='replace')
|
||||||
|
buf += NORMAL_SIZE
|
||||||
|
buf += (business_info.get('rfc', '') + '\n').encode('cp437', errors='replace')
|
||||||
|
buf += (business_info.get('address', '') + '\n').encode('cp437', errors='replace')
|
||||||
|
buf += b'\n'
|
||||||
|
|
||||||
|
# Folio + date
|
||||||
|
buf += ALIGN_LEFT
|
||||||
|
buf += BOLD_ON
|
||||||
|
folio = sale_data.get('folio', 'N/A')
|
||||||
|
date = sale_data.get('date', '')
|
||||||
|
buf += f'Folio: {folio}\n'.encode('cp437', errors='replace')
|
||||||
|
buf += BOLD_OFF
|
||||||
|
buf += f'Fecha: {date}\n'.encode('cp437', errors='replace')
|
||||||
|
buf += f'Cajero: {sale_data.get("employee", "")}\n'.encode('cp437', errors='replace')
|
||||||
|
if sale_data.get('customer'):
|
||||||
|
buf += f'Cliente: {sale_data["customer"]}\n'.encode('cp437', errors='replace')
|
||||||
|
buf += ('-' * chars + '\n').encode()
|
||||||
|
|
||||||
|
# Column header
|
||||||
|
buf += BOLD_ON
|
||||||
|
hdr = _format_line('Cant Descripcion', 'Importe', chars)
|
||||||
|
buf += (hdr + '\n').encode('cp437', errors='replace')
|
||||||
|
buf += BOLD_OFF
|
||||||
|
buf += ('-' * chars + '\n').encode()
|
||||||
|
|
||||||
|
# Items
|
||||||
|
for item in sale_data.get('items', []):
|
||||||
|
name = item.get('name', '')[:chars - 10]
|
||||||
|
qty = item.get('quantity', 1)
|
||||||
|
subtotal = item.get('subtotal', 0)
|
||||||
|
buf += f'{qty}x {name}\n'.encode('cp437', errors='replace')
|
||||||
|
buf += ALIGN_RIGHT
|
||||||
|
buf += f'${subtotal:,.2f}\n'.encode('cp437', errors='replace')
|
||||||
|
buf += ALIGN_LEFT
|
||||||
|
|
||||||
|
buf += ('-' * chars + '\n').encode()
|
||||||
|
|
||||||
|
# Totals
|
||||||
|
buf += ALIGN_RIGHT
|
||||||
|
buf += _total_line('Subtotal:', sale_data.get('subtotal', 0), chars).encode('cp437', errors='replace')
|
||||||
|
if sale_data.get('discount_total', 0) > 0:
|
||||||
|
buf += _total_line('Descuento:', -sale_data['discount_total'], chars).encode('cp437', errors='replace')
|
||||||
|
buf += _total_line('IVA 16%:', sale_data.get('tax_total', 0), chars).encode('cp437', errors='replace')
|
||||||
|
buf += BOLD_ON + DOUBLE_HEIGHT
|
||||||
|
buf += _total_line('TOTAL:', sale_data.get('total', 0), chars).encode('cp437', errors='replace')
|
||||||
|
buf += NORMAL_SIZE + BOLD_OFF
|
||||||
|
|
||||||
|
# Payment
|
||||||
|
buf += ALIGN_LEFT
|
||||||
|
buf += f'\nPago: {sale_data.get("payment_method", "Efectivo")}\n'.encode('cp437', errors='replace')
|
||||||
|
if sale_data.get('amount_paid'):
|
||||||
|
buf += f'Recibido: ${sale_data["amount_paid"]:,.2f}\n'.encode('cp437', errors='replace')
|
||||||
|
if sale_data.get('change_given'):
|
||||||
|
buf += f'Cambio: ${sale_data["change_given"]:,.2f}\n'.encode('cp437', errors='replace')
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
buf += b'\n'
|
||||||
|
buf += ALIGN_CENTER
|
||||||
|
buf += 'Gracias por su compra!\n'.encode('cp437', errors='replace')
|
||||||
|
buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace')
|
||||||
|
buf += b'\n\n\n'
|
||||||
|
buf += PARTIAL_CUT
|
||||||
|
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_line(left, right, width):
|
||||||
|
"""Pad a left-right line to fill the ticket width."""
|
||||||
|
space = width - len(left) - len(right)
|
||||||
|
if space < 1:
|
||||||
|
space = 1
|
||||||
|
return left + ' ' * space + right
|
||||||
|
|
||||||
|
|
||||||
|
def _total_line(label, amount, width):
|
||||||
|
"""Format a totals line like 'Subtotal: $1,234.56'."""
|
||||||
|
val = f'${abs(amount):,.2f}' if amount >= 0 else f'-${abs(amount):,.2f}'
|
||||||
|
return _format_line(label, val, width) + '\n'
|
||||||
@@ -26,7 +26,11 @@ const POS = (() => {
|
|||||||
let searchTimeout = null;
|
let searchTimeout = null;
|
||||||
let customerSearchTimeout = 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() {
|
function headers() {
|
||||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||||
@@ -940,6 +944,45 @@ const POS = (() => {
|
|||||||
setTimeout(() => { if (area) area.style.display = 'none'; }, 500);
|
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 ───────────────────────
|
// ─── Last Sale ───────────────────────
|
||||||
async function showLastSale() {
|
async function showLastSale() {
|
||||||
if (!lastSaleId) { showToast('No hay venta reciente'); return; }
|
if (!lastSaleId) { showToast('No hay venta reciente'); return; }
|
||||||
@@ -1078,5 +1121,6 @@ const POS = (() => {
|
|||||||
creditSale, saveQuotation, createLayaway,
|
creditSale, saveQuotation, createLayaway,
|
||||||
showLastSale, openDrawer,
|
showLastSale, openDrawer,
|
||||||
showTicket, closeTicketModal, printTicket,
|
showTicket, closeTicketModal, printTicket,
|
||||||
|
connectThermal, thermalPrint,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
152
pos/static/js/printer.js
Normal file
152
pos/static/js/printer.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/**
|
||||||
|
* NexusPrinter — Web USB / Web Serial bridge for thermal printers.
|
||||||
|
*
|
||||||
|
* Connects to 58mm or 80mm ESC/POS printers via WebUSB or Web Serial APIs
|
||||||
|
* and sends raw byte commands generated by the backend.
|
||||||
|
*/
|
||||||
|
window.NexusPrinter = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let _device = null; // WebUSB device
|
||||||
|
let _endpoint = null; // USB bulk-out endpoint number
|
||||||
|
let _serialPort = null; // Web Serial port
|
||||||
|
let _writer = null; // Serial writable stream writer
|
||||||
|
let _type = null; // 'usb' | 'serial' | null
|
||||||
|
|
||||||
|
// ── Connection ─────────────────────────────────
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
// Try WebUSB first
|
||||||
|
if ('usb' in navigator) {
|
||||||
|
try {
|
||||||
|
_device = await navigator.usb.requestDevice({ filters: [] });
|
||||||
|
await _device.open();
|
||||||
|
|
||||||
|
// Select first configuration if needed
|
||||||
|
if (_device.configuration === null) {
|
||||||
|
await _device.selectConfiguration(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claim first interface and find bulk-out endpoint
|
||||||
|
const iface = _device.configuration.interfaces[0];
|
||||||
|
await _device.claimInterface(iface.interfaceNumber);
|
||||||
|
|
||||||
|
const alt = iface.alternates[0];
|
||||||
|
const ep = alt.endpoints.find(e => e.direction === 'out' && e.type === 'bulk');
|
||||||
|
if (ep) {
|
||||||
|
_endpoint = ep.endpointNumber;
|
||||||
|
_type = 'usb';
|
||||||
|
_save();
|
||||||
|
return { ok: true, type: 'usb', name: _device.productName || 'USB Printer' };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// User cancelled or no USB device available
|
||||||
|
_device = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Web Serial
|
||||||
|
if ('serial' in navigator) {
|
||||||
|
try {
|
||||||
|
_serialPort = await navigator.serial.requestPort();
|
||||||
|
await _serialPort.open({ baudRate: 9600 });
|
||||||
|
_writer = _serialPort.writable.getWriter();
|
||||||
|
_type = 'serial';
|
||||||
|
_save();
|
||||||
|
return { ok: true, type: 'serial', name: 'Serial Printer' };
|
||||||
|
} catch (e) {
|
||||||
|
_serialPort = null;
|
||||||
|
_writer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, error: 'No printer connected. Use a browser that supports WebUSB or Web Serial (Chrome/Edge).' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnect() {
|
||||||
|
try {
|
||||||
|
if (_type === 'usb' && _device) {
|
||||||
|
await _device.close();
|
||||||
|
}
|
||||||
|
if (_type === 'serial') {
|
||||||
|
if (_writer) { _writer.releaseLock(); _writer = null; }
|
||||||
|
if (_serialPort) { await _serialPort.close(); _serialPort = null; }
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
_device = null;
|
||||||
|
_endpoint = null;
|
||||||
|
_type = null;
|
||||||
|
localStorage.removeItem('nexus_printer');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConnected() {
|
||||||
|
return _type !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Printing ───────────────────────────────────
|
||||||
|
|
||||||
|
async function sendRaw(bytes) {
|
||||||
|
if (!_type) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (_type === 'usb' && _device && _endpoint !== null) {
|
||||||
|
// Send in chunks of 512 bytes (common USB max packet size)
|
||||||
|
const chunk = 512;
|
||||||
|
for (let i = 0; i < bytes.length; i += chunk) {
|
||||||
|
await _device.transferOut(_endpoint, bytes.slice(i, i + chunk));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (_type === 'serial' && _writer) {
|
||||||
|
await _writer.write(bytes);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[NexusPrinter] send error:', e);
|
||||||
|
// Connection likely lost
|
||||||
|
await disconnect();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print a sale ticket by fetching ESC/POS bytes from the backend.
|
||||||
|
* @param {number} saleId
|
||||||
|
* @param {number} [width=80] — 58 or 80 mm
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async function printSale(saleId, width) {
|
||||||
|
width = width || 80;
|
||||||
|
const token = localStorage.getItem('pos_token');
|
||||||
|
const resp = await fetch('/pos/api/sales/' + saleId + '/print', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ printer_type: 'escpos_raw', width: width })
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.error('[NexusPrinter] backend error', resp.status);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const buf = await resp.arrayBuffer();
|
||||||
|
return sendRaw(new Uint8Array(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistence helpers ────────────────────────
|
||||||
|
|
||||||
|
function _save() {
|
||||||
|
try { localStorage.setItem('nexus_printer', _type || ''); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ─────────────────────────────────
|
||||||
|
|
||||||
|
return {
|
||||||
|
connect: connect,
|
||||||
|
disconnect: disconnect,
|
||||||
|
isConnected: isConnected,
|
||||||
|
sendRaw: sendRaw,
|
||||||
|
printSale: printSale
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -1380,6 +1380,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button class="btn btn-ghost" onclick="POS.closeTicketModal()">Cerrar</button>
|
<button class="btn btn-ghost" onclick="POS.closeTicketModal()">Cerrar</button>
|
||||||
|
<button class="btn btn-ghost" id="btnConnectPrinter" onclick="POS.connectThermal()" title="Conectar impresora termica USB/Serial">🖨 Conectar</button>
|
||||||
|
<button class="btn btn-secondary" id="btnThermalPrint" onclick="POS.thermalPrint()" style="display:none;" title="Imprimir en impresora termica">🖨 Termica</button>
|
||||||
<button class="btn btn-primary" onclick="POS.printTicket()">Imprimir</button>
|
<button class="btn btn-primary" onclick="POS.printTicket()">Imprimir</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1477,8 +1479,10 @@
|
|||||||
<!-- ================================================================
|
<!-- ================================================================
|
||||||
JAVASCRIPT
|
JAVASCRIPT
|
||||||
================================================================ -->
|
================================================================ -->
|
||||||
|
<script src="/pos/static/js/i18n.js"></script>
|
||||||
<script src="/pos/static/js/app-init.js"></script>
|
<script src="/pos/static/js/app-init.js"></script>
|
||||||
<script src="/pos/static/js/push.js"></script>
|
<script src="/pos/static/js/push.js"></script>
|
||||||
|
<script src="/pos/static/js/printer.js"></script>
|
||||||
<script src="/pos/static/js/pos.js"></script>
|
<script src="/pos/static/js/pos.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
Reference in New Issue
Block a user