WFDASH-WEBAPP/public/pos/history.html
Dave 8232a87f99 feat(pos): ex-VAT totals, ad-hoc custom lines, fixed discount tiers
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.
2026-10-12 22:54:09 +02:00

513 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[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>