WFDASH-WEBAPP/public/pos/slip-layout.js
Dave 6bedf27617 fix(pos): print the letterhead's own logo, and match delivery note to the sale slip
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.
2026-09-06 23:03:24 +02:00

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,
};
})();