chore(pos): copy WFSALES POS mockup into public/pos/ (unwired)
pb-config.js deliberately not copied — it hardcoded a PocketBase superuser email/password in client-shipped JS. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4d58bde6ff
commit
78f357de69
9 changed files with 2625 additions and 0 deletions
388
public/pos/cashup.html
Normal file
388
public/pos/cashup.html
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
<!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>Cash-up</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" type="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;--surface-2:#f7f7f7;--surface-card:#ffffff;
|
||||
--fg:#222222;--ink-soft:#717171;--ink-faint:#999999;
|
||||
--muted:#dddddd;--border:#ffe3e8;--accent:#ff385c;--accent-soft:#fff5f5;--accent-2:#ffd9df;
|
||||
--ok:#0f8a3f;--ok-soft:#e8f5ec;--warn:#b14a00;--warn-soft:#fdf2e6;--danger:#c92a2a;--danger-soft:#fff0f0;--danger-2:#ffd0d6;
|
||||
--shadow:0 1px 2px rgba(34,34,34,.04), 0 8px 24px rgba(34,34,34,.06);
|
||||
--focus-ring:0 0 0 3px rgba(255,56,92,.30);
|
||||
--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:15px/1.45 var(--font);-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
||||
body{display:flex;flex-direction:column;min-height:100dvh;max-width:480px;margin:0 auto;padding-bottom:env(safe-area-inset-bottom);background:var(--bg)}
|
||||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||||
input{font:inherit;color:inherit}
|
||||
button:focus-visible,input:focus-visible,select:focus-visible{outline:0;box-shadow:var(--focus-ring);border-radius:10px}
|
||||
.topbar .back:focus-visible,.topbar .theme-btn:focus-visible,.topbar .netpill:focus-visible{box-shadow:var(--focus-ring);border-radius:50%}
|
||||
|
||||
/* ---- Topbar ---- */
|
||||
.topbar{display:flex;align-items:center;gap:10px;padding:14px 16px 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-card);border:1px solid var(--muted);flex:0 0 auto}
|
||||
.topbar .back:active{background:var(--surface)}
|
||||
.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:var(--ink-soft);padding:4px 8px;border:1px solid var(--muted);border-radius:999px;background:var(--surface-card);flex:0 0 auto;white-space:nowrap}
|
||||
.topbar .netpill.off{color:var(--danger);border-color:var(--danger);background:var(--danger-soft)}
|
||||
.theme-btn{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);background:var(--surface-card);border:1px solid var(--muted);cursor:pointer;flex:0 0 auto}
|
||||
.theme-btn:active{background:var(--surface)}
|
||||
.theme-btn svg{width:18px;height:18px}
|
||||
.theme-btn .i-moon{display:none}
|
||||
:root[data-theme="dark"] .theme-btn .i-sun{display:none}
|
||||
:root[data-theme="dark"] .theme-btn .i-moon{display:block}
|
||||
|
||||
/* ---- Day tabs ---- */
|
||||
.day-tabs{display:flex;gap:6px;padding:10px 16px 12px;background:var(--bg);position:sticky;top:60px;z-index:9;border-bottom:1px solid var(--muted)}
|
||||
.day-tab{flex:1;padding:10px;border:1px solid var(--muted);border-radius:10px;background:var(--surface-card);font-size:13px;font-weight:500;text-align:center;color:var(--fg);transition:background .12s ease,color .12s ease,border-color .12s ease}
|
||||
.day-tab.active{background:var(--fg);color:var(--bg);border-color:var(--fg)}
|
||||
|
||||
/* ---- KPIs ---- */
|
||||
.kpis{padding:16px;display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.kpi{padding:16px;border:1px solid var(--muted);border-radius:14px;background:var(--surface-card);box-shadow:var(--shadow)}
|
||||
.kpi .lbl{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);margin-bottom:6px}
|
||||
.kpi .val{font-size:22px;font-weight:700;letter-spacing:-.02em;color:var(--fg);font-variant-numeric:tabular-nums;line-height:1.1}
|
||||
.kpi .sub{font-size:11px;color:var(--ink-soft);margin-top:2px;letter-spacing:.01em}
|
||||
.kpi.accent{background:var(--accent-soft);border-color:var(--accent-2)}
|
||||
.kpi.accent .val{color:var(--accent)}
|
||||
|
||||
/* ---- Methods ---- */
|
||||
.methods{padding:0 16px 16px;display:flex;flex-direction:column;gap:8px}
|
||||
.method-row{display:flex;align-items:center;gap:12px;padding:14px 16px;border:1px solid var(--muted);border-radius:12px;background:var(--surface-card);box-shadow:var(--shadow)}
|
||||
.method-row .lbl{font-size:13px;font-weight:600;letter-spacing:-.01em;flex:1;color:var(--fg)}
|
||||
.method-row .ic{width:32px;height:32px;border-radius:8px;background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;flex:0 0 auto}
|
||||
.method-row .ic svg{width:18px;height:18px;stroke-width:1.7}
|
||||
.method-row .count{font-size:11px;color:var(--ink-soft);margin-right:8px;letter-spacing:.02em;font-variant-numeric:tabular-nums}
|
||||
.method-row .pr{font-size:16px;font-weight:700;letter-spacing:-.01em;font-variant-numeric:tabular-nums;color:var(--fg);min-width:90px;text-align:right}
|
||||
|
||||
/* ---- Denominations ---- */
|
||||
.denoms{padding:0 16px 16px}
|
||||
.denoms h3{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);margin:8px 0 8px}
|
||||
.denom{display:grid;grid-template-columns:60px 1fr 100px;gap:10px;align-items:center;padding:10px 0;border-bottom:1px solid var(--muted)}
|
||||
.denom:last-child{border-bottom:0}
|
||||
.denom .lbl{font-size:14px;font-weight:600;letter-spacing:-.01em;color:var(--fg)}
|
||||
.denom .lbl small{font-size:11px;color:var(--ink-soft);font-weight:500;margin-left:4px;letter-spacing:.04em}
|
||||
.denom input{width:100%;padding:11px 12px;border:1px solid var(--muted);border-radius:10px;font-size:16px;font-weight:600;text-align:center;font-variant-numeric:tabular-nums;outline:0;background:var(--surface-card);min-width:0;color:var(--fg)}
|
||||
.denom input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.12)}
|
||||
.denom .sub{font-size:14px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--fg);text-align:right;letter-spacing:-.005em}
|
||||
|
||||
/* ---- Summary ---- */
|
||||
.summary{padding:0 16px 16px;border-top:1px solid var(--muted);margin-top:6px;padding-top:14px}
|
||||
.summary .row{display:flex;justify-content:space-between;align-items:baseline;padding:6px 0;font-size:14px;color:var(--ink-soft);letter-spacing:.01em;gap:8px}
|
||||
.summary .row b{color:var(--fg);font-variant-numeric:tabular-nums;font-weight:600}
|
||||
.summary .row.diff b{color:var(--warn)}
|
||||
.summary .row.diff.ok b{color:var(--ok)}
|
||||
.summary .row.grand{padding-top:12px;margin-top:8px;border-top:1px solid var(--muted);font-size:16px;color:var(--fg);font-weight:700;letter-spacing:-.01em}
|
||||
|
||||
/* ---- Actions ---- */
|
||||
.actions{padding:0 16px calc(20px + env(safe-area-inset-bottom));display:flex;flex-direction:column;gap:10px;margin-top:auto}
|
||||
.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:var(--surface-card);color:var(--fg);transition:transform .12s ease,background .12s ease}
|
||||
.btn:active{transform:translateY(1px);background:var(--surface)}
|
||||
.btn svg{width:20px;height:20px;stroke-width:1.7}
|
||||
.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}
|
||||
|
||||
.skeleton{padding:24px 16px;text-align:center;color:var(--ink-faint);font-size:13px}
|
||||
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root:not([data-theme="light"]){
|
||||
--bg:#0f0f0f;--surface:#1c1416;--surface-2:#181818;--surface-card:#181818;
|
||||
--fg:#f0f0f0;--ink-soft:#a0a0a0;--ink-faint:#777;
|
||||
--muted:#2a2a2a;--border:#3a2429;
|
||||
--accent-soft:#2a1a1a;--accent-2:#3a2429;
|
||||
--ok-soft:#1a2e22;--warn-soft:#2a1a1a;--danger-soft:#2a1a1a;--danger-2:#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"]) .topbar .back{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .topbar .netpill{background:var(--surface-card);color:var(--ink-soft);border-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .topbar .netpill.off{color:#ff6680;border-color:#ff6680;background:var(--danger-soft)}
|
||||
:root:not([data-theme="light"]) .theme-btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .day-tabs{background:var(--bg);border-bottom-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .day-tab{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .day-tab.active{background:var(--fg);color:var(--bg);border-color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .kpi{background:var(--surface-card);border-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .kpi .lbl{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .kpi .val{color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .kpi .sub{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .kpi.accent{background:var(--accent-soft);border-color:var(--accent-2)}
|
||||
:root:not([data-theme="light"]) .kpi.accent .val{color:#ff8a9e}
|
||||
:root:not([data-theme="light"]) .method-row{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .method-row .lbl{color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .method-row .ic{background:var(--accent-soft);color:#ff8a9e}
|
||||
:root:not([data-theme="light"]) .method-row .count{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .method-row .pr{color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .denoms h3{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .denom{border-bottom-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .denom .lbl{color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .denom .lbl small{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .denom input{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .denom input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.2)}
|
||||
:root:not([data-theme="light"]) .denom .sub{color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .summary{border-top-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .summary .row{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .summary .row b{color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .summary .row.diff b{color:#ffa552}
|
||||
:root:not([data-theme="light"]) .summary .row.diff.ok b{color:#5fc77c}
|
||||
:root:not([data-theme="light"]) .summary .row.grand{border-top-color:var(--muted);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .btn:active{background:var(--surface)}
|
||||
:root:not([data-theme="light"]) .skeleton{color:var(--ink-faint)}
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#0f0f0f;--surface:#1c1416;--surface-2:#181818;--surface-card:#181818;
|
||||
--fg:#f0f0f0;--ink-soft:#a0a0a0;--ink-faint:#777;
|
||||
--muted:#2a2a2a;--border:#3a2429;
|
||||
--accent-soft:#2a1a1a;--accent-2:#3a2429;
|
||||
--ok-soft:#1a2e22;--warn-soft:#2a1a1a;--danger-soft:#2a1a1a;--danger-2:#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"] .topbar .back{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .topbar .netpill{background:var(--surface-card);color:var(--ink-soft);border-color:var(--muted)}
|
||||
:root[data-theme="dark"] .topbar .netpill.off{color:#ff6680;border-color:#ff6680;background:var(--danger-soft)}
|
||||
:root[data-theme="dark"] .theme-btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .day-tabs{background:var(--bg);border-bottom-color:var(--muted)}
|
||||
:root[data-theme="dark"] .day-tab{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .day-tab.active{background:var(--fg);color:var(--bg);border-color:var(--fg)}
|
||||
:root[data-theme="dark"] .kpi{background:var(--surface-card);border-color:var(--muted)}
|
||||
:root[data-theme="dark"] .kpi .lbl{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .kpi .val{color:var(--fg)}
|
||||
:root[data-theme="dark"] .kpi .sub{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .kpi.accent{background:var(--accent-soft);border-color:var(--accent-2)}
|
||||
:root[data-theme="dark"] .kpi.accent .val{color:#ff8a9e}
|
||||
:root[data-theme="dark"] .method-row{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .method-row .lbl{color:var(--fg)}
|
||||
:root[data-theme="dark"] .method-row .ic{background:var(--accent-soft);color:#ff8a9e}
|
||||
:root[data-theme="dark"] .method-row .count{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .method-row .pr{color:var(--fg)}
|
||||
:root[data-theme="dark"] .denoms h3{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .denom{border-bottom-color:var(--muted)}
|
||||
:root[data-theme="dark"] .denom .lbl{color:var(--fg)}
|
||||
:root[data-theme="dark"] .denom .lbl small{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .denom input{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .denom input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.2)}
|
||||
:root[data-theme="dark"] .denom .sub{color:var(--fg)}
|
||||
:root[data-theme="dark"] .summary{border-top-color:var(--muted)}
|
||||
:root[data-theme="dark"] .summary .row{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .summary .row b{color:var(--fg)}
|
||||
:root[data-theme="dark"] .summary .row.diff b{color:#ffa552}
|
||||
:root[data-theme="dark"] .summary .row.diff.ok b{color:#5fc77c}
|
||||
:root[data-theme="dark"] .summary .row.grand{border-top-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .btn:active{background:var(--surface)}
|
||||
:root[data-theme="dark"] .skeleton{color:var(--ink-faint)}
|
||||
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
*{transition-duration:0.01ms !important;animation-duration:0.01ms !important}
|
||||
}
|
||||
|
||||
@media print{
|
||||
.topbar,.theme-btn,.actions,.netpill{display:none !important}
|
||||
body{background:#fff}
|
||||
.kpi,.method-row,.btn{box-shadow:none;background:#fff}
|
||||
}
|
||||
</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>Cash-up</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>
|
||||
|
||||
<div class="day-tabs" id="days">
|
||||
<button class="day-tab active" data-shift="today">Today</button>
|
||||
<button class="day-tab" data-shift="yesterday">Yesterday</button>
|
||||
</div>
|
||||
|
||||
<div class="kpis" id="kpis">
|
||||
<div class="kpi" data-od-id="kpi-sales"><div class="lbl">Sales</div><div class="val" id="kCount">—</div><div class="sub" id="kRange">—</div></div>
|
||||
<div class="kpi accent" data-od-id="kpi-takings"><div class="lbl">Takings</div><div class="val" id="kTotal">R 0</div><div class="sub">All methods</div></div>
|
||||
</div>
|
||||
|
||||
<div class="methods" id="methods"></div>
|
||||
|
||||
<div class="denoms">
|
||||
<h3>Cash counted in drawer</h3>
|
||||
<div id="denoms"></div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="row"><span>Cash sales</span><b id="sCashSales">R 0.00</b></div>
|
||||
<div class="row"><span>Cash counted</span><b id="sCashCounted">R 0.00</b></div>
|
||||
<div class="row diff" id="diffRow"><span>Variance</span><b id="sDiff">R 0.00</b></div>
|
||||
<div class="row grand"><span>Takings</span><b id="sGrand">R 0.00</b></div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn primary" data-od-id="print-cashup" onclick="window.print()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M6 9V3h12v6M6 18H4v-7h16v7h-2M6 14h12v7H6z"/></svg>
|
||||
Print cash-up
|
||||
</button>
|
||||
<button class="btn" data-od-id="home-btn" onclick="location.href='index.html'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 11l9-7 9 7v9h-6v-6h-6v6H3z"/></svg>
|
||||
Home
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script src="pb-config.js"></script>
|
||||
<script src="theme.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
'use strict';
|
||||
const cfg = window.PB_CONFIG;
|
||||
const fmt = n => 'R ' + (Number(n)||0).toLocaleString('en-ZA',{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||
const esc = s => String(s||'').replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
|
||||
|
||||
// Network status indicator
|
||||
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();
|
||||
|
||||
const DENOMS = [
|
||||
{lbl:'R200', sub:'note', v:200}, {lbl:'R100', sub:'note', v:100}, {lbl:'R50', sub:'note', v:50}, {lbl:'R20', sub:'note', v:20},
|
||||
{lbl:'R10', sub:'coin', v:10}, {lbl:'R5', sub:'coin', v:5}, {lbl:'R2', sub:'coin', v:2}, {lbl:'R1', sub:'coin', v:1}, {lbl:'50c', sub:'coin', v:0.5}
|
||||
];
|
||||
|
||||
let shift = 'today';
|
||||
let cashSales = 0, cashCounted = 0, totals = {cash:0, card:0, eft:0, account:0, count:0}, grand = 0;
|
||||
|
||||
document.querySelectorAll('.day-tab').forEach(b=>b.addEventListener('click',()=>{
|
||||
document.querySelectorAll('.day-tab').forEach(x=>x.classList.remove('active'));
|
||||
b.classList.add('active');
|
||||
shift = b.dataset.shift;
|
||||
load();
|
||||
}));
|
||||
|
||||
function paintDenoms(){
|
||||
document.getElementById('denoms').innerHTML = DENOMS.map(d=>`
|
||||
<div class="denom" data-v="${d.v}">
|
||||
<div class="lbl">${d.lbl}<small>${d.sub}</small></div>
|
||||
<input type="number" min="0" step="1" placeholder="0" inputmode="numeric" oninput="window.__cu.recalc()">
|
||||
<div class="sub">R 0.00</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
function getDenomCounts(){
|
||||
const counts = {};
|
||||
document.querySelectorAll('.denom').forEach(row=>{
|
||||
const v = Number(row.dataset.v);
|
||||
const inp = row.querySelector('input');
|
||||
const n = Math.max(0, Math.floor(Number(inp.value)||0));
|
||||
counts[v] = n;
|
||||
row.querySelector('.sub').textContent = fmt(n * v);
|
||||
});
|
||||
return counts;
|
||||
}
|
||||
function recalc(){
|
||||
const c = getDenomCounts();
|
||||
cashCounted = Object.entries(c).reduce((s,[v,n])=>s + v*n, 0);
|
||||
document.getElementById('sCashCounted').textContent = fmt(cashCounted);
|
||||
const diff = cashCounted - cashSales;
|
||||
const drow = document.getElementById('diffRow');
|
||||
drow.classList.toggle('ok', Math.abs(diff)<0.5);
|
||||
document.getElementById('sDiff').textContent = fmt(diff);
|
||||
document.getElementById('sGrand').textContent = fmt(grand);
|
||||
}
|
||||
window.__cu = { recalc };
|
||||
|
||||
function paintMethods(){
|
||||
const m = document.getElementById('methods');
|
||||
m.innerHTML = `
|
||||
<div class="method-row" data-od-id="method-cash">
|
||||
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="6" width="18" height="12" rx="2"/><circle cx="12" cy="12" r="2.5"/></svg></span>
|
||||
<div class="lbl">Cash</div>
|
||||
<div class="count" id="mCashN">0 sales</div>
|
||||
<div class="pr">${fmt(totals.cash)}</div>
|
||||
</div>
|
||||
<div class="method-row" data-od-id="method-card">
|
||||
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="6" width="18" height="12" rx="2"/><path d="M3 10h18"/></svg></span>
|
||||
<div class="lbl">Card</div>
|
||||
<div class="count" id="mCardN">0 sales</div>
|
||||
<div class="pr">${fmt(totals.card)}</div>
|
||||
</div>
|
||||
<div class="method-row" data-od-id="method-eft">
|
||||
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 7h16M4 12h16M4 17h10"/></svg></span>
|
||||
<div class="lbl">EFT</div>
|
||||
<div class="count" id="mEftN">0 sales</div>
|
||||
<div class="pr">${fmt(totals.eft)}</div>
|
||||
</div>
|
||||
<div class="method-row" data-od-id="method-account">
|
||||
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg></span>
|
||||
<div class="lbl">Account</div>
|
||||
<div class="count" id="mAccN">0 sales</div>
|
||||
<div class="pr">${fmt(totals.account)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function load(){
|
||||
const d = new Date();
|
||||
if(shift==='yesterday') d.setDate(d.getDate()-1);
|
||||
const start = d.toISOString().slice(0,10) + ' 00:00:00';
|
||||
const end = d.toISOString().slice(0,10) + ' 23:59:59';
|
||||
document.getElementById('kRange').textContent = d.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
|
||||
document.getElementById('kCount').textContent = '—';
|
||||
document.getElementById('kTotal').textContent = 'R 0';
|
||||
try{
|
||||
await window.pbAuth();
|
||||
const r = await window.pbFetch(`/api/collections/${cfg.collections.orders}/records?perPage=500&filter=${encodeURIComponent(`created >= '${start}' && created <= '${end}'`)}`);
|
||||
const j = await r.json();
|
||||
totals = {cash:0, card:0, eft:0, account:0, count: j.items.length};
|
||||
const counts = {cash:0, card:0, eft:0, account:0};
|
||||
for(const o of j.items){
|
||||
const li = await window.pbFetch(`/api/collections/${cfg.collections.orderItems}/records?perPage=200&filter=order='${o.id}'`);
|
||||
const liJ = await li.json();
|
||||
const total = (liJ.items||[]).reduce((s,it)=> s + (it.selling_price||0)*(it.quantity||0), 0);
|
||||
const isAccount = /ACCOUNT/.test(o.notes||'');
|
||||
const method = isAccount ? 'account' : (/EFT/.test(o.notes||'')?'eft':(/CARD/.test(o.notes||'')?'card':'cash'));
|
||||
totals[method] += total;
|
||||
counts[method]++;
|
||||
}
|
||||
grand = totals.cash + totals.card + totals.eft + totals.account;
|
||||
cashSales = totals.cash;
|
||||
document.getElementById('kCount').textContent = totals.count;
|
||||
document.getElementById('kTotal').textContent = fmt(grand);
|
||||
document.getElementById('sCashSales').textContent = fmt(cashSales);
|
||||
document.getElementById('sGrand').textContent = fmt(grand);
|
||||
paintMethods();
|
||||
document.getElementById('mCashN').textContent = counts.cash + ' sales';
|
||||
document.getElementById('mCardN').textContent = counts.card + ' sales';
|
||||
document.getElementById('mEftN').textContent = counts.eft + ' sales';
|
||||
document.getElementById('mAccN').textContent = counts.account + ' sales';
|
||||
recalc();
|
||||
}catch(e){
|
||||
document.getElementById('kCount').textContent = '—';
|
||||
document.getElementById('kTotal').textContent = '—';
|
||||
}
|
||||
}
|
||||
|
||||
paintDenoms();
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
|
||||
</body>
|
||||
</html>
|
||||
257
public/pos/history.html
Normal file
257
public/pos/history.html
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
<!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>Sales history</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<link rel="icon" type="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;--surface-2:#f7f7f7;--surface-card:#ffffff;
|
||||
--fg:#222222;--ink-soft:#717171;--ink-faint:#999999;
|
||||
--muted:#dddddd;--border:#ffe3e8;--accent:#ff385c;--accent-soft:#fff5f5;--accent-2:#ffd9df;
|
||||
--ok:#0f8a3f;--warn:#b14a00;--danger:#c92a2a;--danger-soft:#fff0f0;--danger-2:#ffd0d6;
|
||||
--shadow:0 1px 2px rgba(34,34,34,.04), 0 8px 24px rgba(34,34,34,.06);
|
||||
--focus-ring:0 0 0 3px rgba(255,56,92,.30);
|
||||
--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:15px/1.45 var(--font);-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
||||
body{display:flex;flex-direction:column;min-height:100dvh;max-width:480px;margin:0 auto;padding-bottom:env(safe-area-inset-bottom);background:var(--bg)}
|
||||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||||
button:focus-visible,input:focus-visible,select:focus-visible{outline:0;box-shadow:var(--focus-ring);border-radius:10px}
|
||||
.topbar .back:focus-visible,.topbar .theme-btn:focus-visible,.topbar .netpill:focus-visible{box-shadow:var(--focus-ring);border-radius:50%}
|
||||
|
||||
/* ---- Topbar ---- */
|
||||
.topbar{display:flex;align-items:center;gap:10px;padding:14px 16px 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-card);border:1px solid var(--muted);flex:0 0 auto}
|
||||
.topbar .back:active{background:var(--surface)}
|
||||
.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:var(--ink-soft);padding:4px 8px;border:1px solid var(--muted);border-radius:999px;background:var(--surface-card);flex:0 0 auto;white-space:nowrap}
|
||||
.topbar .netpill.off{color:var(--danger);border-color:var(--danger);background:var(--danger-soft)}
|
||||
.theme-btn{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);background:var(--surface-card);border:1px solid var(--muted);cursor:pointer;flex:0 0 auto}
|
||||
.theme-btn:active{background:var(--surface)}
|
||||
.theme-btn svg{width:18px;height:18px}
|
||||
.theme-btn .i-moon{display:none}
|
||||
:root[data-theme="dark"] .theme-btn .i-sun{display:none}
|
||||
:root[data-theme="dark"] .theme-btn .i-moon{display:block}
|
||||
|
||||
/* ---- Day strip ---- */
|
||||
.day-strip{display:flex;gap:8px;padding:10px 16px 12px;background:var(--bg);position:sticky;top:60px;z-index:9;overflow-x:auto;scrollbar-width:none;border-bottom:1px solid var(--muted)}
|
||||
.day-strip::-webkit-scrollbar{display:none}
|
||||
.day-chip{flex:0 0 auto;padding:9px 14px;border:1px solid var(--muted);border-radius:10px;background:var(--surface-card);font-size:13px;font-weight:500;min-width:84px;text-align:center;color:var(--fg);transition:background .12s ease,color .12s ease,border-color .12s ease}
|
||||
.day-chip small{display:block;font-size:11px;color:var(--ink-soft);letter-spacing:.04em;text-transform:uppercase;margin-top:2px}
|
||||
.day-chip.active{background:var(--fg);color:var(--bg);border-color:var(--fg)}
|
||||
.day-chip.active small{color:rgba(255,255,255,.6)}
|
||||
:root[data-theme="dark"] .day-chip.active small{color:rgba(0,0,0,.6)}
|
||||
|
||||
/* ---- Summary KPIs ---- */
|
||||
.summary{padding:16px;display:grid;grid-template-columns:1fr 1fr;gap:10px}
|
||||
.kpi{padding:14px;border:1px solid var(--muted);border-radius:12px;background:var(--surface-card);box-shadow:var(--shadow)}
|
||||
.kpi .lbl{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);margin-bottom:4px}
|
||||
.kpi .val{font-size:20px;font-weight:700;letter-spacing:-.02em;color:var(--fg);font-variant-numeric:tabular-nums}
|
||||
|
||||
/* ---- List ---- */
|
||||
.list{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:0 16px 24px}
|
||||
.sale-row{display:flex;align-items:center;gap:12px;padding:14px 0;border-bottom:1px solid var(--muted);background:none;width:100%;text-align:left;min-height:60px;color:inherit}
|
||||
.sale-row:active{background:var(--surface)}
|
||||
.sale-row .nm{flex:1;min-width:0}
|
||||
.sale-row .nm b{font-size:14px;font-weight:600;letter-spacing:-.005em;display:block;color:var(--fg)}
|
||||
.sale-row .nm small{font-size:11px;color:var(--ink-soft);display:block;margin-top:2px;letter-spacing:.01em}
|
||||
|
||||
.method{padding:3px 8px;border-radius:999px;background:var(--surface);border:1px solid var(--border);font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--fg);flex:0 0 auto;white-space:nowrap}
|
||||
.method.account{background:var(--accent-soft);border-color:var(--accent-2);color:var(--accent)}
|
||||
.method.card,.method.eft{background:var(--surface);border-color:var(--border);color:var(--fg)}
|
||||
|
||||
.sale-row .pr{font-size:14px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
|
||||
/* ---- Empty / loading / error states ---- */
|
||||
.empty{padding:60px 20px;text-align:center;color:var(--ink-faint)}
|
||||
.empty h3{font-size:16px;font-weight:600;color:var(--fg);margin-bottom:6px;letter-spacing:-.01em}
|
||||
.empty p{font-size:13px;line-height:1.5}
|
||||
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root:not([data-theme="light"]){
|
||||
--bg:#0f0f0f;--surface:#1c1416;--surface-2:#181818;--surface-card:#181818;
|
||||
--fg:#f0f0f0;--ink-soft:#a0a0a0;--ink-faint:#777;
|
||||
--muted:#2a2a2a;--border:#3a2429;
|
||||
--accent-soft:#2a1a1a;--accent-2:#3a2429;
|
||||
--danger-soft:#2a1a1a;--danger-2:#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"]) .topbar .back{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .topbar .netpill{background:var(--surface-card);color:var(--ink-soft);border-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .topbar .netpill.off{color:#ff6680;border-color:#ff6680;background:var(--danger-soft)}
|
||||
:root:not([data-theme="light"]) .theme-btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .day-strip{background:var(--bg);border-bottom-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .day-chip{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .day-chip small{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .kpi{background:var(--surface-card);border-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .kpi .lbl{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .kpi .val{color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .sale-row{border-bottom-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .sale-row .nm b{color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .sale-row .nm small{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .method{background:var(--surface);border-color:var(--border);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .method.account{background:var(--accent-soft);border-color:var(--accent-2);color:#ff8a9e}
|
||||
:root:not([data-theme="light"]) .empty{color:var(--ink-faint)}
|
||||
:root:not([data-theme="light"]) .empty h3{color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .empty p{color:var(--ink-soft)}
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#0f0f0f;--surface:#1c1416;--surface-2:#181818;--surface-card:#181818;
|
||||
--fg:#f0f0f0;--ink-soft:#a0a0a0;--ink-faint:#777;
|
||||
--muted:#2a2a2a;--border:#3a2429;
|
||||
--accent-soft:#2a1a1a;--accent-2:#3a2429;
|
||||
--danger-soft:#2a1a1a;--danger-2:#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"] .topbar .back{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .topbar .netpill{background:var(--surface-card);color:var(--ink-soft);border-color:var(--muted)}
|
||||
:root[data-theme="dark"] .topbar .netpill.off{color:#ff6680;border-color:#ff6680;background:var(--danger-soft)}
|
||||
:root[data-theme="dark"] .theme-btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .day-strip{background:var(--bg);border-bottom-color:var(--muted)}
|
||||
:root[data-theme="dark"] .day-chip{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .day-chip small{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .kpi{background:var(--surface-card);border-color:var(--muted)}
|
||||
:root[data-theme="dark"] .kpi .lbl{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .kpi .val{color:var(--fg)}
|
||||
:root[data-theme="dark"] .sale-row{border-bottom-color:var(--muted)}
|
||||
:root[data-theme="dark"] .sale-row .nm b{color:var(--fg)}
|
||||
:root[data-theme="dark"] .sale-row .nm small{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .method{background:var(--surface);border-color:var(--border);color:var(--fg)}
|
||||
:root[data-theme="dark"] .method.account{background:var(--accent-soft);border-color:var(--accent-2);color:#ff8a9e}
|
||||
:root[data-theme="dark"] .empty{color:var(--ink-faint)}
|
||||
:root[data-theme="dark"] .empty h3{color:var(--fg)}
|
||||
:root[data-theme="dark"] .empty p{color:var(--ink-soft)}
|
||||
|
||||
@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>Sales history</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>
|
||||
|
||||
<div class="day-strip" id="days" data-od-id="day-strip"></div>
|
||||
<div class="summary" id="summary">
|
||||
<div class="kpi" data-od-id="kpi-sales"><div class="lbl">Sales</div><div class="val" id="kpiCount">—</div></div>
|
||||
<div class="kpi" data-od-id="kpi-total"><div class="lbl">Total</div><div class="val" id="kpiTotal">R 0</div></div>
|
||||
</div>
|
||||
<main class="list" id="list" data-od-id="sale-list">
|
||||
<div class="empty"><h3>Loading…</h3></div>
|
||||
</main>
|
||||
|
||||
<script src="pb-config.js"></script>
|
||||
<script src="theme.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
'use strict';
|
||||
const cfg = window.PB_CONFIG;
|
||||
const fmt = n => 'R ' + (Number(n)||0).toLocaleString('en-ZA',{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||
const esc = s => String(s||'').replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
|
||||
|
||||
// Network status indicator
|
||||
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();
|
||||
|
||||
const today = new Date();
|
||||
let activeDay = today.toISOString().slice(0,10);
|
||||
const days = [];
|
||||
for(let i=0;i<7;i++){
|
||||
const d = new Date(today); d.setDate(today.getDate()-i);
|
||||
days.push({key: d.toISOString().slice(0,10), label: d.toLocaleDateString('en-ZA',{weekday:'short'}), date: d.getDate(), month: d.toLocaleDateString('en-ZA',{month:'short'})});
|
||||
}
|
||||
|
||||
function paintDays(){
|
||||
const el = document.getElementById('days');
|
||||
el.innerHTML = days.map(d=>`<button class="day-chip ${d.key===activeDay?'active':''}" data-key="${d.key}">
|
||||
<small>${d.label}</small>${d.date} ${d.month}
|
||||
</button>`).join('');
|
||||
el.querySelectorAll('.day-chip').forEach(b=>b.addEventListener('click',()=>{
|
||||
activeDay = b.dataset.key;
|
||||
paintDays(); load();
|
||||
}));
|
||||
}
|
||||
|
||||
async function load(){
|
||||
const list = document.getElementById('list');
|
||||
list.innerHTML = '<div class="empty"><h3>Loading…</h3></div>';
|
||||
document.getElementById('kpiCount').textContent = '—';
|
||||
document.getElementById('kpiTotal').textContent = 'R 0';
|
||||
const start = activeDay + ' 00:00:00';
|
||||
const end = activeDay + ' 23:59:59';
|
||||
const filter = `created >= '${start}' && created <= '${end}'`;
|
||||
try{
|
||||
await window.pbAuth();
|
||||
const r = await window.pbFetch(`/api/collections/${cfg.collections.orders}/records?perPage=200&sort=-created&filter=${encodeURIComponent(filter)}`);
|
||||
const j = await r.json();
|
||||
const rows = [];
|
||||
let grandTotal = 0;
|
||||
for(const o of j.items){
|
||||
const li = await window.pbFetch(`/api/collections/${cfg.collections.orderItems}/records?perPage=200&filter=order='${o.id}'`);
|
||||
const liJ = await li.json();
|
||||
const items = liJ.items || [];
|
||||
const total = items.reduce((s,it)=> s + (it.selling_price||0) * (it.quantity||0), 0);
|
||||
grandTotal += total;
|
||||
const isAccount = /ACCOUNT/.test(o.notes||'');
|
||||
const method = isAccount ? 'Account' : (/EFT/.test(o.notes||'')?'EFT':(/CARD/.test(o.notes||'')?'Card':'Cash'));
|
||||
const dt = new Date(o.created);
|
||||
rows.push({o, items, total, method, dt});
|
||||
}
|
||||
document.getElementById('kpiCount').textContent = j.items.length;
|
||||
document.getElementById('kpiTotal').textContent = fmt(grandTotal);
|
||||
if(!rows.length){
|
||||
list.innerHTML = '<div class="empty"><h3>No sales</h3><p>No sales recorded on this day.</p></div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = rows.map(r=>{
|
||||
const dt = r.dt.toLocaleTimeString('en-ZA',{hour:'2-digit',minute:'2-digit'});
|
||||
const mClass = r.method.toLowerCase();
|
||||
return `<button class="sale-row" data-od-id="sale-${r.o.id}" onclick="reprint('${r.o.id}')">
|
||||
<div class="nm">
|
||||
<b>${esc(r.o.order_number||r.o.id.slice(-6))}</b>
|
||||
<small>${dt} · ${r.items.length} item${r.items.length===1?'':'s'}</small>
|
||||
</div>
|
||||
<span class="method ${mClass}">${esc(r.method)}</span>
|
||||
<div class="pr">${fmt(r.total)}</div>
|
||||
</button>`;
|
||||
}).join('');
|
||||
}catch(e){
|
||||
list.innerHTML = '<div class="empty"><h3>Offline</h3><p>Could not reach the register. Reconnect to view today\'s sales.</p></div>';
|
||||
}
|
||||
}
|
||||
|
||||
window.reprint = function(orderId){
|
||||
alert('Reprint: slip ' + orderId + '. (Use the browser print dialog to re-print.)');
|
||||
};
|
||||
|
||||
paintDays(); load();
|
||||
})();
|
||||
</script>
|
||||
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
|
||||
</body>
|
||||
</html>
|
||||
172
public/pos/index.html
Normal file
172
public/pos/index.html
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<!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>WFSales — Register</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), 0 16px 32px rgba(34,34,34,.08);
|
||||
--font:'Inter',system-ui,-apple-system,Segoe UI,Helvetica Neue,Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
a:focus-visible,button:focus-visible{outline:0;box-shadow:0 0 0 3px rgba(255,56,92,.30);border-radius:14px}
|
||||
.theme-btn:focus-visible{border-radius:50%}
|
||||
.card:focus-visible{border-color:var(--accent)}
|
||||
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:24px 20px 32px;background:var(--bg)}
|
||||
header{display:flex;align-items:center;gap:12px;padding:12px 0 24px}
|
||||
.logo{width:42px;height:42px;border-radius:10px;background:var(--accent);color:#fff;display:grid;place-items:center;font-weight:700;font-size:20px;letter-spacing:-.01em;flex:0 0 auto}
|
||||
.brand{display:flex;flex-direction:column;line-height:1.15}
|
||||
.brand .name{font-weight:700;font-size:17px;letter-spacing:-.01em}
|
||||
.brand .sub{font-size:12px;color:#717171;letter-spacing:.02em}
|
||||
.pill{margin-left:auto;display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border:1px solid var(--border);border-radius:999px;background:var(--surface);font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--fg)}
|
||||
.pill .dot{width:6px;height:6px;border-radius:50%;background:var(--ok)}
|
||||
.pill.offline .dot{background:var(--danger)}
|
||||
h1{font-size:28px;font-weight:700;letter-spacing:-.02em;line-height:1.15;margin:8px 0 4px}
|
||||
.lede{color:#717171;font-size:14px;line-height:1.55;margin-bottom:20px;max-width:38ch}
|
||||
.cards{display:grid;gap:12px;margin-top:auto}
|
||||
.card{display:flex;align-items:center;gap:14px;padding:18px 16px;background:#fff;border:1px solid var(--muted);border-radius:14px;text-decoration:none;color:var(--fg);box-shadow:var(--shadow);transition:transform .12s ease,box-shadow .12s ease;min-height:64px}
|
||||
.card:active{transform:translateY(1px);box-shadow:0 1px 2px rgba(34,34,34,.04),0 2px 6px rgba(34,34,34,.06)}
|
||||
.card .ic{width:44px;height:44px;border-radius:50%;background:var(--surface);border:1px solid var(--border);display:grid;place-items:center;color:var(--accent);flex:0 0 auto}
|
||||
.card .ic svg{width:22px;height:22px;stroke-width:1.7}
|
||||
.card .body{flex:1;min-width:0}
|
||||
.card .title{font-size:16px;font-weight:600;letter-spacing:-.01em;line-height:1.2}
|
||||
.card .desc{font-size:12px;color:#717171;margin-top:2px;line-height:1.4}
|
||||
.card .go{color:var(--accent);font-size:12px;font-weight:600;letter-spacing:.04em;text-transform:uppercase}
|
||||
.section-label{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#717171;margin:18px 4px 8px}
|
||||
.foot{margin-top:18px;text-align:center;font-size:11px;color:#999;letter-spacing:.04em}
|
||||
.foot b{color:var(--fg);font-weight:600}
|
||||
.statusbar{margin-top:14px;padding:10px 12px;border:1px solid var(--border);background:var(--surface);border-radius:10px;font-size:12px;color:#717171;display:flex;align-items:center;gap:8px}
|
||||
.statusbar .ic{width:14px;height:14px;flex:0 0 auto}
|
||||
|
||||
.theme-btn{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);flex:0 0 auto;background:transparent;border:0;cursor:pointer;position:relative}
|
||||
.theme-btn:active{background:var(--surface)}
|
||||
.theme-btn svg{width:18px;height:18px}
|
||||
.theme-btn .i-moon{display:none}
|
||||
:root[data-theme="dark"] .theme-btn .i-sun{display:none}
|
||||
:root[data-theme="dark"] .theme-btn .i-moon{display:block}
|
||||
|
||||
@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"]) .lede,:root:not([data-theme="light"]) .brand .sub,:root:not([data-theme="light"]) .card .desc,:root:not([data-theme="light"]) .foot{color:#a0a0a0}
|
||||
:root:not([data-theme="light"]) .card{background:#181818}
|
||||
:root:not([data-theme="light"]) .statusbar{background:#181818;border-color:#3a2429;color:#a0a0a0}
|
||||
:root:not([data-theme="light"]) .pill{background:#181818;border-color:#3a2429;color:#f0f0f0}
|
||||
:root:not([data-theme="light"]) .pill .dot{box-shadow:0 0 0 3px rgba(15,138,63,.2)}
|
||||
}
|
||||
: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"] .lede,:root[data-theme="dark"] .brand .sub,:root[data-theme="dark"] .card .desc,:root[data-theme="dark"] .foot{color:#a0a0a0}
|
||||
:root[data-theme="dark"] .card{background:#181818}
|
||||
:root[data-theme="dark"] .statusbar{background:#181818;border-color:#3a2429;color:#a0a0a0}
|
||||
:root[data-theme="dark"] .pill{background:#181818;border-color:#3a2429;color:#f0f0f0}
|
||||
@media (prefers-reduced-motion: reduce){*{transition-duration:0.01ms !important;animation-duration:0.01ms !important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="logo" aria-hidden="true">J</div>
|
||||
<div class="brand">
|
||||
<span class="name" id="brandName">WFSALES POS</span>
|
||||
<span class="sub" id="brandSub">Point of sale</span>
|
||||
</div>
|
||||
<span class="pill" id="netPill" data-state="online"><span class="dot"></span><span id="netLabel">Online</span></span>
|
||||
<button class="theme-btn" data-theme-toggle data-od-id="theme-toggle" aria-label="Toggle theme" title="Toggle theme" style="margin-left:6px" 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>
|
||||
|
||||
<h1>Register</h1>
|
||||
<p class="lede" id="lede">Start a sale, reprint a slip, or close out the till. Works offline — your sales sync when you reconnect.</p>
|
||||
|
||||
<div class="cards">
|
||||
<a class="card" data-od-id="launcher-card-pos" href="pos.html">
|
||||
<span class="ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h18l-2 12H5L3 7z"/><path d="M8 7V5a4 4 0 0 1 8 0v2"/></svg></span>
|
||||
<span class="body">
|
||||
<span class="title">New sale</span>
|
||||
<span class="desc">Browse catalog, build cart, take payment</span>
|
||||
</span>
|
||||
<span class="go">Open ›</span>
|
||||
</a>
|
||||
<a class="card" data-od-id="launcher-card-history" href="history.html">
|
||||
<span class="ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg></span>
|
||||
<span class="body">
|
||||
<span class="title">Sales history</span>
|
||||
<span class="desc">Reprint slip, view today's sales</span>
|
||||
</span>
|
||||
<span class="go">Open ›</span>
|
||||
</a>
|
||||
<a class="card" data-od-id="launcher-card-cashup" href="cashup.html">
|
||||
<span class="ic" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 19V8M9 19V8M14 19V8M19 19V8"/><path d="M3 5h18M3 21h18"/></svg></span>
|
||||
<span class="body">
|
||||
<span class="title">Cash-up</span>
|
||||
<span class="desc">End-of-day takings report</span>
|
||||
</span>
|
||||
<span class="go">Open ›</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Till</div>
|
||||
<div class="statusbar" id="statusBar">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
<span id="statusMsg">Connecting to register…</span>
|
||||
</div>
|
||||
|
||||
<p class="foot"><b id="prodCount">—</b> products · cache <span id="cacheState">pending</span></p>
|
||||
|
||||
<script src="pb-config.js"></script>
|
||||
<script src="theme.js"></script>
|
||||
<script>
|
||||
(async function(){
|
||||
const cfg = window.PB_CONFIG;
|
||||
// Letterhead (subline only — the app name is fixed; letterhead supplies the trading name)
|
||||
try{
|
||||
const r = await window.pbFetch('/api/collections/letterhead_settings/records?perPage=1&filter=is_active=true');
|
||||
const j = await r.json();
|
||||
if(j.items && j.items[0]){
|
||||
const lh = j.items[0];
|
||||
// Append the trading name to the subline, never replace the app name
|
||||
if(lh.address) document.getElementById('brandSub').textContent = lh.address.split('\n')[0];
|
||||
}
|
||||
}catch{}
|
||||
// Product count
|
||||
try{
|
||||
const r = await window.pbFetch('/api/collections/pos/records?perPage=1');
|
||||
const j = await r.json();
|
||||
document.getElementById('prodCount').textContent = j.totalItems?.toLocaleString() || '—';
|
||||
}catch{}
|
||||
// Cache state
|
||||
try{
|
||||
const c = await caches.open('pos-v1');
|
||||
const keys = await c.keys();
|
||||
document.getElementById('cacheState').textContent = keys.length ? 'ready' : 'cold';
|
||||
}catch{ document.getElementById('cacheState').textContent = '—'; }
|
||||
// Network pill
|
||||
function paintNet(){
|
||||
const on = navigator.onLine;
|
||||
const p = document.getElementById('netPill');
|
||||
const l = document.getElementById('netLabel');
|
||||
p.classList.toggle('offline', !on);
|
||||
l.textContent = on ? 'Online' : 'Offline';
|
||||
const s = document.getElementById('statusMsg');
|
||||
s.textContent = on ? 'Connected to PocketBase' : 'Offline — sales will sync when you reconnect';
|
||||
}
|
||||
window.addEventListener('online', paintNet);
|
||||
window.addEventListener('offline', paintNet);
|
||||
paintNet();
|
||||
if('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(()=>{});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
15
public/pos/manifest.json
Normal file
15
public/pos/manifest.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "WFSALES POS",
|
||||
"short_name": "WFSALES POS",
|
||||
"description": "WFSALES POS — point of sale",
|
||||
"start_url": "index.html",
|
||||
"scope": ".",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ff385c",
|
||||
"icons": [
|
||||
{ "src": "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 192 192'><rect width='192' height='192' rx='40' fill='%23ff385c'/><text x='96' y='128' font-family='-apple-system,sans-serif' font-size='100' font-weight='700' text-anchor='middle' fill='white'>W</text></svg>", "sizes": "192x192", "type": "image/svg+xml" },
|
||||
{ "src": "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><rect width='512' height='512' rx='100' fill='%23ff385c'/><text x='256' y='340' font-family='-apple-system,sans-serif' font-size='260' font-weight='700' text-anchor='middle' fill='white'>W</text></svg>", "sizes": "512x512", "type": "image/svg+xml" }
|
||||
]
|
||||
}
|
||||
633
public/pos/pos-app.js
Normal file
633
public/pos/pos-app.js
Normal file
|
|
@ -0,0 +1,633 @@
|
|||
// POS app — catalog, cart, payment, offline queue, slip generation.
|
||||
// All routes: /api/collections/{name}/records
|
||||
(function(){
|
||||
'use strict';
|
||||
|
||||
const cfg = window.PB_CONFIG;
|
||||
const CACHE = 'pos-v1';
|
||||
const LS_CART = 'pos.cart';
|
||||
const LS_QUEUE = 'pos.queue';
|
||||
|
||||
// ---------------- state ----------------
|
||||
const state = {
|
||||
products: [],
|
||||
productsById: new Map(),
|
||||
categories: [],
|
||||
activeCat: 'all',
|
||||
search: '',
|
||||
cart: new Map(), // id -> {p, qty}
|
||||
customer: null,
|
||||
paymentMethod: null,
|
||||
cashGiven: 0,
|
||||
letterhead: null
|
||||
};
|
||||
|
||||
// ---------------- IndexedDB ----------------
|
||||
const DB_NAME='pos', DB_VER=1, STORE_PRODUCTS='products', STORE_QUEUE='queue';
|
||||
let dbPromise = null;
|
||||
function openDB(){
|
||||
if(dbPromise) return dbPromise;
|
||||
dbPromise = new Promise((res,rej)=>{
|
||||
const req = indexedDB.open(DB_NAME, DB_VER);
|
||||
req.onupgradeneeded = e => {
|
||||
const db = e.target.result;
|
||||
if(!db.objectStoreNames.contains(STORE_PRODUCTS)) db.createObjectStore(STORE_PRODUCTS,{keyPath:'id'});
|
||||
if(!db.objectStoreNames.contains(STORE_QUEUE)) db.createObjectStore(STORE_QUEUE,{keyPath:'localId'});
|
||||
};
|
||||
req.onsuccess = e => res(e.target.result);
|
||||
req.onerror = e => rej(e.target.error);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
async function dbPut(store, val){
|
||||
const db = await openDB();
|
||||
return new Promise((res,rej)=>{
|
||||
const tx = db.transaction(store,'readwrite');
|
||||
tx.objectStore(store).put(val);
|
||||
tx.oncomplete = ()=>res();
|
||||
tx.onerror = e=>rej(e.target.error);
|
||||
});
|
||||
}
|
||||
async function dbAll(store){
|
||||
const db = await openDB();
|
||||
return new Promise((res,rej)=>{
|
||||
const tx = db.transaction(store,'readonly');
|
||||
const req = tx.objectStore(store).getAll();
|
||||
req.onsuccess = ()=>res(req.result||[]);
|
||||
req.onerror = e=>rej(e.target.error);
|
||||
});
|
||||
}
|
||||
async function dbDel(store, key){
|
||||
const db = await openDB();
|
||||
return new Promise((res,rej)=>{
|
||||
const tx = db.transaction(store,'readwrite');
|
||||
tx.objectStore(store).delete(key);
|
||||
tx.oncomplete = ()=>res();
|
||||
tx.onerror = e=>rej(e.target.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- formatting ----------------
|
||||
const fmt = n => 'R ' + (Number(n)||0).toLocaleString('en-ZA',{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||
const fmtN = n => (Number(n)||0).toLocaleString('en-ZA',{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||
const uid = ()=> 'loc_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2,8);
|
||||
|
||||
// ---------------- catalog loading ----------------
|
||||
async function loadProducts(){
|
||||
// Try IDB first
|
||||
try{
|
||||
const cached = await dbAll(STORE_PRODUCTS);
|
||||
if(cached.length){
|
||||
state.products = cached;
|
||||
state.productsById = new Map(cached.map(p=>[p.id,p]));
|
||||
buildCategories();
|
||||
render();
|
||||
}
|
||||
}catch{}
|
||||
// Then network refresh
|
||||
try{
|
||||
await window.pbAuth();
|
||||
const all = [];
|
||||
let page = 1;
|
||||
while(true){
|
||||
const r = await window.pbFetch(`/api/collections/${cfg.collections.products}/records?perPage=200&page=${page}&sort=description`);
|
||||
const j = await r.json();
|
||||
all.push(...j.items);
|
||||
if(j.page >= j.totalPages) break;
|
||||
page++;
|
||||
if(page > 30) break; // hard cap
|
||||
}
|
||||
state.products = all;
|
||||
state.productsById = new Map(all.map(p=>[p.id,p]));
|
||||
buildCategories();
|
||||
render();
|
||||
// Persist
|
||||
for(const p of all) await dbPut(STORE_PRODUCTS, p);
|
||||
}catch(e){
|
||||
console.warn('Network load failed', e);
|
||||
if(!state.products.length){
|
||||
const el = document.getElementById('empty');
|
||||
el.innerHTML = '<h3>Offline</h3><p>No cached catalog. Reconnect and reload.</p>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLetterhead(){
|
||||
try{
|
||||
const r = await window.pbFetch(`/api/collections/${cfg.collections.letterhead}/records?perPage=1&filter=is_active=true`);
|
||||
const j = await r.json();
|
||||
if(j.items && j.items[0]) state.letterhead = j.items[0];
|
||||
}catch{}
|
||||
}
|
||||
|
||||
function buildCategories(){
|
||||
const set = new Map();
|
||||
for(const p of state.products){
|
||||
if(!p.category) continue;
|
||||
set.set(p.category, (set.get(p.category)||0)+1);
|
||||
}
|
||||
state.categories = Array.from(set.entries()).sort((a,b)=>b[1]-a[1]);
|
||||
|
||||
// Label on the trigger
|
||||
const lbl = document.getElementById('catLabel');
|
||||
const cnt = document.getElementById('catCount');
|
||||
lbl.textContent = state.activeCat === 'all' ? 'All categories' : state.activeCat;
|
||||
cnt.textContent = state.products.length + ' products';
|
||||
|
||||
// Menu items
|
||||
const head = document.getElementById('catMenuItems');
|
||||
const total = state.products.length;
|
||||
const totalUncategorized = state.products.filter(p => !p.category).length;
|
||||
head.innerHTML =
|
||||
`<button class="cat-item ${state.activeCat==='all'?'active':''}" data-cat="all" role="option">
|
||||
<span class="nm">All categories</span><span class="ct">${total}</span>
|
||||
</button>` +
|
||||
(totalUncategorized ? `<button class="cat-item ${state.activeCat==='__uncat'?'active':''}" data-cat="__uncat" role="option">
|
||||
<span class="nm">Uncategorised</span><span class="ct">${totalUncategorized}</span>
|
||||
</button>` : '') +
|
||||
state.categories.map(([c,n])=>`<button class="cat-item ${state.activeCat===c?'active':''}" data-cat="${escapeAttr(c)}" role="option">
|
||||
<span class="nm">${escapeHTML(c)}</span><span class="ct">${n}</span>
|
||||
</button>`).join('');
|
||||
|
||||
head.querySelectorAll('.cat-item').forEach(b=>{
|
||||
b.addEventListener('click', ()=>{
|
||||
const v = b.dataset.cat;
|
||||
state.activeCat = v === '__uncat' ? '__uncat' : v;
|
||||
closeCatMenu();
|
||||
buildCategories();
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openCatMenu(){
|
||||
const menu = document.getElementById('catMenu');
|
||||
const back = document.getElementById('catBackdrop');
|
||||
const trigger = document.getElementById('catTrigger');
|
||||
// Position menu below the trigger
|
||||
const r = trigger.getBoundingClientRect();
|
||||
menu.style.top = (r.bottom + 6) + 'px';
|
||||
menu.hidden = false;
|
||||
back.hidden = false;
|
||||
trigger.setAttribute('aria-expanded','true');
|
||||
}
|
||||
function closeCatMenu(){
|
||||
const menu = document.getElementById('catMenu');
|
||||
const back = document.getElementById('catBackdrop');
|
||||
const trigger = document.getElementById('catTrigger');
|
||||
menu.hidden = true;
|
||||
back.hidden = true;
|
||||
trigger.setAttribute('aria-expanded','false');
|
||||
}
|
||||
|
||||
function filteredProducts(){
|
||||
const q = state.search.trim().toLowerCase();
|
||||
return state.products.filter(p=>{
|
||||
if(state.activeCat==='__uncat'){
|
||||
if(p.category) return false;
|
||||
} else if(state.activeCat!=='all' && p.category!==state.activeCat){
|
||||
return false;
|
||||
}
|
||||
if(!q) return true;
|
||||
return (p.sku||'').toLowerCase().includes(q)
|
||||
|| (p.sku_normalised||'').toLowerCase().includes(q)
|
||||
|| (p.description||'').toLowerCase().includes(q)
|
||||
|| (p.brand||'').toLowerCase().includes(q)
|
||||
|| (p.search_text||'').toLowerCase().includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
function render(){
|
||||
const list = filteredProducts();
|
||||
const cat = document.getElementById('catalog');
|
||||
if(!list.length){
|
||||
cat.innerHTML = '<div class="empty"><h3>No matches</h3><p>Try a different search or category.</p></div>';
|
||||
return;
|
||||
}
|
||||
cat.innerHTML = list.map(p=>{
|
||||
const price = p.selling_price ?? p.cost_price ?? 0;
|
||||
const inCart = state.cart.get(p.id)?.qty || 0;
|
||||
return `<button class="prod" data-id="${p.id}" data-od-id="prod-${p.id}">
|
||||
<div class="ic">${initials(p.category||p.brand||'?')}</div>
|
||||
<div class="body">
|
||||
<div class="name">${escapeHTML(p.description||p.sku||'—')}</div>
|
||||
<div class="sub">${escapeHTML(p.sku||'')}${p.brand?' · '+escapeHTML(p.brand):''}${p.unit?' · '+escapeHTML(p.unit):''}${inCart?' · '+inCart+' in cart':''}</div>
|
||||
</div>
|
||||
<div class="price">${fmt(price)}<small>/${escapeHTML(p.unit||'ea')}</small></div>
|
||||
</button>`;
|
||||
}).join('');
|
||||
cat.querySelectorAll('.prod').forEach(b=>{
|
||||
b.addEventListener('click', ()=>addToCart(b.dataset.id));
|
||||
});
|
||||
}
|
||||
|
||||
function initials(s){
|
||||
const parts = s.trim().split(/\s+/).slice(0,2);
|
||||
return parts.map(p=>p[0]||'').join('').toUpperCase().slice(0,2);
|
||||
}
|
||||
|
||||
// ---------------- cart ----------------
|
||||
function addToCart(id){
|
||||
const p = state.productsById.get(id);
|
||||
if(!p) return;
|
||||
const cur = state.cart.get(id);
|
||||
if(cur) cur.qty++;
|
||||
else state.cart.set(id, {p, qty:1});
|
||||
persistCart();
|
||||
render();
|
||||
paintCart();
|
||||
// tactile on the bar
|
||||
const bar = document.getElementById('cartBar');
|
||||
bar.animate([{transform:'translateY(0) scale(1)'},{transform:'translateY(-2px) scale(1.01)'},{transform:'translateY(0) scale(1)'}],{duration:200});
|
||||
}
|
||||
function changeQty(id, delta){
|
||||
const cur = state.cart.get(id);
|
||||
if(!cur) return;
|
||||
cur.qty += delta;
|
||||
if(cur.qty<=0) state.cart.delete(id);
|
||||
persistCart();
|
||||
render();
|
||||
paintCart();
|
||||
if(document.getElementById('cartDrawer').classList.contains('show')) renderCartDrawer();
|
||||
}
|
||||
function removeFromCart(id){
|
||||
state.cart.delete(id);
|
||||
persistCart();
|
||||
render();
|
||||
paintCart();
|
||||
renderCartDrawer();
|
||||
}
|
||||
function cartTotals(){
|
||||
let count=0, total=0;
|
||||
for(const {p,qty} of state.cart.values()){
|
||||
count += qty;
|
||||
total += (p.selling_price ?? p.cost_price ?? 0) * qty;
|
||||
}
|
||||
// VAT-inclusive model: selling_price is the shelf price (incl. VAT).
|
||||
// Subtotal excluding VAT = total / 1.15, VAT = total - sub
|
||||
const sub = total / 1.15;
|
||||
const vat = total - sub;
|
||||
return {count, sub, vat, total};
|
||||
}
|
||||
function persistCart(){
|
||||
const arr = Array.from(state.cart.entries()).map(([id,{p,qty}])=>({id, qty, p}));
|
||||
try{ localStorage.setItem(LS_CART, JSON.stringify(arr)); }catch{}
|
||||
}
|
||||
function loadCart(){
|
||||
try{
|
||||
const arr = JSON.parse(localStorage.getItem(LS_CART)||'[]');
|
||||
for(const {id,p,qty} of arr) state.cart.set(id,{p,qty});
|
||||
}catch{}
|
||||
}
|
||||
function paintCart(){
|
||||
const t = cartTotals();
|
||||
document.getElementById('cartCount').textContent = t.count;
|
||||
document.getElementById('cartTotal').textContent = fmt(t.total);
|
||||
document.getElementById('cartBar').classList.toggle('show', t.count>0);
|
||||
}
|
||||
|
||||
function openDrawer(){
|
||||
renderCartDrawer();
|
||||
document.getElementById('cartDrawer').classList.add('show');
|
||||
}
|
||||
function closeDrawer(){
|
||||
document.getElementById('cartDrawer').classList.remove('show');
|
||||
}
|
||||
function renderCartDrawer(){
|
||||
const body = document.getElementById('cartLines');
|
||||
if(state.cart.size===0){
|
||||
body.innerHTML = '<div class="empty"><h3>Empty cart</h3><p>Add products from the catalog.</p></div>';
|
||||
document.getElementById('subTotal').textContent = fmt(0);
|
||||
document.getElementById('grandTotal').textContent = fmt(0);
|
||||
return;
|
||||
}
|
||||
body.innerHTML = Array.from(state.cart.values()).map(({p,qty})=>{
|
||||
const price = p.selling_price ?? p.cost_price ?? 0;
|
||||
return `<div class="line" data-od-id="line-${p.id}">
|
||||
<div class="nm">${escapeHTML(p.description||p.sku)}<small>${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}</small></div>
|
||||
<div class="qty">
|
||||
<button onclick="window.__pos.changeQty('${p.id}',-1)" aria-label="Decrease">−</button>
|
||||
<div class="v">${qty}</div>
|
||||
<button onclick="window.__pos.changeQty('${p.id}',1)" aria-label="Increase">+</button>
|
||||
</div>
|
||||
<div class="pr">${fmt(price*qty)}</div>
|
||||
<button class="rm" onclick="window.__pos.removeFromCart('${p.id}')" aria-label="Remove">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const t = cartTotals();
|
||||
document.getElementById('subTotal').textContent = fmt(t.sub);
|
||||
document.getElementById('vatTotal').textContent = fmt(t.vat);
|
||||
document.getElementById('grandTotal').textContent = fmt(t.total);
|
||||
}
|
||||
|
||||
// ---------------- payment ----------------
|
||||
function openPayment(){
|
||||
if(state.cart.size===0) return;
|
||||
const t = cartTotals();
|
||||
state.paymentMethod = null;
|
||||
state.cashGiven = 0;
|
||||
state.customer = null;
|
||||
document.getElementById('payTotal').textContent = fmt(t.total);
|
||||
document.getElementById('paySub').textContent = fmt(t.sub);
|
||||
document.getElementById('payVat').textContent = fmt(t.vat);
|
||||
document.getElementById('dueIn').value = fmt(t.total);
|
||||
document.getElementById('givenIn').value = '';
|
||||
document.getElementById('changeOut').classList.remove('show','short');
|
||||
document.getElementById('cashBlock').classList.remove('show');
|
||||
document.getElementById('custBlock').classList.remove('show');
|
||||
document.getElementById('custPicked').classList.remove('show');
|
||||
document.getElementById('custResults').innerHTML = '';
|
||||
document.getElementById('custQ').value = '';
|
||||
document.querySelectorAll('.pay-tile').forEach(t=>t.classList.remove('selected'));
|
||||
document.getElementById('confirmBtn').disabled = true;
|
||||
buildQuickAmounts(t.total);
|
||||
document.getElementById('payDrawer').classList.add('show');
|
||||
}
|
||||
function closePayment(){
|
||||
document.getElementById('payDrawer').classList.remove('show');
|
||||
}
|
||||
function buildQuickAmounts(total){
|
||||
// Rounded suggestions above the total
|
||||
const base = Math.ceil(total/10)*10;
|
||||
const suggestions = [];
|
||||
if(base>total) suggestions.push(base);
|
||||
for(const m of [50,100,200,500,1000]){
|
||||
const v = Math.ceil(total/m)*m;
|
||||
if(v>total && !suggestions.includes(v)) suggestions.push(v);
|
||||
}
|
||||
suggestions.sort((a,b)=>a-b);
|
||||
document.getElementById('quick').innerHTML = suggestions.slice(0,5).map(v=>`<button data-v="${v}">${fmt(v)}</button>`).join('');
|
||||
document.getElementById('quick').querySelectorAll('button').forEach(b=>{
|
||||
b.addEventListener('click',()=>{
|
||||
document.getElementById('givenIn').value = fmtN(b.dataset.v);
|
||||
state.cashGiven = Number(b.dataset.v);
|
||||
paintChange();
|
||||
});
|
||||
});
|
||||
}
|
||||
function pickMethod(m){
|
||||
state.paymentMethod = m;
|
||||
document.querySelectorAll('.pay-tile').forEach(t=>t.classList.toggle('selected', t.dataset.meth===m));
|
||||
document.getElementById('cashBlock').classList.toggle('show', m==='cash');
|
||||
document.getElementById('custBlock').classList.toggle('show', m==='account');
|
||||
if(m!=='cash') document.getElementById('changeOut').classList.remove('show');
|
||||
if(m!=='account') document.getElementById('custPicked').classList.remove('show');
|
||||
validateConfirm();
|
||||
if(m==='cash'){
|
||||
setTimeout(()=>document.getElementById('givenIn').focus(), 250);
|
||||
} else if(m==='account'){
|
||||
setTimeout(()=>document.getElementById('custQ').focus(), 250);
|
||||
}
|
||||
}
|
||||
function paintChange(){
|
||||
const total = cartTotals().total;
|
||||
const given = state.cashGiven || 0;
|
||||
const change = given - total;
|
||||
const out = document.getElementById('changeOut');
|
||||
if(given<=0){ out.classList.remove('show','short'); return; }
|
||||
out.classList.add('show');
|
||||
out.classList.toggle('short', change<-0.005);
|
||||
document.getElementById('changeAmt').textContent = (change>=0?'+ ':'− ') + fmt(Math.abs(change));
|
||||
validateConfirm();
|
||||
}
|
||||
function validateConfirm(){
|
||||
const m = state.paymentMethod;
|
||||
const btn = document.getElementById('confirmBtn');
|
||||
if(!m){ btn.disabled = true; return; }
|
||||
if(m==='cash'){
|
||||
btn.disabled = !(state.cashGiven >= cartTotals().total - 0.005);
|
||||
} else if(m==='account'){
|
||||
btn.disabled = !state.customer;
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- customer search ----------------
|
||||
let custDebounce;
|
||||
document.addEventListener('input', e=>{
|
||||
if(e.target.id!=='custQ') return;
|
||||
clearTimeout(custDebounce);
|
||||
const q = e.target.value.trim();
|
||||
custDebounce = setTimeout(()=>searchCustomers(q), 200);
|
||||
});
|
||||
document.addEventListener('input', e=>{
|
||||
if(e.target.id!=='givenIn') return;
|
||||
const raw = e.target.value.replace(/[^0-9.]/g,'');
|
||||
e.target.value = raw;
|
||||
state.cashGiven = Number(raw)||0;
|
||||
paintChange();
|
||||
});
|
||||
async function searchCustomers(q){
|
||||
const box = document.getElementById('custResults');
|
||||
if(!q){ box.innerHTML=''; return; }
|
||||
try{
|
||||
const f = `(customer~'${q}' || email~'${q}' || number~'${q}')`;
|
||||
const r = await window.pbFetch(`/api/collections/${cfg.collections.customers}/records?perPage=8&filter=${encodeURIComponent(f)}`);
|
||||
const j = await r.json();
|
||||
box.innerHTML = j.items.map(c=>`<div data-id="${c.id}" data-name="${escapeAttr(c.customer||c.email||c.number||'?')}">
|
||||
<span>${escapeHTML(c.customer||c.email||'—')}</span>
|
||||
<small>${escapeHTML(c.number||c.email||'')}</small>
|
||||
</div>`).join('');
|
||||
box.querySelectorAll('div').forEach(d=>{
|
||||
d.addEventListener('click',()=>{
|
||||
state.customer = {id:d.dataset.id, name:d.dataset.name};
|
||||
const pk = document.getElementById('custPicked');
|
||||
pk.classList.add('show');
|
||||
pk.querySelector('b').textContent = d.dataset.name;
|
||||
document.getElementById('custQ').value = '';
|
||||
box.innerHTML = '';
|
||||
validateConfirm();
|
||||
});
|
||||
});
|
||||
}catch{}
|
||||
}
|
||||
function clearCust(){
|
||||
state.customer = null;
|
||||
document.getElementById('custPicked').classList.remove('show');
|
||||
validateConfirm();
|
||||
}
|
||||
|
||||
// ---------------- save sale ----------------
|
||||
async function confirmSale(){
|
||||
const btn = document.getElementById('confirmBtn');
|
||||
if(btn.disabled) return;
|
||||
btn.disabled = true; btn.innerHTML = 'Saving…';
|
||||
const items = Array.from(state.cart.values()).map(({p,qty})=>({
|
||||
sku: p.sku, description: p.description, unit: p.unit, brand: p.brand,
|
||||
category: p.category, subcategory: p.subcategory, supplier: p.supplier,
|
||||
cost_price: p.cost_price, selling_price: p.selling_price, qty, product_id: p.id
|
||||
}));
|
||||
const totals = cartTotals();
|
||||
const sub = totals.sub, vat = totals.vat, total = totals.total;
|
||||
const sale = {
|
||||
localId: uid(),
|
||||
createdAt: new Date().toISOString(),
|
||||
payment: state.paymentMethod,
|
||||
cashGiven: state.cashGiven || null,
|
||||
change: state.paymentMethod==='cash' ? Math.max(0, state.cashGiven - total) : 0,
|
||||
sub, vat, total, items,
|
||||
customer: state.customer || null,
|
||||
letterhead: state.letterhead ? {name: state.letterhead.display_name, address: state.letterhead.address, phone: state.letterhead.phone, email: state.letterhead.email, vat_number: state.letterhead.vat_number, registration_number: state.letterhead.registration_number, footer_text: state.letterhead.footer_text, logo_url: state.letterhead.logo} : null,
|
||||
synced: false
|
||||
};
|
||||
|
||||
// Try to write to PocketBase now; if offline or fails, queue.
|
||||
let serverRef = null;
|
||||
let serverError = null;
|
||||
try{
|
||||
const orderBody = {
|
||||
order_number: 'S-' + Date.now().toString(36).toUpperCase(),
|
||||
notes: 'POS sale · ' + sale.payment + (sale.customer?` · ${sale.customer.name}`:''),
|
||||
invoiced: sale.payment==='account' ? false : true,
|
||||
};
|
||||
if(sale.customer) orderBody.customer = sale.customer.id;
|
||||
const r = await window.pbFetch('/api/collections/'+cfg.collections.orders+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(orderBody)});
|
||||
if(r.ok){
|
||||
const j = await r.json();
|
||||
serverRef = j.id;
|
||||
// Line items
|
||||
for(const it of sale.items){
|
||||
try{
|
||||
await window.pbFetch('/api/collections/'+cfg.collections.orderItems+'/records',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({order: j.id, description: it.description, quantity: it.qty, cost_price: it.cost_price, selling_price: it.selling_price, item_status:'invoiced'})
|
||||
});
|
||||
}catch(liErr){ console.warn('line item write failed', liErr); }
|
||||
}
|
||||
// If account sale, create delivery note
|
||||
if(sale.payment==='account'){
|
||||
try{
|
||||
const dn = await window.pbFetch('/api/collections/'+cfg.collections.deliveryNotes+'/records',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({delivery_note_number: 'DN-'+Date.now().toString(36).toUpperCase(), order: j.id, customer: sale.customer?.id, status:'pending', notes:'Account sale — deliver to customer.'})
|
||||
});
|
||||
if(dn.ok){
|
||||
const dnJ = await dn.json();
|
||||
for(const it of sale.items){
|
||||
try{
|
||||
await window.pbFetch('/api/collections/'+cfg.collections.deliveryNoteItems+'/records',{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({delivery_note: dnJ.id, description: it.description, quantity: it.qty})
|
||||
});
|
||||
}catch(_){}
|
||||
}
|
||||
}
|
||||
}catch(dnErr){ console.warn('delivery note write failed', dnErr); }
|
||||
}
|
||||
sale.synced = true;
|
||||
sale.serverRef = serverRef;
|
||||
} else {
|
||||
serverError = 'server returned ' + r.status;
|
||||
throw new Error(serverError);
|
||||
}
|
||||
}catch(e){
|
||||
console.warn('Sale queued for sync', e);
|
||||
// Queue locally — never let this throw escape
|
||||
try{
|
||||
await dbPut(STORE_QUEUE, sale);
|
||||
}catch(qErr){
|
||||
console.error('IndexedDB queue failed too — saving to localStorage as fallback', qErr);
|
||||
try{
|
||||
const arr = JSON.parse(localStorage.getItem('wfsales.queue') || '[]');
|
||||
arr.push(sale);
|
||||
localStorage.setItem('wfsales.queue', JSON.stringify(arr));
|
||||
}catch(_){}
|
||||
}
|
||||
}
|
||||
|
||||
// ALWAYS navigate to receipt — never leave the user stuck on "Saving…"
|
||||
try{ sessionStorage.setItem('pos.lastSale', JSON.stringify(sale)); }catch(_){}
|
||||
state.cart.clear(); persistCart(); paintCart(); render();
|
||||
closePayment();
|
||||
// Use a small delay so the drawer close animation starts before navigation
|
||||
setTimeout(()=>{ location.href = 'receipt.html?id=' + encodeURIComponent(sale.localId); }, 60);
|
||||
}
|
||||
|
||||
// ---------------- network pill + boot ----------------
|
||||
function paintNet(){
|
||||
const p = document.getElementById('netpill');
|
||||
p.textContent = navigator.onLine ? 'Online' : 'Offline';
|
||||
p.classList.toggle('off', !navigator.onLine);
|
||||
}
|
||||
window.addEventListener('online', ()=>{ paintNet(); flushQueue(); });
|
||||
window.addEventListener('offline', paintNet);
|
||||
|
||||
async function flushQueue(){
|
||||
if(!navigator.onLine) return;
|
||||
const q = await dbAll(STORE_QUEUE);
|
||||
for(const s of q){
|
||||
try{
|
||||
const r = await window.pbFetch('/api/collections/'+cfg.collections.orders+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({order_number:'S-'+Date.now().toString(36).toUpperCase(), notes:'POS sale (offline) · '+s.payment, invoiced: s.payment!=='account'})});
|
||||
if(r.ok){
|
||||
const j = await r.json();
|
||||
for(const it of s.items){
|
||||
await window.pbFetch('/api/collections/'+cfg.collections.orderItems+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({order:j.id, description:it.description, quantity:it.qty, cost_price:it.cost_price, selling_price:it.selling_price, item_status:'invoiced'})});
|
||||
}
|
||||
if(s.payment==='account' && s.customer){
|
||||
const dn = await window.pbFetch('/api/collections/'+cfg.collections.deliveryNotes+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delivery_note_number:'DN-'+Date.now().toString(36).toUpperCase(), order:j.id, customer:s.customer.id, status:'pending'})});
|
||||
if(dn.ok){ const dnJ = await dn.json(); for(const it of s.items){ await window.pbFetch('/api/collections/'+cfg.collections.deliveryNoteItems+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delivery_note:dnJ.id, description:it.description, quantity:it.qty})}); } }
|
||||
}
|
||||
await dbDel(STORE_QUEUE, s.localId);
|
||||
}
|
||||
}catch(e){ console.warn('queue item failed', e); break; }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- search input + topbar scroll ----------------
|
||||
document.addEventListener('input', e=>{
|
||||
if(e.target.id!=='q') return;
|
||||
state.search = e.target.value;
|
||||
render();
|
||||
});
|
||||
const topbar = document.getElementById('topbar');
|
||||
const cat = document.getElementById('catalog');
|
||||
cat.addEventListener('scroll', ()=>topbar.classList.toggle('scrolled', cat.scrollTop>4));
|
||||
|
||||
// ---------------- category dropdown wiring ----------------
|
||||
document.getElementById('catTrigger').addEventListener('click', e=>{
|
||||
e.stopPropagation();
|
||||
const t = e.currentTarget;
|
||||
if(t.getAttribute('aria-expanded')==='true') closeCatMenu();
|
||||
else openCatMenu();
|
||||
});
|
||||
document.getElementById('catBackdrop').addEventListener('click', closeCatMenu);
|
||||
document.addEventListener('keydown', e=>{
|
||||
if(e.key==='Escape'){
|
||||
const t = document.getElementById('catTrigger');
|
||||
if(t && t.getAttribute('aria-expanded')==='true') closeCatMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------- utils ----------------
|
||||
function escapeHTML(s){ return String(s).replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]); }
|
||||
function escapeAttr(s){ return escapeHTML(s).replace(/`/g,'`'); }
|
||||
|
||||
// expose for inline handlers
|
||||
window.__pos = { changeQty, removeFromCart, openDrawer, closeDrawer, openPayment, closePayment, pickMethod, confirmSale };
|
||||
// Also expose as bare globals so inline onclick="" in pos.html works
|
||||
window.openDrawer = openDrawer;
|
||||
window.closeDrawer = closeDrawer;
|
||||
window.openPayment = openPayment;
|
||||
window.closePayment = closePayment;
|
||||
window.pickMethod = pickMethod;
|
||||
window.confirmSale = confirmSale;
|
||||
window.changeQty = changeQty;
|
||||
window.removeFromCart = removeFromCart;
|
||||
|
||||
// ---------------- boot ----------------
|
||||
(async function boot(){
|
||||
paintNet();
|
||||
loadCart();
|
||||
paintCart();
|
||||
await Promise.all([loadLetterhead(), loadProducts()]);
|
||||
flushQueue();
|
||||
// register SW
|
||||
if('serviceWorker' in navigator){
|
||||
navigator.serviceWorker.register('sw.js').catch(()=>{});
|
||||
}
|
||||
})();
|
||||
|
||||
})();
|
||||
393
public/pos/pos.html
Normal file
393
public/pos/pos.html
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no">
|
||||
<meta name="theme-color" content="#ff385c">
|
||||
<title>New sale</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-up:0 -1px 0 var(--muted), 0 -8px 24px 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:15px/1.45 var(--font);-webkit-font-smoothing:antialiased;overscroll-behavior:none;overflow-x:hidden}
|
||||
body{display:flex;flex-direction:column;min-height:100dvh;max-width:480px;margin:0 auto;background:var(--bg);padding-bottom:env(safe-area-inset-bottom)}
|
||||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||||
button:focus-visible,input:focus-visible,select:focus-visible{outline:0;box-shadow:0 0 0 3px rgba(255,56,92,.30);border-radius:10px}
|
||||
.topbar .back:focus-visible,.topbar .theme-btn:focus-visible{box-shadow:0 0 0 3px rgba(255,56,92,.30);border-radius:50%}
|
||||
input{font:inherit;color:inherit}
|
||||
|
||||
/* ---- Topbar ---- */
|
||||
.topbar{display:flex;align-items:center;gap:10px;padding:14px 16px 10px;background:var(--bg);position:sticky;top:0;z-index:10;border-bottom:1px solid transparent;transition:border-color .15s}
|
||||
.topbar.scrolled{border-bottom-color:var(--muted)}
|
||||
.topbar .back{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg)}
|
||||
.topbar .back:active{background:var(--surface)}
|
||||
.topbar h1{font-size:17px;font-weight:600;letter-spacing:-.01em;flex:1}
|
||||
.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}
|
||||
.topbar .netpill.off{color:var(--danger);border-color:var(--danger);background:#fff5f5}
|
||||
|
||||
/* ---- Search ---- */
|
||||
.search{padding:6px 16px 10px;background:var(--bg);position:sticky;top:60px;z-index:9}
|
||||
.search-box{display:flex;align-items:center;gap:10px;padding:11px 14px;border:1px solid var(--muted);border-radius:12px;background:#fff;box-shadow:0 1px 2px rgba(34,34,34,.02)}
|
||||
.search-box:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.12)}
|
||||
.search-box svg{width:18px;height:18px;color:#717171;flex:0 0 auto;stroke-width:1.7}
|
||||
.search-box input{flex:1;border:0;outline:0;background:transparent;font-size:15px;min-width:0}
|
||||
.search-box input::placeholder{color:#9a9a9a}
|
||||
|
||||
/* ---- Category dropdown ---- */
|
||||
.cat-row{display:flex;align-items:center;gap:10px;padding:4px 16px 12px;background:var(--bg);position:sticky;top:118px;z-index:8}
|
||||
.cat-trigger{display:flex;align-items:center;gap:8px;padding:10px 14px;border:1px solid var(--muted);border-radius:10px;background:#fff;font-size:14px;font-weight:500;color:var(--fg);min-height:42px;max-width:65%;letter-spacing:-.005em}
|
||||
.cat-trigger:active{background:var(--surface)}
|
||||
.cat-trigger .cat-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.cat-trigger .cat-chev{width:16px;height:16px;flex:0 0 auto;color:#717171;transition:transform .18s}
|
||||
.cat-trigger[aria-expanded="true"] .cat-chev{transform:rotate(180deg)}
|
||||
.cat-count{font-size:12px;color:#717171;letter-spacing:.01em;font-variant-numeric:tabular-nums}
|
||||
|
||||
.cat-menu{position:fixed;left:16px;right:16px;top:auto;background:#fff;border:1px solid var(--muted);border-radius:14px;box-shadow:0 12px 40px rgba(34,34,34,.18);z-index:60;max-width:448px;margin:0 auto;overflow:hidden;transform-origin:top center;animation:catIn .15s ease}
|
||||
@keyframes catIn{from{opacity:0;transform:translateY(-4px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}
|
||||
.cat-menu[hidden]{display:none}
|
||||
.cat-menu-head{max-height:60dvh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:4px 0}
|
||||
.cat-item{display:flex;align-items:center;gap:12px;padding:12px 16px;width:100%;text-align:left;font-size:14px;color:var(--fg);font-weight:500;letter-spacing:-.005em;border-bottom:1px solid var(--muted);background:none;min-height:44px}
|
||||
.cat-item:last-child{border-bottom:0}
|
||||
.cat-item:active{background:var(--surface)}
|
||||
.cat-item .nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.cat-item .ct{font-size:12px;color:#717171;font-variant-numeric:tabular-nums;letter-spacing:.02em;flex:0 0 auto}
|
||||
.cat-item.active{background:var(--surface);color:var(--accent)}
|
||||
.cat-item.active .ct{color:var(--accent)}
|
||||
.cat-item.active::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--accent);flex:0 0 auto}
|
||||
|
||||
.cat-backdrop{position:fixed;inset:0;background:transparent;z-index:55}
|
||||
.cat-backdrop[hidden]{display:none}
|
||||
|
||||
/* ---- Catalog list ---- */
|
||||
.catalog{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:0 16px 220px}
|
||||
.catalog .meta{padding:12px 0 6px;font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#717171;display:flex;justify-content:space-between}
|
||||
.catalog .empty{padding:60px 20px;text-align:center;color:#9a9a9a}
|
||||
.catalog .empty h3{font-size:16px;font-weight:600;color:var(--fg);margin-bottom:6px;letter-spacing:-.01em}
|
||||
.catalog .empty p{font-size:13px;line-height:1.5}
|
||||
|
||||
.prod{display:flex;align-items:center;gap:12px;padding:12px 0;border-bottom:1px solid var(--muted);min-height:60px;background:none;border-left:0;border-right:0;border-top:0;width:100%;text-align:left}
|
||||
.prod:active{background:var(--surface)}
|
||||
.prod .ic{width:40px;height:40px;border-radius:10px;background:var(--surface);border:1px solid var(--border);display:grid;place-items:center;color:var(--accent);font-size:12px;font-weight:700;letter-spacing:.04em;flex:0 0 auto}
|
||||
.prod .body{flex:1;min-width:0;padding-right:8px}
|
||||
.prod .name{font-size:14px;font-weight:500;line-height:1.3;letter-spacing:-.005em;color:var(--fg);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
|
||||
.prod .sub{font-size:11px;color:#717171;margin-top:2px;letter-spacing:.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.prod .price{font-size:14px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.prod .price small{font-size:11px;color:#717171;font-weight:500;margin-left:2px}
|
||||
|
||||
/* ---- Cart bottom sheet ---- */
|
||||
.cart-bar{position:fixed;left:0;right:0;bottom:0;background:#fff;border-top:1px solid var(--muted);padding:12px 16px calc(12px + env(safe-area-inset-bottom));z-index:20;display:flex;align-items:center;gap:12px;box-shadow:var(--shadow-up);max-width:480px;margin:0 auto;transform:translateY(100%);transition:transform .22s cubic-bezier(.2,.8,.2,1)}
|
||||
.cart-bar.show{transform:translateY(0)}
|
||||
.cart-bar .count{display:flex;flex-direction:column;line-height:1.15}
|
||||
.cart-bar .count b{font-size:20px;font-weight:700;letter-spacing:-.02em;font-variant-numeric:tabular-nums}
|
||||
.cart-bar .count span{font-size:11px;color:#717171;letter-spacing:.04em;text-transform:uppercase}
|
||||
.cart-bar .total{margin-left:auto;font-size:20px;font-weight:700;letter-spacing:-.02em;color:var(--fg);font-variant-numeric:tabular-nums}
|
||||
.cart-bar .checkout{padding:14px 22px;background:var(--accent);color:#fff;border-radius:12px;font-weight:600;font-size:15px;letter-spacing:-.005em;min-height:48px;display:flex;align-items:center;gap:6px}
|
||||
.cart-bar .checkout:active{background:#e5324f}
|
||||
.cart-bar .checkout svg{width:18px;height:18px;stroke-width:2}
|
||||
|
||||
/* ---- Cart drawer ---- */
|
||||
.drawer{position:fixed;inset:0;background:rgba(34,34,34,.5);z-index:50;opacity:0;pointer-events:none;transition:opacity .2s}
|
||||
.drawer.show{opacity:1;pointer-events:auto}
|
||||
.sheet{position:absolute;left:0;right:0;bottom:0;background:#fff;border-radius:18px 18px 0 0;max-height:88dvh;display:flex;flex-direction:column;transform:translateY(100%);transition:transform .25s cubic-bezier(.2,.8,.2,1);max-width:480px;margin:0 auto;padding-bottom:env(safe-area-inset-bottom)}
|
||||
.drawer.show .sheet{transform:translateY(0)}
|
||||
.sheet-head{display:flex;align-items:center;padding:18px 20px 8px;border-bottom:1px solid var(--muted)}
|
||||
.sheet-head h2{font-size:18px;font-weight:700;letter-spacing:-.01em;flex:1}
|
||||
.sheet-head .x{width:32px;height:32px;border-radius:50%;background:var(--surface);display:grid;place-items:center;color:var(--fg)}
|
||||
.sheet-body{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:4px 0}
|
||||
.line{display:flex;align-items:center;gap:12px;padding:14px 20px;border-bottom:1px solid var(--muted)}
|
||||
.line .nm{flex:1;min-width:0;font-size:14px;font-weight:500;line-height:1.3;letter-spacing:-.005em}
|
||||
.line .nm small{display:block;font-size:11px;color:#717171;margin-top:2px;letter-spacing:.01em}
|
||||
.qty{display:flex;align-items:center;border:1px solid var(--muted);border-radius:10px;overflow:hidden}
|
||||
.qty button{width:34px;height:34px;display:grid;place-items:center;color:var(--fg);font-size:16px;font-weight:600}
|
||||
.qty button:active{background:var(--surface)}
|
||||
.qty .v{width:30px;text-align:center;font-size:14px;font-weight:600;font-variant-numeric:tabular-nums}
|
||||
.line .pr{width:78px;text-align:right;font-size:14px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--fg)}
|
||||
.line .rm{width:32px;height:32px;border-radius:50%;color:#9a9a9a;display:grid;place-items:center}
|
||||
.line .rm:active{background:#fff0f0;color:var(--danger)}
|
||||
.sheet-foot{padding:14px 20px 18px;border-top:1px solid var(--muted);background:#fff}
|
||||
.totals{display:flex;justify-content:space-between;padding:6px 0;font-size:14px;color:#717171;letter-spacing:.01em}
|
||||
.totals b{color:var(--fg);font-variant-numeric:tabular-nums}
|
||||
.totals.grand{padding-top:12px;margin-top:8px;border-top:1px solid var(--muted);font-size:17px;color:var(--fg);font-weight:700;letter-spacing:-.01em}
|
||||
|
||||
/* ---- Payment sheet ---- */
|
||||
.pay-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:8px 20px 16px}
|
||||
.pay-tile{padding:18px 14px;border:1.5px solid var(--muted);border-radius:14px;background:#fff;text-align:left;min-height:92px;display:flex;flex-direction:column;justify-content:space-between}
|
||||
.pay-tile:active{background:var(--surface)}
|
||||
.pay-tile .lab{font-size:14px;font-weight:600;letter-spacing:-.01em}
|
||||
.pay-tile .sub{font-size:11px;color:#717171;margin-top:2px;letter-spacing:.01em}
|
||||
.pay-tile .ic{width:32px;height:32px;border-radius:8px;background:var(--surface);color:var(--accent);display:grid;place-items:center;margin-bottom:8px}
|
||||
.pay-tile .ic svg{width:18px;height:18px;stroke-width:1.7}
|
||||
.pay-tile.selected{border-color:var(--accent);background:#fff8fa}
|
||||
.pay-tile.selected .ic{background:var(--accent);color:#fff}
|
||||
|
||||
.cash-block{padding:0 16px 20px;display:none}
|
||||
.cash-block.show{display:block}
|
||||
.cash-row{display:flex;gap:8px;margin-bottom:10px;min-width:0}
|
||||
.cash-row .field{flex:1 1 0;min-width:0;display:flex;flex-direction:column}
|
||||
.cash-row label{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:#717171;margin-bottom:4px}
|
||||
.cash-row input{width:100%;box-sizing:border-box;padding:12px 10px;border:1px solid var(--muted);border-radius:10px;font-size:16px;font-weight:600;text-align:right;font-variant-numeric:tabular-nums;outline:0;background:#fff;min-width:0}
|
||||
.cash-row input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.12)}
|
||||
.quick{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
|
||||
.quick button{padding:9px 13px;border:1px solid var(--muted);border-radius:999px;font-size:13px;font-weight:500;background:#fff;color:var(--fg);min-height:36px}
|
||||
.quick button:active{background:var(--surface);border-color:#bbb}
|
||||
.change-out{padding:14px;border-radius:12px;background:var(--surface);border:1px solid var(--border);text-align:center;min-height:64px;display:flex;flex-direction:column;justify-content:center;opacity:0;max-height:0;padding-top:0;padding-bottom:0;overflow:hidden;transition:opacity .15s, max-height .2s, padding .2s, border-width .2s;border-width:0}
|
||||
.change-out.show{opacity:1;max-height:120px;padding:14px;border-width:1px}
|
||||
.change-out .lbl{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#717171}
|
||||
.change-out .amt{font-size:32px;font-weight:700;letter-spacing:-.02em;color:var(--ok);font-variant-numeric:tabular-nums;margin-top:4px}
|
||||
.change-out.short .amt{color:var(--danger)}
|
||||
|
||||
.cust-block{padding:0 16px 16px;display:none}
|
||||
.cust-block.show{display:block}
|
||||
.cust-block .search-row{display:flex;gap:8px;margin-bottom:10px;min-width:0}
|
||||
.cust-block input{flex:1 1 0;width:100%;box-sizing:border-box;min-width:0;padding:12px 10px;border:1px solid var(--muted);border-radius:10px;font-size:14px;outline:0;background:#fff}
|
||||
.cust-block input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.12)}
|
||||
.cust-results{border:1px solid var(--muted);border-radius:10px;max-height:160px;overflow-y:auto;background:#fff}
|
||||
.cust-results:empty{display:none}
|
||||
.cust-results div{padding:10px 14px;border-bottom:1px solid var(--muted);font-size:14px;display:flex;align-items:center;gap:8px;cursor:pointer}
|
||||
.cust-results div:last-child{border-bottom:0}
|
||||
.cust-results div:active{background:var(--surface)}
|
||||
.cust-results small{color:#717171;font-size:11px;margin-left:auto;letter-spacing:.02em}
|
||||
.cust-picked{padding:10px 12px;background:var(--surface);border:1px solid var(--border);border-radius:10px;font-size:13px;display:none;align-items:center;gap:8px;margin-bottom:8px}
|
||||
.cust-picked.show{display:flex}
|
||||
.cust-picked b{flex:1;font-weight:600}
|
||||
.cust-picked button{color:var(--danger);font-size:12px;font-weight:600;letter-spacing:.04em;text-transform:uppercase}
|
||||
|
||||
.final-cta{width:calc(100% - 40px);margin:0 20px 18px;padding:16px;background:var(--accent);color:#fff;border-radius:14px;font-size:16px;font-weight:600;letter-spacing:-.01em;min-height:54px;display:flex;align-items:center;justify-content:center;gap:8px}
|
||||
.final-cta:active{background:#e5324f}
|
||||
.final-cta:disabled{background:#e0e0e0;color:#999}
|
||||
.final-cta svg{width:20px;height:20px;stroke-width:2}
|
||||
|
||||
/* ---- theme toggle ---- */
|
||||
.theme-btn{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);flex:0 0 auto;background:transparent;border:0;cursor:pointer;position:relative}
|
||||
.theme-btn:active{background:var(--surface)}
|
||||
.theme-btn svg{width:18px;height:18px}
|
||||
.theme-btn .i-moon{display:none}
|
||||
:root[data-theme="dark"] .theme-btn .i-sun{display:none}
|
||||
:root[data-theme="dark"] .theme-btn .i-moon{display:block}
|
||||
:root[data-theme="auto"] .theme-btn .i-moon{display:none}
|
||||
:root[data-theme="auto"] .theme-btn .i-sun{display:block}
|
||||
|
||||
/* ---- dark theme (auto follows prefers-color-scheme) ---- */
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#0f0f0f; --surface:#1c1416; --fg:#f0f0f0; --muted:#2a2a2a; --border:#3a2429;
|
||||
--shadow-up:0 -1px 0 var(--muted), 0 -8px 24px rgba(0,0,0,.4);
|
||||
}
|
||||
:root[data-theme="dark"] body{background:var(--bg);color:var(--fg)}
|
||||
:root[data-theme="dark"] .topbar{background:var(--bg);border-bottom-color:var(--muted)}
|
||||
:root[data-theme="dark"] .search-box{background:#181818;border-color:var(--muted)}
|
||||
:root[data-theme="dark"] .search-box:focus-within{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.2)}
|
||||
:root[data-theme="dark"] .search-box svg{color:#888}
|
||||
:root[data-theme="dark"] .search-box input{color:var(--fg)}
|
||||
:root[data-theme="dark"] .search-box input::placeholder{color:#666}
|
||||
:root[data-theme="dark"] .chip{background:#181818;border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .chip.active{background:var(--fg);color:var(--bg);border-color:var(--fg)}
|
||||
:root[data-theme="dark"] .cat-trigger{background:#181818;border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .cat-trigger .cat-chev{color:#888}
|
||||
:root[data-theme="dark"] .cat-count{color:#888}
|
||||
:root[data-theme="dark"] .cat-menu{background:#181818;border-color:var(--muted);box-shadow:0 12px 40px rgba(0,0,0,.5)}
|
||||
:root[data-theme="dark"] .cat-item{color:var(--fg);border-bottom-color:var(--muted)}
|
||||
:root[data-theme="dark"] .cat-item:active{background:var(--surface)}
|
||||
:root[data-theme="dark"] .cat-item .ct{color:#888}
|
||||
:root[data-theme="dark"] .cat-item.active{background:var(--surface);color:var(--accent)}
|
||||
:root[data-theme="dark"] .prod{border-bottom-color:var(--muted)}
|
||||
:root[data-theme="dark"] .prod:active{background:var(--surface)}
|
||||
:root[data-theme="dark"] .prod .ic{background:var(--surface);border-color:var(--border);color:var(--accent)}
|
||||
:root[data-theme="dark"] .prod .name{color:var(--fg)}
|
||||
:root[data-theme="dark"] .prod .sub{color:#888}
|
||||
:root[data-theme="dark"] .prod .price{color:var(--fg)}
|
||||
:root[data-theme="dark"] .prod .price small{color:#888}
|
||||
:root[data-theme="dark"] .cart-bar{background:#181818;border-top-color:var(--muted);box-shadow:var(--shadow-up)}
|
||||
:root[data-theme="dark"] .cart-bar .count span{color:#888}
|
||||
:root[data-theme="dark"] .cart-bar .total{color:var(--fg)}
|
||||
:root[data-theme="dark"] .drawer{background:rgba(0,0,0,.7)}
|
||||
:root[data-theme="dark"] .sheet{background:#181818}
|
||||
:root[data-theme="dark"] .sheet-head{border-bottom-color:var(--muted)}
|
||||
:root[data-theme="dark"] .sheet-head .x{background:var(--surface);color:var(--fg)}
|
||||
:root[data-theme="dark"] .line{border-bottom-color:var(--muted)}
|
||||
:root[data-theme="dark"] .line .nm{color:var(--fg)}
|
||||
:root[data-theme="dark"] .line .nm small{color:#888}
|
||||
:root[data-theme="dark"] .qty{border-color:var(--muted)}
|
||||
:root[data-theme="dark"] .qty button{color:var(--fg)}
|
||||
:root[data-theme="dark"] .qty button:active{background:var(--surface)}
|
||||
:root[data-theme="dark"] .line .pr{color:var(--fg)}
|
||||
:root[data-theme="dark"] .line .rm{color:#666}
|
||||
:root[data-theme="dark"] .line .rm:active{background:#3a2429;color:var(--accent)}
|
||||
:root[data-theme="dark"] .sheet-foot{background:#181818;border-top-color:var(--muted)}
|
||||
:root[data-theme="dark"] .totals{color:#888}
|
||||
:root[data-theme="dark"] .totals b{color:var(--fg)}
|
||||
:root[data-theme="dark"] .totals.grand{border-top-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .empty{color:#666}
|
||||
:root[data-theme="dark"] .empty h3{color:var(--fg)}
|
||||
:root[data-theme="dark"] .empty p{color:#888}
|
||||
:root[data-theme="dark"] .netpill{background:transparent;color:#888;border-color:var(--muted)}
|
||||
:root[data-theme="dark"] .netpill.off{color:#ff6680;border-color:#ff6680;background:#2a1a1a}
|
||||
:root[data-theme="dark"] .back:active{background:var(--surface)}
|
||||
:root[data-theme="dark"] .pay-tile{background:#181818;border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .pay-tile:active{background:var(--surface)}
|
||||
:root[data-theme="dark"] .pay-tile .ic{background:var(--surface);color:var(--accent)}
|
||||
:root[data-theme="dark"] .pay-tile.selected{border-color:var(--accent);background:#2a1a1a}
|
||||
:root[data-theme="dark"] .pay-tile.selected .ic{background:var(--accent);color:#fff}
|
||||
:root[data-theme="dark"] .cash-row input{background:#181818;border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .cash-row input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.2)}
|
||||
:root[data-theme="dark"] .cash-row input[readonly]{background:#222;color:#aaa}
|
||||
:root[data-theme="dark"] .cash-row label{color:#888}
|
||||
:root[data-theme="dark"] .quick button{background:#181818;border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .quick button:active{background:var(--surface);border-color:#444}
|
||||
:root[data-theme="dark"] .change-out{background:var(--surface);border-color:var(--border);color:var(--fg)}
|
||||
:root[data-theme="dark"] .change-out .lbl{color:#888}
|
||||
:root[data-theme="dark"] .cust-block input{background:#181818;border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .cust-block input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(255,56,92,.2)}
|
||||
:root[data-theme="dark"] .cust-results{background:#181818;border-color:var(--muted)}
|
||||
:root[data-theme="dark"] .cust-results div{border-bottom-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .cust-results div:active{background:var(--surface)}
|
||||
:root[data-theme="dark"] .cust-results small{color:#888}
|
||||
:root[data-theme="dark"] .cust-picked{background:var(--surface);border-color:var(--border);color:var(--fg)}
|
||||
:root[data-theme="dark"] .final-cta:disabled{background:#2a2a2a;color:#666}
|
||||
@media (prefers-reduced-motion: reduce){*{transition-duration:0.01ms !important;animation-duration:0.01ms !important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar" id="topbar">
|
||||
<button class="back" data-od-id="back-btn" onclick="if(history.length>1)history.back();else 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>New sale</h1>
|
||||
<span class="netpill" 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>
|
||||
|
||||
<div class="search">
|
||||
<div class="search-box">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||
<input id="q" type="search" placeholder="Search SKU or product" autocomplete="off" inputmode="search" enterkeyhint="search">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cat-row">
|
||||
<button class="cat-trigger" id="catTrigger" data-od-id="cat-trigger" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span class="cat-label" id="catLabel">All categories</span>
|
||||
<svg class="cat-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
<span class="cat-count" id="catCount">— products</span>
|
||||
</div>
|
||||
|
||||
<div class="cat-menu" id="catMenu" role="listbox" hidden>
|
||||
<div class="cat-menu-head" id="catMenuItems"></div>
|
||||
</div>
|
||||
|
||||
<div class="cat-backdrop" id="catBackdrop" hidden></div>
|
||||
|
||||
<main class="catalog" id="catalog">
|
||||
<div class="empty" id="empty">
|
||||
<h3>Loading catalog…</h3>
|
||||
<p>Fetching products from the register.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="cart-bar" id="cartBar" data-od-id="cart-bar">
|
||||
<button class="back" style="width:44px;height:44px;border-radius:50%;background:var(--surface);border:1px solid var(--border)" onclick="openDrawer()" aria-label="View cart">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M3 6h2l2 12h12l2-9H6"/><circle cx="9" cy="20" r="1.4"/><circle cx="17" cy="20" r="1.4"/></svg>
|
||||
</button>
|
||||
<div class="count"><b id="cartCount">0</b><span>items</span></div>
|
||||
<div class="total" id="cartTotal">R 0.00</div>
|
||||
<button class="checkout" data-od-id="checkout-btn" onclick="openPayment()">
|
||||
Charge
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Cart drawer -->
|
||||
<div class="drawer" id="cartDrawer">
|
||||
<div class="sheet">
|
||||
<div class="sheet-head">
|
||||
<h2>Cart</h2>
|
||||
<button class="x" onclick="closeDrawer()" aria-label="Close">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sheet-body" id="cartLines"></div>
|
||||
<div class="sheet-foot">
|
||||
<div class="totals"><span>Subtotal</span><b id="subTotal">R 0.00</b></div>
|
||||
<div class="totals"><span>VAT 15%</span><b id="vatTotal">R 0.00</b></div>
|
||||
<div class="totals grand"><span>Total (incl. VAT)</span><b id="grandTotal">R 0.00</b></div>
|
||||
<button class="final-cta" data-od-id="cart-checkout" onclick="closeDrawer();openPayment()">
|
||||
Take payment
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment drawer -->
|
||||
<div class="drawer" id="payDrawer">
|
||||
<div class="sheet">
|
||||
<div class="sheet-head">
|
||||
<h2>Take payment</h2>
|
||||
<button class="x" onclick="closePayment()" aria-label="Close">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sheet-body">
|
||||
<div class="totals" style="padding:10px 20px 0"><span>Subtotal</span><b id="paySub">R 0.00</b></div>
|
||||
<div class="totals" style="padding:0 20px"><span>VAT 15%</span><b id="payVat">R 0.00</b></div>
|
||||
<div class="totals grand" style="padding:14px 20px"><span>Total due</span><b id="payTotal">R 0.00</b></div>
|
||||
<div class="pay-grid" id="payGrid">
|
||||
<button class="pay-tile" data-od-id="pay-cash" data-meth="cash" onclick="pickMethod('cash')">
|
||||
<div><div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="6" width="18" height="12" rx="2"/><circle cx="12" cy="12" r="2.5"/></svg></div><div class="lab">Cash</div><div class="sub">Calculate change</div></div>
|
||||
</button>
|
||||
<button class="pay-tile" data-od-id="pay-card" data-meth="card" onclick="pickMethod('card')">
|
||||
<div><div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="6" width="18" height="12" rx="2"/><path d="M3 10h18"/></svg></div><div class="lab">Card</div><div class="sub">No change</div></div>
|
||||
</button>
|
||||
<button class="pay-tile" data-od-id="pay-eft" data-meth="eft" onclick="pickMethod('eft')">
|
||||
<div><div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 7h16M4 12h16M4 17h10"/></svg></div><div class="lab">EFT</div><div class="sub">Instant transfer</div></div>
|
||||
</button>
|
||||
<button class="pay-tile" data-od-id="pay-account" data-meth="account" onclick="pickMethod('account')">
|
||||
<div><div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/></svg></div><div class="lab">Account</div><div class="sub">Print delivery note</div></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cash-block" id="cashBlock">
|
||||
<div class="cash-row">
|
||||
<div class="field"><label>Total due</label><input id="dueIn" type="text" readonly tabindex="-1"></div>
|
||||
<div class="field"><label>Cash given</label><input id="givenIn" type="text" inputmode="decimal" placeholder="0.00"></div>
|
||||
</div>
|
||||
<div class="quick" id="quick"></div>
|
||||
<div class="change-out" id="changeOut">
|
||||
<div class="lbl">Change to give</div>
|
||||
<div class="amt" id="changeAmt">R 0.00</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cust-block" id="custBlock">
|
||||
<div class="cust-picked" id="custPicked"><b></b><button onclick="clearCust()">Remove</button></div>
|
||||
<div class="search-row">
|
||||
<input id="custQ" placeholder="Search customer" autocomplete="off">
|
||||
</div>
|
||||
<div class="cust-results" id="custResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0 20px 18px">
|
||||
<button class="final-cta" id="confirmBtn" data-od-id="confirm-pay" disabled onclick="confirmSale()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 12l5 5 9-11"/></svg>
|
||||
Complete sale
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="pb-config.js"></script>
|
||||
<script src="theme.js"></script>
|
||||
<script src="pos-app.js"></script>
|
||||
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
|
||||
</body>
|
||||
</html>
|
||||
600
public/pos/receipt.html
Normal file
600
public/pos/receipt.html
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
<!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>Receipt</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{
|
||||
/* Brand palette — Airbnb-inspired Rausch system */
|
||||
--bg:#ffffff;--surface:#fff7f8;--surface-2:#f7f7f7;--fg:#222222;--muted:#dddddd;--border:#ffe3e8;
|
||||
--ink:#222222;--ink-soft:#717171;--ink-faint:#999999;
|
||||
--accent:#ff385c;--accent-soft:#fff5f5;--accent-2:#ffd9df;--accent-hover:#e5324f;--accent-soft-hover:#ffeef1;
|
||||
--surface-card:#ffffff;
|
||||
/* Semantic */
|
||||
--ok:#0f8a3f;--ok-soft:#e8f5ec;--warn:#b14a00;--danger:#c92a2a;--danger-soft:#fff0f0;--danger-2:#ffd0d6;
|
||||
--shadow:0 1px 2px rgba(34,34,34,.04), 0 8px 24px rgba(34,34,34,.06);
|
||||
--focus-ring:0 0 0 3px rgba(255,56,92,.30);
|
||||
--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;overflow-x:hidden}
|
||||
body{display:flex;flex-direction:column;min-height:100dvh;max-width:480px;margin:0 auto;padding-bottom:env(safe-area-inset-bottom);background:var(--bg)}
|
||||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||||
button:focus-visible{outline:0;box-shadow:var(--focus-ring);border-radius:14px}
|
||||
.btn:focus-visible{box-shadow:var(--focus-ring)}
|
||||
.topbar .back:focus-visible,.topbar .theme-btn:focus-visible{box-shadow:var(--focus-ring);border-radius:50%}
|
||||
input,select{font:inherit;color:inherit}
|
||||
|
||||
/* ---- Topbar ---- */
|
||||
.topbar{display:flex;align-items:center;gap:10px;padding:14px 16px 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-card);border:1px solid var(--muted)}
|
||||
.topbar .back:active{background:var(--surface)}
|
||||
.topbar h1{font-size:17px;font-weight:600;letter-spacing:-.01em;flex:1}
|
||||
.topbar .netpill{font-size:11px;letter-spacing:.06em;text-transform:uppercase;font-weight:600;color:var(--ink-soft);padding:4px 8px;border:1px solid var(--muted);border-radius:999px;background:var(--surface-card)}
|
||||
.topbar .netpill.off{color:var(--danger);border-color:var(--danger);background:var(--danger-soft)}
|
||||
.theme-btn{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);background:var(--surface-card);border:1px solid var(--muted);cursor:pointer;flex:0 0 auto}
|
||||
.theme-btn:active{background:var(--surface)}
|
||||
.theme-btn svg{width:18px;height:18px}
|
||||
.theme-btn .i-moon{display:none}
|
||||
:root[data-theme="dark"] .theme-btn .i-sun{display:none}
|
||||
:root[data-theme="dark"] .theme-btn .i-moon{display:block}
|
||||
|
||||
/* ---- Layout ---- */
|
||||
.content{flex:1;padding:8px 16px calc(28px + env(safe-area-inset-bottom));display:flex;flex-direction:column;align-items:stretch;gap:14px}
|
||||
|
||||
/* ---- Success (restrained — white card, not full-bleed green) ---- */
|
||||
.success{display:flex;align-items:center;gap:14px;padding:14px 16px;background:var(--surface-card);border:1px solid var(--muted);border-radius:14px;box-shadow:var(--shadow)}
|
||||
.success .check{width:36px;height:36px;border-radius:50%;background:var(--ok-soft);color:var(--ok);display:grid;place-items:center;flex:0 0 auto}
|
||||
.success .check svg{width:20px;height:20px;stroke-width:2.6}
|
||||
.success .txt{display:flex;flex-direction:column;line-height:1.2;min-width:0;flex:1}
|
||||
.success .h{font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--fg)}
|
||||
.success .s{font-size:13px;color:var(--ink-soft);margin-top:2px;letter-spacing:.01em}
|
||||
.success .synced-flag{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;background:var(--ok-soft);color:var(--ok);padding:3px 8px;border-radius:999px;flex:0 0 auto;align-self:center;display:inline-flex;align-items:center;gap:5px}
|
||||
.success .synced-flag::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ok)}
|
||||
|
||||
/* ---- Card (the receipt itself) ---- */
|
||||
.receipt{background:var(--surface-card);border:1px solid var(--muted);border-radius:16px;box-shadow:var(--shadow);overflow:hidden}
|
||||
.r-head{display:flex;align-items:center;gap:12px;padding:18px 18px 14px;border-bottom:1px solid var(--muted)}
|
||||
.r-head .ic{width:40px;height:40px;border-radius:50%;background:var(--accent);color:#fff;display:grid;place-items:center;flex:0 0 auto}
|
||||
.r-head .ic svg{width:22px;height:22px;stroke-width:1.8}
|
||||
.r-head .nm{flex:1;min-width:0;line-height:1.25;min-width:0}
|
||||
.r-head .nm .biz{font-size:17px;font-weight:700;letter-spacing:-.01em;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.r-head .nm .addr{font-size:12px;color:var(--ink-soft);letter-spacing:.01em;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.r-head .nm .vat{font-size:11px;color:var(--ink-soft);letter-spacing:.04em;margin-top:3px}
|
||||
|
||||
.r-body{padding:6px 18px 18px}
|
||||
.r-meta{display:grid;grid-template-columns:1fr 1fr;gap:14px 18px;padding:14px 0;border-bottom:1px solid var(--muted)}
|
||||
.r-meta .k{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);margin-bottom:4px}
|
||||
.r-meta .v{font-size:14px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
.r-cust{margin-top:14px;padding:12px 14px;background:var(--surface-2);border-radius:10px;display:flex;align-items:center;gap:10px}
|
||||
.r-cust .lbl{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);flex:0 0 auto}
|
||||
.r-cust .nm{font-size:14px;font-weight:600;letter-spacing:-.005em;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
.r-items{margin-top:14px}
|
||||
.r-item{display:flex;align-items:flex-start;gap:10px;padding:12px 0;border-bottom:1px solid var(--muted)}
|
||||
.r-item:last-child{border-bottom:0}
|
||||
.r-item .qty{font-size:13px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;min-width:34px;flex:0 0 34px;padding-top:1px}
|
||||
.r-item .body{flex:1;min-width:0}
|
||||
.r-item .name{font-size:14px;font-weight:500;letter-spacing:-.005em;line-height:1.35;color:var(--fg);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
|
||||
.r-item .meta{font-size:12px;color:var(--ink-soft);margin-top:3px;letter-spacing:.02em;display:flex;gap:6px;flex-wrap:wrap}
|
||||
.r-item .meta .sep{color:var(--ink-faint)}
|
||||
.r-item .pr{font-size:14px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;white-space:nowrap;padding-top:1px}
|
||||
|
||||
.r-totals{margin-top:14px;padding:14px 0 0;border-top:1px solid var(--muted)}
|
||||
.r-row{display:flex;justify-content:space-between;align-items:baseline;padding:5px 0;font-size:14px;color:var(--ink-soft);letter-spacing:.01em;gap:8px}
|
||||
.r-row .l{display:flex;align-items:baseline;gap:8px}
|
||||
.r-row .b{color:var(--fg);font-weight:600;font-variant-numeric:tabular-nums}
|
||||
.r-row.grand{padding-top:14px;margin-top:8px;border-top:1px solid var(--muted);color:var(--fg);font-size:18px;font-weight:700;letter-spacing:-.01em}
|
||||
.r-row.grand .b{color:var(--fg);font-size:22px;font-weight:700}
|
||||
.r-row .pill{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;padding:2px 7px;border-radius:999px;background:var(--surface);color:var(--fg);border:1px solid var(--border)}
|
||||
.r-row.change .b{color:var(--ok)}
|
||||
|
||||
.r-foot{text-align:center;padding:14px 18px 16px;border-top:1px dashed var(--muted);font-size:12px;color:var(--ink-soft);letter-spacing:.04em;line-height:1.55;white-space:pre-line}
|
||||
|
||||
/* ---- Delivery note ---- */
|
||||
.dn{background:var(--surface-card);border:1.5px dashed var(--accent);border-radius:16px;box-shadow:var(--shadow);padding:18px}
|
||||
.dn-head{display:flex;align-items:center;gap:10px;margin-bottom:14px}
|
||||
.dn-head .ic{width:36px;height:36px;border-radius:10px;background:var(--accent-soft);border:1px solid var(--accent-2);color:var(--accent);display:grid;place-items:center;flex:0 0 auto}
|
||||
.dn-head .ic svg{width:20px;height:20px;stroke-width:1.8}
|
||||
.dn-head .nm{flex:1;min-width:0;line-height:1.2}
|
||||
.dn-head .nm .t{font-size:11px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);margin-bottom:2px}
|
||||
.dn-head .nm .s{font-size:15px;font-weight:600;letter-spacing:-.005em;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.dn-meta{display:grid;grid-template-columns:1fr 1fr;gap:10px 14px;padding:12px;background:var(--surface-2);border-radius:10px;margin-bottom:14px}
|
||||
.dn-meta .k{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);margin-bottom:3px}
|
||||
.dn-meta .v{font-size:13px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.dn-items{margin-bottom:14px}
|
||||
.dn-line{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:10px 0;border-bottom:1px solid var(--muted);font-size:13px;letter-spacing:-.005em;color:var(--fg)}
|
||||
.dn-line:last-child{border-bottom:0}
|
||||
.dn-line .nm{flex:1;min-width:0;font-weight:500;line-height:1.35}
|
||||
.dn-line .qt{font-size:12px;color:var(--ink-soft);letter-spacing:.02em;white-space:nowrap;padding-top:2px;font-variant-numeric:tabular-nums}
|
||||
.dn-sign{display:grid;grid-template-columns:1fr 1fr;gap:24px;padding-top:18px;margin-top:6px;border-top:1px dashed var(--muted)}
|
||||
.dn-sign > div{padding-top:8px;border-top:1.5px solid var(--fg);font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-soft);text-align:center}
|
||||
|
||||
/* ---- Actions ---- */
|
||||
.actions{display:flex;flex-direction:column;gap:10px;padding:18px 0 4px;margin-top:auto}
|
||||
.btn{display:flex;align-items:center;justify-content:center;gap:8px;min-height:54px;padding:0 18px;border-radius:14px;font-size:16px;font-weight:600;letter-spacing:-.005em;border:1.5px solid var(--muted);background:var(--surface-card);color:var(--fg);transition:transform .12s ease, background .12s ease}
|
||||
.btn:active{transform:translateY(1px);background:var(--surface)}
|
||||
.btn:disabled{opacity:.5;pointer-events:none}
|
||||
.btn svg{width:20px;height:20px;stroke-width:1.8}
|
||||
.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:var(--accent-hover);box-shadow:none}
|
||||
.btn.acct{background:var(--accent-soft);border-color:var(--accent-2);color:var(--accent)}
|
||||
.btn.acct:active{background:var(--accent-soft-hover)}
|
||||
|
||||
/* ---- Status banner ---- */
|
||||
.status{padding:12px 14px;border-radius:12px;background:var(--surface);border:1px solid var(--border);font-size:13px;display:flex;align-items:center;gap:10px;color:var(--fg);line-height:1.4}
|
||||
.empty-state{padding:48px 18px;text-align:center;color:var(--ink-soft)}
|
||||
.empty-state h3{font-size:16px;font-weight:600;color:var(--fg);margin-bottom:6px;letter-spacing:-.01em}
|
||||
.empty-state p{font-size:13px;line-height:1.5}
|
||||
.status.err{background:var(--danger-soft);border-color:var(--danger-2);color:var(--danger)}
|
||||
.status .ic{flex:0 0 auto}
|
||||
.status svg{width:18px;height:18px;stroke-width:1.7}
|
||||
.status .t{flex:1;min-width:0}
|
||||
|
||||
/* ---- Dark theme ---- */
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root:not([data-theme="light"]){
|
||||
--bg:#0f0f0f;--surface:#1c1416;--surface-2:#181818;--fg:#f0f0f0;--muted:#2a2a2a;--border:#3a2429;
|
||||
--ink:#f0f0f0;--ink-soft:#a0a0a0;--ink-faint:#777;
|
||||
--surface-card:#181818;--accent-soft:#2a1a1a;--accent-2:#3a2429;--accent-soft-hover:#3a2429;
|
||||
--ok-soft:#1a2e22;--danger-soft:#2a1a1a;--danger-2:#3a2429;
|
||||
--focus-ring:0 0 0 3px rgba(255,102,128,.35);
|
||||
}
|
||||
:root:not([data-theme="light"]) body{background:var(--bg)}
|
||||
:root:not([data-theme="light"]) .topbar{background:var(--bg)}
|
||||
:root:not([data-theme="light"]) .topbar .back{background:var(--surface-card);border-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .netpill{background:var(--surface-card);color:var(--ink-soft);border-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .netpill.off{color:#ff6680;border-color:#ff6680;background:var(--danger-soft)}
|
||||
:root:not([data-theme="light"]) .theme-btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .success{background:var(--surface-card);border-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .success .check{background:var(--ok-soft);color:#5fc77c}
|
||||
:root:not([data-theme="light"]) .success .synced-flag{background:var(--ok-soft);color:#5fc77c}
|
||||
:root:not([data-theme="light"]) .success .synced-flag::before{background:#5fc77c}
|
||||
:root:not([data-theme="light"]) .receipt{background:var(--surface-card);border-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .r-head{border-bottom-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .r-head .nm .biz{color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .r-head .nm .addr,:root:not([data-theme="light"]) .r-head .nm .vat{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .r-meta{border-bottom-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .r-meta .k{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .r-meta .v{color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .r-cust{background:var(--surface-2)}
|
||||
:root:not([data-theme="light"]) .r-cust .lbl{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .r-item{border-bottom-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .r-item .name{color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .r-item .meta{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .r-totals{border-top-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .r-row{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .r-row .b{color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .r-row .pill{background:var(--surface);color:var(--fg);border-color:var(--border)}
|
||||
:root:not([data-theme="light"]) .r-row.grand{border-top-color:var(--muted);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .r-row.change .b{color:#5fc77c}
|
||||
:root:not([data-theme="light"]) .r-foot{border-top-color:var(--muted);color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .dn{background:var(--surface-card);border-color:#ff6680}
|
||||
:root:not([data-theme="light"]) .dn-head .ic{background:var(--accent-soft);border-color:var(--accent-2)}
|
||||
:root:not([data-theme="light"]) .dn-meta{background:var(--surface-2)}
|
||||
:root:not([data-theme="light"]) .dn-meta .k{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .dn-line{border-bottom-color:var(--muted);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .dn-line .qt{color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .dn-sign{border-top-color:var(--muted)}
|
||||
:root:not([data-theme="light"]) .dn-sign > div{border-top-color:var(--fg);color:var(--ink-soft)}
|
||||
:root:not([data-theme="light"]) .status{background:var(--surface);border-color:var(--border);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .status.err{background:var(--danger-soft);border-color:var(--danger-2);color:#ff8a9e}
|
||||
:root:not([data-theme="light"]) .btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root:not([data-theme="light"]) .btn:active{background:var(--surface)}
|
||||
:root:not([data-theme="light"]) .btn.acct{background:var(--accent-soft);border-color:var(--accent-2);color:#ff8a9e}
|
||||
:root:not([data-theme="light"]) .btn.acct:active{background:var(--accent-soft-hover)}
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#0f0f0f;--surface:#1c1416;--surface-2:#181818;--fg:#f0f0f0;--muted:#2a2a2a;--border:#3a2429;
|
||||
--ink:#f0f0f0;--ink-soft:#a0a0a0;--ink-faint:#777;
|
||||
--surface-card:#181818;--accent-soft:#2a1a1a;--accent-2:#3a2429;--accent-soft-hover:#3a2429;
|
||||
--ok-soft:#1a2e22;--danger-soft:#2a1a1a;--danger-2:#3a2429;
|
||||
--focus-ring:0 0 0 3px rgba(255,102,128,.35);
|
||||
}
|
||||
:root[data-theme="dark"] body{background:var(--bg)}
|
||||
:root[data-theme="dark"] .topbar{background:var(--bg)}
|
||||
:root[data-theme="dark"] .topbar .back{background:var(--surface-card);border-color:var(--muted)}
|
||||
:root[data-theme="dark"] .netpill{background:var(--surface-card);color:var(--ink-soft);border-color:var(--muted)}
|
||||
:root[data-theme="dark"] .netpill.off{color:#ff6680;border-color:#ff6680;background:var(--danger-soft)}
|
||||
:root[data-theme="dark"] .theme-btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .success{background:var(--surface-card);border-color:var(--muted)}
|
||||
:root[data-theme="dark"] .success .check{background:var(--ok-soft);color:#5fc77c}
|
||||
:root[data-theme="dark"] .success .synced-flag{background:var(--ok-soft);color:#5fc77c}
|
||||
:root[data-theme="dark"] .success .synced-flag::before{background:#5fc77c}
|
||||
:root[data-theme="dark"] .receipt{background:var(--surface-card);border-color:var(--muted)}
|
||||
:root[data-theme="dark"] .r-head{border-bottom-color:var(--muted)}
|
||||
:root[data-theme="dark"] .r-head .nm .biz{color:var(--fg)}
|
||||
:root[data-theme="dark"] .r-head .nm .addr,:root[data-theme="dark"] .r-head .nm .vat{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .r-meta{border-bottom-color:var(--muted)}
|
||||
:root[data-theme="dark"] .r-meta .k{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .r-meta .v{color:var(--fg)}
|
||||
:root[data-theme="dark"] .r-cust{background:var(--surface-2)}
|
||||
:root[data-theme="dark"] .r-cust .lbl{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .r-item{border-bottom-color:var(--muted)}
|
||||
:root[data-theme="dark"] .r-item .name{color:var(--fg)}
|
||||
:root[data-theme="dark"] .r-item .meta{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .r-totals{border-top-color:var(--muted)}
|
||||
:root[data-theme="dark"] .r-row{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .r-row .b{color:var(--fg)}
|
||||
:root[data-theme="dark"] .r-row .pill{background:var(--surface);color:var(--fg);border-color:var(--border)}
|
||||
:root[data-theme="dark"] .r-row.grand{border-top-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .r-row.change .b{color:#5fc77c}
|
||||
:root[data-theme="dark"] .r-foot{border-top-color:var(--muted);color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .dn{background:var(--surface-card);border-color:#ff6680}
|
||||
:root[data-theme="dark"] .dn-head .ic{background:var(--accent-soft);border-color:var(--accent-2)}
|
||||
:root[data-theme="dark"] .dn-meta{background:var(--surface-2)}
|
||||
:root[data-theme="dark"] .dn-meta .k{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .dn-line{border-bottom-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .dn-line .qt{color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .dn-sign{border-top-color:var(--muted)}
|
||||
:root[data-theme="dark"] .dn-sign > div{border-top-color:var(--fg);color:var(--ink-soft)}
|
||||
:root[data-theme="dark"] .status{background:var(--surface);border-color:var(--border);color:var(--fg)}
|
||||
:root[data-theme="dark"] .status.err{background:var(--danger-soft);border-color:var(--danger-2);color:#ff8a9e}
|
||||
:root[data-theme="dark"] .btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .btn:active{background:var(--surface)}
|
||||
:root[data-theme="dark"] .btn.acct{background:var(--accent-soft);border-color:var(--accent-2);color:#ff8a9e}
|
||||
:root[data-theme="dark"] .btn.acct:active{background:var(--accent-soft-hover)}
|
||||
|
||||
/* ---- Reduced motion ---- */
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
*{transition-duration:0.01ms !important;animation-duration:0.01ms !important}
|
||||
.btn:active{transform:none}
|
||||
}
|
||||
|
||||
@media print{
|
||||
body{background:#fff}
|
||||
.topbar,.theme-btn,.actions,.status,.synced-flag,.success .synced-flag{display:none !important}
|
||||
.content{padding:0}
|
||||
.success{background:#fff !important;color:#000 !important;box-shadow:none;border:1px solid #000;border-radius:0;padding:14px}
|
||||
.success .check{background:#000 !important;color:#fff !important}
|
||||
.receipt,.dn{box-shadow:none;border:1px solid #999;border-radius:0;page-break-inside:avoid}
|
||||
.btn{display:none}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<button class="back" data-od-id="back-btn" onclick="location.href='pos.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>Receipt</h1>
|
||||
<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>
|
||||
<span class="netpill" id="netpill">…</span>
|
||||
</header>
|
||||
|
||||
<div class="content">
|
||||
|
||||
<div class="success" id="success" data-od-id="success-banner">
|
||||
<div class="check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 12l5 5 9-11"/></svg></div>
|
||||
<div class="txt">
|
||||
<div class="h" id="successH">Sale complete</div>
|
||||
<div class="s" id="successSub">Saved to register</div>
|
||||
</div>
|
||||
<span class="synced-flag" id="syncedFlag" style="display:none">Synced</span>
|
||||
</div>
|
||||
|
||||
<section class="receipt" id="receiptCard" data-od-id="receipt"></section>
|
||||
|
||||
<div id="deliveryWrap" style="display:none">
|
||||
<section class="dn" id="deliveryCard" data-od-id="delivery-note"></section>
|
||||
</div>
|
||||
|
||||
<div class="status" id="status">
|
||||
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.5"/></svg></span>
|
||||
<span class="t" id="statusText">Ready to print.</span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn primary" data-od-id="print-thermal" id="printBtn" onclick="printThermal()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h18v6H3zM7 13v6h10v-6M7 17h10"/></svg>
|
||||
Print slip
|
||||
</button>
|
||||
<button class="btn acct" data-od-id="print-delivery" id="printDnBtn" style="display:none" onclick="printDelivery()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 4h11l3 3v13H5zM9 9h6M9 13h6M9 17h4"/></svg>
|
||||
Print delivery note
|
||||
</button>
|
||||
<button class="btn" data-od-id="new-sale" onclick="location.href='pos.html'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>
|
||||
New sale
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="pb-config.js"></script>
|
||||
<script src="theme.js"></script>
|
||||
<script>
|
||||
(function(){
|
||||
'use strict';
|
||||
const cfg = window.PB_CONFIG;
|
||||
const fmt = n => 'R ' + (Number(n)||0).toLocaleString('en-ZA',{minimumFractionDigits:2,maximumFractionDigits:2});
|
||||
const esc = s => String(s||'').replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
|
||||
const escAttr = s => String(s).replace(/"/g,'"');
|
||||
|
||||
function paintNet(){
|
||||
const p = document.getElementById('netpill');
|
||||
p.textContent = navigator.onLine ? 'Online' : 'Offline';
|
||||
p.classList.toggle('off', !navigator.onLine);
|
||||
}
|
||||
window.addEventListener('online', paintNet);
|
||||
window.addEventListener('offline', paintNet);
|
||||
|
||||
const sale = JSON.parse(sessionStorage.getItem('pos.lastSale')||'null');
|
||||
if(!sale){
|
||||
document.getElementById('receiptCard').innerHTML = '<div class="empty-state"><h3>No receipt</h3><p>Start a new sale from the catalog.</p></div>';
|
||||
document.getElementById('status').style.display='none';
|
||||
document.getElementById('printBtn').style.display='none';
|
||||
} else {
|
||||
renderReceipt(sale);
|
||||
if(sale.synced) document.getElementById('syncedFlag').style.display = 'inline-flex';
|
||||
else document.getElementById('successSub').textContent = 'Saved locally · will sync when online';
|
||||
if(sale.payment==='account'){
|
||||
document.getElementById('deliveryWrap').style.display='block';
|
||||
document.getElementById('printDnBtn').style.display='flex';
|
||||
renderDeliveryNote(sale);
|
||||
}
|
||||
}
|
||||
|
||||
function renderReceipt(s){
|
||||
const lh = s.letterhead || {};
|
||||
const date = new Date(s.createdAt);
|
||||
const dateStr = date.toLocaleString('en-ZA',{day:'2-digit',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'});
|
||||
const method = s.payment==='account' ? 'ACCOUNT' : s.payment.toUpperCase();
|
||||
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
|
||||
const vat = s.vat != null ? s.vat : (s.total * 15/115);
|
||||
const paid = s.payment==='cash' ? fmt(s.cashGiven||0) : fmt(s.total);
|
||||
|
||||
const biz = lh.name || 'WFSALES POS';
|
||||
const addr = (lh.address||'').split('\n')[0] || '';
|
||||
const tel = lh.phone || '';
|
||||
const vatNum = lh.vat_number || '';
|
||||
const logoUrl = lh.logo_url || '';
|
||||
const ic = logoUrl
|
||||
? `<img src="${escAttr(logoUrl)}" alt="" style="max-height:34px;max-width:120px;display:block">`
|
||||
: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M9 12h6M9 16h6M9 8h6"/><rect x="5" y="3" width="14" height="18" rx="2"/></svg>`;
|
||||
|
||||
const itemsHTML = s.items.map(it=>{
|
||||
const price = it.selling_price || 0;
|
||||
const lineTotal = price * it.qty;
|
||||
const parts = [];
|
||||
if(it.sku) parts.push(`<span>${esc(it.sku)}</span>`);
|
||||
if(price) parts.push(`<span class="sep">·</span><span>${fmt(price)}/${esc(it.unit||'ea')}</span>`);
|
||||
return `<div class="r-item">
|
||||
<div class="qty">${it.qty}×</div>
|
||||
<div class="body">
|
||||
<div class="name">${esc(it.description||it.sku||'—')}</div>
|
||||
<div class="meta">${parts.join(' ')}</div>
|
||||
</div>
|
||||
<div class="pr">${fmt(lineTotal)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const customerBlock = s.customer
|
||||
? `<div class="r-cust"><span class="lbl">Customer</span><span class="nm">${esc(s.customer.name)}</span></div>`
|
||||
: '';
|
||||
|
||||
const html = `
|
||||
<div class="r-head">
|
||||
<div class="ic">${ic}</div>
|
||||
<div class="nm">
|
||||
<div class="biz">${esc(biz)}</div>
|
||||
${addr ? `<div class="addr">${esc(addr)}${tel?' · '+esc(tel):''}</div>` : (tel?`<div class="addr">${esc(tel)}</div>`:'')}
|
||||
${vatNum ? `<div class="vat">VAT ${esc(vatNum)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="r-body">
|
||||
<div class="r-meta">
|
||||
<div><div class="k">Date</div><div class="v">${dateStr}</div></div>
|
||||
<div><div class="k">Slip</div><div class="v">${(s.localId||'').slice(-8).toUpperCase()}</div></div>
|
||||
</div>
|
||||
${customerBlock}
|
||||
<div class="r-items">${itemsHTML}</div>
|
||||
<div class="r-totals">
|
||||
<div class="r-row"><span>Subtotal</span><span class="b">${fmt(sub)}</span></div>
|
||||
<div class="r-row"><span>VAT 15%</span><span class="b">${fmt(vat)}</span></div>
|
||||
<div class="r-row grand"><span>Total</span><span class="b">${fmt(s.total)}</span></div>
|
||||
<div class="r-row" style="margin-top:6px"><span><span class="pill">${esc(method)}</span> Paid</span><span class="b">${paid}</span></div>
|
||||
${s.payment==='cash' ? `<div class="r-row change" style="margin-top:2px"><span>Change</span><span class="b">${fmt(s.change||0)}</span></div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="r-foot">${esc(lh.footer_text || 'Thank you for your purchase.')}</div>
|
||||
`;
|
||||
document.getElementById('receiptCard').innerHTML = html;
|
||||
}
|
||||
|
||||
function renderDeliveryNote(s){
|
||||
const lh = s.letterhead || {};
|
||||
const date = new Date(s.createdAt);
|
||||
const dateStr = date.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
|
||||
const lines = s.items.map(it=>`<div class="dn-line">
|
||||
<div class="nm">${esc(it.description||it.sku)}</div>
|
||||
<div class="qt">${it.qty} ${esc(it.unit||'ea')}</div>
|
||||
</div>`).join('');
|
||||
const html = `
|
||||
<div class="dn-head">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h13l3 4h2v6h-2M3 17V7M3 17a2 2 0 1 0 4 0M17 17a2 2 0 1 0 4 0"/></svg></div>
|
||||
<div class="nm">
|
||||
<div class="t">Delivery note</div>
|
||||
<div class="s">${esc(s.customer?.name || '—')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dn-meta">
|
||||
<div><div class="k">Customer</div><div class="v">${esc(s.customer?.name||'—')}</div></div>
|
||||
<div><div class="k">Date</div><div class="v">${dateStr}</div></div>
|
||||
<div><div class="k">Slip</div><div class="v">${(s.localId||'').slice(-8).toUpperCase()}</div></div>
|
||||
<div><div class="k">Sale total</div><div class="v">${fmt(s.total)}</div></div>
|
||||
</div>
|
||||
<div class="dn-items">${lines}</div>
|
||||
<div class="dn-sign"><div>Received by</div><div>Driver</div></div>
|
||||
`;
|
||||
document.getElementById('deliveryCard').innerHTML = html;
|
||||
}
|
||||
|
||||
// ---------------- Bluetooth thermal printing ----------------
|
||||
let btDevice = null;
|
||||
let btChar = null;
|
||||
|
||||
async function printThermal(){
|
||||
const btn = document.getElementById('printBtn');
|
||||
const status = document.getElementById('statusText');
|
||||
const errBox = document.getElementById('status');
|
||||
errBox.classList.remove('err');
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Connecting to printer…';
|
||||
|
||||
if(!('bluetooth' in navigator)){
|
||||
status.textContent = 'Web Bluetooth not supported on this device.';
|
||||
errBox.classList.add('err');
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
try{
|
||||
btDevice = await navigator.bluetooth.requestDevice({
|
||||
acceptAllDevices: true,
|
||||
optionalServices: [0x18f0, 0x1101, 0x0000ff00, '000018f0-0000-1000-8000-00805f9b34fb']
|
||||
});
|
||||
status.textContent = 'Pairing ' + (btDevice.name||'printer') + '…';
|
||||
const server = await btDevice.gatt.connect();
|
||||
const services = await server.getPrimaryServices();
|
||||
for(const s of services){
|
||||
const chars = await s.getCharacteristics();
|
||||
for(const c of chars){
|
||||
if(c.properties.write || c.properties.writeWithoutResponse){ btChar = c; break; }
|
||||
}
|
||||
if(btChar) break;
|
||||
}
|
||||
if(!btChar) throw new Error('No writable characteristic on printer.');
|
||||
status.textContent = 'Printing…';
|
||||
const payload = buildEscPos(sale);
|
||||
const CHUNK = 80;
|
||||
for(let i=0;i<payload.length;i+=CHUNK){
|
||||
const slice = payload.slice(i, i+CHUNK);
|
||||
await btChar.writeValueWithoutResponse(slice);
|
||||
await new Promise(r=>setTimeout(r,30));
|
||||
}
|
||||
status.textContent = 'Printed · ' + (btDevice.name||'printer');
|
||||
}catch(e){
|
||||
if(e.name==='NotFoundError') status.textContent = 'Cancelled.';
|
||||
else status.textContent = 'Print failed: ' + e.message;
|
||||
errBox.classList.add('err');
|
||||
}finally{
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildEscPos(s){
|
||||
const E = (n) => String.fromCharCode(n);
|
||||
const ESC = E(0x1B), GS = E(0x1D);
|
||||
const cmds = [];
|
||||
cmds.push(ESC+'@');
|
||||
cmds.push(ESC+'a'+E(1));
|
||||
cmds.push(ESC+'!'+E(0x30));
|
||||
cmds.push('WFSALES POS\n');
|
||||
cmds.push(ESC+'!'+E(0x00));
|
||||
if(s.letterhead?.name) cmds.push(s.letterhead.name+'\n');
|
||||
cmds.push(ESC+'a'+E(0));
|
||||
if(s.letterhead){
|
||||
if(s.letterhead.address) cmds.push(s.letterhead.address.split('\n')[0]+'\n');
|
||||
if(s.letterhead.phone) cmds.push('Tel: '+s.letterhead.phone+'\n');
|
||||
if(s.letterhead.vat_number) cmds.push('VAT: '+s.letterhead.vat_number+'\n');
|
||||
}
|
||||
cmds.push('--------------------------------\n');
|
||||
const d = new Date(s.createdAt);
|
||||
cmds.push('Date: ' + d.toLocaleString('en-ZA')+'\n');
|
||||
cmds.push('Slip: ' + (s.localId||'').slice(-8).toUpperCase()+'\n');
|
||||
if(s.customer) cmds.push('Customer: ' + s.customer.name + '\n');
|
||||
cmds.push('--------------------------------\n');
|
||||
for(const it of s.items){
|
||||
cmds.push((it.description||it.sku) + '\n');
|
||||
const line = ` ${it.qty} x ${(it.selling_price||0).toFixed(2)} ${(it.selling_price*it.qty).toFixed(2)}`;
|
||||
cmds.push(line + '\n');
|
||||
}
|
||||
cmds.push('--------------------------------\n');
|
||||
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
|
||||
const vat = s.vat != null ? s.vat : (s.total * 15/115);
|
||||
cmds.push(`Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}\n`);
|
||||
cmds.push(`VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}\n`);
|
||||
cmds.push(ESC+'!'+E(0x08));
|
||||
cmds.push(`TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}\n`);
|
||||
cmds.push(ESC+'!'+E(0x00));
|
||||
cmds.push('Paid: ' + (s.payment==='cash' ? `CASH R ${(s.cashGiven||0).toFixed(2)}` : s.payment.toUpperCase()) + '\n');
|
||||
if(s.payment==='cash') cmds.push(`Change${' '.repeat(24)}R ${(s.change||0).toFixed(2)}\n`);
|
||||
cmds.push('--------------------------------\n');
|
||||
if(s.letterhead?.footer_text) cmds.push(s.letterhead.footer_text+'\n');
|
||||
cmds.push('\n\n');
|
||||
cmds.push(GS+'V'+E(0x41)+E(0x10));
|
||||
const text = cmds.join('');
|
||||
const bytes = new Uint8Array(text.length);
|
||||
for(let i=0;i<text.length;i++) bytes[i] = text.charCodeAt(i) & 0xff;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function printDelivery(){
|
||||
if(!sale) return;
|
||||
const btn = document.getElementById('printDnBtn');
|
||||
const status = document.getElementById('statusText');
|
||||
const errBox = document.getElementById('status');
|
||||
errBox.classList.remove('err');
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Printing delivery note…';
|
||||
if(!btChar){
|
||||
status.textContent = 'Pair a Bluetooth printer first to print.';
|
||||
errBox.classList.add('err');
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const E = (n) => String.fromCharCode(n);
|
||||
const ESC = E(0x1B);
|
||||
const lines = [];
|
||||
lines.push(ESC+'@');
|
||||
lines.push(ESC+'a'+E(1));
|
||||
lines.push(ESC+'!'+E(0x10));
|
||||
lines.push('WFSALES POS\n');
|
||||
lines.push(ESC+'!'+E(0x00));
|
||||
if(sale.letterhead?.name) lines.push(sale.letterhead.name+'\n');
|
||||
lines.push('Delivery Note\n');
|
||||
lines.push(ESC+'a'+E(0));
|
||||
lines.push('Customer: ' + (sale.customer?.name||'—') + '\n');
|
||||
const d = new Date(sale.createdAt);
|
||||
lines.push('Date: ' + d.toLocaleDateString('en-ZA')+'\n');
|
||||
lines.push('--------------------------------\n');
|
||||
for(const it of sale.items){
|
||||
lines.push((it.description||it.sku)+'\n');
|
||||
lines.push(` ${it.qty} ${it.unit||'ea'}\n`);
|
||||
}
|
||||
lines.push('--------------------------------\n');
|
||||
lines.push('Received by: __________ Driver: __________\n');
|
||||
lines.push('\n\n');
|
||||
lines.push(String.fromCharCode(0x1D)+'V'+String.fromCharCode(0x41)+String.fromCharCode(0x10));
|
||||
const text = lines.join('');
|
||||
const bytes = new Uint8Array(text.length);
|
||||
for(let i=0;i<text.length;i++) bytes[i] = text.charCodeAt(i) & 0xff;
|
||||
try{
|
||||
for(let i=0;i<bytes.length;i+=80){
|
||||
await btChar.writeValueWithoutResponse(bytes.slice(i, i+80));
|
||||
await new Promise(r=>setTimeout(r,30));
|
||||
}
|
||||
status.textContent = 'Delivery note printed.';
|
||||
}catch(e){
|
||||
errBox.classList.add('err');
|
||||
status.textContent = 'DN print failed: ' + e.message;
|
||||
}finally{
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
paintNet();
|
||||
})();
|
||||
</script>
|
||||
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
|
||||
</body>
|
||||
</html>
|
||||
76
public/pos/sw.js
Normal file
76
public/pos/sw.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
|
||||
const CACHE = "pos-v12";
|
||||
const APP_SHELL = [
|
||||
'./',
|
||||
'./index.html',
|
||||
'./pos.html',
|
||||
'./receipt.html',
|
||||
'./history.html',
|
||||
'./cashup.html',
|
||||
'./pb-config.js',
|
||||
'./pos-app.js',
|
||||
'./theme.js',
|
||||
'./manifest.json'
|
||||
];
|
||||
|
||||
// Files that must always be fresh (network-first, fallback to cache)
|
||||
const NETWORK_FIRST = new Set([
|
||||
'theme.js'
|
||||
]);
|
||||
|
||||
self.addEventListener('install', e => {
|
||||
e.waitUntil(caches.open(CACHE).then(c => c.addAll(APP_SHELL)).then(()=>self.skipWaiting()));
|
||||
});
|
||||
|
||||
self.addEventListener('activate', e => {
|
||||
e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))).then(()=>self.clients.claim()));
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', e => {
|
||||
const url = new URL(e.request.url);
|
||||
// Never cache PocketBase API
|
||||
if(url.hostname.includes('pocketbase') || url.hostname.includes('sslip.io')) return;
|
||||
// HTML navigations: always network-first so the user gets the latest page
|
||||
if(e.request.mode === 'navigate'){
|
||||
e.respondWith(
|
||||
fetch(e.request).then(res => {
|
||||
if(res && res.ok) {
|
||||
const clone = res.clone();
|
||||
caches.open(CACHE).then(c => c.put(e.request, clone));
|
||||
}
|
||||
return res;
|
||||
}).catch(() => caches.match(e.request).then(r => r || new Response('<h1>Offline</h1>', {status:503, headers:{'Content-Type':'text/html'}})))
|
||||
);
|
||||
return;
|
||||
}
|
||||
if(e.request.method !== 'GET') return;
|
||||
|
||||
// theme.js (and other network-first assets) always go to network
|
||||
const fname = url.pathname.split('/').pop();
|
||||
if(NETWORK_FIRST.has(fname)){
|
||||
e.respondWith(
|
||||
fetch(e.request).then(res => {
|
||||
if(res && res.ok){
|
||||
const clone = res.clone();
|
||||
caches.open(CACHE).then(c => c.put(e.request, clone));
|
||||
}
|
||||
return res;
|
||||
}).catch(() => caches.match(e.request).then(r => r || new Response('', {status:504})))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Default: cache-first for static assets
|
||||
e.respondWith(
|
||||
caches.match(e.request).then(cached => {
|
||||
const fetchP = fetch(e.request).then(res => {
|
||||
if(res && res.ok && (e.request.url.startsWith('http'))) {
|
||||
const clone = res.clone();
|
||||
caches.open(CACHE).then(c => c.put(e.request, clone));
|
||||
}
|
||||
return res;
|
||||
}).catch(() => cached);
|
||||
return cached || fetchP;
|
||||
})
|
||||
);
|
||||
});
|
||||
91
public/pos/theme.js
Normal file
91
public/pos/theme.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Shared dark-mode toggle.
|
||||
// Usage: <script src="theme.js"></script> at the end of <body>.
|
||||
// Set <html data-theme="light|dark|auto">. Default = light.
|
||||
//
|
||||
// Self-contained: the toggle works even if this file fails to load,
|
||||
// because the button's inline onclick uses WFTheme.cycle() which is
|
||||
// defined here AND duplicated as a fallback. We also listen for
|
||||
// storage events so the theme stays in sync across tabs.
|
||||
(function(){
|
||||
const KEY = 'wfsales.theme';
|
||||
const html = document.documentElement;
|
||||
function systemPref(){
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
function apply(){
|
||||
const saved = localStorage.getItem(KEY) || 'light';
|
||||
const v = saved === 'auto' ? systemPref() : saved;
|
||||
html.setAttribute('data-theme', v);
|
||||
document.querySelectorAll('[data-theme-toggle]').forEach(btn => {
|
||||
btn.setAttribute('data-current', saved);
|
||||
btn.setAttribute('aria-pressed', saved === 'dark' ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
// API
|
||||
function setTheme(v){
|
||||
if(!['light','dark','auto'].includes(v)) v = 'light';
|
||||
try{ localStorage.setItem(KEY, v); }catch(_){}
|
||||
apply();
|
||||
}
|
||||
function currentTheme(){
|
||||
try{ return localStorage.getItem(KEY) || 'light'; }catch(_){ return 'light'; }
|
||||
}
|
||||
function cycleTheme(){
|
||||
const cur = currentTheme();
|
||||
setTheme(cur === 'light' ? 'dark' : cur === 'dark' ? 'auto' : 'light');
|
||||
}
|
||||
// Expose as early as possible so the button's inline onclick (which
|
||||
// guards on window.WFTheme) never sees undefined.
|
||||
window.WFTheme = { set: setTheme, current: currentTheme, cycle: cycleTheme };
|
||||
// Also: install a global fallback so clicking the button works even
|
||||
// if this script never executes (network blocked, SW serving a stale
|
||||
// version, etc.). The button's inline onclick calls
|
||||
// (WFTheme && WFTheme.cycle || fallback)() — we register a global
|
||||
// __wfCycle function so the click always works.
|
||||
window.__wfCycle = cycleTheme;
|
||||
|
||||
function onThemeTap(e){
|
||||
let el = e.target;
|
||||
while(el && el !== document){
|
||||
if(el.matches && el.matches('[data-theme-toggle]')){
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cycleTheme();
|
||||
return;
|
||||
}
|
||||
el = el.parentNode;
|
||||
}
|
||||
}
|
||||
// Capture phase + both click and pointerup so the handler runs even
|
||||
// if a deeper listener calls stopPropagation(), and so the toggle
|
||||
// works on touch devices where the SVG is the actual target.
|
||||
document.addEventListener('click', onThemeTap, true);
|
||||
document.addEventListener('pointerup', onThemeTap, true);
|
||||
// Also bind directly to any current or future theme buttons
|
||||
function bindAll(){
|
||||
document.querySelectorAll('[data-theme-toggle]').forEach(btn=>{
|
||||
if(btn.__wfBound) return;
|
||||
btn.__wfBound = true;
|
||||
btn.addEventListener('click', e=>{
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
cycleTheme();
|
||||
});
|
||||
});
|
||||
}
|
||||
bindAll();
|
||||
// Watch for new buttons (e.g. drawer-injected HTML) and bind them
|
||||
try{ new MutationObserver(bindAll).observe(document.body, {childList:true, subtree:true}); }catch(_){}
|
||||
// React to system change when in auto mode
|
||||
if(window.matchMedia){
|
||||
try{ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ()=>{
|
||||
if((currentTheme()) === 'auto') apply();
|
||||
}); }catch(_){}
|
||||
}
|
||||
// Apply the saved theme on load
|
||||
apply();
|
||||
// Cross-tab sync: if the user toggles in one tab, other tabs update
|
||||
window.addEventListener('storage', e=>{
|
||||
if(e.key === KEY) apply();
|
||||
});
|
||||
})();
|
||||
|
||||
Loading…
Reference in a new issue