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.
870 lines
48 KiB
HTML
870 lines
48 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<meta name="theme-color" content="#ff385c">
|
||
<title>Receipt</title>
|
||
<link rel="manifest" href="manifest.json">
|
||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%23ff385c'/><text x='16' y='22' font-family='-apple-system,sans-serif' font-size='17' font-weight='700' text-anchor='middle' fill='white'>W</text></svg>">
|
||
<style>
|
||
:root{
|
||
/* Brand palette — Airbnb-inspired Rausch system */
|
||
--bg:#ffffff;--surface:#fff7f8;--surface-2:#f7f7f7;--fg:#222222;--muted:#dddddd;--border:#ffe3e8;
|
||
--ink:#222222;--ink-soft:#717171;--ink-faint:#999999;
|
||
--accent:#ff385c;--accent-soft:#fff5f5;--accent-2:#ffd9df;--accent-hover:#e5324f;--accent-soft-hover:#ffeef1;
|
||
--surface-card:#ffffff;
|
||
/* Semantic */
|
||
--ok:#0f8a3f;--ok-soft:#e8f5ec;--warn:#b14a00;--danger:#c92a2a;--danger-soft:#fff0f0;--danger-2:#ffd0d6;
|
||
--shadow:0 1px 2px rgba(34,34,34,.04), 0 8px 24px rgba(34,34,34,.06);
|
||
--focus-ring:0 0 0 3px rgba(255,56,92,.30);
|
||
--font:'Inter',system-ui,-apple-system,Segoe UI,Helvetica Neue,Arial,sans-serif;
|
||
}
|
||
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
|
||
html,body{height:100%;background:var(--bg);color:var(--fg);font:16px/1.5 var(--font);-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
||
body{display:flex;flex-direction:column;min-height:100dvh;max-width:480px;margin:0 auto;padding-bottom:env(safe-area-inset-bottom);background:var(--bg)}
|
||
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
|
||
button:focus-visible{outline:0;box-shadow:var(--focus-ring);border-radius:14px}
|
||
.btn:focus-visible{box-shadow:var(--focus-ring)}
|
||
.topbar .back:focus-visible,.topbar .theme-btn:focus-visible{box-shadow:var(--focus-ring);border-radius:50%}
|
||
input,select{font:inherit;color:inherit}
|
||
|
||
/* ---- Topbar ---- */
|
||
.topbar{display:flex;align-items:center;gap:10px;padding:14px 16px 10px;background:var(--bg);position:sticky;top:0;z-index:10}
|
||
.topbar .back{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);background:var(--surface-card);border:1px solid var(--muted)}
|
||
.topbar .back:active{background:var(--surface)}
|
||
.topbar h1{font-size:17px;font-weight:600;letter-spacing:-.01em;flex:1}
|
||
.topbar .netpill{font-size:11px;letter-spacing:.06em;text-transform:uppercase;font-weight:600;color:var(--ink-soft);padding:4px 8px;border:1px solid var(--muted);border-radius:999px;background:var(--surface-card)}
|
||
.topbar .netpill.off{color:var(--danger);border-color:var(--danger);background:var(--danger-soft)}
|
||
.theme-btn{width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--fg);background:var(--surface-card);border:1px solid var(--muted);cursor:pointer;flex:0 0 auto}
|
||
.theme-btn:active{background:var(--surface)}
|
||
.theme-btn svg{width:18px;height:18px}
|
||
.theme-btn .i-moon{display:none}
|
||
:root[data-theme="dark"] .theme-btn .i-sun{display:none}
|
||
:root[data-theme="dark"] .theme-btn .i-moon{display:block}
|
||
|
||
/* ---- Layout ---- */
|
||
.content{flex:1;padding:8px 16px calc(28px + env(safe-area-inset-bottom));display:flex;flex-direction:column;align-items:stretch;gap:14px}
|
||
|
||
/* ---- Success (restrained — white card, not full-bleed green) ---- */
|
||
.success{display:flex;align-items:center;gap:14px;padding:14px 16px;background:var(--surface-card);border:1px solid var(--muted);border-radius:14px;box-shadow:var(--shadow)}
|
||
.success .check{width:36px;height:36px;border-radius:50%;background:var(--ok-soft);color:var(--ok);display:grid;place-items:center;flex:0 0 auto}
|
||
.success .check svg{width:20px;height:20px;stroke-width:2.6}
|
||
.success .txt{display:flex;flex-direction:column;line-height:1.2;min-width:0;flex:1}
|
||
.success .h{font-size:16px;font-weight:700;letter-spacing:-.01em;color:var(--fg)}
|
||
.success .s{font-size:13px;color:var(--ink-soft);margin-top:2px;letter-spacing:.01em}
|
||
.success .synced-flag{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;background:var(--ok-soft);color:var(--ok);padding:3px 8px;border-radius:999px;flex:0 0 auto;align-self:center;display:inline-flex;align-items:center;gap:5px}
|
||
.success .synced-flag::before{content:"";width:6px;height:6px;border-radius:50%;background:var(--ok)}
|
||
|
||
/* ---- Card (the receipt itself) ---- */
|
||
.receipt{background:var(--surface-card);border:1px solid var(--muted);border-radius:16px;box-shadow:var(--shadow);overflow:hidden}
|
||
.r-head{display:flex;align-items:center;gap:12px;padding:18px 18px 14px;border-bottom:1px solid var(--muted)}
|
||
.r-head .ic{width:40px;height:40px;border-radius:50%;background:var(--accent);color:#fff;display:grid;place-items:center;flex:0 0 auto}
|
||
.r-head .ic svg{width:22px;height:22px;stroke-width:1.8}
|
||
.r-head .nm{flex:1;min-width:0;line-height:1.25;min-width:0}
|
||
.r-head .nm .biz{font-size:17px;font-weight:700;letter-spacing:-.01em;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.r-head .nm .addr{font-size:12px;color:var(--ink-soft);letter-spacing:.01em;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.r-head .nm .vat{font-size:11px;color:var(--ink-soft);letter-spacing:.04em;margin-top:3px}
|
||
|
||
.r-body{padding:6px 18px 18px}
|
||
.r-meta{display:grid;grid-template-columns:1fr 1fr;gap:14px 18px;padding:14px 0;border-bottom:1px solid var(--muted)}
|
||
.r-meta .k{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);margin-bottom:4px}
|
||
.r-meta .v{font-size:14px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
|
||
.r-cust{margin-top:14px;padding:12px 14px;background:var(--surface-2);border-radius:10px;display:flex;align-items:center;gap:10px}
|
||
.r-cust .lbl{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);flex:0 0 auto}
|
||
.r-cust .nm{font-size:14px;font-weight:600;letter-spacing:-.005em;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
|
||
.r-items{margin-top:14px}
|
||
.r-item{display:flex;align-items:flex-start;gap:10px;padding:12px 0;border-bottom:1px solid var(--muted)}
|
||
.r-item:last-child{border-bottom:0}
|
||
.r-item .qty{font-size:13px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;min-width:34px;flex:0 0 34px;padding-top:1px}
|
||
.r-item .body{flex:1;min-width:0}
|
||
.r-item .name{font-size:14px;font-weight:500;letter-spacing:-.005em;line-height:1.35;color:var(--fg);overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
|
||
.r-item .meta{font-size:12px;color:var(--ink-soft);margin-top:3px;letter-spacing:.02em;display:flex;gap:6px;flex-wrap:wrap}
|
||
.r-item .meta .sep{color:var(--ink-faint)}
|
||
.r-item .pr{font-size:14px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;white-space:nowrap;padding-top:1px}
|
||
|
||
.r-totals{margin-top:14px;padding:14px 0 0;border-top:1px solid var(--muted)}
|
||
.r-row{display:flex;justify-content:space-between;align-items:baseline;padding:5px 0;font-size:14px;color:var(--ink-soft);letter-spacing:.01em;gap:8px}
|
||
.r-row .l{display:flex;align-items:baseline;gap:8px}
|
||
.r-row .b{color:var(--fg);font-weight:600;font-variant-numeric:tabular-nums}
|
||
.r-row.grand{padding-top:14px;margin-top:8px;border-top:1px solid var(--muted);color:var(--fg);font-size:18px;font-weight:700;letter-spacing:-.01em}
|
||
.r-row.grand .b{color:var(--fg);font-size:22px;font-weight:700}
|
||
.r-row .pill{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;padding:2px 7px;border-radius:999px;background:var(--surface);color:var(--fg);border:1px solid var(--border)}
|
||
.r-row.change .b{color:var(--ok)}
|
||
|
||
.r-foot{text-align:center;padding:14px 18px 16px;border-top:1px dashed var(--muted);font-size:12px;color:var(--ink-soft);letter-spacing:.04em;line-height:1.55;white-space:pre-line}
|
||
|
||
/* ---- Delivery note ---- */
|
||
.dn{background:var(--surface-card);border:1.5px dashed var(--accent);border-radius:16px;box-shadow:var(--shadow);padding:18px}
|
||
.dn-head{display:flex;align-items:center;gap:10px;margin-bottom:14px}
|
||
.dn-head .ic{width:36px;height:36px;border-radius:10px;background:var(--accent-soft);border:1px solid var(--accent-2);color:var(--accent);display:grid;place-items:center;flex:0 0 auto}
|
||
.dn-head .ic svg{width:20px;height:20px;stroke-width:1.8}
|
||
.dn-head .nm{flex:1;min-width:0;line-height:1.2}
|
||
.dn-head .nm .t{font-size:11px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--accent);margin-bottom:2px}
|
||
.dn-head .nm .s{font-size:15px;font-weight:600;letter-spacing:-.005em;color:var(--fg);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.dn-meta{display:grid;grid-template-columns:1fr 1fr;gap:10px 14px;padding:12px;background:var(--surface-2);border-radius:10px;margin-bottom:14px}
|
||
.dn-meta .k{font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-soft);margin-bottom:3px}
|
||
.dn-meta .v{font-size:13px;font-weight:600;letter-spacing:-.005em;color:var(--fg);font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||
.dn-items{margin-bottom:14px}
|
||
.dn-line{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:10px 0;border-bottom:1px solid var(--muted);font-size:13px;letter-spacing:-.005em;color:var(--fg)}
|
||
.dn-line:last-child{border-bottom:0}
|
||
.dn-line .nm{flex:1;min-width:0;font-weight:500;line-height:1.35}
|
||
.dn-line .qt{font-size:12px;color:var(--ink-soft);letter-spacing:.02em;white-space:nowrap;padding-top:2px;font-variant-numeric:tabular-nums}
|
||
.dn-line .pr{font-size:13px;font-weight:600;color:var(--fg);white-space:nowrap;font-variant-numeric:tabular-nums;min-width:76px;text-align:right}
|
||
.dn-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--ink-soft);margin:10px 0;padding:0 2px}
|
||
.dn-toggle input{width:16px;height:16px}
|
||
@media print{.dn-toggle{display:none}}
|
||
.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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]);
|
||
const escAttr = s => String(s).replace(/"/g,'"');
|
||
|
||
function paintNet(){
|
||
const p = document.getElementById('netpill');
|
||
p.textContent = navigator.onLine ? 'Online' : 'Offline';
|
||
p.classList.toggle('off', !navigator.onLine);
|
||
}
|
||
window.addEventListener('online', paintNet);
|
||
window.addEventListener('offline', paintNet);
|
||
|
||
const 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>
|