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.
383 lines
20 KiB
HTML
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:#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)}
|
|
}
|
|
: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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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>
|