From 8e879d144735d4fdf505c0971a99ec680e4f88bb Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 4 Sep 2026 13:24:40 +0200 Subject: [PATCH] feat(pos): add shared slip-layout block catalog and resolver --- public/pos/slip-layout.js | 181 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 public/pos/slip-layout.js diff --git a/public/pos/slip-layout.js b/public/pos/slip-layout.js new file mode 100644 index 0000000..89a0037 --- /dev/null +++ b/public/pos/slip-layout.js @@ -0,0 +1,181 @@ +// Per-letterhead slip layout: which blocks print, in what order, +// enabled or not, with what custom text. Single source of truth for +// the editor (slip-layout.html), its live preview, and both print +// transports (epos-print.js's XML builders, receipt.html's ESC/POS +// builders) — all three must resolve the same way or the editor +// would silently not apply to whichever transport a till is using. +window.PosSlipLayout = (function () { + 'use strict'; + + // Blocks that can be reordered but never disabled — a slip missing + // these stops being a valid record of the transaction (e.g. a + // receipt with no total). + const REQUIRED = { + sale: ['items', 'totals'], + delivery: ['items', 'totals'], + cashup: ['payment_breakdown', 'grand_total'], + }; + + const LABELS = { + header_title: 'Header title', + logo: 'Logo', + business_info: 'Business info', + date_slip: 'Date & slip number', + customer: 'Customer', + items: 'Item list', + totals: 'Totals & VAT', + payment: 'Payment & change', + footer: 'Footer message', + date: 'Date', + signature: 'Signature line', + shift_date: 'Shift & date', + payment_breakdown: 'Payment breakdown', + grand_total: 'Grand total', + cash_reconciliation: 'Cash reconciliation', + denominations: 'Denominations counted', + }; + + // Matches what each builder actually prints today (verified by + // reading buildEposXml/buildEposDeliveryXml/buildEposCashupXml and + // their Bluetooth ESC/POS equivalents) — this formalizes existing + // content into blocks, it doesn't add anything new. Notably, + // delivery notes don't print business info today (only a title), + // and cash-up's title line becomes its own header_title block for + // consistency with the other two slip types. + const DEFAULTS = { + sale: [ + { id: 'header_title', enabled: true, text: null }, + { id: 'logo', enabled: true }, + { id: 'business_info', enabled: true }, + { id: 'date_slip', enabled: true }, + { id: 'customer', enabled: true }, + { id: 'items', enabled: true }, + { id: 'totals', enabled: true }, + { id: 'payment', enabled: true }, + { id: 'footer', enabled: true, text: null }, + ], + delivery: [ + { id: 'header_title', enabled: true, text: null }, + { id: 'customer', enabled: true }, + { id: 'date', enabled: true }, + { id: 'items', enabled: true }, + { id: 'totals', enabled: true }, + { id: 'signature', enabled: true }, + ], + cashup: [ + { id: 'header_title', enabled: true, text: null }, + { id: 'business_info', enabled: true }, + { id: 'shift_date', enabled: true }, + { id: 'payment_breakdown', enabled: true }, + { id: 'grand_total', enabled: true }, + { id: 'cash_reconciliation', enabled: true }, + { id: 'denominations', enabled: true }, + { id: 'footer', enabled: true, text: null }, + ], + }; + + const DEFAULT_SHOW_PRICING = true; + const DEFAULT_HEADER_TITLE = 'WFSALES POS'; + + function isRequired(slipType, blockId) { + return (REQUIRED[slipType] || []).includes(blockId); + } + + function labelFor(blockId) { + return LABELS[blockId] || blockId; + } + + // Merge saved blocks with the default catalog: saved order/enabled/ + // text wins. Any default block missing from `saved` (first time + // this slip type has been customized, or a new block id shipped + // after the letterhead's layout was last saved) is appended so + // nothing silently disappears. Unknown/retired ids in `saved` are + // dropped. Required blocks are forced enabled regardless of what + // was saved, defensively. + function resolveBlocks(slipType, saved) { + const defaults = DEFAULTS[slipType] || []; + const base = Array.isArray(saved) && saved.length ? saved.slice() : defaults.map(function (b) { return Object.assign({}, b); }); + const seen = {}; + base.forEach(function (b) { seen[b.id] = true; }); + defaults.forEach(function (def) { + if (!seen[def.id]) base.push(Object.assign({}, def)); + }); + return base + .filter(function (b) { return defaults.some(function (d) { return d.id === b.id; }); }) + .map(function (b) { + return { + id: b.id, + enabled: isRequired(slipType, b.id) ? true : !!b.enabled, + text: b.text != null ? b.text : null, + }; + }); + } + + // -------- cache (till-local, mirrors letterhead-config.js) -------- + + function cacheKey(letterheadId) { return 'pos.slipLayout.' + letterheadId; } + + function getCached(letterheadId) { + try { return JSON.parse(localStorage.getItem(cacheKey(letterheadId)) || 'null'); } catch (e) { return null; } + } + + function setCached(letterheadId, layout) { + try { localStorage.setItem(cacheKey(letterheadId), JSON.stringify(layout)); } catch (e) { /* quota or disabled */ } + } + + // -------- network -------- + + async function fetchLayout(letterheadId) { + const r = await window.posFetch('/slip-layout/' + encodeURIComponent(letterheadId)); + if (!r.ok) throw new Error('slip-layout fetch returned ' + r.status); + const j = await r.json(); + if (j.layout) setCached(letterheadId, j.layout); + return j.layout; // null when nothing saved yet + } + + async function saveLayout(letterheadId, input) { + const r = await window.posFetch('/slip-layout/' + encodeURIComponent(letterheadId), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (!r.ok) throw new Error('slip-layout save returned ' + r.status); + const j = await r.json(); + if (j.layout) setCached(letterheadId, j.layout); + return j.layout; + } + + // -------- print-time helpers -------- + // Printing must never block on a settings-sync failure: use the + // cached layout if we have one (refreshing it in the background for + // next time), else the hardcoded defaults. + + function getBlocksForPrint(letterheadId, slipType) { + const cached = letterheadId ? getCached(letterheadId) : null; + if (letterheadId) { + fetchLayout(letterheadId).catch(function () { /* keep cached/defaults on failure */ }); + } + const saved = cached ? cached[slipType + '_blocks'] : null; + return resolveBlocks(slipType, saved); + } + + function getShowPricingForPrint(letterheadId) { + const cached = letterheadId ? getCached(letterheadId) : null; + return cached && cached.delivery_show_pricing != null ? !!cached.delivery_show_pricing : DEFAULT_SHOW_PRICING; + } + + return { + DEFAULTS: DEFAULTS, + DEFAULT_SHOW_PRICING: DEFAULT_SHOW_PRICING, + DEFAULT_HEADER_TITLE: DEFAULT_HEADER_TITLE, + isRequired: isRequired, + labelFor: labelFor, + resolveBlocks: resolveBlocks, + getCached: getCached, + setCached: setCached, + fetchLayout: fetchLayout, + saveLayout: saveLayout, + getBlocksForPrint: getBlocksForPrint, + getShowPricingForPrint: getShowPricingForPrint, + }; +})();