WFDASH-WEBAPP/public/pos/slip-layout.html

384 lines
20 KiB
HTML
Raw Normal View History

<!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){
fix(pos): dead discount toggle + stale discount figure, lift dark palette Two reported symptoms that turned out to be one bug, plus the dark mode the user described as "too black". The `hidden` attribute does nothing on the discount controls `hidden` is implemented by the UA stylesheet as `[hidden]{display:none}`. Any author rule that sets `display` outranks it — and `.totals` and `.discount-body` both set `display:flex`. So `el.hidden = true` was a no-op on both: - the discount tier panel could not be collapsed, making "Add discount" look like a dead control; - the Discount totals row stayed on screen after the discount was cleared, still showing the last figure (R 98.37) under a total that no longer contained it. The JS only ever set `hidden` and never cleared the text, so the operator had no way to tell stale text from a live one. `.cat-menu` and `.cat-backdrop` already carried per-element `[hidden]{display:none}` overrides for exactly this reason; the two discount elements were missed. Replaced with one global rule so the whole class is covered rather than relying on each element to remember. The clear path now also blanks the amount. Verified statically rather than in a browser: jsdom does not model the UA-vs-author cascade and reported the broken and fixed cases identically, so the cascade was checked by resolving which rules apply. Regression test fails without the fix. Dark palette The POS canvas was #0f0f0f (L* 4.3 — effectively black) with surfaces only 3-4 L* above it, mixing a warm #1c1416 against a neutral #181818 in the same UI. That near-black base plus two competing surface tones is what read as muddy. Replaced with a single cool-neutral ramp with four real steps, and menus/modals now sit on a `--raised` layer instead of reusing the surface colour. `#717171` was the secondary text colour in 25 places and measured 3.93:1 on the old canvas — below WCAG AA. It now measures 6.49:1. Both the text greys and the surfaces were hardcoded per component across seven POS pages; they are now tokens, with the dark block rebinding them. The shared `.dark` block in themes.css (drives all 24 themes) sat at oklch(0.15) ≈ L* 3.1. Lifted +0.04 across the ramp, preserving its shape and separation; muted-foreground goes 6.32:1 → 5.95:1, still above AA. Cursor/Supabase, which override it darker, were at L* 1.3 and lifted too. Service worker cache bumped to pos-v36 — pos.html and pos-app.js are served cache-first, so without the bump the tills keep serving the broken files. 3863 tests pass (3 new); build clean.
2026-10-12 21:17:57 +00:00
:root:not([data-theme="light"]){--bg:#161619;--surface:#1e1e23;--fg:#f2f2f5;--muted:#303039;--border:#3b3b46}
: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:var(--surface);border-color:var(--muted);color:var(--fg)}
}
fix(pos): dead discount toggle + stale discount figure, lift dark palette Two reported symptoms that turned out to be one bug, plus the dark mode the user described as "too black". The `hidden` attribute does nothing on the discount controls `hidden` is implemented by the UA stylesheet as `[hidden]{display:none}`. Any author rule that sets `display` outranks it — and `.totals` and `.discount-body` both set `display:flex`. So `el.hidden = true` was a no-op on both: - the discount tier panel could not be collapsed, making "Add discount" look like a dead control; - the Discount totals row stayed on screen after the discount was cleared, still showing the last figure (R 98.37) under a total that no longer contained it. The JS only ever set `hidden` and never cleared the text, so the operator had no way to tell stale text from a live one. `.cat-menu` and `.cat-backdrop` already carried per-element `[hidden]{display:none}` overrides for exactly this reason; the two discount elements were missed. Replaced with one global rule so the whole class is covered rather than relying on each element to remember. The clear path now also blanks the amount. Verified statically rather than in a browser: jsdom does not model the UA-vs-author cascade and reported the broken and fixed cases identically, so the cascade was checked by resolving which rules apply. Regression test fails without the fix. Dark palette The POS canvas was #0f0f0f (L* 4.3 — effectively black) with surfaces only 3-4 L* above it, mixing a warm #1c1416 against a neutral #181818 in the same UI. That near-black base plus two competing surface tones is what read as muddy. Replaced with a single cool-neutral ramp with four real steps, and menus/modals now sit on a `--raised` layer instead of reusing the surface colour. `#717171` was the secondary text colour in 25 places and measured 3.93:1 on the old canvas — below WCAG AA. It now measures 6.49:1. Both the text greys and the surfaces were hardcoded per component across seven POS pages; they are now tokens, with the dark block rebinding them. The shared `.dark` block in themes.css (drives all 24 themes) sat at oklch(0.15) ≈ L* 3.1. Lifted +0.04 across the ramp, preserving its shape and separation; muted-foreground goes 6.32:1 → 5.95:1, still above AA. Cursor/Supabase, which override it darker, were at L* 1.3 and lifted too. Service worker cache bumped to pos-v36 — pos.html and pos-app.js are served cache-first, so without the bump the tills keep serving the broken files. 3863 tests pass (3 new); build clean.
2026-10-12 21:17:57 +00:00
:root[data-theme="dark"]{--bg:#161619;--surface:#1e1e23;--fg:#f2f2f5;--muted:#303039;--border:#3b3b46}
:root[data-theme="dark"] .block-row, :root[data-theme="dark"] .pricing-row, :root[data-theme="dark"] .btn, :root[data-theme="dark"] .preview-tab{background:var(--surface);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)],
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 21:03:24 +00:00
// 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 = {
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 21:03:24 +00:00
// 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')],
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 21:03:24 +00:00
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')],
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 21:03:24 +00:00
// 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>