669 lines
32 KiB
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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>
|