WFDASH-WEBAPP/public/pos/receipt.html
Dave 0377c51602 fix(pos): dead discount toggle + stale discount figure, lift dark palette
Two reported symptoms that turned out to be one bug, plus the dark
mode the user described as "too black".

The `hidden` attribute does nothing on the discount controls

`hidden` is implemented by the UA stylesheet as `[hidden]{display:none}`.
Any author rule that sets `display` outranks it — and `.totals` and
`.discount-body` both set `display:flex`. So `el.hidden = true` was a
no-op on both:

  - the discount tier panel could not be collapsed, making "Add
    discount" look like a dead control;
  - the Discount totals row stayed on screen after the discount was
    cleared, still showing the last figure (R 98.37) under a total
    that no longer contained it. The JS only ever set `hidden` and
    never cleared the text, so the operator had no way to tell stale
    text from a live one.

`.cat-menu` and `.cat-backdrop` already carried per-element
`[hidden]{display:none}` overrides for exactly this reason; the two
discount elements were missed. Replaced with one global rule so the
whole class is covered rather than relying on each element to
remember. The clear path now also blanks the amount.

Verified statically rather than in a browser: jsdom does not model
the UA-vs-author cascade and reported the broken and fixed cases
identically, so the cascade was checked by resolving which rules
apply. Regression test fails without the fix.

Dark palette

The POS canvas was #0f0f0f (L* 4.3 — effectively black) with
surfaces only 3-4 L* above it, mixing a warm #1c1416 against a
neutral #181818 in the same UI. That near-black base plus two
competing surface tones is what read as muddy. Replaced with a
single cool-neutral ramp with four real steps, and menus/modals now
sit on a `--raised` layer instead of reusing the surface colour.

`#717171` was the secondary text colour in 25 places and measured
3.93:1 on the old canvas — below WCAG AA. It now measures 6.49:1.
Both the text greys and the surfaces were hardcoded per component
across seven POS pages; they are now tokens, with the dark block
rebinding them.

The shared `.dark` block in themes.css (drives all 24 themes) sat at
oklch(0.15) ≈ L* 3.1. Lifted +0.04 across the ramp, preserving its
shape and separation; muted-foreground goes 6.32:1 → 5.95:1, still
above AA. Cursor/Supabase, which override it darker, were at L* 1.3
and lifted too.

Service worker cache bumped to pos-v36 — pos.html and pos-app.js are
served cache-first, so without the bump the tills keep serving the
broken files.

3863 tests pass (3 new); build clean.
2026-10-12 23:17:57 +02:00

