feat(config): agrega personalización de ticket con logo y opciones
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

- Nuevos endpoints /pos/api/config/receipt (GET/PUT)
- Sección en config.html para subir logo, datos del negocio y toggles visuales
- pos.js carga la config y renderiza el ticket usando logo, RFC, dirección,
  teléfono, mensajes y opciones de desglose/empleado/pago configurables
- Logo se redimensiona a 300px de ancho y se guarda como base64 JPEG en tenant_config

Tests: 35 passed
This commit is contained in:
2026-06-30 08:35:10 +00:00
parent 70a600cc66
commit c1e481fb79
4 changed files with 350 additions and 12 deletions

View File

@@ -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 = '<img src="' + escapeHtml(_receiptLogo) + '" style="max-width:100%;max-height:100%;object-fit:contain;" alt="Logo ticket">';
if (removeBtn) removeBtn.style.display = '';
} else {
thumb.innerHTML = '<span style="color:var(--color-text-muted);font-size:var(--text-caption);text-align:center;padding:var(--space-2);">Sin logo</span>';
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
};

View File

@@ -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 = (() => {
</div>`;
});
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 ? `<div class="ticket-logo-wrap"><img src="${cfg.logo}" alt="Logo" class="ticket-logo" style="max-width:140px;max-height:70px;object-fit:contain;"></div>` : '';
const ticketHtml = `
<div class="store-name">NEXUS AUTOPARTS</div>
<div class="store-tagline">Tu conexion con las refacciones</div>
${logoHtml}
<div class="store-name">${storeName}</div>
${storeTagline ? `<div class="store-tagline">${storeTagline}</div>` : ''}
<div class="store-info">
Sucursal: ${currentRegister ? currentRegister.branch_name || '' : ''}<br>
RFC: NAU210315XX1
${storeInfoLines.join('<br>')}
</div>
<hr class="divider-double">
<div class="folio-line">
@@ -1314,13 +1355,12 @@ const POS = (() => {
<span>Subtotal:</span><span>${fmt(sale.subtotal)}</span>
</div>
${sale.discount_total > 0 ? `<div class="total-line"><span>Descuento:</span><span>-${fmt(sale.discount_total)}</span></div>` : ''}
<div class="total-line">
<span>IVA 16%:</span><span>${fmt(sale.tax_total)}</span>
</div>
${showIva ? `<div class="total-line"><span>IVA:</span><span>${fmt(sale.tax_total)}</span></div>` : ''}
<div class="total-line grand">
<span>TOTAL:</span><span>${fmt(sale.total)}</span>
</div>
</div>
${showPayment ? `
<hr class="divider">
<div class="payment-section">
<div class="ticket-row">
@@ -1333,11 +1373,11 @@ const POS = (() => {
<div class="ticket-row" style="font-weight: bold;">
<span>Cambio:</span><span>${fmt(sale.change_given || 0)}</span>
</div>` : ''}
</div>
</div>` : ''}
<hr class="divider">
<div class="footer-section">
<div class="thanks">Gracias por su compra!</div>
<div>Conserve su ticket como comprobante.</div>
<div class="thanks">${thanksMsg}</div>
${footerMsg ? `<div>${footerMsg}</div>` : ''}
</div>
`;