2026-09-04 11:42:01 +00:00
|
|
|
<!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)}
|
2026-09-04 12:07:39 +00:00
|
|
|
.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}
|
2026-09-04 11:42:01 +00:00
|
|
|
.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)}
|
2026-09-04 11:42:01 +00:00
|
|
|
}
|
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)}
|
2026-09-04 11:42:01 +00:00
|
|
|
</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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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 ]')] : [],
|
2026-09-04 11:42:01 +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;
|
|
|
|
|
},
|
|
|
|
|
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 ]')] : [],
|
2026-09-04 11:42:01 +00:00
|
|
|
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;
|
|
|
|
|
},
|
2026-09-04 11:42:01 +00:00
|
|
|
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 ]')] : [],
|
2026-09-04 11:42:01 +00:00
|
|
|
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>
|