WFDASH-WEBAPP/public/pos/receipt.html
Dave c6e35ad5d7 feat(pos): delivery-note slip shows company name and optional prices
The Include-prices toggle drives both the on-screen slip and the
Bluetooth thermal print, so what prints matches what the operator saw.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:48:22 +02:00

624 lines
36 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}
.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="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>
(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();
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 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>`);
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>`
: '';
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">
<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. Default to showing, toggle re-renders in place.
const toggle = document.getElementById('dnShowPrices');
const showPrices = toggle ? !!toggle.checked : true;
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 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…';
const payload = buildEscPos(sale);
const CHUNK = 80;
for(let i=0;i<payload.length;i+=CHUNK){
const slice = payload.slice(i, i+CHUNK);
await btChar.writeValueWithoutResponse(slice);
await new Promise(r=>setTimeout(r,30));
}
status.textContent = 'Printed · ' + (btDevice.name||'printer');
}catch(e){
if(e.name==='NotFoundError') status.textContent = 'Cancelled.';
else status.textContent = 'Print failed: ' + e.message;
errBox.classList.add('err');
}finally{
btn.disabled = false;
}
}
function buildEscPos(s){
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B), GS = E(0x1D);
const cmds = [];
cmds.push(ESC+'@');
cmds.push(ESC+'a'+E(1));
cmds.push(ESC+'!'+E(0x30));
cmds.push('WFSALES POS\n');
cmds.push(ESC+'!'+E(0x00));
if(s.letterhead?.name) cmds.push(s.letterhead.name+'\n');
cmds.push(ESC+'a'+E(0));
if(s.letterhead){
if(s.letterhead.address) cmds.push(s.letterhead.address.split('\n')[0]+'\n');
if(s.letterhead.phone) cmds.push('Tel: '+s.letterhead.phone+'\n');
if(s.letterhead.vat_number) cmds.push('VAT: '+s.letterhead.vat_number+'\n');
}
cmds.push('--------------------------------\n');
const d = new Date(s.createdAt);
cmds.push('Date: ' + d.toLocaleString('en-ZA')+'\n');
cmds.push('Slip: ' + (s.localId||'').slice(-8).toUpperCase()+'\n');
if(s.customer) cmds.push('Customer: ' + s.customer.name + '\n');
cmds.push('--------------------------------\n');
for(const it of s.items){
cmds.push((it.description||it.sku) + '\n');
const line = ` ${it.qty} x ${(it.selling_price||0).toFixed(2)} ${(it.selling_price*it.qty).toFixed(2)}`;
cmds.push(line + '\n');
}
cmds.push('--------------------------------\n');
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
const vat = s.vat != null ? s.vat : (s.total * 15/115);
cmds.push(`Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}\n`);
cmds.push(`VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}\n`);
cmds.push(ESC+'!'+E(0x08));
cmds.push(`TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}\n`);
cmds.push(ESC+'!'+E(0x00));
cmds.push('Paid: ' + (s.payment==='cash' ? `CASH R ${(s.cashGiven||0).toFixed(2)}` : s.payment.toUpperCase()) + '\n');
if(s.payment==='cash') cmds.push(`Change${' '.repeat(24)}R ${(s.change||0).toFixed(2)}\n`);
cmds.push('--------------------------------\n');
if(s.letterhead?.footer_text) cmds.push(s.letterhead.footer_text+'\n');
cmds.push('\n\n');
cmds.push(GS+'V'+E(0x41)+E(0x10));
const text = cmds.join('');
const bytes = new Uint8Array(text.length);
for(let i=0;i<text.length;i++) bytes[i] = text.charCodeAt(i) & 0xff;
return bytes;
}
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…';
if(!btChar){
status.textContent = 'Pair a Bluetooth printer first to print.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B);
// Mirror the on-screen "Include prices" toggle so the printed slip
// matches what the operator just checked.
const dnToggle = document.getElementById('dnShowPrices');
const showPrices = dnToggle ? !!dnToggle.checked : true;
const lines = [];
lines.push(ESC+'@');
lines.push(ESC+'a'+E(1));
lines.push(ESC+'!'+E(0x10));
lines.push('WFSALES POS\n');
lines.push(ESC+'!'+E(0x00));
lines.push('Delivery Note\n');
lines.push(ESC+'a'+E(0));
if(sale.customer?.company) lines.push('Company: ' + sale.customer.company + '\n');
lines.push('Customer: ' + (sale.customer?.name||'—') + '\n');
const d = new Date(sale.createdAt);
lines.push('Date: ' + d.toLocaleDateString('en-ZA')+'\n');
lines.push('--------------------------------\n');
for(const it of sale.items){
lines.push((it.description||it.sku)+'\n');
const qtyLine = ` ${it.qty} ${it.unit||'ea'}`;
lines.push(showPrices
? `${qtyLine} ${fmt((it.selling_price||0)*it.qty)}\n`
: `${qtyLine}\n`);
}
lines.push('--------------------------------\n');
if(showPrices) lines.push('TOTAL: ' + fmt(sale.total) + '\n');
lines.push('Received by: __________ Driver: __________\n');
lines.push('\n\n');
lines.push(String.fromCharCode(0x1D)+'V'+String.fromCharCode(0x41)+String.fromCharCode(0x10));
const text = lines.join('');
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;
}
}
paintNet();
})();
</script>
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
</body>
</html>