WFDASH-WEBAPP/public/pos/receipt.html
Dave fc361edfc8 fix(pos): reset stateful printer attributes per-block instead of relying on order
Root cause of the oversized/wrapping text on the printed slip: ePOS-
Print XML's <text> attributes (width, height, em) are stateful on the
printer — they persist across subsequent <text> tags until explicitly
changed back, not per-tag like CSS. The sale slip's header_title block
sets width="2" height="2" for the big title and nothing after it ever
reset back to width="1" height="1", so the entire rest of the receipt
(business info, items, totals, payment, footer) inherited double-size
text — exactly the wrapping/misalignment seen in the photo. Same
issue existed in the delivery note (header's height="2" leaking into
every line after it) and cash-up (GRAND TOTAL's em="true" leaking
into CASH RECONCILIATION's non-bold lines).

Now every <text> tag in all three XML builders explicitly declares
width/height/em rather than relying on inheritance from whatever
tag ran before it. This isn't just a display fix — since blocks are
now user-reorderable/toggleable, no block can safely assume what
state a neighboring block left behind; every block must be correct
in isolation regardless of order.

Same class of bug existed on the Bluetooth ESC/POS side: ESC/POS's
alignment command is equally persistent, and the sale slip's
header_title centered the title but relied on the next block
(business_info) to reset it back to left — broken the moment
business_info is disabled or reordered. Fixed the same way: each
block resets what it changed before returning.
2026-09-04 15:58:08 +02:00

858 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;
const biz = 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);
xml = window.EposPrint.buildEposDeliveryXml(sale, deliveryBlocks, showPricing);
successLabel = 'Delivery note printed · ';
} else {
const logo = await window.EposPrint.imageToRaster('/wfdash-logo.png', 384).catch(()=>null);
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: () => '',
business_info: () => {
let out = ESC+'a'+E(0);
if(s.letterhead?.name) out += s.letterhead.name+'\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;
},
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 xml = window.EposPrint.buildEposDeliveryXml(sale, deliveryBlocksNet, showPricesNet);
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;
}
}
// Note: this transport's delivery note has always printed letterhead
// business info (name/address/phone/VAT) unconditionally after the
// title — the network XML version (EposPrint.buildEposDeliveryXml)
// never has. That's a pre-existing divergence between the two
// transports, not something introduced here; this refactor preserves
// each transport's current exact output rather than unilaterally
// picking one, so the business info stays baked into this
// transport's header_title block specifically.
function deliveryEscPosRenderers(s, showPrices){
return {
header_title: (block) => {
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B);
const title = block.text || 'WFSALES POS';
let out = ESC+'a'+E(1) + ESC+'!'+E(0x10) + title + '\n' + ESC+'!'+E(0x00) + 'Delivery Note\n' + ESC+'a'+E(0);
if(s.letterhead && s.letterhead.name) out += s.letterhead.name + '\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;
},
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>