WFDASH-WEBAPP/public/pos/receipt.html
Dave a499265b26 feat(pos): raise a system delivery note on an account sale
An account sale at the till used to produce a delivery-note-shaped slip that
was rendered entirely on the device and printed on the 80mm thermal printer.
Nothing reached the server, so the paperwork the client signed had no record
behind it. Cash / card / EFT are untouched and still get the thermal slip.

An account sale now creates a real `delivery_notes` record. The DN is raised
server-side INSIDE `checkoutSale` rather than by a follow-up call from the
till, because the offline queue (`flushQueue`) re-posts `/checkout` and
nothing else — a client-side second call would be silently dropped for every
sale taken while the network was down, which is exactly when a till is
flakiest.

`pos_sales.delivery_note` is the idempotency anchor that makes it
exactly-once: on a replay it hands back the existing DN instead of making a
second one, and a sale whose first attempt left the field blank retries. It
self-heals.

Deliberately non-fatal: if the DN can't be created the sale still succeeds.
The money and the stock movement are real by then; failing the checkout would
leave the drawer disagreeing with the stock ledger. The till is told instead.

Also in here:
  - DN number collision retry. The generator reads the highest existing number
    and adds one, which is not atomic, while PB holds a unique index on it. The
    POS adds a second concurrent writer, so two creates could collide and 500
    on a benign condition.
  - GET /api/v1/pos/sales now expands customer, customer.company and
    delivery_note so history.html can rebuild the DN card on a reprint. The
    nested customer.company matters — customer.company is itself a relation,
    so without it the card printed a raw PB id where the name belongs.
  - "Print slip" is hidden for account sales; that tender produces a delivery
    note, not a slip.

New GET /api/v1/pos/delivery-note/[number]/pdf streams the A4 original behind
assertPosAuth, so the till's bearer key stays its only credential. It does not
point at the CRM's unauthenticated invoicing route.

Verified end to end against a live note: DN26100401, correct company, both
lines with pricing, PDF attached, stock decremented once, and a replay of the
same client_ref returned the same DN without a duplicate.
2026-10-04 13:15:29 +02:00

