diff --git a/pos/blueprints/config_bp.py b/pos/blueprints/config_bp.py index 4674f23..72f28ff 100644 --- a/pos/blueprints/config_bp.py +++ b/pos/blueprints/config_bp.py @@ -752,3 +752,99 @@ def set_onboarding_status(): cur.close() conn.close() return jsonify({'completed': completed == 'true'}) + + +# ─── Receipt / Ticket Customization ──────────────────────────────────────── + +RECEIPT_CONFIG_KEYS = [ + 'receipt_logo', + 'receipt_store_name', + 'receipt_tagline', + 'receipt_rfc', + 'receipt_address', + 'receipt_phone', + 'receipt_footer', + 'receipt_thanks_message', + 'receipt_show_logo', + 'receipt_show_rfc', + 'receipt_show_address', + 'receipt_show_phone', + 'receipt_show_iva_breakdown', + 'receipt_show_payment_details', + 'receipt_show_employee', +] + + +@config_bp.route('/receipt', methods=['GET']) +@require_auth('pos.view') +def get_receipt_config(): + """Get receipt customization settings.""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute( + "SELECT key, value FROM tenant_config WHERE key = ANY(%s)", + (RECEIPT_CONFIG_KEYS,) + ) + rows = {row[0]: row[1] for row in cur.fetchall()} + cur.close() + conn.close() + + def _bool(key, default=False): + v = rows.get(key, 'true' if default else 'false') + return str(v).lower() == 'true' + + return jsonify({ + 'logo': rows.get('receipt_logo', ''), + 'store_name': rows.get('receipt_store_name', ''), + 'tagline': rows.get('receipt_tagline', ''), + 'rfc': rows.get('receipt_rfc', ''), + 'address': rows.get('receipt_address', ''), + 'phone': rows.get('receipt_phone', ''), + 'footer': rows.get('receipt_footer', ''), + 'thanks_message': rows.get('receipt_thanks_message', 'Gracias por su compra!'), + 'show_logo': _bool('receipt_show_logo', True), + 'show_rfc': _bool('receipt_show_rfc', True), + 'show_address': _bool('receipt_show_address', False), + 'show_phone': _bool('receipt_show_phone', False), + 'show_iva_breakdown': _bool('receipt_show_iva_breakdown', True), + 'show_payment_details': _bool('receipt_show_payment_details', True), + 'show_employee': _bool('receipt_show_employee', False), + }) + + +@config_bp.route('/receipt', methods=['PUT']) +@require_auth('config.edit') +def update_receipt_config(): + """Update receipt customization settings.""" + data = request.get_json() or {} + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + settings = { + 'receipt_logo': data.get('logo', ''), + 'receipt_store_name': data.get('store_name', ''), + 'receipt_tagline': data.get('tagline', ''), + 'receipt_rfc': data.get('rfc', ''), + 'receipt_address': data.get('address', ''), + 'receipt_phone': data.get('phone', ''), + 'receipt_footer': data.get('footer', ''), + 'receipt_thanks_message': data.get('thanks_message', 'Gracias por su compra!'), + 'receipt_show_logo': 'true' if data.get('show_logo') else 'false', + 'receipt_show_rfc': 'true' if data.get('show_rfc') else 'false', + 'receipt_show_address': 'true' if data.get('show_address') else 'false', + 'receipt_show_phone': 'true' if data.get('show_phone') else 'false', + 'receipt_show_iva_breakdown': 'true' if data.get('show_iva_breakdown') else 'false', + 'receipt_show_payment_details': 'true' if data.get('show_payment_details') else 'false', + 'receipt_show_employee': 'true' if data.get('show_employee') else 'false', + } + + for key, value in settings.items(): + cur.execute(""" + INSERT INTO tenant_config (key, value) VALUES (%s, %s) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value + """, (key, value)) + + conn.commit() + cur.close() + conn.close() + return jsonify({'message': 'Receipt configuration updated'}) diff --git a/pos/static/js/config.js b/pos/static/js/config.js index af95ea6..86a82ab 100644 --- a/pos/static/js/config.js +++ b/pos/static/js/config.js @@ -443,6 +443,133 @@ const Config = (() => { } } + // ------------------------------------------------------------------------- + // Receipt / ticket customization + // ------------------------------------------------------------------------- + let _receiptLogo = ''; + + async function loadReceiptConfig() { + try { + var res = await fetch(API + '/receipt', { headers: headers() }); + if (!res.ok) return; + var d = await res.json(); + _receiptLogo = d.logo || ''; + setVal('receipt-store-name', d.store_name); + setVal('receipt-tagline', d.tagline); + setVal('receipt-rfc', d.rfc); + setVal('receipt-address', d.address); + setVal('receipt-phone', d.phone); + setVal('receipt-thanks', d.thanks_message); + setVal('receipt-footer', d.footer); + setChecked('receipt-show-logo', d.show_logo); + setChecked('receipt-show-rfc', d.show_rfc); + setChecked('receipt-show-address', d.show_address); + setChecked('receipt-show-phone', d.show_phone); + setChecked('receipt-show-iva', d.show_iva_breakdown); + setChecked('receipt-show-payment', d.show_payment_details); + setChecked('receipt-show-employee', d.show_employee); + renderReceiptLogoThumb(); + } catch (e) { + console.error('Config.loadReceiptConfig:', e); + } + } + + function setChecked(id, v) { + var el = document.getElementById(id); + if (el) el.checked = !!v; + } + + function getChecked(id) { + var el = document.getElementById(id); + return el ? el.checked : false; + } + + function renderReceiptLogoThumb() { + var thumb = document.getElementById('receipt-logo-thumb'); + var removeBtn = document.getElementById('receipt-logo-remove'); + if (!thumb) return; + if (_receiptLogo) { + thumb.innerHTML = 'Logo ticket'; + if (removeBtn) removeBtn.style.display = ''; + } else { + thumb.innerHTML = 'Sin logo'; + if (removeBtn) removeBtn.style.display = 'none'; + } + } + + function handleReceiptLogo(input) { + var file = input && input.files ? input.files[0] : null; + if (!file) return; + if (!file.type.match(/^image\/(png|jpeg|jpg|webp)$/)) { + toast('Solo se permiten imágenes PNG, JPG o WebP', 'error'); + input.value = ''; + return; + } + var reader = new FileReader(); + reader.onload = function(e) { + var img = new Image(); + img.onload = function() { + var maxWidth = 300; + var scale = Math.min(1, maxWidth / img.width); + var w = Math.round(img.width * scale); + var h = Math.round(img.height * scale); + var canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + var ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, w, h); + ctx.drawImage(img, 0, 0, w, h); + _receiptLogo = canvas.toDataURL('image/jpeg', 0.85); + renderReceiptLogoThumb(); + toast('Logo cargado. Guarda los cambios para aplicarlo.', 'ok'); + }; + img.src = e.target.result; + }; + reader.readAsDataURL(file); + input.value = ''; + } + + function removeReceiptLogo() { + _receiptLogo = ''; + renderReceiptLogoThumb(); + } + + async function saveReceiptConfig() { + if (!checkAuth()) return; + var data = { + logo: _receiptLogo, + store_name: getVal('receipt-store-name'), + tagline: getVal('receipt-tagline'), + rfc: getVal('receipt-rfc'), + address: getVal('receipt-address'), + phone: getVal('receipt-phone'), + thanks_message: getVal('receipt-thanks'), + footer: getVal('receipt-footer'), + show_logo: getChecked('receipt-show-logo'), + show_rfc: getChecked('receipt-show-rfc'), + show_address: getChecked('receipt-show-address'), + show_phone: getChecked('receipt-show-phone'), + show_iva_breakdown: getChecked('receipt-show-iva'), + show_payment_details: getChecked('receipt-show-payment'), + show_employee: getChecked('receipt-show-employee'), + }; + try { + var res = await fetch(API + '/receipt', { + method: 'PUT', + headers: headers(), + body: JSON.stringify(data), + }); + if (!res.ok) { + var err = await res.json().catch(function() { return { error: res.statusText }; }); + throw new Error(err.error || 'Error al guardar'); + } + toast('Configuración de ticket guardada', 'ok'); + } catch (e) { + toast(e.message, 'error'); + } + } + async function saveAll() { if (!checkAuth()) return; var btn = document.getElementById('btn-save-all'); @@ -454,6 +581,7 @@ const Config = (() => { await saveVehicleCompatSource(); await saveAllowedBrands(); await saveModules(); + await saveReceiptConfig(); toast('Configuración guardada', 'ok'); } catch (e) { toast(e.message, 'error'); @@ -881,6 +1009,7 @@ const Config = (() => { loadVehicleCompatSource(); loadAllowedBrands(); loadModules(); + loadReceiptConfig(); } document.addEventListener('DOMContentLoaded', init); @@ -900,6 +1029,7 @@ const Config = (() => { loadCurrency, saveCurrency, loadVehicleCompatSource, saveVehicleCompatSource, loadModules, saveModules, + loadReceiptConfig, saveReceiptConfig, handleReceiptLogo, removeReceiptLogo, openModal, closeModal, openBranchModal, editBranch }; diff --git a/pos/static/js/pos.js b/pos/static/js/pos.js index bcdde1e..f3607d4 100644 --- a/pos/static/js/pos.js +++ b/pos/static/js/pos.js @@ -30,6 +30,7 @@ const POS = (() => { let canEditPrice = false; let canCreateWorkshopOrder = false; let canCreateLayaway = false; + let receiptConfig = {}; // Currency-aware formatter: reads pos_currency from localStorage const _posCurrency = localStorage.getItem('pos_currency') || 'MXN'; @@ -134,8 +135,9 @@ const POS = (() => { showToast(`Modo conversion: Cotizacion #${convertQuoteId}. El pago convertira la cotizacion en venta.`); } - // Load current register + // Load current register and receipt config await loadRegister(); + await loadReceiptConfig(); // Setup event listeners setupKeyboard(); @@ -163,6 +165,20 @@ const POS = (() => { } } + async function loadReceiptConfig() { + try { + receiptConfig = await api('/pos/api/config/receipt'); + } catch (e) { + console.warn('Could not load receipt config:', e); + receiptConfig = {}; + } + } + + function rc(key, fallback) { + const v = receiptConfig[key]; + return v !== undefined && v !== null && v !== '' ? v : fallback; + } + function showOpenRegisterModal() { document.getElementById('openRegisterModal').classList.add('open'); document.getElementById('registerOpenResult').innerHTML = ''; @@ -1283,12 +1299,37 @@ const POS = (() => { `; }); + const cfg = receiptConfig || {}; + const showLogo = cfg.show_logo && cfg.logo; + const showRfc = cfg.show_rfc !== false; + const showAddress = cfg.show_address; + const showPhone = cfg.show_phone; + const showIva = cfg.show_iva_breakdown !== false; + const showPayment = cfg.show_payment_details !== false; + const showEmployee = cfg.show_employee; + const storeName = rc('store_name', 'NEXUS AUTOPARTS'); + const storeTagline = rc('tagline', 'Tu conexion con las refacciones'); + const storeRfc = rc('rfc', 'NAU210315XX1'); + const storeAddress = rc('address', ''); + const storePhone = rc('phone', ''); + const thanksMsg = rc('thanks_message', 'Gracias por su compra!'); + const footerMsg = rc('footer', 'Conserve su ticket como comprobante.'); + + let storeInfoLines = []; + if (currentRegister && currentRegister.branch_name) storeInfoLines.push(`Sucursal: ${currentRegister.branch_name}`); + if (showEmployee && sale.employee_name) storeInfoLines.push(`Atendió: ${sale.employee_name}`); + if (showRfc && storeRfc) storeInfoLines.push(`RFC: ${storeRfc}`); + if (showAddress && storeAddress) storeInfoLines.push(storeAddress); + if (showPhone && storePhone) storeInfoLines.push(`Tel: ${storePhone}`); + + const logoHtml = showLogo ? `
` : ''; + const ticketHtml = ` -
NEXUS AUTOPARTS
-
Tu conexion con las refacciones
+ ${logoHtml} +
${storeName}
+ ${storeTagline ? `
${storeTagline}
` : ''}
- Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}
- RFC: NAU210315XX1 + ${storeInfoLines.join('
')}

@@ -1314,13 +1355,12 @@ const POS = (() => { Subtotal:${fmt(sale.subtotal)}
${sale.discount_total > 0 ? `
Descuento:-${fmt(sale.discount_total)}
` : ''} -
- IVA 16%:${fmt(sale.tax_total)} -
+ ${showIva ? `
IVA:${fmt(sale.tax_total)}
` : ''}
TOTAL:${fmt(sale.total)}
+ ${showPayment ? `
@@ -1333,11 +1373,11 @@ const POS = (() => {
Cambio:${fmt(sale.change_given || 0)}
` : ''} -
+
` : ''}
`; diff --git a/pos/templates/config.html b/pos/templates/config.html index 920c5c0..9b63066 100644 --- a/pos/templates/config.html +++ b/pos/templates/config.html @@ -255,7 +255,79 @@ +
+
+
+ +
+
+
Personalización de Ticket
+
Logo, datos y opciones que aparecen en el ticket de venta
+
+
+ +
+
+
+ + +
+
+ Sin logo +
+ + +
+
Recomendado: PNG/JPG con fondo blanco o transparente, máx. 300x150 px.
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + + + +
+
+ +
+
+
+ +