WFDASH-WEBAPP/public/pos/history.html
Dave a499265b26 feat(pos): raise a system delivery note on an account sale
An account sale at the till used to produce a delivery-note-shaped slip that
was rendered entirely on the device and printed on the 80mm thermal printer.
Nothing reached the server, so the paperwork the client signed had no record
behind it. Cash / card / EFT are untouched and still get the thermal slip.

An account sale now creates a real `delivery_notes` record. The DN is raised
server-side INSIDE `checkoutSale` rather than by a follow-up call from the
till, because the offline queue (`flushQueue`) re-posts `/checkout` and
nothing else — a client-side second call would be silently dropped for every
sale taken while the network was down, which is exactly when a till is
flakiest.

`pos_sales.delivery_note` is the idempotency anchor that makes it
exactly-once: on a replay it hands back the existing DN instead of making a
second one, and a sale whose first attempt left the field blank retries. It
self-heals.

Deliberately non-fatal: if the DN can't be created the sale still succeeds.
The money and the stock movement are real by then; failing the checkout would
leave the drawer disagreeing with the stock ledger. The till is told instead.

Also in here:
  - DN number collision retry. The generator reads the highest existing number
    and adds one, which is not atomic, while PB holds a unique index on it. The
    POS adds a second concurrent writer, so two creates could collide and 500
    on a benign condition.
  - GET /api/v1/pos/sales now expands customer, customer.company and
    delivery_note so history.html can rebuild the DN card on a reprint. The
    nested customer.company matters — customer.company is itself a relation,
    so without it the card printed a raw PB id where the name belongs.
  - "Print slip" is hidden for account sales; that tender produces a delivery
    note, not a slip.

New GET /api/v1/pos/delivery-note/[number]/pdf streams the A4 original behind
assertPosAuth, so the till's bearer key stays its only credential. It does not
point at the CRM's unauthenticated invoicing route.

Verified end to end against a live note: DN26100401, correct company, both
lines with pricing, PDF attached, stock decremented once, and a replay of the
same client_ref returned the same DN without a duplicate.
2026-10-04 13:15:29 +02:00

332 lines
18 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}
.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="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();
return `<button class="sale-row" data-od-id="sale-${r.s.id}" onclick="reprint('${r.s.id}')">
<div class="nm">
<b>${esc(r.s.sale_number||r.s.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>';
}
}
// 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: s.gross_total,
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,
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();
})();
</script>
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
</body>
</html>