840 lines
47 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}}
/* System-DN state note. Green = printable, amber = waiting on the
network, red = a human has to raise the DN by hand. */
.dn-note{margin:0;font-size:13px;line-height:1.45;padding:11px 12px;border-radius:10px;border:1px solid transparent}
.dn-note-linked{background:rgba(16,133,101,.08);border-color:rgba(16,133,101,.28);color:var(--fg)}
.dn-note-pending{background:rgba(200,140,20,.10);border-color:rgba(200,140,20,.32);color:var(--fg)}
.dn-note-failed{background:rgba(201,42,42,.10);border-color:rgba(201,42,42,.32);color:var(--fg)}
.dn-sign{display:grid;grid-template-columns:1fr 1fr;gap:24px;padding-top:18px;margin-top:6px;border-top:1px dashed var(--muted)}
.dn-sign > div{padding-top:8px;border-top:1.5px solid var(--fg);font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--ink-soft);text-align:center}
/* ---- Actions ---- */
.actions{display:flex;flex-direction:column;gap:10px;padding:18px 0 4px;margin-top:auto}
.btn{display:flex;align-items:center;justify-content:center;gap:8px;min-height:54px;padding:0 18px;border-radius:14px;font-size:16px;font-weight:600;letter-spacing:-.005em;border:1.5px solid var(--muted);background:var(--surface-card);color:var(--fg);transition:transform .12s ease, background .12s ease}
.btn:active{transform:translateY(1px);background:var(--surface)}
.btn:disabled{opacity:.5;pointer-events:none}
.btn svg{width:20px;height:20px;stroke-width:1.8}
.btn.primary{background:var(--accent);color:#fff;border-color:var(--accent);box-shadow:0 4px 14px rgba(255,56,92,.25)}
.btn.primary:active{background:var(--accent-hover);box-shadow:none}
.btn.acct{background:var(--accent-soft);border-color:var(--accent-2);color:var(--accent)}
.btn.acct:active{background:var(--accent-soft-hover)}
/* ---- Status banner ---- */
.status{padding:12px 14px;border-radius:12px;background:var(--surface);border:1px solid var(--border);font-size:13px;display:flex;align-items:center;gap:10px;color:var(--fg);line-height:1.4}
.printer-settings{padding:12px 14px;border-radius:12px;border:1px solid var(--border);background:var(--surface-card);display:flex;flex-direction:column;gap:8px}
.printer-settings label{font-size:12px;font-weight:600;color:var(--ink-soft);display:flex;flex-direction:column;gap:4px}
.printer-settings select,.printer-settings input{padding:9px 10px;border-radius:8px;border:1px solid var(--muted);background:var(--bg);color:var(--fg);font:inherit}
.empty-state{padding:48px 18px;text-align:center;color:var(--ink-soft)}
.empty-state h3{font-size:16px;font-weight:600;color:var(--fg);margin-bottom:6px;letter-spacing:-.01em}
.empty-state p{font-size:13px;line-height:1.5}
.status.err{background:var(--danger-soft);border-color:var(--danger-2);color:var(--danger)}
.status .ic{flex:0 0 auto}
.status svg{width:18px;height:18px;stroke-width:1.7}
.status .t{flex:1;min-width:0}
/* ---- Dark theme ---- */
@media (prefers-color-scheme: dark){
:root:not([data-theme="light"]){
--bg:#0f0f0f;--surface:#1c1416;--surface-2:#181818;--fg:#f0f0f0;--muted:#2a2a2a;--border:#3a2429;
--ink:#f0f0f0;--ink-soft:#a0a0a0;--ink-faint:#777;
--surface-card:#181818;--accent-soft:#2a1a1a;--accent-2:#3a2429;--accent-soft-hover:#3a2429;
--ok-soft:#1a2e22;--danger-soft:#2a1a1a;--danger-2:#3a2429;
--focus-ring:0 0 0 3px rgba(255,102,128,.35);
}
:root:not([data-theme="light"]) body{background:var(--bg)}
:root:not([data-theme="light"]) .topbar{background:var(--bg)}
:root:not([data-theme="light"]) .topbar .back{background:var(--surface-card);border-color:var(--muted)}
:root:not([data-theme="light"]) .netpill{background:var(--surface-card);color:var(--ink-soft);border-color:var(--muted)}
:root:not([data-theme="light"]) .netpill.off{color:#ff6680;border-color:#ff6680;background:var(--danger-soft)}
:root:not([data-theme="light"]) .theme-btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root:not([data-theme="light"]) .success{background:var(--surface-card);border-color:var(--muted)}
:root:not([data-theme="light"]) .success .check{background:var(--ok-soft);color:#5fc77c}
:root:not([data-theme="light"]) .success .synced-flag{background:var(--ok-soft);color:#5fc77c}
:root:not([data-theme="light"]) .success .synced-flag::before{background:#5fc77c}
:root:not([data-theme="light"]) .receipt{background:var(--surface-card);border-color:var(--muted)}
:root:not([data-theme="light"]) .r-head{border-bottom-color:var(--muted)}
:root:not([data-theme="light"]) .r-head .nm .biz{color:var(--fg)}
:root:not([data-theme="light"]) .r-head .nm .addr,:root:not([data-theme="light"]) .r-head .nm .vat{color:var(--ink-soft)}
:root:not([data-theme="light"]) .r-meta{border-bottom-color:var(--muted)}
:root:not([data-theme="light"]) .r-meta .k{color:var(--ink-soft)}
:root:not([data-theme="light"]) .r-meta .v{color:var(--fg)}
:root:not([data-theme="light"]) .r-cust{background:var(--surface-2)}
:root:not([data-theme="light"]) .r-cust .lbl{color:var(--ink-soft)}
:root:not([data-theme="light"]) .r-item{border-bottom-color:var(--muted)}
:root:not([data-theme="light"]) .r-item .name{color:var(--fg)}
:root:not([data-theme="light"]) .r-item .meta{color:var(--ink-soft)}
:root:not([data-theme="light"]) .r-totals{border-top-color:var(--muted)}
:root:not([data-theme="light"]) .r-row{color:var(--ink-soft)}
:root:not([data-theme="light"]) .r-row .b{color:var(--fg)}
:root:not([data-theme="light"]) .r-row .pill{background:var(--surface);color:var(--fg);border-color:var(--border)}
:root:not([data-theme="light"]) .r-row.grand{border-top-color:var(--muted);color:var(--fg)}
:root:not([data-theme="light"]) .r-row.change .b{color:#5fc77c}
:root:not([data-theme="light"]) .r-foot{border-top-color:var(--muted);color:var(--ink-soft)}
:root:not([data-theme="light"]) .dn{background:var(--surface-card);border-color:#ff6680}
:root:not([data-theme="light"]) .dn-head .ic{background:var(--accent-soft);border-color:var(--accent-2)}
:root:not([data-theme="light"]) .dn-meta{background:var(--surface-2)}
:root:not([data-theme="light"]) .dn-meta .k{color:var(--ink-soft)}
:root:not([data-theme="light"]) .dn-line{border-bottom-color:var(--muted);color:var(--fg)}
:root:not([data-theme="light"]) .dn-line .qt{color:var(--ink-soft)}
:root:not([data-theme="light"]) .dn-sign{border-top-color:var(--muted)}
:root:not([data-theme="light"]) .dn-sign > div{border-top-color:var(--fg);color:var(--ink-soft)}
:root:not([data-theme="light"]) .status{background:var(--surface);border-color:var(--border);color:var(--fg)}
:root:not([data-theme="light"]) .status.err{background:var(--danger-soft);border-color:var(--danger-2);color:#ff8a9e}
:root:not([data-theme="light"]) .btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root:not([data-theme="light"]) .btn:active{background:var(--surface)}
:root:not([data-theme="light"]) .btn.acct{background:var(--accent-soft);border-color:var(--accent-2);color:#ff8a9e}
:root:not([data-theme="light"]) .btn.acct:active{background:var(--accent-soft-hover)}
}
:root[data-theme="dark"]{
--bg:#0f0f0f;--surface:#1c1416;--surface-2:#181818;--fg:#f0f0f0;--muted:#2a2a2a;--border:#3a2429;
--ink:#f0f0f0;--ink-soft:#a0a0a0;--ink-faint:#777;
--surface-card:#181818;--accent-soft:#2a1a1a;--accent-2:#3a2429;--accent-soft-hover:#3a2429;
--ok-soft:#1a2e22;--danger-soft:#2a1a1a;--danger-2:#3a2429;
--focus-ring:0 0 0 3px rgba(255,102,128,.35);
}
:root[data-theme="dark"] body{background:var(--bg)}
:root[data-theme="dark"] .topbar{background:var(--bg)}
:root[data-theme="dark"] .topbar .back{background:var(--surface-card);border-color:var(--muted)}
:root[data-theme="dark"] .netpill{background:var(--surface-card);color:var(--ink-soft);border-color:var(--muted)}
:root[data-theme="dark"] .netpill.off{color:#ff6680;border-color:#ff6680;background:var(--danger-soft)}
:root[data-theme="dark"] .theme-btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .success{background:var(--surface-card);border-color:var(--muted)}
:root[data-theme="dark"] .success .check{background:var(--ok-soft);color:#5fc77c}
:root[data-theme="dark"] .success .synced-flag{background:var(--ok-soft);color:#5fc77c}
:root[data-theme="dark"] .success .synced-flag::before{background:#5fc77c}
:root[data-theme="dark"] .receipt{background:var(--surface-card);border-color:var(--muted)}
:root[data-theme="dark"] .r-head{border-bottom-color:var(--muted)}
:root[data-theme="dark"] .r-head .nm .biz{color:var(--fg)}
:root[data-theme="dark"] .r-head .nm .addr,:root[data-theme="dark"] .r-head .nm .vat{color:var(--ink-soft)}
:root[data-theme="dark"] .r-meta{border-bottom-color:var(--muted)}
:root[data-theme="dark"] .r-meta .k{color:var(--ink-soft)}
:root[data-theme="dark"] .r-meta .v{color:var(--fg)}
:root[data-theme="dark"] .r-cust{background:var(--surface-2)}
:root[data-theme="dark"] .r-cust .lbl{color:var(--ink-soft)}
:root[data-theme="dark"] .r-item{border-bottom-color:var(--muted)}
:root[data-theme="dark"] .r-item .name{color:var(--fg)}
:root[data-theme="dark"] .r-item .meta{color:var(--ink-soft)}
:root[data-theme="dark"] .r-totals{border-top-color:var(--muted)}
:root[data-theme="dark"] .r-row{color:var(--ink-soft)}
:root[data-theme="dark"] .r-row .b{color:var(--fg)}
:root[data-theme="dark"] .r-row .pill{background:var(--surface);color:var(--fg);border-color:var(--border)}
:root[data-theme="dark"] .r-row.grand{border-top-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .r-row.change .b{color:#5fc77c}
:root[data-theme="dark"] .r-foot{border-top-color:var(--muted);color:var(--ink-soft)}
:root[data-theme="dark"] .dn{background:var(--surface-card);border-color:#ff6680}
:root[data-theme="dark"] .dn-head .ic{background:var(--accent-soft);border-color:var(--accent-2)}
:root[data-theme="dark"] .dn-meta{background:var(--surface-2)}
:root[data-theme="dark"] .dn-meta .k{color:var(--ink-soft)}
:root[data-theme="dark"] .dn-line{border-bottom-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .dn-line .qt{color:var(--ink-soft)}
:root[data-theme="dark"] .dn-sign{border-top-color:var(--muted)}
:root[data-theme="dark"] .dn-sign > div{border-top-color:var(--fg);color:var(--ink-soft)}
:root[data-theme="dark"] .status{background:var(--surface);border-color:var(--border);color:var(--fg)}
:root[data-theme="dark"] .status.err{background:var(--danger-soft);border-color:var(--danger-2);color:#ff8a9e}
:root[data-theme="dark"] .btn{background:var(--surface-card);border-color:var(--muted);color:var(--fg)}
:root[data-theme="dark"] .btn:active{background:var(--surface)}
:root[data-theme="dark"] .btn.acct{background:var(--accent-soft);border-color:var(--accent-2);color:#ff8a9e}
:root[data-theme="dark"] .btn.acct:active{background:var(--accent-soft-hover)}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce){
*{transition-duration:0.01ms !important;animation-duration:0.01ms !important}
.btn:active{transform:none}
}
@media print{
body{background:#fff}
.topbar,.theme-btn,.actions,.status,.synced-flag,.success .synced-flag{display:none !important}
.content{padding:0}
.success{background:#fff !important;color:#000 !important;box-shadow:none;border:1px solid #000;border-radius:0;padding:14px}
.success .check{background:#000 !important;color:#fff !important}
.receipt,.dn{box-shadow:none;border:1px solid #999;border-radius:0;page-break-inside:avoid}
.btn{display:none}
}
</style>
</head>
<body>
<header class="topbar">
<button class="back" data-od-id="back-btn" onclick="location.href='pos.html'" aria-label="Back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 6l-6 6 6 6"/></svg>
</button>
<h1>Receipt</h1>
<button class="theme-btn" data-theme-toggle data-od-id="theme-toggle" aria-label="Toggle theme" title="Toggle theme" onclick="(window.WFTheme&&window.WFTheme.cycle())||(window.__wfCycle&&window.__wfCycle())">
<svg class="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<svg class="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
<span class="netpill" id="netpill">…</span>
</header>
<div class="content">
<div class="success" id="success" data-od-id="success-banner">
<div class="check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 12l5 5 9-11"/></svg></div>
<div class="txt">
<div class="h" id="successH">Sale complete</div>
<div class="s" id="successSub">Saved to register</div>
</div>
<span class="synced-flag" id="syncedFlag" style="display:none">Synced</span>
</div>
<section class="receipt" id="receiptCard" data-od-id="receipt"></section>
<div id="deliveryWrap" style="display:none">
<section class="dn" id="deliveryCard" data-od-id="delivery-note"></section>
</div>
<div class="status" id="status">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.5"/></svg></span>
<span class="t" id="statusText">Ready to print.</span>
</div>
<div class="status" id="printerSettingsToggle" style="cursor:pointer" onclick="togglePrinterSettings()">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h18v6H3zM7 13v6h10v-6M7 17h10"/></svg></span>
<span class="t" id="printerSettingsLabel">Printer: Bluetooth</span>
</div>
<div class="printer-settings" id="printerSettingsForm" style="display:none">
<label>Printer type
<select id="printerTypeSelect">
<option value="bluetooth">Bluetooth</option>
<option value="network">Network (Ethernet)</option>
</select>
</label>
<label>Printer IP address
<input id="printerIpInput" placeholder="192.168.1.33">
</label>
<button class="btn" style="min-height:40px" onclick="savePrinterSettings()">Save printer settings</button>
</div>
<div class="actions">
<button class="btn primary" data-od-id="print-thermal" id="printBtn" onclick="printThermal()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h18v6H3zM7 13v6h10v-6M7 17h10"/></svg>
Print slip
</button>
<button class="btn acct" data-od-id="print-delivery" id="printDnBtn" style="display:none" onclick="printDelivery()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M5 4h11l3 3v13H5zM9 9h6M9 13h6M9 17h4"/></svg>
Print delivery note (A4)
</button>
<button class="btn" data-od-id="new-sale" onclick="location.href='pos.html'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>
New sale
</button>
</div>
</div>
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="slip-layout.js"></script>
<script src="epos-print.js"></script>
<script>
(function(){
'use strict';
const fmt = n => 'R ' + (Number(n)||0).toLocaleString('en-ZA',{minimumFractionDigits:2,maximumFractionDigits:2});
const esc = s => String(s||'').replace(/[&<>"']/g, c=>({'&':'&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'){
document.getElementById('deliveryWrap').style.display='block';
// An account sale never gets a paper slip. The goods leave on the
// delivery note and that is the document the client signs, so
// offering "Print slip" here just invites someone to print the
// wrong thing at the counter. The till slip stays for cash / card
// / EFT, which are the tenders where payment is actually taken.
const slipBtn = document.getElementById('printBtn');
if (slipBtn) slipBtn.style.display = 'none';
// The print button only makes sense once a DN actually exists.
// A queued (offline) sale or a failed creation still shows the
// card with its state, but has nothing to print yet.
if(sale.deliveryNote) document.getElementById('printDnBtn').style.display='flex';
renderDeliveryNote(sale);
}
}
function renderReceipt(s){
const lh = s.letterhead || {};
const date = new Date(s.createdAt);
const dateStr = date.toLocaleString('en-ZA',{day:'2-digit',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'});
const method = s.payment==='account' ? 'ACCOUNT' : s.payment.toUpperCase();
// The till's `s.sub` / `s.vat` / `s.total` are the POST-discount
// values; the gross_* are pre-discount. The receipt surfaces
// both when a discount was applied, so the customer can see what
// they saved.
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;
}
// The system delivery note. The account sale raised this server-side
// inside `checkoutSale`; this card just shows the number the operator
// quotes to the client and hands them the A4 print.
//
// The old 80mm thermal delivery-note slip is gone from this path by
// design — the client gets the same document the office keeps, printed
// on the office printer. The thermal slip still prints for cash / card
// / EFT, which never reach this function.
function renderDeliveryNote(s){
const date = new Date(s.createdAt);
const dateStr = date.toLocaleDateString('en-ZA',{day:'2-digit',month:'short',year:'numeric'});
const customerName = (s.customer && s.customer.name) || '—';
const companyName = (s.customer && s.customer.company) || '';
// Three states, and the operator has to be able to tell them apart
// at a glance:
// linked — the DN exists, print it
// pending — the sale is still in the offline queue; the DN is
// raised automatically on the next reconnect
// failed — the sale banked but the DN could not be created, so a
// human has to raise it from the CRM
let state, numberHtml, noteHtml;
if (s.deliveryNote) {
state = 'linked';
numberHtml = `<div class="v" style="font-size:17px">${esc(s.deliveryNote.delivery_note_number)}</div>`;
noteHtml = 'Print two copies for the client to sign — one to keep, one to bring back to the office.';
} else if (s.synced) {
state = 'failed';
numberHtml = '<div class="v" style="font-size:17px">Not created</div>';
noteHtml = esc(s.deliveryNoteError
|| 'The sale was recorded but no delivery note was created. Raise one by hand from the CRM.');
} else {
state = 'pending';
numberHtml = '<div class="v" style="font-size:17px">Pending</div>';
noteHtml = 'This sale is saved on the till. The delivery note is raised automatically once the connection is back — pick it up from the CRM, not the thermal printer.';
}
const html = `
<div class="dn-head">
<div class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h13l3 4h2v6h-2M3 17V7M3 17a2 2 0 1 0 4 0M17 17a2 2 0 1 0 4 0"/></svg></div>
<div class="nm">
<div class="t">Delivery note</div>
<div class="s">${esc(companyName || customerName)}</div>
</div>
</div>
<div class="dn-meta">
<div><div class="k">Customer</div><div class="v">${esc(customerName)}</div>${companyName?`<div class="v">${esc(companyName)}</div>`:''}</div>
<div><div class="k">Date</div><div class="v">${dateStr}</div></div>
<div><div class="k">DN number</div>${numberHtml}</div>
<div><div class="k">Items</div><div class="v">${s.items.length}</div></div>
</div>
<p class="dn-note dn-note-${state}">${noteHtml}</p>
`;
document.getElementById('deliveryCard').innerHTML = html;
window.__dnState = state;
}
window.renderDeliveryNote = renderDeliveryNote;
// ---------------- Bluetooth thermal printing ----------------
let btDevice = null;
let btChar = null;
async function printThermal(){
const settings = window.EposPrint.getSettings();
if(settings.type === 'network') return printThermalNetwork(settings);
return printThermalBluetooth();
}
async function printThermalNetwork(settings){
const btn = document.getElementById('printBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
errBox.classList.remove('err');
btn.disabled = true;
if(!settings.ip){
status.textContent = 'Set the printer IP address in Printer settings first.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
status.textContent = 'Sending to ' + settings.ip + '…';
try{
// Account sales print a delivery note (no payment line, has
// received-by / driver lines) — that's the document the driver
// hands to the customer at drop-off, not a "paid" receipt. Cash
// / card / EFT get the standard sale slip.
let xml, successLabel;
// Always the standard sale slip. Account sales no longer print a
// thermal delivery note — the system A4 DN is printed by
// `printDelivery()` instead, so this button is the till slip for
// every tender type.
const logo = await window.EposPrint.letterheadLogoRaster(sale.letterhead, 384);
const letterheadId = sale.letterhead && sale.letterhead.id;
const blocks = window.PosSlipLayout.getBlocksForPrint(letterheadId, 'sale');
xml = window.EposPrint.buildEposXml(sale, logo, blocks);
successLabel = 'Printed · ';
await window.EposPrint.printViaNetwork(settings.ip, xml);
status.textContent = successLabel + settings.ip;
}catch(e){
status.textContent = 'Print failed: ' + e.message;
errBox.classList.add('err');
}finally{
btn.disabled = false;
}
}
async function printThermalBluetooth(){
const btn = document.getElementById('printBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
errBox.classList.remove('err');
btn.disabled = true;
status.textContent = 'Connecting to printer…';
if(!('bluetooth' in navigator)){
status.textContent = 'Web Bluetooth not supported on this device.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
try{
btDevice = await navigator.bluetooth.requestDevice({
acceptAllDevices: true,
optionalServices: [0x18f0, 0x1101, 0x0000ff00, '000018f0-0000-1000-8000-00805f9b34fb']
});
status.textContent = 'Pairing ' + (btDevice.name||'printer') + '…';
const server = await btDevice.gatt.connect();
const services = await server.getPrimaryServices();
for(const s of services){
const chars = await s.getCharacteristics();
for(const c of chars){
if(c.properties.write || c.properties.writeWithoutResponse){ btChar = c; break; }
}
if(btChar) break;
}
if(!btChar) throw new Error('No writable characteristic on printer.');
status.textContent = 'Printing…';
// Account sales print a delivery note (received-by / driver lines,
// no payment line). Everything else prints the standard sale slip.
const letterheadIdBt = sale.letterhead && sale.letterhead.id;
// Standard sale slip for every tender — see the note on the
// network path above. Account sales print the system A4 DN
// through `printDelivery()` instead.
const text = buildEscPos(sale, window.PosSlipLayout.getBlocksForPrint(letterheadIdBt, 'sale'));
const CHUNK = 80;
const enc = new TextEncoder();
for(let i=0;i<text.length;i+=CHUNK){
const slice = text.slice(i, i+CHUNK);
await btChar.writeValueWithoutResponse(enc.encode(slice));
await new Promise(r=>setTimeout(r,30));
}
status.textContent = 'Printed · ' + (btDevice.name||'printer');
}catch(e){
if(e.name==='NotFoundError') status.textContent = 'Cancelled.';
else status.textContent = 'Print failed: ' + e.message;
errBox.classList.add('err');
}finally{
btn.disabled = false;
}
}
function saleEscPosRenderers(s){
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B), GS = E(0x1D);
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 — the caller runs it through
// TextEncoder before writing to the Bluetooth characteristic. (Note:
// this used to manually pack a Uint8Array here, which the caller then
// fed into TextEncoder.encode() — encoding a Uint8Array as if it were
// a string, producing garbled output. Fixed as part of this refactor.)
function buildEscPos(s, blocks){
const E = (n) => String.fromCharCode(n);
const ESC = E(0x1B), GS = E(0x1D);
const renderers = saleEscPosRenderers(s);
const body = blocks.filter(b=>b.enabled).map(b => renderers[b.id] ? renderers[b.id](b) : '').join('');
return ESC+'@' + body + '\n\n' + GS+'V'+E(0x41)+E(0x10);
}
// Prints the SYSTEM delivery note — the A4 original the office keeps —
// by opening it in a new tab and letting the operator send it to the
// office network printer from there (set Copies to 2 for the client).
//
// This replaced the old 80mm thermal delivery-note print. The thermal
// slip is still the path for cash / card / EFT via `printThermal`,
// and its button is hidden for account sales entirely.
async function printDelivery(){
if(!sale) return;
const dn = sale.deliveryNote;
const btn = document.getElementById('printDnBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
if(btn) btn.disabled = true;
errBox.classList.remove('err');
if(!dn){
if(status) status.textContent = 'No delivery note on this sale yet.';
if(btn) btn.disabled = false;
return;
}
if(status) status.textContent = 'Fetching ' + dn.delivery_note_number + '…';
let url = null;
try{
// Fetched through posFetch because the route sits behind
// assertPosAuth — an <iframe src> or a plain window.open() to the
// route cannot carry the bearer header, so the bytes come in as a
// blob first and the blob URL is what gets opened.
const r = await window.posFetch('/delivery-note/' + encodeURIComponent(dn.delivery_note_number) + '/pdf');
if(!r.ok){
let msg = 'HTTP ' + r.status;
try{ const j = await r.json(); if(j && j.error) msg = j.error; }catch(_){}
throw new Error(msg);
}
const blob = await r.blob();
url = URL.createObjectURL(blob);
// Open the blob in a NEW TAB rather than calling print() on a
// hidden iframe.
//
// An earlier version of this built a 1x1 iframe and called
// `contentWindow.print()`, which looks right and is not. Chrome
// backs ANY pdf — src URL or blob URL — with its internal
// `pdf_embedder` extension, so the frame's document is an empty
// shell:
//
// <html><head><link rel="stylesheet"
// href="chrome-extension://…/pdf_embedder.css"></head>
// <body></body></html>
//
// `print()` then prints that empty body: a dialog of blank pages
// with no error to explain why. Verified by reading the iframe's
// document out of a live page. A new tab gets Chrome's real PDF
// viewer, whose own Print button works.
const win = window.open(url, '_blank');
if(!win){
errBox.classList.add('err');
status.textContent = 'Pop-up blocked — allow pop-ups for this site, or use the office computer to open the delivery note from WFDASH.';
return;
}
status.textContent = dn.delivery_note_number + ' opened in a new tab — press Print there, and set Copies to 2 for the client.';
}catch(e){
errBox.classList.add('err');
if(status) status.textContent = 'DN print failed: ' + e.message;
}finally{
// Keep the blob alive long enough for the new tab to load it.
if(url) setTimeout(() => { URL.revokeObjectURL(url); }, 60000);
if(btn) btn.disabled = false;
}
}
function togglePrinterSettings(){
const form = document.getElementById('printerSettingsForm');
form.style.display = form.style.display === 'none' ? 'flex' : 'none';
}
function savePrinterSettings(){
const type = document.getElementById('printerTypeSelect').value;
const ip = document.getElementById('printerIpInput').value.trim();
window.EposPrint.setSettings({ type, ip });
paintPrinterSettings();
document.getElementById('printerSettingsForm').style.display = 'none';
}
function paintPrinterSettings(){
const settings = window.EposPrint.getSettings();
document.getElementById('printerTypeSelect').value = settings.type;
document.getElementById('printerIpInput').value = settings.ip || '';
document.getElementById('printerSettingsLabel').textContent =
'Printer: ' + (settings.type === 'network' ? ('Network' + (settings.ip ? ' (' + settings.ip + ')' : '')) : 'Bluetooth');
}
paintPrinterSettings();
paintNet();
// Expose the onclick-bound functions on window — they're defined
// inside the IIFE above so they're not visible to inline `onclick`
// attributes (which evaluate against window). Without this the
// "Printer: ..." row, "Save printer settings" button, "Print slip"
// button, and "Print delivery note" button all silently do nothing.
window.togglePrinterSettings = togglePrinterSettings;
window.savePrinterSettings = savePrinterSettings;
window.printThermal = printThermal;
window.printDelivery = printDelivery;
})();
</script>
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
</body>
</html>