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);