diff --git a/public/pos/cashup.html b/public/pos/cashup.html index 25fe9a6..2148c88 100644 --- a/public/pos/cashup.html +++ b/public/pos/cashup.html @@ -265,7 +265,8 @@ paintNet(); const DENOMS = [ {lbl:'R200', sub:'note', v:200}, {lbl:'R100', sub:'note', v:100}, {lbl:'R50', sub:'note', v:50}, {lbl:'R20', sub:'note', v:20}, - {lbl:'R10', sub:'coin', v:10}, {lbl:'R5', sub:'coin', v:5}, {lbl:'R2', sub:'coin', v:2}, {lbl:'R1', sub:'coin', v:1}, {lbl:'50c', sub:'coin', v:0.5} + {lbl:'R10', sub:'coin', v:10}, {lbl:'R5', sub:'coin', v:5}, {lbl:'R2', sub:'coin', v:2}, {lbl:'R1', sub:'coin', v:1}, + {lbl:'50c', sub:'coin', v:0.5}, {lbl:'20c', sub:'coin', v:0.2}, {lbl:'10c', sub:'coin', v:0.1} ]; let shift = 'today'; @@ -300,7 +301,10 @@ function getDenomCounts(){ } function recalc(){ const c = getDenomCounts(); - cashCounted = Object.entries(c).reduce((s,[v,n])=>s + v*n, 0); + // Round to whole cents: 10c/20c aren't exact in binary floating point, + // so a drawer of 3x10c would otherwise total 0.30000000000000004 and + // print a variance that looks like a rounding bug on the slip. + cashCounted = Math.round(Object.entries(c).reduce((s,[v,n])=>s + v*n, 0) * 100) / 100; document.getElementById('sCashCounted').textContent = fmt(cashCounted); const diff = cashCounted - cashSales; const drow = document.getElementById('diffRow'); @@ -390,8 +394,9 @@ load(); // which went to the OS print dialog instead of the thermal printer. async function printCashup(){ const btn = document.querySelector('[data-od-id="print-cashup"]'); - const status = btn ? btn.querySelector('span') : null; - const oldLabel = btn ? btn.textContent : ''; + // Keep the markup, not just the text — the button holds a printer , + // and setting textContent to restore it would drop the icon for good. + const oldLabel = btn ? btn.innerHTML : ''; if(btn){ btn.disabled = true; btn.textContent = 'Printing…'; } try{ // Letterhead must be loaded — same as the sale slip. @@ -454,22 +459,30 @@ async function printCashup(){ } await window.EposPrint.printViaNetwork(settings.ip, xml); } else { - // Bluetooth — re-use the receipt page's printThermalBluetooth - // would couple cashup to receipt.html, so for the cashup slip - // we keep things simple: fall back to the OS print dialog - // (which is what the till was doing before this whole change). - // Operators using Bluetooth need a separate "print from the - // browser" workflow for the cashup slip; the sale slip on the - // till still uses Bluetooth via the receipt page. - window.print(); - return; + // Bluetooth. The ESC/POS builders live in receipt.html, so there's + // no cash-up renderer for this transport yet — only the sale slip + // speaks Bluetooth. Say so plainly rather than quietly opening the + // OS print dialog: getSettings() DEFAULTS to bluetooth, so a till + // with cleared settings would otherwise land here and look like the + // button does nothing at all. Same stance as EposPrint.printTest(). + throw new Error( + 'The cash-up slip prints over the network printer only. ' + + 'Set the printer to Network (and its IP) in Printer settings.' + ); } }catch(e){ alert('Cash-up print failed: ' + (e.message || e)); }finally{ - if(btn){ btn.disabled = false; btn.textContent = oldLabel || 'Print cash-up'; } + if(btn){ btn.disabled = false; btn.innerHTML = oldLabel; } } } + +// This whole script is IIFE-wrapped, so `printCashup` is NOT a global — +// and the button wires it up with an inline onclick="printCashup()", +// which is evaluated in global scope. Without this line the click throws +// ReferenceError into the console and the button silently does nothing. +// Same fix, same reason, as receipt.html's print buttons. +window.printCashup = printCashup; })(); diff --git a/public/pos/pos-app.js b/public/pos/pos-app.js index 977a2a3..abc1ca4 100644 --- a/public/pos/pos-app.js +++ b/public/pos/pos-app.js @@ -1006,6 +1006,13 @@ window.confirmSale = confirmSale; window.changeQty = changeQty; window.removeFromCart = removeFromCart; window.openEditLine = openEditLine; +// The edit-line modal could be opened but not dismissed or committed: +// its Save / Cancel / Remove buttons (and the customer "Remove" chip) +// call these from inline onclick, so they need to be global too. +window.closeEditLine = closeEditLine; +window.editSave = editSave; +window.editRemove = editRemove; +window.clearCust = clearCust; window.toggleDiscount = toggleDiscount; window.setDiscountKind = setDiscountKind; window.applyDiscount = applyDiscount; diff --git a/public/pos/sw.js b/public/pos/sw.js index 549e62c..3f1822b 100644 --- a/public/pos/sw.js +++ b/public/pos/sw.js @@ -1,5 +1,5 @@ // Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static. -const CACHE = "pos-v25"; +const CACHE = "pos-v26"; const APP_SHELL = [ './', './index.html', diff --git a/src/lib/pos-inline-handlers.test.ts b/src/lib/pos-inline-handlers.test.ts new file mode 100644 index 0000000..7484945 --- /dev/null +++ b/src/lib/pos-inline-handlers.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +/** + * The POS till pages under `public/pos` are plain HTML wired with inline + * `onclick="doThing()"` attributes. An inline handler is evaluated in the + * GLOBAL scope, so `doThing` must be reachable off `window`. + * + * Most of these pages wrap their script in an IIFE. A function declared + * inside that IIFE is NOT global, so the handler throws + * `ReferenceError: doThing is not defined` — which surfaces only as an + * uncaught console error. The button looks completely dead: no dialog, + * no feedback, nothing. + * + * This has now bitten the till twice (receipt.html's print buttons, then + * cashup.html's "Print cash-up"), so it gets a guard. The rule: + * + * every identifier called from an inline on* attribute must be either a + * browser builtin, a namespaced call (`window.Foo.bar()`), or exported + * with `window.name = name`. + */ + +const POS_DIR = join(process.cwd(), "public", "pos"); + +/** Browser globals that inline handlers legitimately call. */ +const BUILTINS = new Set([ + "alert", + "confirm", + "print", + "open", + "Number", + "String", + "parseInt", + "parseFloat", + "encodeURIComponent", +]); + +/** + * Drop the run of leading comments/whitespace so we can look at the first + * real statement. Deliberately anchored and non-global: a line-comment + * pattern led by `\s` and flagged `m` matches across newlines, which + * silently eats real code rather than just the comments. + */ +const firstStatement = (src: string) => + src.replace(/^(?:\s|\/\/[^\n]*|\/\*[\s\S]*?\*\/)*/, ""); + +/** A script whose whole body is `(function(){ ... })()` leaks nothing. */ +const isIifeWrapped = (src: string) => + /^\(\s*(?:function\s*\([^)]*\)|\([^)]*\)\s*=>)\s*\{/.test( + firstStatement(src), + ); + +/** Names this source makes reachable from global scope. */ +function globalsFrom(src: string): Set { + const names = new Set(); + // Explicit exports work regardless of wrapping. + for (const m of src.matchAll(/window\.([A-Za-z_$][\w$]*)\s*=/g)) { + names.add(m[1]); + } + // Top-level declarations are global only when not IIFE-wrapped. + if (!isIifeWrapped(src)) { + for (const m of src.matchAll( + /^(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/gm, + )) { + names.add(m[1]); + } + for (const m of src.matchAll( + /^(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function|\()/gm, + )) { + names.add(m[1]); + } + } + return names; +} + +/** Identifiers invoked from inline `on*="..."` attributes, ignoring `a.b()`. */ +function inlineHandlerCalls(html: string): Set { + const calls = new Set(); + for (const attr of html.matchAll(/\son[a-z]+\s*=\s*"([^"]*)"/g)) { + for (const call of attr[1].matchAll( + /(?:^|[^.\w$])([A-Za-z_$][\w$]*)\s*\(/g, + )) { + calls.add(call[1]); + } + } + return calls; +} + +const htmlFiles = readdirSync(POS_DIR).filter((f) => f.endsWith(".html")); + +describe("POS inline event handlers are reachable from global scope", () => { + it.each(htmlFiles)("%s has no dead inline handlers", (file) => { + const html = readFileSync(join(POS_DIR, file), "utf8"); + + // Everything this page executes: its own inline blocks plus local scripts. + const sources: string[] = [ + ...[...html.matchAll(/]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/g)].map( + (m) => m[1], + ), + ...[...html.matchAll(/]*\ssrc="([^"]+\.js)"/g)] + .map((m) => m[1]) + .filter((src) => !src.startsWith("http")) + .map((src) => readFileSync(join(POS_DIR, src), "utf8")), + ]; + + const reachable = new Set(); + for (const src of sources) { + for (const name of globalsFrom(src)) reachable.add(name); + } + + const dead = [...inlineHandlerCalls(html)] + .filter((name) => !BUILTINS.has(name) && !reachable.has(name)) + .sort(); + + expect(dead, `${file}: inline handler(s) not reachable on window`).toEqual( + [], + ); + }); +});