Three changes to the POS sale pipeline, all in one commit because pos-service.ts / types.ts / pos-app.js are each touched by all three and splitting them would only produce intermediate broken states. VAT is now charged on top, not backed out Catalogue prices are ex-VAT: `selling_price` is derived as `cost_price x markup` from the supplier's ex-VAT cost, with no VAT uplift anywhere (verified — median selling/cost across the catalogue is exactly 1.8). The till nevertheless treated them as VAT-inclusive and recovered the net with `total / 1.15`, so every sale was recorded with an understated net and an understated VAT line. An R100 ex-VAT line now rings up as R115. The rate comes from the till's letterhead, read server-side rather than accepted off the wire, and is stored per sale so a later letterhead rate change cannot rewrite what was charged. All 3,623 catalogue prices are untouched. Ad-hoc "custom" lines A cashier can now ring up something that was never a catalogue item — "bolt cutting" at R20 agreed at the counter — via a new "+ Add a custom item" composer. The line prints on the slip and lands in pos_sale_items, but carries no stock_item relation and takes no stock movement: no ledger row, no current_qty decrement, no catalogue entry. pos_sale_items.stock_item is no longer required at the schema level and an explicit `is_custom` flag marks the line, rather than inferring it from a blank relation — so "how much did we make on bolt cutting this month" is a plain query, and a future stock-less line type cannot be confused with a corrupt one. Custom lines are refused on account sales: those raise a delivery note the customer signs for and feed their statement, so an ad-hoc charge must not slip onto one. Enforced server-side, not just in the UI. Discounts are now 20% or 40%, nothing else The R-off / %-off pills and the free-typed number box are gone. The server treats DISCOUNT_PERCENT_OPTIONS as an allowlist and refuses anything outside it rather than coercing it, so a stale till cannot invent a discount. The discount lands on the ex-VAT subtotal, making "20% off" a true 20% off both the net and the VAT on it. Two defects found while doing this pos_sales was missing six fields that checkoutSale has been writing all along (gross_subtotal, gross_vat, gross_total, discount_amount, discount_kind, discount_input). PocketBase accepts unknown fields without error and silently drops them, so all 192 historical sales lack them and a discounted sale reprinted as though no discount had been applied. Added, plus vat_percent. History also passed gross_total (VAT-inclusive) where the receipt derives the gross VAT from an ex-VAT base — a reprint double-counted VAT. Corrected. Verification: 3860 tests pass (14 new); typecheck, lint and production build clean; live PocketBase probe confirms a pos_sale_items row with stock_item="" and is_custom=true round-trips, with the probe removed afterwards.
513 lines
28 KiB
HTML
513 lines
28 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<meta name="theme-color" content="#ff385c">
|
||
<title>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}
|
||
/* A refunded sale stays in the list — it is the audit trail — but reads as
|
||
gone: dimmed, struck through the amount, badged. Never colour alone: the
|
||
word "refunded" is in the row. */
|
||
.sale-row.refunded{opacity:.6}
|
||
.sale-row.refunded .nm b em{font-style:normal;font-size:10px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--danger);border:1px solid currentColor;border-radius:4px;padding:1px 4px;margin-left:4px}
|
||
.sale-row.refunded .pr{text-decoration:line-through}
|
||
.line-holding{background:var(--surface);transition:background .15s}
|
||
|
||
/* ---- Refund sheet ---- */
|
||
.rf-backdrop{position:fixed;inset:0;background:rgba(34,34,34,.5);z-index:70;opacity:0;pointer-events:none;transition:opacity .15s}
|
||
.rf-backdrop.show{opacity:1;pointer-events:auto}
|
||
.rf-sheet{position:fixed;left:50%;bottom:24px;z-index:71;transform:translate(-50%,24px);width:calc(100% - 32px);max-width:448px;background:#fff;border-radius:18px;padding:18px 18px 14px;opacity:0;pointer-events:none;transition:opacity .15s,transform .22s cubic-bezier(.2,.8,.2,1);box-shadow:0 20px 60px rgba(34,34,34,.30);display:flex;flex-direction:column;gap:12px;max-height:80dvh;overflow-y:auto;padding-bottom:calc(14px + env(safe-area-inset-bottom))}
|
||
.rf-sheet.show{opacity:1;transform:translate(-50%,0);pointer-events:auto}
|
||
.rf-head{display:flex;align-items:center;gap:8px}
|
||
.rf-head h3{margin:0;font-size:16px;font-weight:700;letter-spacing:-.01em;flex:1;min-width:0}
|
||
.rf-head h3 small{display:block;font-size:11px;font-weight:500;color:var(--ink-soft);margin-top:2px}
|
||
.rf-meta{display:flex;gap:8px;flex-wrap:wrap}
|
||
.rf-chip{font-size:11px;font-weight:600;color:var(--ink-soft);border:1px solid var(--muted);border-radius:999px;padding:3px 9px}
|
||
.rf-warn{background:#fff6f6;border:1px solid #f3c9c9;border-radius:10px;padding:10px 12px;font-size:12px;line-height:1.45;color:#8a2b2b}
|
||
.rf-items{border:1px solid var(--muted);border-radius:10px;overflow:hidden}
|
||
.rf-item{display:flex;align-items:center;gap:10px;padding:9px 12px;border-bottom:1px solid var(--muted);font-size:13px}
|
||
.rf-item:last-child{border-bottom:0}
|
||
.rf-item span{flex:1;min-width:0}
|
||
.rf-item b{font-variant-numeric:tabular-nums;font-weight:600}
|
||
.rf-total{display:flex;align-items:baseline;justify-content:space-between;font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--fg)}
|
||
.rf-total b{font-variant-numeric:tabular-nums}
|
||
.rf-actions{display:flex;gap:8px;margin-top:2px}
|
||
.rf-actions button{flex:1;padding:13px;border-radius:12px;font-size:14px;font-weight:600;letter-spacing:-.005em;min-height:46px}
|
||
.rf-actions .secondary{background:var(--surface);color:var(--fg);border:1px solid var(--border)}
|
||
.rf-actions .danger{background:#c92a2a;color:#fff}
|
||
.rf-actions .danger:disabled{opacity:.6}
|
||
:root[data-theme="dark"] .sale-row .nm b em{color:#ff6680}
|
||
:root[data-theme="dark"] .rf-sheet{background:#181818;box-shadow:0 20px 60px rgba(0,0,0,.6)}
|
||
:root[data-theme="dark"] .rf-head h3 small{color:#888}
|
||
:root[data-theme="dark"] .rf-chip{border-color:var(--muted);color:#888}
|
||
:root[data-theme="dark"] .rf-warn{background:#2a1a1a;border-color:#5a2429;color:#ff9db0}
|
||
:root[data-theme="dark"] .rf-item{border-bottom-color:var(--muted)}
|
||
:root[data-theme="dark"] .rf-actions .secondary{background:#222;border-color:var(--muted)}
|
||
|
||
.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>
|
||
|
||
<!-- Refund sheet. Opened by HOLDING a sale row; tap is reprint, so the
|
||
money-moving action needs a gesture you can't do by accident. -->
|
||
<div class="rf-backdrop" id="rfBackdrop" onclick="closeRefund()"></div>
|
||
<div class="rf-sheet" id="rfSheet" role="dialog" aria-modal="true" aria-labelledby="rfTitle">
|
||
<div class="rf-head">
|
||
<h3 id="rfTitle">Refund this sale?<small>Sale <span id="rfNumber">—</span> · <span id="rfWhen">—</span></small></h3>
|
||
</div>
|
||
<div class="rf-meta">
|
||
<span class="rf-chip">Paid by <span id="rfMethod">—</span></span>
|
||
</div>
|
||
<div class="rf-warn" id="rfWhere"></div>
|
||
<div class="rf-items" id="rfItems"></div>
|
||
<div class="rf-total"><span>Credit back</span><b id="rfTotal">R 0.00</b></div>
|
||
<div class="rf-actions">
|
||
<button class="secondary" onclick="closeRefund()">Cancel</button>
|
||
<button class="danger" id="rfConfirm" onclick="confirmRefund()">Refund this sale</button>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="pos-config.js"></script>
|
||
<script src="theme.js"></script>
|
||
<script>
|
||
(function(){
|
||
'use strict';
|
||
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();
|
||
}));
|
||
}
|
||
|
||
const METHOD_LABELS = {cash:'Cash', card:'Card', eft:'EFT', account:'Account'};
|
||
|
||
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 from = activeDay + ' 00:00:00';
|
||
const to = activeDay + ' 23:59:59';
|
||
try{
|
||
const r = await window.posFetch(`/sales?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
||
if(!r.ok) throw new Error('sales returned ' + r.status);
|
||
const j = await r.json();
|
||
const sales = j.sales || [];
|
||
const itemsBySale = new Map();
|
||
for(const it of (j.items||[])){
|
||
if(!itemsBySale.has(it.sale)) itemsBySale.set(it.sale, []);
|
||
itemsBySale.get(it.sale).push(it);
|
||
}
|
||
const rows = sales.map(s => ({
|
||
s, items: itemsBySale.get(s.id)||[], total: s.total,
|
||
method: METHOD_LABELS[s.payment_method] || 'Cash',
|
||
dt: new Date(s.created),
|
||
}));
|
||
// Cache for reprint() — needs both the PosSale and its PosSaleItems
|
||
// to reconstruct the sessionStorage shape that receipt.html expects.
|
||
cachedRows = rows;
|
||
const grandTotal = rows.reduce((sum,r)=>sum+r.total,0);
|
||
document.getElementById('kpiCount').textContent = sales.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();
|
||
const done = !!r.s.refunded;
|
||
return `<button class="sale-row${done?' refunded':''}" data-od-id="sale-${r.s.id}" data-id="${r.s.id}" data-refunded="${done}" onclick="reprint('${r.s.id}')">
|
||
<div class="nm">
|
||
<b>${esc(r.s.sale_number||r.s.id.slice(-6))}${done?' <em>refunded</em>':''}</b>
|
||
<small>${dt} · ${r.items.length} item${r.items.length===1?'':'s'}${done?' · credited back':''}</small>
|
||
</div>
|
||
<span class="method ${mClass}">${esc(r.method)}</span>
|
||
<div class="pr">${done?'−':''}${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>';
|
||
}
|
||
}
|
||
|
||
// Cached after each load() — lets reprint() look up the full PosSale +
|
||
// PosSaleItems for a clicked row without re-fetching. The list is the
|
||
// only source of truth for "what's on screen", so this stays in sync
|
||
// whenever the user picks a different day.
|
||
let cachedRows = [];
|
||
|
||
// Reprint = hop to receipt.html with the sale stashed in
|
||
// sessionStorage. The receipt page already handles loading from
|
||
// sessionStorage and has the full Bluetooth / Network print logic
|
||
// (including the epos-print.js network path added 2026-09-03), so we
|
||
// just translate the API shape into the till-rendered shape and let
|
||
// the existing code drive the printer.
|
||
window.reprint = function(orderId){
|
||
const row = cachedRows.find(r => r.s.id === orderId);
|
||
if(!row){
|
||
alert('Sale not loaded — try again after the list finishes loading.');
|
||
return;
|
||
}
|
||
const s = row.s;
|
||
const sessionSale = {
|
||
localId: s.client_ref || s.id,
|
||
createdAt: s.created,
|
||
payment: s.payment_method,
|
||
cashGiven: s.cash_given || 0,
|
||
change: s.change_given || 0,
|
||
// `gross_subtotal` is the EX-VAT pre-discount net — the receipt
|
||
// derives the gross VAT from it. `gross_total` is that figure
|
||
// plus VAT, so passing it here would double-count the VAT.
|
||
gross: s.gross_subtotal,
|
||
discount: {
|
||
kind: s.discount_kind || 'none',
|
||
input: s.discount_input || 0,
|
||
amount: s.discount_amount || 0,
|
||
},
|
||
sub: s.subtotal,
|
||
vat: s.vat,
|
||
total: s.total,
|
||
// The rate this sale was rung up at, so the reprint labels its
|
||
// VAT line correctly even after a letterhead rate change.
|
||
vatPct: s.vat_percent,
|
||
items: row.items.map(it => ({
|
||
sku: it.sku,
|
||
description: it.description,
|
||
unit: 'ea',
|
||
cost_price: it.unit_cost,
|
||
selling_price: it.unit_price,
|
||
edited: false,
|
||
qty: it.qty,
|
||
product_id: it.stock_item,
|
||
})),
|
||
// An account sale has a real delivery note behind it (raised
|
||
// server-side inside `checkoutSale`). Carry the link across so
|
||
// receipt.html can show the DN number and reprint the A4 original
|
||
// — without this the reprint screen said "Not created" for a DN
|
||
// that exists, and offered no way to print it.
|
||
deliveryNote: (s.expand && s.expand.delivery_note && s.expand.delivery_note.delivery_note_number)
|
||
? {
|
||
id: s.delivery_note,
|
||
delivery_note_number: s.expand.delivery_note.delivery_note_number,
|
||
}
|
||
: null,
|
||
// Expanded by GET /api/v1/pos/sales (customer + its company +
|
||
// the linked DN). Needed so the reprinted DN card names the
|
||
// account the client is about to sign for.
|
||
customer: s.expand && s.expand.customer
|
||
? {
|
||
id: s.expand.customer.id,
|
||
name: s.expand.customer.customer,
|
||
// `customer.company` is a relation id; the NAME lives on the
|
||
// nested expand. Without it the card showed "rpqogiu03d97lcv"
|
||
// where a company belongs.
|
||
company: (s.expand.customer.expand
|
||
&& s.expand.customer.expand.company
|
||
&& s.expand.customer.expand.company.company) || '',
|
||
}
|
||
: null,
|
||
synced: true,
|
||
serverRef: s.id,
|
||
};
|
||
try{ sessionStorage.setItem('pos.lastSale', JSON.stringify(sessionSale)); }catch(_){}
|
||
setTimeout(() => { location.href = 'receipt.html'; }, 80);
|
||
};
|
||
|
||
paintDays(); load();
|
||
|
||
// ---------------- refund ----------------
|
||
// Hold a sale row to refund it. Reprint stays on TAP (it is the common
|
||
// action); refunding moves money and puts stock back, so it deliberately
|
||
// sits behind a gesture you have to hold rather than reach for by mistake.
|
||
//
|
||
// The server refuses a second refund (409), so a double-tap here cannot
|
||
// credit the customer twice — but the till should not even offer it.
|
||
window.__refundSale = function(saleId){
|
||
const row = cachedRows.find(r => r.s.id === saleId);
|
||
if(!row){ alert('Sale not loaded — try again after the list finishes loading.'); return; }
|
||
if(row.s.refunded){ alert('This sale has already been refunded.'); return; }
|
||
|
||
const s = row.s;
|
||
document.getElementById('rfConfirm').dataset.id = saleId;
|
||
document.getElementById('rfConfirm').disabled = false;
|
||
document.getElementById('rfConfirm').textContent = 'Refund this sale';
|
||
const method = (METHOD_LABELS[s.payment_method] || 'Cash');
|
||
const items = row.items.map(it => `<div class="rf-item"><span>${it.qty} × ${esc(it.description||it.sku||'')}</span><b>${fmt((Number(it.unit_price)||0)*it.qty)}</b></div>`).join('');
|
||
const back = document.getElementById('rfBackdrop');
|
||
const box = document.getElementById('rfSheet');
|
||
document.getElementById('rfNumber').textContent = s.sale_number || saleId.slice(-6);
|
||
document.getElementById('rfWhen').textContent = new Date(s.created).toLocaleString('en-ZA');
|
||
document.getElementById('rfMethod').textContent = method;
|
||
document.getElementById('rfItems').innerHTML = items;
|
||
document.getElementById('rfTotal').textContent = fmt(s.total);
|
||
// A cash refund physically empties the drawer, and cashup.html subtracts
|
||
// it from the expected figure — so say which one this is, plainly.
|
||
document.getElementById('rfWhere').textContent = s.payment_method === 'cash'
|
||
? `Hand back ${fmt(s.total)} in cash. The drawer count for this day will drop by that amount.`
|
||
: s.payment_method === 'account'
|
||
? `Reduce what this customer owes by ${fmt(s.total)}.`
|
||
: `Refund ${fmt(s.total)} on the ${method.toLowerCase()} machine.`;
|
||
box.classList.add('show');
|
||
back.classList.add('show');
|
||
};
|
||
|
||
function closeRefund(){
|
||
document.getElementById('rfSheet').classList.remove('show');
|
||
document.getElementById('rfBackdrop').classList.remove('show');
|
||
}
|
||
|
||
async function confirmRefund(){
|
||
const id = document.getElementById('rfConfirm').dataset.id;
|
||
const btn = document.getElementById('rfConfirm');
|
||
btn.disabled = true;
|
||
btn.textContent = 'Refunding…';
|
||
try{
|
||
const r = await window.posFetch(`/sales/${id}/refund`, { method: 'POST', body: {} });
|
||
const j = await r.json().catch(() => ({}));
|
||
if(!r.ok){
|
||
alert(j.error || `Refund failed (${r.status}).`);
|
||
btn.disabled = false; btn.textContent = 'Refund this sale';
|
||
return;
|
||
}
|
||
closeRefund();
|
||
if((j.stockErrors||[]).length){
|
||
alert(`Refunded ${j.refundAmount}. ${j.stockErrors.length} item(s) could not be put back into stock — please correct the counts by hand.`);
|
||
}
|
||
// Jump to a printable credit slip.
|
||
const refSession = {
|
||
refund: true,
|
||
serverRef: j.sale.id,
|
||
saleNumber: j.sale.sale_number || j.sale.id.slice(-6),
|
||
originalPayment: j.tender,
|
||
refundAmount: j.refundAmount,
|
||
when: j.sale.refunded_at || j.sale.created,
|
||
customer: (j.sale.expand && j.sale.expand.customer) ? {
|
||
id: j.sale.expand.customer.id,
|
||
name: j.sale.expand.customer.customer,
|
||
company: (j.sale.expand.customer.expand && j.sale.expand.customer.expand.company
|
||
&& j.sale.expand.customer.expand.company.company) || '',
|
||
} : null,
|
||
items: (j.items||[]).map(it => ({
|
||
sku: it.sku, description: it.description, qty: it.qty,
|
||
selling_price: it.unit_price,
|
||
})),
|
||
synced: true,
|
||
};
|
||
try{ sessionStorage.setItem('pos.lastRefund', JSON.stringify(refSession)); }catch(_){}
|
||
setTimeout(() => { location.href = 'receipt.html?refund=1'; }, 80);
|
||
}catch(e){
|
||
alert('Could not reach the register. Nothing was refunded — reconnect and try again.');
|
||
btn.disabled = false; btn.textContent = 'Refund this sale';
|
||
}
|
||
}
|
||
|
||
// Hold-to-refund on the sale rows. Same 600ms threshold the cart uses.
|
||
(function(){
|
||
let timer = null;
|
||
let held = null;
|
||
const clear = () => {
|
||
if(timer) { clearTimeout(timer); timer = null; }
|
||
if(held){ held.classList.remove('line-holding'); held = null; }
|
||
};
|
||
document.addEventListener('pointerdown', e=>{
|
||
const row = e.target.closest('.sale-row[data-id]');
|
||
if(!row) return;
|
||
if(row.dataset.refunded === 'true') return;
|
||
held = row;
|
||
row.classList.add('line-holding');
|
||
timer = setTimeout(()=>{ const id = row.dataset.id; clear(); window.__refundSale(id); }, 600);
|
||
}, { passive: true });
|
||
document.addEventListener('pointerup', clear);
|
||
document.addEventListener('pointercancel', clear);
|
||
document.addEventListener('pointermove', e=>{
|
||
// Sliding off the row (a scroll gesture) cancels, like a tap.
|
||
if(held && e.target !== held) clear();
|
||
}, { passive: true });
|
||
})();
|
||
|
||
// The refund sheet's buttons use inline onclick, which evaluates in the
|
||
// GLOBAL scope — functions declared inside this IIFE are invisible to it
|
||
// and the buttons would silently do nothing.
|
||
window.closeRefund = closeRefund;
|
||
window.confirmRefund = confirmRefund;
|
||
|
||
})();
|
||
</script>
|
||
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
|
||
</body>
|
||
</html>
|