WFDASH-WEBAPP/public/pos/settings.html

669 lines
32 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>Settings — 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 .back:active{background:var(--bg)}
.topbar h1{font-size:17px;font-weight:600;letter-spacing:-.01em;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.topbar .netpill{font-size:11px;letter-spacing:.06em;text-transform:uppercase;font-weight:600;color:#717171;padding:4px 8px;border:1px solid var(--muted);border-radius:999px;flex:0 0 auto;white-space:nowrap}
.topbar .netpill.off{color:var(--danger);border-color:var(--danger);background:#fff5f5}
.topbar .theme-btn{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);background:transparent;border:0;cursor:pointer;flex:0 0 auto}
.topbar .theme-btn:active{background:var(--surface)}
.topbar .theme-btn svg{width:18px;height:18px}
.topbar .theme-btn .i-moon{display:none}
:root[data-theme="dark"] .topbar .theme-btn .i-sun{display:none}
:root[data-theme="dark"] .topbar .theme-btn .i-moon{display:block}
.lede{color:#717171;font-size:14px;line-height:1.55;margin:6px 0 16px;max-width:38ch}
/* ---- Active letterhead card ---- */
.active{padding:16px;border:1.5px solid var(--accent);border-radius:14px;background:#fff8fa;display:flex;align-items:center;gap:14px;box-shadow:var(--shadow)}
.active .logo{width:64px;height:64px;border-radius:10px;background:#fff;border:1px solid var(--border);display:grid;place-items:center;overflow:hidden;flex:0 0 auto}
.active .logo img{max-width:100%;max-height:100%;object-fit:contain}
.active .logo .placeholder{font-weight:700;font-size:22px;color:var(--accent)}
.active .body{flex:1;min-width:0}
.active .name{font-size:17px;font-weight:700;letter-spacing:-.01em;color:var(--fg);line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.active .sub{font-size:12px;color:var(--ink-soft, #717171);margin-top:4px;letter-spacing:.01em;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.active .pill{font-size:10px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;background:var(--accent);color:#fff;padding:3px 8px;border-radius:999px}
.active .pill.muted{background:var(--surface);color:var(--fg);border:1px solid var(--border)}
/* ---- Letterhead list (for switching) ---- */
.section-label{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#717171;margin:24px 4px 10px}
.lh-list{display:flex;flex-direction:column;gap:8px}
.lh-card{display:flex;align-items:center;gap:12px;padding:12px;border:1.5px solid var(--muted);border-radius:12px;background:#fff;text-align:left;width:100%;cursor:pointer;transition:border-color .12s, background .12s}
.lh-card:active{background:var(--surface)}
.lh-card.selected{border-color:var(--accent);background:#fff8fa}
.lh-card .logo{width:48px;height:48px;border-radius:8px;background:#fff;border:1px solid var(--border);display:grid;place-items:center;overflow:hidden;flex:0 0 auto}
.lh-card .logo img{max-width:100%;max-height:100%;object-fit:contain}
.lh-card .logo .placeholder{font-weight:700;font-size:16px;color:var(--accent)}
.lh-card .body{flex:1;min-width:0}
.lh-card .name{font-size:15px;font-weight:600;letter-spacing:-.01em;color:var(--fg);line-height:1.2;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.lh-card .meta{font-size:11px;color:#717171;margin-top:2px;letter-spacing:.01em}
.lh-card .check{width:18px;height:18px;border-radius:50%;background:var(--accent);color:#fff;display:grid;place-items:center;flex:0 0 auto;font-size:11px;font-weight:700;line-height:1}
.lh-card:not(.selected) .check{background:transparent;border:1.5px solid var(--muted);color:transparent}
/* ---- Actions ---- */
.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);box-shadow:0 4px 14px rgba(255,56,92,.25)}
.btn.primary:active{background:#e5324f;box-shadow:none}
.btn svg{width:18px;height:18px;stroke-width:1.7}
.btn:disabled{opacity:.5;pointer-events:none}
/* ---- Empty / error states ---- */
.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 .ic{flex:0 0 auto;width:16px;height:16px;margin-top:1px}
.banner .t{flex:1;min-width:0}
.skeleton{padding:24px 16px;text-align:center;color:#999;font-size:13px}
/* ---- Dark theme ---- */
@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"]) body{background:var(--bg);color:var(--fg)}
:root:not([data-theme="light"]) .topbar{background:var(--bg)}
:root:not([data-theme="light"]) .lede{color:#a0a0a0}
:root:not([data-theme="light"]) .topbar .back{background:var(--surface);border-color:var(--border);color:var(--fg)}
:root:not([data-theme="light"]) .topbar .netpill{color:#a0a0a0;border-color:var(--muted)}
:root:not([data-theme="light"]) .topbar .netpill.off{color:#ff6680;border-color:#ff6680;background:#2a1a1a}
:root:not([data-theme="light"]) .topbar .theme-btn{color:var(--fg)}
:root:not([data-theme="light"]) .section-label{color:#a0a0a0}
:root:not([data-theme="light"]) .active{background:#2a1a1a;border-color:#ff385c}
:root:not([data-theme="light"]) .active .logo{background:#181818;border-color:var(--muted)}
:root:not([data-theme="light"]) .active .name{color:var(--fg)}
:root:not([data-theme="light"]) .lh-card{background:#181818;border-color:var(--muted);color:var(--fg)}
:root:not([data-theme="light"]) .lh-card:active{background:var(--surface)}
:root:not([data-theme="light"]) .lh-card.selected{background:#2a1a1a;border-color:#ff385c}
:root:not([data-theme="light"]) .lh-card .logo{background:#181818;border-color:var(--muted)}
:root:not([data-theme="light"]) .lh-card .meta{color:#a0a0a0}
:root:not([data-theme="light"]) .lh-card:not(.selected) .check{border-color:var(--muted)}
:root:not([data-theme="light"]) .btn{background:#181818;border-color:var(--muted);color:var(--fg)}
:root:not([data-theme="light"]) .btn:active{background:var(--surface)}
:root:not([data-theme="light"]) .banner.warn{background:#2a1a1a;border-color:#5a3a1a;color:#ffb060}
:root:not([data-theme="light"]) .banner.err{background:#2a1a1a;border-color:#5a2429;color:#ff6680}
:root:not([data-theme="light"]) .skeleton{color:#666}
}
:root[data-theme="dark"]{
--bg:#0f0f0f;--surface:#1c1416;--fg:#f0f0f0;--muted:#2a2a2a;--border:#3a2429;
background:var(--bg);color:var(--fg);
}
:root[data-theme="dark"] body{background:var(--bg);color:var(--fg)}
:root[data-theme="dark"] .topbar{background:var(--bg)}
:root[data-theme="dark"] .lede{color:#a0a0a0}
:root[data-theme="dark"] .topbar .back{background:var(--surface);border-color:var(--border);color:var(--fg)}
:root[data-theme="dark"] .topbar .netpill{color:#a0a0a0;border-color:var(--muted)}
:root[data-theme="dark"] .topbar .netpill.off{color:#ff6680;border-color:#ff6680;background:#2a1a1a}
:root[data-theme="dark"] .topbar .theme-btn{color:var(--fg)}
:root[data-theme="dark"] .section-label{color:#a0a0a0}
:root[data-theme="dark"] .active{background:#2a1a1a;border-color:#ff385c}
:root[data-theme="dark"] .active .logo{background:#181818;border-color:var(--muted)}
:root[data-theme="dark"] .active .name{color:var(--fg)}
:root[data-theme="dark"] .lh-card{background:#181818;border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .lh-card:active{background:var(--surface)}
:root[data-theme="dark"] .lh-card.selected{background:#2a1a1a;border-color:#ff385c}
:root[data-theme="dark"] .lh-card .logo{background:#181818;border-color:var(--muted)}
:root[data-theme="dark"] .lh-card .meta{color:#a0a0a0}
:root[data-theme="dark"] .lh-card:not(.selected) .check{border-color:var(--muted)}
:root[data-theme="dark"] .btn{background:#181818;border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .btn:active{background:var(--surface)}
:root[data-theme="dark"] .banner.warn{background:#2a1a1a;border-color:#5a3a1a;color:#ffb060}
:root[data-theme="dark"] .banner.err{background:#2a1a1a;border-color:#5a2429;color:#ff6680}
:root[data-theme="dark"] .skeleton{color:#666}
:root[data-theme="dark"] .preview-modal{background:#1a1416}
:root[data-theme="dark"] .preview-modal .x{background:var(--surface)}
/* ---- Slip preview tabs ---- */
.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);transition:background .12s, color .12s, border-color .12s}
.preview-tab:active{background:var(--surface)}
.preview-tab.active{background:var(--fg);color:var(--bg);border-color:var(--fg)}
/* ---- Letterhead card — clickable to open the slip preview modal ---- */
.active .body{cursor:pointer;-webkit-tap-highlight-color:transparent}
.active .body:active{opacity:.85}
.active .body .name{text-decoration:underline;text-decoration-color:rgba(255,56,92,.3);text-underline-offset:3px}
.preview-note{margin:6px 4px 0;font-size:11px;color:#888;letter-spacing:.01em}
/* ---- Slip preview modal ---- */
.preview-modal-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:80;opacity:0;pointer-events:none;transition:opacity .15s}
.preview-modal-backdrop.show{opacity:1;pointer-events:auto}
.preview-modal{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%) scale(.96);background:var(--bg);border-radius:16px;padding:20px 20px 18px;width:calc(100% - 32px);max-width:440px;max-height:88dvh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.3);opacity:0;pointer-events:none;transition:opacity .15s, transform .2s cubic-bezier(.2,.8,.2,1);z-index:81}
.preview-modal-backdrop.show .preview-modal{opacity:1;transform:translate(-50%,-50%) scale(1);pointer-events:auto}
.preview-modal h2{font-size:16px;font-weight:700;letter-spacing:-.01em;margin:0 0 4px}
.preview-modal .modal-sub{font-size:12px;color:var(--ink-soft,#717171);margin:0 0 14px}
.preview-modal .x{position:absolute;top:10px;right:10px;width:32px;height:32px;border-radius:50%;background:var(--surface);display:grid;place-items:center;color:var(--fg);border:0;cursor:pointer}
.preview-modal .x:active{background:var(--bg)}
/* ---- Receipt preview pane ---- */
.receipt-preview{
/* `monospace` is the generic CSS family — every browser maps it
to a real monospace font (Menlo/SF Mono on macOS, Consolas on
Windows, Roboto Mono / DejaVu on Linux/Android). The previous
"SF Mono,Menlo,Consolas" stack fell back to a proportional
font on this system and broke the right-column alignment. */
font-family:monospace;
font-size:10px;
line-height:1.35;
background:#fdfdfa;
color:#1a1a1a;
border:1px solid #999;
border-radius:4px;
padding:10px 10px;
/* 24 chars * ~10px + 20px padding = 260px. Anything wider and we
scroll horizontally instead of overflowing. */
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;
/* Mimic thermal paper: slightly off-white, ragged-edge look */
position:relative;
}
.receipt-preview::before, .receipt-preview::after{
content:'';
position:absolute;
left:0; right:0;
height:6px;
background:radial-gradient(circle at 3px 0, #fff 0 1.5px, transparent 1.5px) repeat-x;
background-size:6px 6px;
}
.receipt-preview::before{ top:-3px; }
.receipt-preview::after{ bottom:-3px; transform:scaleY(-1); }
.receipt-preview .center{text-align:center;display:block}
.receipt-preview .row{display:flex;justify-content:space-between;gap:8px}
.receipt-preview .grow{flex:1}
.receipt-preview .right{text-align:right;flex:0 0 auto}
.receipt-preview .dim{color:#666}
.receipt-preview .em{font-weight:700;color:#000}
.receipt-preview .total{font-weight:700}
.receipt-preview .divider{border-top:1px dashed #999;margin:6px 0;height:0}
.receipt-preview .signoff{text-align:center;color:#666;margin-top:6px}
.receipt-preview .empty{text-align:center;color:#999;padding:30px 0;font-family:inherit;white-space:normal}
/* The preview pane is intentionally light regardless of the till's
theme — thermal paper is always light. The wrapper around it still
follows the theme. */
@media (prefers-reduced-motion: reduce){*{transition-duration:0.01ms !important;animation-duration:0.01ms !important}}
</style>
</head>
<body>
<header class="topbar">
<button class="back" data-od-id="back-btn" onclick="location.href='index.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>Settings</h1>
<span class="netpill" id="netpill" data-od-id="netpill">…</span>
<button class="theme-btn" data-theme-toggle data-od-id="theme-toggle" aria-label="Toggle theme" title="Toggle theme" onclick="(window.WFTheme&&window.WFTheme.cycle())||(window.__wfCycle&&window.__wfCycle())">
<svg class="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<svg class="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
</header>
<p class="lede">This till runs under the selected letterhead. Slips and receipts will use its name, address, VAT rate, and logo.</p>
<div id="activeBlock" class="skeleton">Loading…</div>
<div class="section-label">Switch letterhead</div>
<div class="lh-list" id="lhList"></div>
<div class="section-label">Slip preview</div>
<p class="lede" style="margin:0 4px 10px">A preview of what each slip type will print with the active letterhead. Sample data — not from a real sale.</p>
<div class="preview-tabs" role="tablist">
<button class="preview-tab active" data-type="sale" onclick="switchPreview('sale')" type="button">Sale slip</button>
<button class="preview-tab" data-type="delivery" onclick="switchPreview('delivery')" type="button">Delivery note</button>
<button class="preview-tab" data-type="cashup" onclick="switchPreview('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>
<p class="preview-note" id="previewNote"></p>
<div id="banner" hidden></div>
<div class="actions">
<button class="btn primary" data-od-id="test-print" onclick="testPrint()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M6 9V3h12v6M6 18H4v-7h16v7h-2M6 14h12v7H6z"/></svg>
Test print
</button>
<button class="btn" data-od-id="done-btn" onclick="location.href='index.html'">
Done
</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 src="epos-print.js"></script>
<script>
'use strict';
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]);}
function fmtVat(n){return (Number(n)||0).toString().replace(/\.0$/,'') + '% VAT';}
function initialsOf(name){
const s = String(name||'').trim();
if(!s) return '?';
return s.split(/\s+/).slice(0,2).map(w => w[0]||'').join('').toUpperCase();
}
function logoHtml(item, klass){
if(item && item.has_logo && item.id){
// We don't have the full logo URL on the list view (it lives on
// the full record). The list cards have a placeholder; once the
// till picks one and we refetch the full record, the active card
// shows the real logo.
return '<div class="logo '+klass+'"><span class="placeholder">'+esc(initialsOf(item.display_name))+'</span></div>';
}
return '<div class="logo '+klass+'"><span class="placeholder">'+esc(initialsOf(item.display_name))+'</span></div>';
}
function paintNet(){
const p = document.getElementById('netpill');
if(!p) return;
p.textContent = navigator.onLine ? 'Online' : 'Offline';
p.classList.toggle('off', !navigator.onLine);
}
window.addEventListener('online', paintNet);
window.addEventListener('offline', paintNet);
paintNet();
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="ic">'+(kind==='err'
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.5"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 9v4M12 17v.5M3 18l9-15 9 15z"/></svg>'
)+'</span><span class="t">'+esc(text)+'</span></div>';
}
function paintActive(item){
const el = document.getElementById('activeBlock');
if(!item){
el.className = 'banner err';
el.innerHTML = '<span class="ic">'
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.5"/></svg>'
+ '</span><span class="t">No active letterhead. Pick one below to start ringing up sales.</span>';
return;
}
el.className = 'active';
const logo = item.logo_url
? '<img src="'+esc(item.logo_url)+'" alt="">'
: '<span class="placeholder">'+esc(initialsOf(item.display_name))+'</span>';
const vatPill = item.vat_percent > 0
? '<span class="pill">'+esc(fmtVat(item.vat_percent))+'</span>'
: '<span class="pill muted">No VAT</span>';
const acc = item.accent_color
? '<span class="pill muted" style="color:'+esc(item.accent_color)+';border-color:'+esc(item.accent_color)+'">Accent '+esc(item.accent_color)+'</span>'
: '';
el.innerHTML = ''
+ '<div class="logo">'+logo+'</div>'
+ '<div class="body">'
+ '<div class="name">'+esc(item.display_name)+'</div>'
+ '<div class="sub">'
+ '<span>'+esc(item.company || '')+'</span>'
+ vatPill
+ acc
+ '</div>'
+ '</div>';
}
function paintList(list, activeId){
const wrap = document.getElementById('lhList');
if(!list.length){
wrap.innerHTML = '<div class="skeleton">No active letterheads configured in PocketBase.</div>';
return;
}
wrap.innerHTML = list.map(item => {
const isSel = item.id === activeId;
const meta = [
item.vat_percent > 0 ? fmtVat(item.vat_percent) : 'No VAT',
item.accent_color ? 'Accent ' + item.accent_color : null,
].filter(Boolean).join(' · ');
return ''
+ '<button type="button" class="lh-card'+(isSel ? ' selected' : '')+'" data-id="'+esc(item.id)+'" onclick="pickLetterhead(\''+esc(item.id)+'\')">'
+ logoHtml(item, '')
+ '<div class="body">'
+ '<div class="name">'+esc(item.display_name)+'</div>'
+ '<div class="meta">'+esc(meta)+'</div>'
+ '</div>'
+ '<div class="check">✓</div>'
+ '</button>';
}).join('');
}
async function pickLetterhead(id){
showBanner(null);
try{
const item = await window.PosLetterhead.fetchById(id);
paintActive(item);
// Re-render the list so the new "selected" check shows up.
const list = window.PosLetterhead.getCachedList();
paintList(list, item.id);
}catch(e){
showBanner('err', e.message || 'Could not pick letterhead');
}
}
async function testPrint(){
showBanner(null);
const btn = document.querySelector('[data-od-id="test-print"]');
if(btn) btn.disabled = true;
try{
const letterhead = await window.PosLetterhead.ensureActive();
if(!letterhead){
showBanner('warn', 'Pick a letterhead first.');
return;
}
const where = await window.EposPrint.printTest(letterhead);
showBanner(null); // success — no banner
if(btn){
// Briefly flash the success into the button label.
const orig = btn.textContent;
btn.textContent = '✓ ' + where;
setTimeout(() => { btn.textContent = orig; }, 1800);
}
}catch(e){
showBanner('err', e.message || 'Test print failed');
}finally{
if(btn) btn.disabled = false;
}
}
async function boot(){
const wrap = document.getElementById('activeBlock');
wrap.textContent = 'Loading…';
let active = null;
try{
active = await window.PosLetterhead.ensureActive();
}catch(e){
showBanner('err', 'Could not load letterhead: ' + (e.message || e));
}
paintActive(active);
// Always render the slip preview, even if the list fetch fails.
// The preview uses a demo letterhead fallback so the operator can
// sanity-check the layout before committing to a real letterhead.
renderPreview();
const note = document.getElementById('previewNote');
if(note){
note.textContent = active
? 'Preview uses your selected letterhead.'
: 'Preview only — pick a letterhead above to see your real one.';
}
// Try to populate the letterhead picker list (best-effort — the
// preview above already rendered with whatever we have).
let list = window.PosLetterhead.getCachedList();
if(!list.length){
try{ list = await window.PosLetterhead.fetchList(); }
catch(e){
document.getElementById('lhList').innerHTML = '<div class="skeleton">Letterhead list unavailable offline. Reconnect to set up.</div>';
return;
}
}
paintList(list, active ? active.id : null);
}
// ---------------- Slip preview ----------------
// Renders an HTML approximation of the three slip types the till
// prints: sale (cash/card/eft), delivery note (account), and cash-up.
// Sample data is hardcoded — this is a layout/font preview, not a
// receipt of a real sale. The actual printed slip uses the ePOS-Print
// XML emitted by EposPrint.buildEposXml/Delivery/Cashup and rendered
// by the TM-T20II; this is the same data, shaped to look like a
// thermal slip in the browser.
let currentPreviewType = 'sale';
function switchPreview(type){
currentPreviewType = type;
document.querySelectorAll('.preview-tab').forEach(t => {
t.classList.toggle('active', t.dataset.type === type);
});
renderPreview();
}
function pad(n, w){
const s = String(n == null ? 0 : n);
return s.length >= w ? s : (' '.repeat(w - s.length) + s);
}
function money(n){ return 'R ' + (Number(n) || 0).toFixed(2); }
// 24-char width — fits the 288px preview box even with the wider
// monospace fonts on some platforms (each char is ~10-12px at our
// 10px size on macOS Chromium). The real printer is 32 chars on
// 80mm paper; we narrow to 24 here so the preview never wraps on
// a phone.
const W = 24;
const center = (s) => {
s = String(s);
if (s.length >= W) return s;
const padLen = Math.floor((W - s.length) / 2);
return ' '.repeat(padLen) + s;
};
const row = (l, r) => {
// Trim labels that are too long to fit — better a clipped line
// than a wrapped one that pushes the price onto the next row.
const leftMax = Math.max(4, W - String(r).length - 1);
let left = String(l);
if (left.length > leftMax) left = left.slice(0, leftMax - 1) + '…';
const space = Math.max(1, W - left.length - String(r).length);
return left + ' '.repeat(space) + String(r);
};
const divider = '-'.repeat(W);
function renderPreview(){
const el = document.getElementById('receiptPreview');
if(!el) return;
const letterhead = window.PosLetterhead.getActive() || DEMO_LETTERHEAD;
if(currentPreviewType === 'sale') el.innerHTML = previewSale(letterhead);
else if(currentPreviewType === 'delivery') el.innerHTML = previewDelivery(letterhead);
else if(currentPreviewType === 'cashup') el.innerHTML = previewCashup(letterhead);
}
// Fallback letterhead used when the real one can't be loaded (e.g. the
// API is unreachable, or the operator hasn't picked one yet). Lets the
// preview render something useful so the operator can sanity-check the
// layout, font, and divider style before they go through Settings →
// "Test print" against a real printer. A small "preview only" note is
// rendered above the slip so the operator knows this isn't their data.
const DEMO_LETTERHEAD = {
id: '__demo__',
display_name: 'WESTSCOTT GROUP OF COMPANIES',
company: 'WESTSCOTT GROUP OF COMPANIES',
address: '47 Station Street, Carletonville, 2499',
phone: '+27 18 786 2897',
email: 'quotes@wfsales.co.za',
vat_number: '4590136331',
registration_number: '',
footer_text: 'Thank you for your business.',
logo: '',
logo_url: '',
is_active: true,
accent_color: '#FFBF00',
vat_percent: 15,
collection_id: '__demo__',
};
function previewSale(lh){
const vatPct = Number(lh.vat_percent) || 15;
const sampleItems = [
{ description: 'Widget A', sku: 'WA-100', qty: 2, unit: 'ea', selling_price: 45.00 },
{ description: 'Widget B', sku: 'WB-200', qty: 1, unit: 'ea', selling_price: 89.99 },
];
const gross = sampleItems.reduce((s, it) => s + it.qty * it.selling_price, 0);
const factor = vatPct > 0 ? (1 + vatPct / 100) : 1;
const total = gross;
const sub = total / factor;
const vat = total - sub;
const cashGiven = 200, change = cashGiven - total;
const d = new Date();
const dateStr = d.toLocaleString('en-ZA', { day:'2-digit', month:'short', year:'numeric', hour:'2-digit', minute:'2-digit' });
const itemsHTML = sampleItems.map(it => {
const line = money(it.qty * it.selling_price);
return esc(it.description) + '\n'
+ ' ' + it.qty + ' x ' + money(it.selling_price) + ' ' + line + '\n';
}).join('');
// 32-char width like an 80mm thermal printer at the default font.
const W = 32;
const center = (s) => {
s = String(s);
const padLen = Math.max(0, Math.floor((W - s.length) / 2));
return ' '.repeat(padLen) + s;
};
const row = (l, r) => {
const space = Math.max(1, W - String(l).length - String(r).length);
return l + ' '.repeat(space) + r;
};
const divider = '-'.repeat(W);
const out = [];
out.push(center('WFSALES POS'));
if(lh.display_name) out.push(center(lh.display_name));
if(lh.address) out.push(center(String(lh.address).split('\n')[0]));
if(lh.phone) out.push(center('Tel: ' + lh.phone));
if(lh.vat_number) out.push(center('VAT: ' + lh.vat_number));
out.push(divider);
out.push(row('Date: ' + dateStr, 'Slip: SAMPLE'));
out.push(divider);
out.push(itemsHTML.trimEnd());
out.push(divider);
out.push(row('Subtotal', money(sub)));
if(vatPct > 0) out.push(row('VAT ' + vatPct + '%', money(vat)));
out.push(row('TOTAL', money(total)));
out.push(row('Paid: CASH ' + money(cashGiven), ''));
out.push(row('Change', money(change)));
out.push(divider);
if(lh.footer_text) out.push(center(lh.footer_text));
else out.push(center('Thank you for your purchase.'));
// Wrap each line in a span so we can render with white-space:pre
// while keeping tight spacing.
return out.map(line => '<span>' + esc(line) + '</span>').join('');
}
function previewDelivery(lh){
const sampleItems = [
{ description: 'Widget A', sku: 'WA-100', qty: 5, unit: 'ea', selling_price: 45.00 },
{ description: 'Widget C', sku: 'WC-300', qty: 3, unit: 'ea', selling_price: 22.50 },
];
const total = sampleItems.reduce((s, it) => s + it.qty * it.selling_price, 0);
const d = new Date();
const dateStr = d.toLocaleDateString('en-ZA', { day:'2-digit', month:'short', year:'numeric' });
const W = 32;
const center = (s) => {
s = String(s);
const padLen = Math.max(0, Math.floor((W - s.length) / 2));
return ' '.repeat(padLen) + s;
};
const divider = '-'.repeat(W);
const out = [];
out.push(center('WFSALES POS'));
out.push(center('Delivery Note'));
if(lh.display_name) out.push(center(lh.display_name));
if(lh.address) out.push(center(String(lh.address).split('\n')[0]));
if(lh.phone) out.push(center('Tel: ' + lh.phone));
if(lh.vat_number) out.push(center('VAT: ' + lh.vat_number));
out.push('Company: Acme Mining (Pty) Ltd');
out.push('Customer: Joe Example');
out.push('Date: ' + dateStr);
out.push(divider);
sampleItems.forEach(it => {
out.push(esc(it.description));
const qtyLine = ' ' + it.qty + ' ' + (it.unit || 'ea');
out.push(qtyLine + ' ' + money(it.qty * it.selling_price));
});
out.push(divider);
out.push('TOTAL: ' + money(total));
out.push('Received by: __________ Driver: __________');
return out.map(line => '<span>' + esc(line) + '</span>').join('');
}
function previewCashup(lh){
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; // R4.50 short
const variance = cashCounted - totals.cash;
const denoms = [
{ label: 'R200', value: 200, count: 3 },
{ label: 'R100', value: 100, count: 4 },
{ label: 'R50', value: 50, count: 2 },
{ label: 'R20', value: 20, count: 1 },
];
const d = new Date();
const dateLabel = d.toLocaleDateString('en-ZA', { day:'2-digit', month:'short', year:'numeric' });
const W = 32;
const center = (s) => {
s = String(s);
const padLen = Math.max(0, Math.floor((W - s.length) / 2));
return ' '.repeat(padLen) + s;
};
const row = (l, r) => {
const space = Math.max(1, W - String(l).length - String(r).length);
return l + ' '.repeat(space) + r;
};
const divider = '-'.repeat(W);
const out = [];
out.push(center('WFSALES POS'));
out.push(center('CASH-UP REPORT'));
if(lh.display_name) out.push(center(lh.display_name));
if(lh.phone) out.push(center('Tel: ' + lh.phone));
out.push(divider);
out.push(row('Shift: Today', 'Date: ' + dateLabel));
out.push('Total sales: ' + (counts.cash + counts.card + counts.eft + counts.account));
out.push(divider);
out.push('PAYMENT METHOD BREAKDOWN');
out.push(row('Cash ' + pad(counts.cash, 3) + ' sales', money(totals.cash)));
out.push(row('Card ' + pad(counts.card, 3) + ' sales', money(totals.card)));
out.push(row('EFT ' + pad(counts.eft, 3) + ' sales', money(totals.eft)));
out.push(row('Acct ' + pad(counts.account, 3) + ' sales', money(totals.account)));
out.push(divider);
out.push(row('GRAND TOTAL', money(grand)));
out.push(divider);
out.push('CASH RECONCILIATION');
out.push(row('Cash sales:', money(totals.cash)));
out.push(row('Cash counted:', money(cashCounted)));
out.push(row('Variance:', money(variance)));
out.push(divider);
out.push('DENOMINATIONS COUNTED');
denoms.forEach(dn => {
if(dn.count > 0){
out.push(dn.label + ' x ' + pad(dn.count, 3) + ' = ' + money(dn.value * dn.count));
}
});
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>