diff --git a/public/pos/epos-print.js b/public/pos/epos-print.js index 27b82d7..59662ff 100644 --- a/public/pos/epos-print.js +++ b/public/pos/epos-print.js @@ -72,52 +72,87 @@ window.EposPrint = (function () { return String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); } - // Mirrors buildEscPos() in receipt.html — same fields, same discount - // math — but emits ePOS-Print XML tags instead of raw ESC/POS bytes. - function buildEposXml(s, logo) { - const parts = []; - if (logo) parts.push(`${logo.base64}`); - parts.push(''); - parts.push('WFSALES POS '); - if (s.letterhead && s.letterhead.name) { - parts.push(`${xmlEscape(s.letterhead.name)} `); - } - const addrLines = []; - if (s.letterhead && s.letterhead.address) addrLines.push(xmlEscape(s.letterhead.address.split('\n')[0])); - if (s.letterhead && s.letterhead.phone) addrLines.push('Tel: ' + xmlEscape(s.letterhead.phone)); - if (s.letterhead && s.letterhead.vat_number) addrLines.push('VAT: ' + xmlEscape(s.letterhead.vat_number)); - if (addrLines.length) parts.push(`${addrLines.join(' ')} `); - parts.push('-------------------------------- '); - const d = new Date(s.createdAt); - parts.push(`Date: ${xmlEscape(d.toLocaleString('en-ZA'))} Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())} `); - if (s.customer) parts.push(`Customer: ${xmlEscape(s.customer.name)} `); - parts.push('-------------------------------- '); - const itemLines = s.items.map(it => { - const priceLine = ` ${it.qty} x ${(it.selling_price || 0).toFixed(2)} ${(it.selling_price * it.qty).toFixed(2)}`; - return `${xmlEscape(it.description || it.sku)} ${xmlEscape(priceLine)} `; - }).join(''); - parts.push(`${itemLines}`); - parts.push('-------------------------------- '); + // Per-block XML fragment renderers for the sale slip. Each function + // returns the exact XML this block contributes; `block` is the + // resolved block ({id, enabled, text}) so header_title/footer can + // read their custom text. Dividers are owned by whichever block + // came after them in the original fixed sequence — reordering a + // block takes its leading divider with it. + function saleBlockRenderers(s, logo) { const sub = s.sub != null ? s.sub : (s.total - (s.vat || 0)); const vat = s.vat != null ? s.vat : (s.total * 15 / 115); const discount = s.discount || { kind: 'none', input: 0, amount: 0 }; const hasDiscount = discount.kind !== 'none' && Number(discount.amount) > 0; - if (hasDiscount) { - const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price) || 0) * (Number(it.qty) || 0), 0); - const grossSub = gross / 1.15; - const grossVat = gross - grossSub; - parts.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)} VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)} Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)} Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)} `); - } - parts.push(`Subtotal${' '.repeat(22)}R ${sub.toFixed(2)} VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)} `); - parts.push(`TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)} `); - const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase(); - parts.push(`Paid: ${xmlEscape(paidLabel)} `); - if (s.payment === 'cash') parts.push(`Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)} `); - parts.push('-------------------------------- '); - if (s.letterhead && s.letterhead.footer_text) parts.push(`${xmlEscape(s.letterhead.footer_text)} `); - parts.push(''); - parts.push(''); - return `${parts.join('')}`; + return { + header_title: function (block) { + const title = block.text || 'WFSALES POS'; + return '' + xmlEscape(title) + ' '; + }, + logo: function () { + return logo ? `${logo.base64}` : ''; + }, + business_info: function () { + const parts = []; + if (s.letterhead && s.letterhead.name) parts.push(`${xmlEscape(s.letterhead.name)} `); + const addrLines = []; + if (s.letterhead && s.letterhead.address) addrLines.push(xmlEscape(s.letterhead.address.split('\n')[0])); + if (s.letterhead && s.letterhead.phone) addrLines.push('Tel: ' + xmlEscape(s.letterhead.phone)); + if (s.letterhead && s.letterhead.vat_number) addrLines.push('VAT: ' + xmlEscape(s.letterhead.vat_number)); + if (addrLines.length) parts.push(`${addrLines.join(' ')} `); + return parts.join(''); + }, + date_slip: function () { + const d = new Date(s.createdAt); + return '-------------------------------- ' + + `Date: ${xmlEscape(d.toLocaleString('en-ZA'))} Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())} `; + }, + customer: function () { + return s.customer ? `Customer: ${xmlEscape(s.customer.name)} ` : ''; + }, + items: function () { + const itemLines = s.items.map(function (it) { + const priceLine = ` ${it.qty} x ${(it.selling_price || 0).toFixed(2)} ${(it.selling_price * it.qty).toFixed(2)}`; + return `${xmlEscape(it.description || it.sku)} ${xmlEscape(priceLine)} `; + }).join(''); + return `-------------------------------- ${itemLines}`; + }, + totals: function () { + const parts = ['-------------------------------- ']; + if (hasDiscount) { + const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price) || 0) * (Number(it.qty) || 0), 0); + const grossSub = gross / 1.15; + const grossVat = gross - grossSub; + parts.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)} VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)} Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)} Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)} `); + } + parts.push(`Subtotal${' '.repeat(22)}R ${sub.toFixed(2)} VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)} `); + parts.push(`TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)} `); + return parts.join(''); + }, + payment: function () { + const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase(); + let out = `Paid: ${xmlEscape(paidLabel)} `; + if (s.payment === 'cash') out += `Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)} `; + return out; + }, + footer: function (block) { + const text = block.text || (s.letterhead && s.letterhead.footer_text); + let out = '-------------------------------- '; + if (text) out += `${xmlEscape(text)} `; + return out; + }, + }; + } + + // `blocks` is the resolved block list from PosSlipLayout.resolveBlocks('sale', ...) + // (or getBlocksForPrint). Renders each enabled block in order, then + // the fixed closing feed+cut every sale slip ends with. + function buildEposXml(s, logo, blocks) { + const renderers = saleBlockRenderers(s, logo); + const body = blocks + .filter(function (b) { return b.enabled; }) + .map(function (b) { return renderers[b.id] ? renderers[b.id](b) : ''; }) + .join(''); + return `${body}`; } async function printViaNetwork(ip, xml) { @@ -284,7 +319,8 @@ window.EposPrint = (function () { const logo = lh.logo_url ? await imageToRaster(lh.logo_url, 384).catch(() => null) : null; - const xml = buildEposXml(sale, logo); + const blocks = window.PosSlipLayout.getBlocksForPrint(lh.id, 'sale'); + const xml = buildEposXml(sale, logo, blocks); if (settings.type === 'network') { if (!settings.ip) { throw new Error('Set the printer IP address in Printer settings first.'); diff --git a/public/pos/receipt.html b/public/pos/receipt.html index 410faaf..ba974b4 100644 --- a/public/pos/receipt.html +++ b/public/pos/receipt.html @@ -541,7 +541,9 @@ async function printThermalNetwork(settings){ successLabel = 'Delivery note printed · '; } else { const logo = await window.EposPrint.imageToRaster('/wfdash-logo.png', 384).catch(()=>null); - xml = window.EposPrint.buildEposXml(sale, logo); + const letterheadId = sale.letterhead && sale.letterhead.id; + const blocks = window.PosSlipLayout.getBlocksForPrint(letterheadId, 'sale'); + xml = window.EposPrint.buildEposXml(sale, logo, blocks); successLabel = 'Printed · '; } await window.EposPrint.printViaNetwork(settings.ip, xml);