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.
901 lines
51 KiB
HTML
901 lines
51 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>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:#0f0f0f;--surface:#1c1416;--surface-2:#181818;--fg:#f0f0f0;--muted:#2a2a2a;--border:#3a2429;
|
||
--ink:#f0f0f0;--ink-soft:#a0a0a0;--ink-faint:#777;
|
||
--surface-card:#181818;--accent-soft:#2a1a1a;--accent-2:#3a2429;--accent-soft-hover:#3a2429;
|
||
--ok-soft:#1a2e22;--danger-soft:#2a1a1a;--danger-2:#3a2429;
|
||
--focus-ring:0 0 0 3px rgba(255,102,128,.35);
|
||
}
|
||
:root:not([data-theme="light"]) body{background:var(--bg)}
|
||
:root:not([data-theme="light"]) .topbar{background:var(--bg)}
|
||
:root:not([data-theme="light"]) .topbar .back{background:var(--surface-card);border-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .netpill{background:var(--surface-card);color:var(--ink-soft);border-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .netpill.off{color:#ff6680;border-color:#ff6680;background:var(--danger-soft)}
|
||
:root:not([data-theme="light"]) .theme-btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||
:root:not([data-theme="light"]) .success{background:var(--surface-card);border-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .success .check{background:var(--ok-soft);color:#5fc77c}
|
||
:root:not([data-theme="light"]) .success .synced-flag{background:var(--ok-soft);color:#5fc77c}
|
||
:root:not([data-theme="light"]) .success .synced-flag::before{background:#5fc77c}
|
||
:root:not([data-theme="light"]) .receipt{background:var(--surface-card);border-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .r-head{border-bottom-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .r-head .nm .biz{color:var(--fg)}
|
||
:root:not([data-theme="light"]) .r-head .nm .addr,:root:not([data-theme="light"]) .r-head .nm .vat{color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .r-meta{border-bottom-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .r-meta .k{color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .r-meta .v{color:var(--fg)}
|
||
:root:not([data-theme="light"]) .r-cust{background:var(--surface-2)}
|
||
:root:not([data-theme="light"]) .r-cust .lbl{color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .r-item{border-bottom-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .r-item .name{color:var(--fg)}
|
||
:root:not([data-theme="light"]) .r-item .meta{color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .r-totals{border-top-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .r-row{color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .r-row .b{color:var(--fg)}
|
||
:root:not([data-theme="light"]) .r-row .pill{background:var(--surface);color:var(--fg);border-color:var(--border)}
|
||
:root:not([data-theme="light"]) .r-row.grand{border-top-color:var(--muted);color:var(--fg)}
|
||
:root:not([data-theme="light"]) .r-row.change .b{color:#5fc77c}
|
||
:root:not([data-theme="light"]) .r-foot{border-top-color:var(--muted);color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .dn{background:var(--surface-card);border-color:#ff6680}
|
||
:root:not([data-theme="light"]) .dn-head .ic{background:var(--accent-soft);border-color:var(--accent-2)}
|
||
:root:not([data-theme="light"]) .dn-meta{background:var(--surface-2)}
|
||
:root:not([data-theme="light"]) .dn-meta .k{color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .dn-line{border-bottom-color:var(--muted);color:var(--fg)}
|
||
:root:not([data-theme="light"]) .dn-line .qt{color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .dn-sign{border-top-color:var(--muted)}
|
||
:root:not([data-theme="light"]) .dn-sign > div{border-top-color:var(--fg);color:var(--ink-soft)}
|
||
:root:not([data-theme="light"]) .status{background:var(--surface);border-color:var(--border);color:var(--fg)}
|
||
:root:not([data-theme="light"]) .status.err{background:var(--danger-soft);border-color:var(--danger-2);color:#ff8a9e}
|
||
:root:not([data-theme="light"]) .btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||
:root:not([data-theme="light"]) .btn:active{background:var(--surface)}
|
||
:root:not([data-theme="light"]) .btn.acct{background:var(--accent-soft);border-color:var(--accent-2);color:#ff8a9e}
|
||
:root:not([data-theme="light"]) .btn.acct:active{background:var(--accent-soft-hover)}
|
||
}
|
||
:root[data-theme="dark"]{
|
||
--bg:#0f0f0f;--surface:#1c1416;--surface-2:#181818;--fg:#f0f0f0;--muted:#2a2a2a;--border:#3a2429;
|
||
--ink:#f0f0f0;--ink-soft:#a0a0a0;--ink-faint:#777;
|
||
--surface-card:#181818;--accent-soft:#2a1a1a;--accent-2:#3a2429;--accent-soft-hover:#3a2429;
|
||
--ok-soft:#1a2e22;--danger-soft:#2a1a1a;--danger-2:#3a2429;
|
||
--focus-ring:0 0 0 3px rgba(255,102,128,.35);
|
||
}
|
||
:root[data-theme="dark"] body{background:var(--bg)}
|
||
:root[data-theme="dark"] .topbar{background:var(--bg)}
|
||
:root[data-theme="dark"] .topbar .back{background:var(--surface-card);border-color:var(--muted)}
|
||
:root[data-theme="dark"] .netpill{background:var(--surface-card);color:var(--ink-soft);border-color:var(--muted)}
|
||
:root[data-theme="dark"] .netpill.off{color:#ff6680;border-color:#ff6680;background:var(--danger-soft)}
|
||
:root[data-theme="dark"] .theme-btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||
:root[data-theme="dark"] .success{background:var(--surface-card);border-color:var(--muted)}
|
||
:root[data-theme="dark"] .success .check{background:var(--ok-soft);color:#5fc77c}
|
||
:root[data-theme="dark"] .success .synced-flag{background:var(--ok-soft);color:#5fc77c}
|
||
:root[data-theme="dark"] .success .synced-flag::before{background:#5fc77c}
|
||
:root[data-theme="dark"] .receipt{background:var(--surface-card);border-color:var(--muted)}
|
||
:root[data-theme="dark"] .r-head{border-bottom-color:var(--muted)}
|
||
:root[data-theme="dark"] .r-head .nm .biz{color:var(--fg)}
|
||
:root[data-theme="dark"] .r-head .nm .addr,:root[data-theme="dark"] .r-head .nm .vat{color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .r-meta{border-bottom-color:var(--muted)}
|
||
:root[data-theme="dark"] .r-meta .k{color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .r-meta .v{color:var(--fg)}
|
||
:root[data-theme="dark"] .r-cust{background:var(--surface-2)}
|
||
:root[data-theme="dark"] .r-cust .lbl{color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .r-item{border-bottom-color:var(--muted)}
|
||
:root[data-theme="dark"] .r-item .name{color:var(--fg)}
|
||
:root[data-theme="dark"] .r-item .meta{color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .r-totals{border-top-color:var(--muted)}
|
||
:root[data-theme="dark"] .r-row{color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .r-row .b{color:var(--fg)}
|
||
:root[data-theme="dark"] .r-row .pill{background:var(--surface);color:var(--fg);border-color:var(--border)}
|
||
:root[data-theme="dark"] .r-row.grand{border-top-color:var(--muted);color:var(--fg)}
|
||
:root[data-theme="dark"] .r-row.change .b{color:#5fc77c}
|
||
:root[data-theme="dark"] .r-foot{border-top-color:var(--muted);color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .dn{background:var(--surface-card);border-color:#ff6680}
|
||
:root[data-theme="dark"] .dn-head .ic{background:var(--accent-soft);border-color:var(--accent-2)}
|
||
:root[data-theme="dark"] .dn-meta{background:var(--surface-2)}
|
||
:root[data-theme="dark"] .dn-meta .k{color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .dn-line{border-bottom-color:var(--muted);color:var(--fg)}
|
||
:root[data-theme="dark"] .dn-line .qt{color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .dn-sign{border-top-color:var(--muted)}
|
||
:root[data-theme="dark"] .dn-sign > div{border-top-color:var(--fg);color:var(--ink-soft)}
|
||
:root[data-theme="dark"] .status{background:var(--surface);border-color:var(--border);color:var(--fg)}
|
||
:root[data-theme="dark"] .status.err{background:var(--danger-soft);border-color:var(--danger-2);color:#ff8a9e}
|
||
:root[data-theme="dark"] .btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
|
||
:root[data-theme="dark"] .btn:active{background:var(--surface)}
|
||
:root[data-theme="dark"] .btn.acct{background:var(--accent-soft);border-color:var(--accent-2);color:#ff8a9e}
|
||
:root[data-theme="dark"] .btn.acct:active{background:var(--accent-soft-hover)}
|
||
|
||
/* ---- Reduced motion ---- */
|
||
@media (prefers-reduced-motion: reduce){
|
||
*{transition-duration:0.01ms !important;animation-duration:0.01ms !important}
|
||
.btn:active{transform:none}
|
||
}
|
||
|
||
@media print{
|
||
body{background:#fff}
|
||
.topbar,.theme-btn,.actions,.status,.synced-flag,.success .synced-flag{display:none !important}
|
||
.content{padding:0}
|
||
.success{background:#fff !important;color:#000 !important;box-shadow:none;border:1px solid #000;border-radius:0;padding:14px}
|
||
.success .check{background:#000 !important;color:#fff !important}
|
||
.receipt,.dn{box-shadow:none;border:1px solid #999;border-radius:0;page-break-inside:avoid}
|
||
.btn{display:none}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="topbar">
|
||
<button class="back" data-od-id="back-btn" onclick="location.href='pos.html'" aria-label="Back">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 6l-6 6 6 6"/></svg>
|
||
</button>
|
||
<h1>Receipt</h1>
|
||
<button class="theme-btn" data-theme-toggle data-od-id="theme-toggle" aria-label="Toggle theme" title="Toggle theme" onclick="(window.WFTheme&&window.WFTheme.cycle())||(window.__wfCycle&&window.__wfCycle())">
|
||
<svg class="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
|
||
<svg class="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||
</button>
|
||
<span class="netpill" id="netpill">…</span>
|
||
</header>
|
||
|
||
<div class="content">
|
||
|
||
<div class="success" id="success" data-od-id="success-banner">
|
||
<div class="check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 12l5 5 9-11"/></svg></div>
|
||
<div class="txt">
|
||
<div class="h" id="successH">Sale complete</div>
|
||
<div class="s" id="successSub">Saved to register</div>
|
||
</div>
|
||
<span class="synced-flag" id="syncedFlag" style="display:none">Synced</span>
|
||
</div>
|
||
|
||
<section class="receipt" id="receiptCard" data-od-id="receipt"></section>
|
||
|
||
<div id="deliveryWrap" style="display:none">
|
||
<section class="dn" id="deliveryCard" data-od-id="delivery-note"></section>
|
||
</div>
|
||
|
||
<div class="status" id="status">
|
||
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.5"/></svg></span>
|
||
<span class="t" id="statusText">Ready to print.</span>
|
||
</div>
|
||
|
||
<div class="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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
|
||
const escAttr = s => String(s).replace(/"/g,'"');
|
||
|
||
function paintNet(){
|
||
const p = document.getElementById('netpill');
|
||
p.textContent = navigator.onLine ? 'Online' : 'Offline';
|
||
p.classList.toggle('off', !navigator.onLine);
|
||
}
|
||
window.addEventListener('online', paintNet);
|
||
window.addEventListener('offline', paintNet);
|
||
|
||
const 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>
|