901 lines
51 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>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-line .pr{font-size:13px;font-weight:600;color:var(--fg);white-space:nowrap;font-variant-numeric:tabular-nums;min-width:76px;text-align:right}
.dn-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--ink-soft);margin:10px 0;padding:0 2px}
.dn-toggle input{width:16px;height:16px}
@media print{.dn-toggle{display:none}}
/* System-DN state note. Green = printable, amber = waiting on the
network, red = a human has to raise the DN by hand. */
.dn-note{margin:0;font-size:13px;line-height:1.45;padding:11px 12px;border-radius:10px;border:1px solid transparent}
.dn-note-linked{background:rgba(16,133,101,.08);border-color:rgba(16,133,101,.28);color:var(--fg)}
.dn-note-pending{background:rgba(200,140,20,.10);border-color:rgba(200,140,20,.32);color:var(--fg)}
.dn-note-failed{background:rgba(201,42,42,.10);border-color:rgba(201,42,42,.32);color:var(--fg)}
.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}
.printer-settings{padding:12px 14px;border-radius:12px;border:1px solid var(--border);background:var(--surface-card);display:flex;flex-direction:column;gap:8px}
.printer-settings label{font-size:12px;font-weight:600;color:var(--ink-soft);display:flex;flex-direction:column;gap:4px}
.printer-settings select,.printer-settings input{padding:9px 10px;border-radius:8px;border:1px solid var(--muted);background:var(--bg);color:var(--fg);font:inherit}
.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:#161619;--surface:#1e1e23;--surface-2:#1e1e23;--fg:#f2f2f5;--muted:#303039;--border:#3b3b46;
--ink:#f2f2f5;--ink-soft:#a0a0a0;--ink-faint:#8e8e9a;
--surface-card:#26262d;--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:#161619;--surface:#1e1e23;--surface-2:#1e1e23;--fg:#f2f2f5;--muted:#303039;--border:#3b3b46;
--ink:#f2f2f5;--ink-soft:#a0a0a0;--ink-faint:#8e8e9a;
--surface-card:#26262d;--accent-soft:#2b1f24;--accent-2:#3f2b33;--accent-soft-hover:#452f38;
--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="status" id="printerSettingsToggle" style="cursor:pointer" onclick="togglePrinterSettings()">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h18v6H3zM7 13v6h10v-6M7 17h10"/></svg></span>
<span class="t" id="printerSettingsLabel">Printer: Bluetooth</span>
</div>
<div class="printer-settings" id="printerSettingsForm" style="display:none">
<label>Printer type
<select id="printerTypeSelect">
<option value="bluetooth">Bluetooth</option>
<option value="network">Network (Ethernet)</option>
</select>
</label>
<label>Printer IP address
<input id="printerIpInput" placeholder="192.168.1.33">
</label>
<button class="btn" style="min-height:40px" onclick="savePrinterSettings()">Save printer settings</button>
</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 (A4)
</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="pos-config.js"></script>
<script src="theme.js"></script>
<script src="slip-layout.js"></script>
<script src="epos-print.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]);
const escAttr = s => String(s).replace(/"/g,'&quot;');
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 isRefundView = /[?&]refund=1/.test(location.search);
const refund = isRefundView
? JSON.parse(sessionStorage.getItem('pos.lastRefund')||'null')
: null;
// A credit slip reuses the whole sale renderer and the whole printer path —
// the only difference is `refund: true`, which flips the wording in
// renderReceipt and in epos-print.js. One printer path, one set of
// settings, nothing new to keep working.
const sale = refund
? {
refund: true,
localId: (refund.saleNumber || '').slice(-8).toUpperCase(),
saleNumber: refund.saleNumber,
createdAt: refund.when,
payment: refund.originalPayment || 'cash',
total: Number(refund.refundAmount) || 0,
// The credit is the VAT-inclusive total the customer got back;
// split it on the rate the sale was rung up at so the CREDIT
// slip reconciles line-for-line against the original receipt.
vatPct: (() => { const p = Number(refund.vat_percent);
return Number.isFinite(p) && p >= 0 && p <= 100 ? p : 15; })(),
sub: (() => { const t = Number(refund.refundAmount)||0, p = Number(refund.vat_percent) || 15;
return Math.round(t / (1 + p/100) * 100) / 100; })(),
vat: (() => { const t = Number(refund.refundAmount)||0, p = Number(refund.vat_percent) || 15;
const n = t / (1 + p/100); return Math.round((t - n) * 100) / 100; })(),
items: (refund.items||[]).map(it => ({
sku: it.sku, description: it.description,
qty: Number(it.qty)||0, selling_price: Number(it.selling_price)||0,
})),
customer: refund.customer || null,
letterhead: null,
synced: true,
}
: 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.refund){
// Retitle the screen so nobody mistakes this for a fresh receipt, and
// say plainly that the money has already been handed back — the slip
// is a record, not an instruction to pay again.
document.getElementById('successTitle').textContent = 'Refunded';
document.getElementById('successSub').textContent =
`Credit slip for sale ${sale.saleNumber} — goods are back in stock.`;
document.getElementById('syncedFlag').style.display = 'none';
} else 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' && !sale.refund){
document.getElementById('deliveryWrap').style.display='block';
// An account sale never gets a paper slip. The goods leave on the
// delivery note and that is the document the client signs, so
// offering "Print slip" here just invites someone to print the
// wrong thing at the counter. The till slip stays for cash / card
// / EFT, which are the tenders where payment is actually taken.
const slipBtn = document.getElementById('printBtn');
if (slipBtn) slipBtn.style.display = 'none';
// The print button only makes sense once a DN actually exists.
// A queued (offline) sale or a failed creation still shows the
// card with its state, but has nothing to print yet.
if(sale.deliveryNote) 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();
// The till's `s.sub` / `s.vat` / `s.total` are the POST-discount
// values; the gross_* are pre-discount. The receipt surfaces
// both when a discount was applied, so the customer can see what
// they saved.
// The VAT rate is stored on the sale, so a slip re-printed months
// later still labels its VAT line with the rate actually charged
// rather than today's default.
const rawPct = Number(s.vatPct != null ? s.vatPct : (s.vat_percent != null ? s.vat_percent : 15));
const vatPct = Number.isFinite(rawPct) && rawPct >= 0 && rawPct <= 100 ? rawPct : 15;
const vatRateLabel = vatPct > 0 ? `VAT ${vatPct}%` : 'VAT';
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
const vat = s.vat != null ? s.vat : (s.total * vatPct / (100 + vatPct));
const paid = s.payment==='cash' ? fmt(s.cashGiven||0) : fmt(s.total);
const d = s.discount || { kind: 'none', input: 0, amount: 0 };
const hasDiscount = d.kind !== 'none' && Number(d.amount) > 0;
// For backward compat with sales recorded before the discount
// fields existed, fall back to deriving the gross from the
// line items if the till didn't stash it.
const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price)||0) * (Number(it.qty)||0), 0);
const grossSub = gross;
const grossVat = (gross * vatPct) / 100;
// The POS letterhead record exposes `display_name`, not `name` —
// reading `.name` meant this always fell back to 'WFSALES POS'.
const biz = lh.display_name || 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>`);
if(it.edited) parts.push(`<span class="sep">·</span><span style="color:var(--warn)">price edited</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>`
: '';
// Pre-discount block is shown when the till applied a
// discount. Otherwise we just show sub / vat / total. The
// discount line itself sits between gross and the post-
// discount totals so it's obvious what was knocked off.
const grossBlock = hasDiscount ? `
<div class="r-row"><span>Subtotal (gross)</span><span class="b">${fmt(grossSub)}</span></div>
<div class="r-row"><span>${esc(vatRateLabel)} (gross)</span><span class="b">${fmt(grossVat)}</span></div>
<div class="r-row"><span>Total (gross)</span><span class="b">${fmt(gross)}</span></div>
<div class="r-row" style="color:var(--ok)"><span>Discount${d.kind==='percent' ? ` (${d.input}%)` : ''}</span><span class="b">− ${fmt(d.amount)}</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">
${s.refund ? '' : grossBlock}
<div class="r-row"><span>Subtotal</span><span class="b">${fmt(sub)}</span></div>
<div class="r-row"><span>${esc(vatRateLabel)}</span><span class="b">${fmt(vat)}</span></div>
<div class="r-row grand"><span>${s.refund ? 'CREDIT' : 'Total'}</span><span class="b">${fmt(s.total)}</span></div>
${s.refund
? `<div class="r-row" style="margin-top:6px"><span><span class="pill">${esc(method)}</span> Refunded</span><span class="b">${fmt(s.total)}</span></div>
<div class="r-row" style="margin-top:2px"><span>Goods returned to stock</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;
}
// The system delivery note. The account sale raised this server-side
// inside `checkoutSale`; this card just shows the number the operator
// quotes to the client and hands them the A4 print.
//
// The old 80mm thermal delivery-note slip is gone from this path by
// design — the client gets the same document the office keeps, printed
// on the office printer. The thermal slip still prints for cash / card
// / EFT, which never reach this function.
function renderDeliveryNote(s){
const date = new Date(s.createdAt);
const dateStr = date.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
const customerName = (s.customer && s.customer.name) || '—';
const companyName = (s.customer && s.customer.company) || '';
// Three states, and the operator has to be able to tell them apart
// at a glance:
// linked — the DN exists, print it
// pending — the sale is still in the offline queue; the DN is
// raised automatically on the next reconnect
// failed — the sale banked but the DN could not be created, so a
// human has to raise it from the CRM
let state, numberHtml, noteHtml;
if (s.deliveryNote) {
state = 'linked';
numberHtml = `<div class="v" style="font-size:17px">${esc(s.deliveryNote.delivery_note_number)}</div>`;
noteHtml = 'Print two copies for the client to sign — one to keep, one to bring back to the office.';
} else if (s.synced) {
state = 'failed';
numberHtml = '<div class="v" style="font-size:17px">Not created</div>';
noteHtml = esc(s.deliveryNoteError
|| 'The sale was recorded but no delivery note was created. Raise one by hand from the CRM.');
} else {
state = 'pending';
numberHtml = '<div class="v" style="font-size:17px">Pending</div>';
noteHtml = 'This sale is saved on the till. The delivery note is raised automatically once the connection is back — pick it up from the CRM, not the thermal printer.';
}
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(companyName || customerName)}</div>
</div>
</div>
<div class="dn-meta">
<div><div class="k">Customer</div><div class="v">${esc(customerName)}</div>${companyName?`<div class="v">${esc(companyName)}</div>`:''}</div>
<div><div class="k">Date</div><div class="v">${dateStr}</div></div>
<div><div class="k">DN number</div>${numberHtml}</div>
<div><div class="k">Items</div><div class="v">${s.items.length}</div></div>
</div>
<p class="dn-note dn-note-${state}">${noteHtml}</p>
`;
document.getElementById('deliveryCard').innerHTML = html;
window.__dnState = state;
}
window.renderDeliveryNote = renderDeliveryNote;
// ---------------- Bluetooth thermal printing ----------------
let btDevice = null;
let btChar = null;
async function printThermal(){
const settings = window.EposPrint.getSettings();
if(settings.type === 'network') return printThermalNetwork(settings);
return printThermalBluetooth();
}
async function printThermalNetwork(settings){
const btn = document.getElementById('printBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
errBox.classList.remove('err');
btn.disabled = true;
if(!settings.ip){
status.textContent = 'Set the printer IP address in Printer settings first.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
status.textContent = 'Sending to ' + settings.ip + '…';
try{
// Account sales print a delivery note (no payment line, has
// received-by / driver lines) — that's the document the driver
// hands to the customer at drop-off, not a "paid" receipt. Cash
// / card / EFT get the standard sale slip.
let xml, successLabel;
// Always the standard sale slip. Account sales no longer print a
// thermal delivery note — the system A4 DN is printed by
// `printDelivery()` instead, so this button is the till slip for
// every tender type.
const logo = await window.EposPrint.letterheadLogoRaster(sale.letterhead, 384);
const letterheadId = sale.letterhead && sale.letterhead.id;
const blocks = window.PosSlipLayout.getBlocksForPrint(letterheadId, 'sale');
xml = window.EposPrint.buildEposXml(sale, logo, blocks);
successLabel = 'Printed · ';
await window.EposPrint.printViaNetwork(settings.ip, xml);
status.textContent = successLabel + settings.ip;
}catch(e){
status.textContent = 'Print failed: ' + e.message;
errBox.classList.add('err');
}finally{
btn.disabled = false;
}
}
async function printThermalBluetooth(){
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…';
// Account sales print a delivery note (received-by / driver lines,
// no payment line). Everything else prints the standard sale slip.
const letterheadIdBt = sale.letterhead && sale.letterhead.id;
// Standard sale slip for every tender — see the note on the
// network path above. Account sales print the system A4 DN
// through `printDelivery()` instead.
const text = buildEscPos(sale, window.PosSlipLayout.getBlocksForPrint(letterheadIdBt, 'sale'));
const CHUNK = 80;
const enc = new TextEncoder();
for(let i=0;i<text.length;i+=CHUNK){
const slice = text.slice(i, i+CHUNK);
await btChar.writeValueWithoutResponse(enc.encode(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 saleEscPosRenderers(s){
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B), GS = E(0x1D);
// The VAT rate is stored on the sale, so a slip re-printed months
// later still labels its VAT line with the rate actually charged
// rather than today's default.
const rawPct = Number(s.vatPct != null ? s.vatPct : (s.vat_percent != null ? s.vat_percent : 15));
const vatPct = Number.isFinite(rawPct) && rawPct >= 0 && rawPct <= 100 ? rawPct : 15;
const vatRateLabel = vatPct > 0 ? `VAT ${vatPct}%` : 'VAT';
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
const vat = s.vat != null ? s.vat : (s.total * vatPct / (100 + vatPct));
const discount = s.discount || { kind: 'none', input: 0, amount: 0 };
const hasDiscount = discount.kind !== 'none' && Number(discount.amount) > 0;
return {
// ESC/POS alignment (ESC a) and print-mode (ESC !) are persistent
// printer state, not per-line attributes — since blocks are
// reorderable, this must reset both back to normal itself rather
// than relying on the next block (business_info) to do it.
header_title: (block) => ESC+'a'+E(1) + ESC+'!'+E(0x30) + (block.text||'WFSALES POS') + '\n' + ESC+'!'+E(0x00) + ESC+'a'+E(0),
// Web Bluetooth GATT printing has no raster-image support in this
// app — this block intentionally prints nothing on this transport.
logo: () => '',
// Centered, matching the network XML builder's business_info —
// these two transports print the same slip and shouldn't differ.
business_info: () => {
let out = ESC+'a'+E(1);
const bizName = s.letterhead && (s.letterhead.display_name || s.letterhead.name);
if(bizName) out += bizName+'\n';
if(s.letterhead){
if(s.letterhead.address) out += s.letterhead.address.split('\n')[0]+'\n';
if(s.letterhead.phone) out += 'Tel: '+s.letterhead.phone+'\n';
if(s.letterhead.vat_number) out += 'VAT: '+s.letterhead.vat_number+'\n';
}
return out + ESC+'a'+E(0);
},
date_slip: () => {
const d = new Date(s.createdAt);
return '--------------------------------\n'
+ 'Date: ' + d.toLocaleString('en-ZA')+'\n'
+ 'Slip: ' + (s.localId||'').slice(-8).toUpperCase()+'\n';
},
customer: () => s.customer ? ('Customer: ' + s.customer.name + '\n') : '',
items: () => {
let out = '--------------------------------\n';
for(const it of s.items){
out += (it.description||it.sku) + '\n';
out += ` ${it.qty} x ${(it.selling_price||0).toFixed(2)} ${(it.selling_price*it.qty).toFixed(2)}\n`;
}
return out;
},
totals: () => {
let out = '--------------------------------\n';
if (hasDiscount) {
const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price)||0) * (Number(it.qty)||0), 0);
const grossSub = gross;
const grossVat = (gross * vatPct) / 100;
out += `Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}\n`;
out += `${vatRateLabel} (gross)${' '.repeat(Math.max(1,32-vatRateLabel.length-8))}R ${grossVat.toFixed(2)}\n`;
out += `Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}\n`;
out += `Discount${discount.kind==='percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}\n`;
}
out += `Subtotal (ex VAT)${' '.repeat(Math.max(1,32-17))}R ${sub.toFixed(2)}\n`;
out += `${vatRateLabel}${' '.repeat(Math.max(1,32-vatRateLabel.length))}R ${vat.toFixed(2)}\n`;
out += ESC+'!'+E(0x08) + `${s.refund?'CREDIT':'TOTAL'}${' '.repeat(28)}R ${s.total.toFixed(2)}\n` + ESC+'!'+E(0x00);
return out;
},
payment: () => {
if(s.refund){
const how = s.payment==='cash' ? 'CASH' : s.payment==='account' ? 'ACCOUNT CREDIT' : s.payment.toUpperCase();
return 'Refunded by: ' + how + '\n' + 'Goods returned to stock.\n';
}
let out = 'Paid: ' + (s.payment==='cash' ? `CASH R ${(s.cashGiven||0).toFixed(2)}` : s.payment.toUpperCase()) + '\n';
if(s.payment==='cash') out += `Change${' '.repeat(24)}R ${(s.change||0).toFixed(2)}\n`;
return out;
},
footer: (block) => {
const text = block.text || s.letterhead?.footer_text;
let out = '--------------------------------\n';
if(text) out += text+'\n';
return out;
},
};
}
// `blocks` is the resolved block list for 'sale'. Returns a plain
// string — the caller runs it through
// TextEncoder before writing to the Bluetooth characteristic. (Note:
// this used to manually pack a Uint8Array here, which the caller then
// fed into TextEncoder.encode() — encoding a Uint8Array as if it were
// a string, producing garbled output. Fixed as part of this refactor.)
function buildEscPos(s, blocks){
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B), GS = E(0x1D);
const renderers = saleEscPosRenderers(s);
const body = blocks.filter(b=>b.enabled).map(b => renderers[b.id] ? renderers[b.id](b) : '').join('');
return ESC+'@' + body + '\n\n' + GS+'V'+E(0x41)+E(0x10);
}
// Prints the SYSTEM delivery note — the A4 original the office keeps —
// by opening it in a new tab and letting the operator send it to the
// office network printer from there (set Copies to 2 for the client).
//
// This replaced the old 80mm thermal delivery-note print. The thermal
// slip is still the path for cash / card / EFT via `printThermal`,
// and its button is hidden for account sales entirely.
async function printDelivery(){
if(!sale) return;
const dn = sale.deliveryNote;
const btn = document.getElementById('printDnBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
if(btn) btn.disabled = true;
errBox.classList.remove('err');
if(!dn){
if(status) status.textContent = 'No delivery note on this sale yet.';
if(btn) btn.disabled = false;
return;
}
if(status) status.textContent = 'Fetching ' + dn.delivery_note_number + '…';
let url = null;
try{
// Fetched through posFetch because the route sits behind
// assertPosAuth — an <iframe src> or a plain window.open() to the
// route cannot carry the bearer header, so the bytes come in as a
// blob first and the blob URL is what gets opened.
const r = await window.posFetch('/delivery-note/' + encodeURIComponent(dn.delivery_note_number) + '/pdf');
if(!r.ok){
let msg = 'HTTP ' + r.status;
try{ const j = await r.json(); if(j && j.error) msg = j.error; }catch(_){}
throw new Error(msg);
}
const blob = await r.blob();
url = URL.createObjectURL(blob);
// Open the blob in a NEW TAB rather than calling print() on a
// hidden iframe.
//
// An earlier version of this built a 1x1 iframe and called
// `contentWindow.print()`, which looks right and is not. Chrome
// backs ANY pdf — src URL or blob URL — with its internal
// `pdf_embedder` extension, so the frame's document is an empty
// shell:
//
// <html><head><link rel="stylesheet"
// href="chrome-extension://…/pdf_embedder.css"></head>
// <body></body></html>
//
// `print()` then prints that empty body: a dialog of blank pages
// with no error to explain why. Verified by reading the iframe's
// document out of a live page. A new tab gets Chrome's real PDF
// viewer, whose own Print button works.
const win = window.open(url, '_blank');
if(!win){
errBox.classList.add('err');
status.textContent = 'Pop-up blocked — allow pop-ups for this site, or use the office computer to open the delivery note from WFDASH.';
return;
}
status.textContent = dn.delivery_note_number + ' opened in a new tab — press Print there, and set Copies to 2 for the client.';
}catch(e){
errBox.classList.add('err');
if(status) status.textContent = 'DN print failed: ' + e.message;
}finally{
// Keep the blob alive long enough for the new tab to load it.
if(url) setTimeout(() => { URL.revokeObjectURL(url); }, 60000);
if(btn) btn.disabled = false;
}
}
function togglePrinterSettings(){
const form = document.getElementById('printerSettingsForm');
form.style.display = form.style.display === 'none' ? 'flex' : 'none';
}
function savePrinterSettings(){
const type = document.getElementById('printerTypeSelect').value;
const ip = document.getElementById('printerIpInput').value.trim();
window.EposPrint.setSettings({ type, ip });
paintPrinterSettings();
document.getElementById('printerSettingsForm').style.display = 'none';
}
function paintPrinterSettings(){
const settings = window.EposPrint.getSettings();
document.getElementById('printerTypeSelect').value = settings.type;
document.getElementById('printerIpInput').value = settings.ip || '';
document.getElementById('printerSettingsLabel').textContent =
'Printer: ' + (settings.type === 'network' ? ('Network' + (settings.ip ? ' (' + settings.ip + ')' : '')) : 'Bluetooth');
}
paintPrinterSettings();
paintNet();
// Expose the onclick-bound functions on window — they're defined
// inside the IIFE above so they're not visible to inline `onclick`
// attributes (which evaluate against window). Without this the
// "Printer: ..." row, "Save printer settings" button, "Print slip"
// button, and "Print delivery note" button all silently do nothing.
window.togglePrinterSettings = togglePrinterSettings;
window.savePrinterSettings = savePrinterSettings;
window.printThermal = printThermal;
window.printDelivery = printDelivery;
})();
</script>
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
</body>
</html>