WFDASH-WEBAPP/public/pos/receipt.html
Dave 6bedf27617 fix(pos): print the letterhead's own logo, and match delivery note to the sale slip
Logo — three separate faults, all of which had to be fixed for the
letterhead logo to print at all:

1. receipt.html hardcoded '/wfdash-logo.png' for the sale slip, so it
   printed the app's bundled mark (the solid black W block) and never
   looked at the letterhead at all.
2. The paths that DID use the letterhead (printTest, cashup) passed
   PocketBase's fully-qualified cross-origin URL into imageToRaster(),
   which draws to a <canvas> — getImageData() taints and throws on a
   cross-origin image, so the .catch(() => null) silently dropped the
   logo. pos-letterhead-service now returns a same-origin
   /api/v1/files/... URL (per AGENTS.md's file-URL rule).
3. letterhead_settings wasn't whitelisted in that file proxy, so the
   same-origin URL would have 403'd.

Verified against the live WESTSCOTT letterhead: its SVG rasterizes to
384x137 at ~15% ink coverage (line art), no tainted canvas.

Also fixes the company name never printing: every builder read
`letterhead.name`, but the POS letterhead record exposes
`display_name` — so the name line silently rendered empty on every
slip, on screen and on paper.

Delivery note now matches the sale slip, per request: gains the logo
and business_info blocks and the same 2x-size title, instead of being
a stripped-down variant. This also reconciles the transport
divergence flagged earlier (Bluetooth baked business info into its
header; the network builder omitted it entirely) — it is now one
reorderable business_info block on both.

resolveBlocks() now slots a newly-shipped block in at its intended
position rather than appending it to the end. Without this, any
letterhead with an already-saved layout would have printed the new
logo/business_info blocks BELOW the signature line.
2026-09-06 23:03:24 +02:00

870 lines
48 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}}
.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
</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 sale = 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.synced) document.getElementById('syncedFlag').style.display = 'inline-flex';
else document.getElementById('successSub').textContent = 'Saved locally · will sync when online';
if(sale.payment==='account'){
// Stashed so the "Include prices" checkbox can re-render in place.
window.__lastSale = sale;
document.getElementById('deliveryWrap').style.display='block';
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.
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
const vat = s.vat != null ? s.vat : (s.total * 15/115);
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 / 1.15;
const grossVat = gross - grossSub;
// 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>VAT 15% (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">
${grossBlock}
<div class="r-row"><span>Subtotal</span><span class="b">${fmt(sub)}</span></div>
<div class="r-row"><span>VAT 15%</span><span class="b">${fmt(vat)}</span></div>
<div class="r-row grand"><span>Total</span><span class="b">${fmt(s.total)}</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;
}
function renderDeliveryNote(s){
const date = new Date(s.createdAt);
const dateStr = date.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
// Some customers must not see prices on the slip the driver carries;
// others want them. Defaults from the letterhead's saved setting
// (Settings → Edit slip layout); the checkbox is a per-print override.
const toggle = document.getElementById('dnShowPrices');
const letterheadIdDn = s.letterhead && s.letterhead.id;
const showPrices = toggle ? !!toggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdDn);
const lines = s.items.map(it=>{
const price = showPrices ? `<div class="pr">${fmt((it.selling_price||0)*it.qty)}</div>` : '';
return `<div class="dn-line">
<div class="nm">${esc(it.description||it.sku)}</div>
<div class="qt">${it.qty} ${esc(it.unit||'ea')}</div>
${price}
</div>`;
}).join('');
const companyLine = s.customer && s.customer.company
? `<div class="v">${esc(s.customer.company)}</div>` : '';
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((s.customer && s.customer.company) || (s.customer && s.customer.name) || '—')}</div>
</div>
</div>
<div class="dn-meta">
<div><div class="k">Customer</div><div class="v">${esc((s.customer && s.customer.name)||'—')}</div>${companyLine}</div>
<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>
${showPrices ? `<div><div class="k">Sale total</div><div class="v">${fmt(s.total)}</div></div>` : ''}
</div>
<label class="dn-toggle"><input type="checkbox" id="dnShowPrices" ${showPrices?'checked':''} onchange="renderDeliveryNote(window.__lastSale)"> Include prices</label>
<div class="dn-items">${lines}</div>
<div class="dn-sign"><div>Received by</div><div>Driver</div></div>
`;
document.getElementById('deliveryCard').innerHTML = html;
}
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;
if (sale.payment === 'account') {
const letterheadId = sale.letterhead && sale.letterhead.id;
const deliveryBlocks = window.PosSlipLayout.getBlocksForPrint(letterheadId, 'delivery');
const dnToggle = document.getElementById('dnShowPrices');
const showPricing = dnToggle ? !!dnToggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadId);
const dnLogo = await window.EposPrint.letterheadLogoRaster(sale.letterhead, 384);
xml = window.EposPrint.buildEposDeliveryXml(sale, deliveryBlocks, showPricing, dnLogo);
successLabel = 'Delivery note printed · ';
} else {
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;
const dnToggle = document.getElementById('dnShowPrices');
const showPrices = dnToggle ? !!dnToggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdBt);
const text = sale.payment === 'account'
? buildEscPosDelivery(sale, showPrices, window.PosSlipLayout.getBlocksForPrint(letterheadIdBt, 'delivery'))
: 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 = (sale.payment === 'account' ? 'Delivery note printed · ' : '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);
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
const vat = s.vat != null ? s.vat : (s.total * 15/115);
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 / 1.15;
const grossVat = gross - grossSub;
out += `Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}\n`;
out += `VAT 15% (gross)${' '.repeat(10)}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${' '.repeat(22)}R ${sub.toFixed(2)}\n`;
out += `VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}\n`;
out += ESC+'!'+E(0x08) + `TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}\n` + ESC+'!'+E(0x00);
return out;
},
payment: () => {
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 (matching buildEscPosDelivery) — 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);
}
async function printDelivery(){
if(!sale) return;
const btn = document.getElementById('printDnBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
errBox.classList.remove('err');
btn.disabled = true;
status.textContent = 'Printing delivery note…';
const printerSettings = window.EposPrint.getSettings();
if(printerSettings.type === 'network'){
if(!printerSettings.ip){
status.textContent = 'Set the printer IP address in Printer settings first.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
const letterheadIdNet = sale.letterhead && sale.letterhead.id;
const dnToggleNet = document.getElementById('dnShowPrices');
const showPricesNet = dnToggleNet ? !!dnToggleNet.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdNet);
try{
const deliveryBlocksNet = window.PosSlipLayout.getBlocksForPrint(letterheadIdNet, 'delivery');
const dnLogoNet = await window.EposPrint.letterheadLogoRaster(sale.letterhead, 384);
const xml = window.EposPrint.buildEposDeliveryXml(sale, deliveryBlocksNet, showPricesNet, dnLogoNet);
await window.EposPrint.printViaNetwork(printerSettings.ip, xml);
status.textContent = 'Delivery note printed · ' + printerSettings.ip;
}catch(e){
errBox.classList.add('err');
status.textContent = 'DN print failed: ' + e.message;
}finally{
btn.disabled = false;
}
return;
}
if(!btChar){
status.textContent = 'Pair a Bluetooth printer first to print.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
const letterheadIdBtDn = sale.letterhead && sale.letterhead.id;
const dnToggle = document.getElementById('dnShowPrices');
const showPrices = dnToggle ? !!dnToggle.checked : window.PosSlipLayout.getShowPricingForPrint(letterheadIdBtDn);
const text = buildEscPosDelivery(sale, showPrices, window.PosSlipLayout.getBlocksForPrint(letterheadIdBtDn, 'delivery'));
const bytes = new Uint8Array(text.length);
for(let i=0;i<text.length;i++) bytes[i] = text.charCodeAt(i) & 0xff;
try{
for(let i=0;i<bytes.length;i+=80){
await btChar.writeValueWithoutResponse(bytes.slice(i, i+80));
await new Promise(r=>setTimeout(r,30));
}
status.textContent = 'Delivery note printed.';
}catch(e){
errBox.classList.add('err');
status.textContent = 'DN print failed: ' + e.message;
}finally{
btn.disabled = false;
}
}
// Mirrors the sale slip's header: same 2x-size title and the same
// separate business_info block, so a delivery note reads as the same
// company's paperwork. (Business info used to be baked into this
// transport's header_title while the network XML version omitted it
// entirely — the two transports have now been reconciled on the
// sale-slip shape, and business_info is its own reorderable block.)
function deliveryEscPosRenderers(s, showPrices){
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B);
return {
// Web Bluetooth GATT printing has no raster-image support in this
// app — this block intentionally prints nothing on this transport.
logo: () => '',
header_title: (block) => {
const title = block.text || 'WFSALES POS';
return ESC+'a'+E(1) + ESC+'!'+E(0x30) + title + '\n' + ESC+'!'+E(0x00) + 'Delivery Note\n' + ESC+'a'+E(0);
},
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);
},
customer: () => {
let out = '';
if(s.customer && s.customer.company) out += 'Company: ' + s.customer.company + '\n';
out += 'Customer: ' + (s.customer && s.customer.name || '—') + '\n';
return out;
},
date: () => {
const d = new Date(s.createdAt);
return 'Date: ' + d.toLocaleDateString('en-ZA')+'\n';
},
items: () => {
let out = '--------------------------------\n';
for(const it of s.items){
out += (it.description||it.sku)+'\n';
const qtyLine = ` ${it.qty} ${it.unit||'ea'}`;
out += showPrices ? `${qtyLine} ${fmt((it.selling_price||0)*it.qty)}\n` : `${qtyLine}\n`;
}
return out;
},
totals: () => {
let out = '--------------------------------\n';
if(showPrices) out += 'TOTAL: ' + fmt(s.total) + '\n';
return out;
},
signature: () => 'Received by: __________ Driver: __________\n',
};
}
// `blocks` is the resolved block list for 'delivery'. Returns a
// plain string (see buildEscPos's comment on why — TextEncoder needs
// a string, not a Uint8Array).
function buildEscPosDelivery(s, showPrices, blocks){
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B);
const renderers = deliveryEscPosRenderers(s, showPrices);
const body = blocks.filter(b=>b.enabled).map(b => renderers[b.id] ? renderers[b.id](b) : '').join('');
return ESC+'@' + body + '\n\n' + E(0x1D)+'V'+E(0x41)+E(0x10);
}
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>