Logo — three separate faults, all of which had to be fixed for the letterhead logo to print at all: 1. receipt.html hardcoded '/wfdash-logo.png' for the sale slip, so it printed the app's bundled mark (the solid black W block) and never looked at the letterhead at all. 2. The paths that DID use the letterhead (printTest, cashup) passed PocketBase's fully-qualified cross-origin URL into imageToRaster(), which draws to a <canvas> — getImageData() taints and throws on a cross-origin image, so the .catch(() => null) silently dropped the logo. pos-letterhead-service now returns a same-origin /api/v1/files/... URL (per AGENTS.md's file-URL rule). 3. letterhead_settings wasn't whitelisted in that file proxy, so the same-origin URL would have 403'd. Verified against the live WESTSCOTT letterhead: its SVG rasterizes to 384x137 at ~15% ink coverage (line art), no tainted canvas. Also fixes the company name never printing: every builder read `letterhead.name`, but the POS letterhead record exposes `display_name` — so the name line silently rendered empty on every slip, on screen and on paper. Delivery note now matches the sale slip, per request: gains the logo and business_info blocks and the same 2x-size title, instead of being a stripped-down variant. This also reconciles the transport divergence flagged earlier (Bluetooth baked business info into its header; the network builder omitted it entirely) — it is now one reorderable business_info block on both. resolveBlocks() now slots a newly-shipped block in at its intended position rather than appending it to the end. Without this, any letterhead with an already-saved layout would have printed the new logo/business_info blocks BELOW the signature line.
202 lines
7.7 KiB
JavaScript
202 lines
7.7 KiB
JavaScript
// 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: 'logo', enabled: true },
|
|
{ id: 'header_title', enabled: true, text: null },
|
|
{ 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: 'logo', enabled: true },
|
|
{ id: 'header_title', enabled: true, text: null },
|
|
{ id: 'business_info', enabled: true },
|
|
{ id: 'customer', enabled: true },
|
|
{ id: 'date', enabled: true },
|
|
{ id: 'items', enabled: true },
|
|
{ id: 'totals', enabled: true },
|
|
{ id: 'signature', enabled: true },
|
|
],
|
|
cashup: [
|
|
{ id: 'logo', enabled: true },
|
|
{ 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 normalize = function (b) {
|
|
return {
|
|
id: b.id,
|
|
enabled: isRequired(slipType, b.id) ? true : !!b.enabled,
|
|
text: b.text != null ? b.text : null,
|
|
};
|
|
};
|
|
if (!Array.isArray(saved) || !saved.length) {
|
|
return defaults.map(function (b) { return normalize(b); });
|
|
}
|
|
// Start from the saved order, dropping ids we no longer ship.
|
|
const result = saved
|
|
.filter(function (b) { return defaults.some(function (d) { return d.id === b.id; }); })
|
|
.map(function (b) { return Object.assign({}, b); });
|
|
const present = {};
|
|
result.forEach(function (b) { present[b.id] = true; });
|
|
// A block shipped after this letterhead last saved its layout has
|
|
// to be slotted in at its intended spot — appending to the end
|
|
// would drop e.g. a newly added logo/business_info block below the
|
|
// signature line. Anchor each missing block just after the nearest
|
|
// preceding default that IS present, so the operator's own
|
|
// reordering is preserved.
|
|
defaults.forEach(function (def, defIdx) {
|
|
if (present[def.id]) return;
|
|
let insertAt = 0;
|
|
for (let i = defIdx - 1; i >= 0; i--) {
|
|
const pos = result.findIndex(function (b) { return b.id === defaults[i].id; });
|
|
if (pos !== -1) { insertAt = pos + 1; break; }
|
|
}
|
|
result.splice(insertAt, 0, Object.assign({}, def));
|
|
present[def.id] = true;
|
|
});
|
|
return result.map(function (b) { return normalize(b); });
|
|
}
|
|
|
|
// -------- 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,
|
|
};
|
|
})();
|