WFDASH-WEBAPP/public/pos/slip-layout.html
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

383 lines
20 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#ff385c">
<title>Slip layout — WFSales POS</title>
<link rel="manifest" href="manifest.json">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%23ff385c'/><text x='16' y='22' font-family='-apple-system,sans-serif' font-size='17' font-weight='700' text-anchor='middle' fill='white'>W</text></svg>">
<style>
:root{
--bg:#ffffff;--surface:#fff7f8;--fg:#222222;--muted:#dddddd;--border:#ffe3e8;--accent:#ff385c;
--ok:#0f8a3f;--warn:#b14a00;--danger:#c92a2a;
--shadow:0 1px 2px rgba(34,34,34,.04), 0 4px 12px rgba(34,34,34,.06);
--font:'Inter',system-ui,-apple-system,Segoe UI,Helvetica Neue,Arial,sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
html,body{height:100%;background:var(--bg);color:var(--fg);font:16px/1.5 var(--font);-webkit-font-smoothing:antialiased}
body{display:flex;flex-direction:column;min-height:100dvh;max-width:480px;margin:0 auto;padding:0 20px 32px;background:var(--bg)}
.topbar{display:flex;align-items:center;gap:10px;padding:14px 0 10px;background:var(--bg);position:sticky;top:0;z-index:10}
.topbar .back{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);background:var(--surface);border:1px solid var(--border);flex:0 0 auto}
.topbar h1{font-size:17px;font-weight:600;letter-spacing:-.01em;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lede{color:#717171;font-size:14px;line-height:1.55;margin:6px 0 16px;max-width:38ch}
.section-label{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#717171;margin:24px 4px 10px}
.banner{padding:12px 14px;border-radius:12px;font-size:13px;display:flex;align-items:flex-start;gap:10px;line-height:1.4}
.banner.warn{background:#fdf2e6;border:1px solid #f5d9b3;color:#8a4a00}
.banner.err{background:#fff0f0;border:1px solid #f5c2c7;color:#9b1c1c}
.banner .t{flex:1;min-width:0}
.skeleton{padding:24px 16px;text-align:center;color:#999;font-size:13px}
.actions{padding:0;display:flex;flex-direction:column;gap:10px;margin-top:24px}
.btn{display:flex;align-items:center;justify-content:center;gap:8px;min-height:54px;padding:0 18px;border-radius:12px;font-size:15px;font-weight:600;letter-spacing:-.005em;border:1.5px solid var(--muted);background:#fff;color:var(--fg)}
.btn:active{background:var(--surface)}
.btn.primary{background:var(--accent);color:#fff;border-color:var(--accent)}
.btn:disabled{opacity:.5;pointer-events:none}
.preview-tabs{display:flex;gap:6px;padding:4px 4px 10px}
.preview-tab{flex:1;padding:8px 10px;border:1px solid var(--muted);border-radius:10px;background:#fff;font-size:13px;font-weight:600;color:var(--fg)}
.preview-tab.active{background:var(--fg);color:var(--bg);border-color:var(--fg)}
.receipt-preview{font-family:monospace;font-size:10px;line-height:1.35;background:#fdfdfa;color:#1a1a1a;border:1px solid #999;border-radius:4px;padding:10px 10px;width:260px;max-width:100%;margin:0 auto 4px;box-shadow:0 2px 6px rgba(0,0,0,.08);white-space:pre;overflow-x:auto;overflow-wrap:normal;word-break:keep-all;position:relative;flex-shrink:0}
/* Each preview line is its own <span> with no separator between
them in the generated HTML — without this, spans are inline and
the whole preview collapses onto a single squished line. */
.receipt-preview span{display:block}
.receipt-preview .center{text-align:center;display:block}
.receipt-preview .em{font-weight:700;color:#000}
/* ---- Block list ---- */
.block-row{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--muted);border-radius:10px;background:#fff;margin-bottom:8px}
.block-row .name{flex:1;min-width:0;font-size:14px;font-weight:600}
.block-row .reorder{display:flex;flex-direction:column;gap:2px}
.block-row .reorder button{width:28px;height:22px;border:1px solid var(--muted);border-radius:6px;background:#fff;font-size:11px;color:var(--fg)}
.block-row .reorder button:disabled{opacity:.3}
.block-row .toggle{width:40px;height:24px;border-radius:999px;border:1.5px solid var(--muted);background:#eee;position:relative;flex:0 0 auto}
.block-row .toggle.on{background:var(--accent);border-color:var(--accent)}
.block-row .toggle .dot{position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;background:#fff;transition:left .12s}
.block-row .toggle.on .dot{left:18px}
.block-row .toggle.required{opacity:.6;pointer-events:none}
.block-row .edit-text{width:100%;margin-top:6px;padding:8px 10px;border-radius:8px;border:1px solid var(--muted);font:inherit}
.pricing-row{display:flex;align-items:center;justify-content:space-between;padding:12px;border:1px solid var(--muted);border-radius:10px;background:#fff;margin:4px 0 16px}
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){--bg:#0f0f0f;--surface:#1c1416;--fg:#f0f0f0;--muted:#2a2a2a;--border:#3a2429}
:root:not([data-theme="light"]) .block-row, :root:not([data-theme="light"]) .pricing-row, :root:not([data-theme="light"]) .btn, :root:not([data-theme="light"]) .preview-tab{background:#181818;border-color:var(--muted);color:var(--fg)}
}
:root[data-theme="dark"]{--bg:#0f0f0f;--surface:#1c1416;--fg:#f0f0f0;--muted:#2a2a2a;--border:#3a2429}
:root[data-theme="dark"] .block-row, :root[data-theme="dark"] .pricing-row, :root[data-theme="dark"] .btn, :root[data-theme="dark"] .preview-tab{background:#181818;border-color:var(--muted);color:var(--fg)}
</style>
</head>
<body>
<header class="topbar">
<button class="back" data-od-id="back-btn" onclick="location.href='settings.html'" aria-label="Back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 6l-6 6 6 6"/></svg>
</button>
<h1>Slip layout</h1>
</header>
<p class="lede">Choose what prints on each slip, in what order, for the active letterhead.</p>
<div id="banner" hidden></div>
<div class="preview-tabs" role="tablist">
<button class="preview-tab active" data-type="sale" onclick="switchTab('sale')" type="button">Sale slip</button>
<button class="preview-tab" data-type="delivery" onclick="switchTab('delivery')" type="button">Delivery note</button>
<button class="preview-tab" data-type="cashup" onclick="switchTab('cashup')" type="button">Cash-up</button>
</div>
<div class="receipt-preview" id="receiptPreview" aria-live="polite">
<div class="skeleton" style="padding:24px 8px">Loading preview…</div>
</div>
<div class="section-label">Blocks</div>
<div id="blockList"></div>
<div id="pricingSection" style="display:none">
<div class="section-label">Delivery note pricing</div>
<div class="pricing-row">
<span>Show pricing by default</span>
<button class="toggle" id="pricingToggle" onclick="togglePricing()"><span class="dot"></span></button>
</div>
</div>
<div class="actions">
<button class="btn primary" data-od-id="save-layout" onclick="saveCurrentLayout()">Save layout</button>
</div>
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="letterhead-config.js"></script>
<script src="slip-layout.js"></script>
<script>
'use strict';
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);}
function showBanner(kind, text){
const el = document.getElementById('banner');
if(!text){ el.hidden = true; el.innerHTML = ''; return; }
el.hidden = false;
el.innerHTML = '<div class="banner '+kind+'"><span class="t">'+esc(text)+'</span></div>';
}
let letterhead = null;
let currentTab = 'sale';
// Working copies — edited in place, only written to PosSlipLayout on Save.
let workingBlocks = { sale: [], delivery: [], cashup: [] };
let workingShowPricing = true;
function money(n){ return 'R ' + (Number(n) || 0).toFixed(2); }
async function boot(){
letterhead = await window.PosLetterhead.ensureActive();
if(!letterhead){
showBanner('warn', 'Pick a letterhead in Settings first.');
document.querySelector('[data-od-id="save-layout"]').disabled = true;
return;
}
let saved = null;
try{ saved = await window.PosSlipLayout.fetchLayout(letterhead.id); }
catch(e){ saved = window.PosSlipLayout.getCached(letterhead.id); }
workingBlocks.sale = window.PosSlipLayout.resolveBlocks('sale', saved && saved.sale_blocks);
workingBlocks.delivery = window.PosSlipLayout.resolveBlocks('delivery', saved && saved.delivery_blocks);
workingBlocks.cashup = window.PosSlipLayout.resolveBlocks('cashup', saved && saved.cashup_blocks);
workingShowPricing = saved && saved.delivery_show_pricing != null ? !!saved.delivery_show_pricing : window.PosSlipLayout.DEFAULT_SHOW_PRICING;
renderAll();
}
function switchTab(type){
currentTab = type;
document.querySelectorAll('.preview-tab').forEach(t => t.classList.toggle('active', t.dataset.type === type));
renderAll();
}
function renderAll(){
renderBlockList();
renderPricingSection();
renderPreview();
}
function renderBlockList(){
const list = workingBlocks[currentTab];
const wrap = document.getElementById('blockList');
wrap.innerHTML = list.map((b, i) => {
const required = window.PosSlipLayout.isRequired(currentTab, b.id);
const hasText = b.id === 'header_title' || b.id === 'footer';
const placeholder = b.id === 'header_title' ? window.PosSlipLayout.DEFAULT_HEADER_TITLE : (letterhead.footer_text || 'Thank you for your business.');
return '<div class="block-row" style="flex-wrap:wrap">'
+ '<div class="reorder"><button type="button" '+(i===0?'disabled':'')+' onclick="moveBlock('+i+',-1)">▲</button><button type="button" '+(i===list.length-1?'disabled':'')+' onclick="moveBlock('+i+',1)">▼</button></div>'
+ '<div class="name">'+esc(window.PosSlipLayout.labelFor(b.id))+(required?' <small style="font-weight:400;color:#999">(required)</small>':'')+'</div>'
+ '<button type="button" class="toggle '+(b.enabled?'on':'')+' '+(required?'required':'')+'" onclick="toggleBlock('+i+')"><span class="dot"></span></button>'
+ (hasText ? '<input class="edit-text" data-idx="'+i+'" placeholder="'+esc(placeholder)+'" value="'+esc(b.text||'')+'" oninput="editBlockText('+i+', this.value)">' : '')
+ '</div>';
}).join('');
}
function renderPricingSection(){
document.getElementById('pricingSection').style.display = currentTab === 'delivery' ? 'block' : 'none';
document.getElementById('pricingToggle').classList.toggle('on', workingShowPricing);
}
function moveBlock(i, dir){
const list = workingBlocks[currentTab];
const j = i + dir;
if(j < 0 || j >= list.length) return;
const tmp = list[i]; list[i] = list[j]; list[j] = tmp;
renderBlockList();
renderPreview();
}
function toggleBlock(i){
const block = workingBlocks[currentTab][i];
if(window.PosSlipLayout.isRequired(currentTab, block.id)) return;
block.enabled = !block.enabled;
renderBlockList();
renderPreview();
}
function editBlockText(i, value){
workingBlocks[currentTab][i].text = value || null;
renderPreview();
}
function togglePricing(){
workingShowPricing = !workingShowPricing;
renderPricingSection();
renderPreview();
}
async function saveCurrentLayout(){
const btn = document.querySelector('[data-od-id="save-layout"]');
btn.disabled = true;
showBanner(null);
try{
await window.PosSlipLayout.saveLayout(letterhead.id, {
sale_blocks: workingBlocks.sale,
delivery_blocks: workingBlocks.delivery,
cashup_blocks: workingBlocks.cashup,
delivery_show_pricing: workingShowPricing,
});
const orig = btn.textContent;
btn.textContent = '✓ Saved';
setTimeout(() => { btn.textContent = orig; }, 1500);
}catch(e){
showBanner('err', e.message || 'Save failed');
}finally{
btn.disabled = false;
}
}
// ---------------- Preview rendering (block-aware) ----------------
// Same sample data as the old settings.html preview; now walks the
// resolved+edited block list instead of a fixed sequence.
function renderPreview(){
const el = document.getElementById('receiptPreview');
const blocks = workingBlocks[currentTab].filter(b => b.enabled);
if(currentTab === 'sale') el.innerHTML = previewSale(blocks);
else if(currentTab === 'delivery') el.innerHTML = previewDelivery(blocks);
else el.innerHTML = previewCashup(blocks);
}
function previewSale(blocks){
const vatPct = Number(letterhead.vat_percent) || 15;
const sampleItems = [
{ description: 'Widget A', qty: 2, selling_price: 45.00 },
{ description: 'Widget B', qty: 1, selling_price: 89.99 },
];
const total = sampleItems.reduce((s, it) => s + it.qty * it.selling_price, 0);
const factor = vatPct > 0 ? (1 + vatPct / 100) : 1;
const sub = total / factor, vat = total - sub;
const cashGiven = 200, change = cashGiven - total;
const dateStr = new Date().toLocaleString('en-ZA', { day:'2-digit', month:'short', year:'numeric', hour:'2-digit', minute:'2-digit' });
const W = 32;
const center = s => { s = String(s); const p = Math.max(0, Math.floor((W - s.length) / 2)); return ' '.repeat(p) + s; };
const row = (l, r) => { const sp = Math.max(1, W - String(l).length - String(r).length); return l + ' '.repeat(sp) + r; };
const divider = '-'.repeat(W);
const renderers = {
header_title: b => [center(b.text || window.PosSlipLayout.DEFAULT_HEADER_TITLE)],
// The preview is monospace text, so it can't draw the raster
// logo — show a position marker instead, otherwise toggling
// the Logo block appears to do nothing at all.
logo: () => (letterhead.logo_url || letterhead.has_logo) ? [center('[ logo ]')] : [],
business_info: () => {
const out = [];
if(letterhead.display_name) out.push(center(letterhead.display_name));
if(letterhead.address) out.push(center(String(letterhead.address).split('\n')[0]));
if(letterhead.phone) out.push(center('Tel: ' + letterhead.phone));
if(letterhead.vat_number) out.push(center('VAT: ' + letterhead.vat_number));
return out;
},
date_slip: () => [divider, row('Date: ' + dateStr, 'Slip: SAMPLE')],
customer: () => [],
items: () => {
const out = [divider];
sampleItems.forEach(it => { out.push(it.description); out.push(' ' + it.qty + ' x ' + money(it.selling_price) + ' ' + money(it.qty*it.selling_price)); });
return out;
},
totals: () => {
const out = [divider, row('Subtotal', money(sub))];
if(vatPct > 0) out.push(row('VAT ' + vatPct + '%', money(vat)));
out.push(row('TOTAL', money(total)));
return out;
},
payment: () => [row('Paid: CASH ' + money(cashGiven), ''), row('Change', money(change))],
footer: b => [divider, center(b.text || letterhead.footer_text || 'Thank you for your purchase.')],
};
const out = [];
blocks.forEach(b => { if(renderers[b.id]) out.push(...renderers[b.id](b)); });
return out.map(line => '<span>' + esc(line) + '</span>').join('');
}
function previewDelivery(blocks){
const sampleItems = [
{ description: 'Widget A', qty: 5, unit: 'ea', selling_price: 45.00 },
{ description: 'Widget C', qty: 3, unit: 'ea', selling_price: 22.50 },
];
const total = sampleItems.reduce((s, it) => s + it.qty * it.selling_price, 0);
const dateStr = new Date().toLocaleDateString('en-ZA', { day:'2-digit', month:'short', year:'numeric' });
const W = 32;
const center = s => { s = String(s); const p = Math.max(0, Math.floor((W - s.length) / 2)); return ' '.repeat(p) + s; };
const divider = '-'.repeat(W);
const showPricing = workingShowPricing;
const renderers = {
// The preview is monospace text, so it can't draw the raster
// logo — show a position marker instead, otherwise toggling
// the Logo block appears to do nothing at all.
logo: () => (letterhead.logo_url || letterhead.has_logo) ? [center('[ logo ]')] : [],
header_title: b => [center(b.text || window.PosSlipLayout.DEFAULT_HEADER_TITLE), center('Delivery Note')],
business_info: () => {
const out = [];
if(letterhead.display_name) out.push(center(letterhead.display_name));
if(letterhead.address) out.push(center(String(letterhead.address).split('\n')[0]));
if(letterhead.phone) out.push(center('Tel: ' + letterhead.phone));
if(letterhead.vat_number) out.push(center('VAT: ' + letterhead.vat_number));
return out;
},
customer: () => ['Company: Acme Mining (Pty) Ltd', 'Customer: Joe Example'],
date: () => ['Date: ' + dateStr],
items: () => {
const out = [divider];
sampleItems.forEach(it => {
out.push(it.description);
const qtyLine = ' ' + it.qty + ' ' + it.unit;
out.push(showPricing ? qtyLine + ' ' + money(it.qty * it.selling_price) : qtyLine);
});
return out;
},
totals: () => showPricing ? [divider, 'TOTAL: ' + money(total)] : [divider],
signature: () => ['Received by: __________ Driver: __________'],
};
const out = [];
blocks.forEach(b => { if(renderers[b.id]) out.push(...renderers[b.id](b)); });
return out.map(line => '<span>' + esc(line) + '</span>').join('');
}
function previewCashup(blocks){
const counts = { cash: 12, card: 7, eft: 3, account: 2 };
const totals = { cash: 1234.50, card: 890.00, eft: 215.75, account: 1450.00 };
const grand = totals.cash + totals.card + totals.eft + totals.account;
const cashCounted = 1230.00;
const variance = cashCounted - totals.cash;
const denoms = [ { label: 'R200', value: 200, count: 3 }, { label: 'R100', value: 100, count: 4 } ];
const dateLabel = new Date().toLocaleDateString('en-ZA', { day:'2-digit', month:'short', year:'numeric' });
const W = 32;
const center = s => { s = String(s); const p = Math.max(0, Math.floor((W - s.length) / 2)); return ' '.repeat(p) + s; };
const row = (l, r) => { const sp = Math.max(1, W - String(l).length - String(r).length); return l + ' '.repeat(sp) + r; };
const pad = (n, w) => { const s = String(n); return s.length >= w ? s : ' '.repeat(w - s.length) + s; };
const divider = '-'.repeat(W);
const renderers = {
header_title: b => [center(b.text || window.PosSlipLayout.DEFAULT_HEADER_TITLE), center('CASH-UP REPORT')],
// The preview is monospace text, so it can't draw the raster
// logo — show a position marker instead, otherwise toggling
// the Logo block appears to do nothing at all.
logo: () => (letterhead.logo_url || letterhead.has_logo) ? [center('[ logo ]')] : [],
business_info: () => {
const out = [];
if(letterhead.display_name) out.push(center(letterhead.display_name));
if(letterhead.phone) out.push(center('Tel: ' + letterhead.phone));
return out;
},
shift_date: () => [divider, row('Shift: Today', 'Date: ' + dateLabel), 'Total sales: ' + (counts.cash+counts.card+counts.eft+counts.account)],
payment_breakdown: () => [
divider, 'PAYMENT METHOD BREAKDOWN',
row('Cash ' + pad(counts.cash,3) + ' sales', money(totals.cash)),
row('Card ' + pad(counts.card,3) + ' sales', money(totals.card)),
row('EFT ' + pad(counts.eft,3) + ' sales', money(totals.eft)),
row('Acct ' + pad(counts.account,3) + ' sales', money(totals.account)),
],
grand_total: () => [divider, row('GRAND TOTAL', money(grand))],
cash_reconciliation: () => [divider, 'CASH RECONCILIATION', row('Cash sales:', money(totals.cash)), row('Cash counted:', money(cashCounted)), row('Variance:', money(variance))],
denominations: () => {
const out = [divider, 'DENOMINATIONS COUNTED'];
denoms.forEach(d => out.push(d.label + ' x ' + pad(d.count,3) + ' = ' + money(d.value*d.count)));
return out;
},
footer: b => [divider, center(b.text || letterhead.footer_text || '')],
};
const out = [];
blocks.forEach(b => { if(renderers[b.id]) out.push(...renderers[b.id](b)); });
return out.map(line => '<span>' + esc(line) + '</span>').join('');
}
boot();
</script>
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
</body>
</html>