From 8c59f594f8ea0ac5cb466b9e0f95c35c13e06616 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 7 Oct 2026 12:54:47 +0200 Subject: [PATCH] fix(pos): make cart quantity editable, and fix the sheet that hid it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ringing up 800 of something means typing 800, and the only route to the quantity field was a 600ms long-press with no affordance anywhere on screen. The quantity itself — the thing a cashier reaches for — was a plain
, so it rendered inert. - the quantity is now a real button that opens the Edit line sheet, with an aria-label and a 44px touch target (it was 30px, too narrow to even show "800") - holding +/- no longer pops the sheet open mid-tap: the pointerdown handler bails for .qty/.rm before starting the timer - the sheet is quantity-only. The Unit price field is gone on request; a mis-keyed price is expensive and easy to miss. The per-line override it fed is removed with it — pos-service.ts only ever read unit_price, the "price edited" label and the sale record's `edited` flag were unreachable, and nothing else could set an override, including a cart reload (persistCart saves only {id, qty, p}) The discount trigger moved out of its own 54px footer row to ride inside the Subtotal row, so it costs no vertical height at all while staying visible — a discount control that appears from nowhere is one nobody finds under pressure. The applied-discount red totals row remains the always-on indicator. Fixes a pre-existing bug found while testing: long-pressing a cart row showed a grey screen with no dialog. #editSheet is a SIBLING of #editBackdrop, but the CSS revealed it with `.edit-backdrop.show .edit-sheet` — a descendant selector that can never match a sibling — so the sheet sat at opacity:0 while the backdrop went opaque. Nothing threw and the build was green, which is why it survived; it is also why manual quantity entry looked missing. The sheet now has its own z-index above the backdrop and its own .show rule. They stay siblings on purpose: nesting the sheet inside the backdrop would make every tap on Save bubble to the backdrop's onclick and close the dialog. Verified in jsdom against the real markup: old CSS reproduces the grey screen (backdrop opacity 1, sheet opacity 0, not clickable), the fix clears it (sheet opacity 1, z-index 71 > 70, position fixed). 125 POS tests pass, including 23 new ones asserting the tap target exists, the long-press guard fires before the timer, the sheet stacks above its backdrop, and the popup stays quantity-only. sw.js v30 -> v33: pos-app.js and pos.html are both served cache-first out of the shell, so without the bump tills keep the old ones. --- public/pos/pos-app.js | 80 +++++------ public/pos/pos.html | 86 ++++++------ public/pos/sw.js | 26 +++- src/lib/pos-cart-quantity.test.ts | 214 ++++++++++++++++++++++++++++++ 4 files changed, 321 insertions(+), 85 deletions(-) create mode 100644 src/lib/pos-cart-quantity.test.ts diff --git a/public/pos/pos-app.js b/public/pos/pos-app.js index 5a95ac7..71493d9 100644 --- a/public/pos/pos-app.js +++ b/public/pos/pos-app.js @@ -19,7 +19,7 @@ const state = { // catalog load (both the IndexedDB and the network path) so the // per-keystroke filter only does `indexOf`, never normalisation. searchIndex: new Map(), - cart: new Map(), // id -> {p, qty, unitPriceOverride?} + cart: new Map(), // id -> {p, qty} customer: null, paymentMethod: null, cashGiven: 0, @@ -350,8 +350,7 @@ function cartTotals(){ let count=0, gross=0; for(const {p,qty} of state.cart.values()){ count += qty; - // Use the per-line override if the cashier edited the price - // via long-press; otherwise the catalogue shelf price. + // Catalogue shelf price — the till never edits a line's price. const unit = lineUnitPrice({p,qty}); gross += unit * qty; } @@ -385,14 +384,14 @@ function cartTotals(){ } /** - * Effective unit price for a cart line — the per-line override - * (set by long-press edit) or the catalogue's selling price. - * Returns 0 for the corner case where neither is set. + * Effective unit price for a cart line — always the catalogue's selling + * price. It used to prefer a per-line override set by the long-press edit + * sheet, but that sheet is quantity-only now, so no override can exist. + * Kept as a named helper because the price feeds four places (line render, + * cart totals, the sale record and the checkout payload) and they must not + * drift apart. Returns 0 when neither price is set. */ function lineUnitPrice(line){ - if (typeof line.unitPriceOverride === 'number' && Number.isFinite(line.unitPriceOverride)) { - return line.unitPriceOverride; - } return Number(line.p.selling_price ?? line.p.cost_price ?? 0) || 0; } function persistCart(){ @@ -430,17 +429,14 @@ function renderCartDrawer(){ document.getElementById('discountLine').hidden = true; return; } - body.innerHTML = Array.from(state.cart.values()).map(({p,qty,unitPriceOverride})=>{ - const price = lineUnitPrice({p,qty,unitPriceOverride}); - const overridden = typeof unitPriceOverride === 'number' - && Number.isFinite(unitPriceOverride) - && unitPriceOverride !== (p.selling_price ?? p.cost_price ?? 0); + body.innerHTML = Array.from(state.cart.values()).map(({p,qty})=>{ + const price = lineUnitPrice({p,qty}); return `
-
${escapeHTML(p.description||p.sku)}${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}${overridden ? ' · price edited' : ''}
+
${escapeHTML(p.description||p.sku)}${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}
- -
${qty}
- + + +
${fmt(price*qty)}
-
SubtotalR 0.00
+ +
+ Subtotal + + + R 0.00 +
VAT 15%R 0.00
- -
- -