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 `
Hold to edit again, or change a value and tap Save.
+
Type a quantity like 800, then tap Save.
diff --git a/public/pos/sw.js b/public/pos/sw.js
index 9bd17cd..9d12e27 100644
--- a/public/pos/sw.js
+++ b/public/pos/sw.js
@@ -7,8 +7,30 @@
// running the step-1 build of it indefinitely.
// Measured on the live catalog: "nuts m20" 0 → 12, "washers flat" 0 → 14,
// with nothing that used to match ceasing to match.
-// Bumped so tills evict the stale app shell.
-const CACHE = "pos-v30";
+// v33 — the Edit line sheet is quantity-only. The Unit price field is gone
+// on request: the till sells at the catalogue shelf price, and a mis-keyed
+// price is expensive and easy to miss. The per-line price override it fed is
+// removed with it — the server only ever read `unit_price`, and the
+// "price edited" label plus the sale record's `edited` flag are now
+// unreachable. Nothing else could set an override, including a cart reload.
+// v32 — fix the Edit line sheet, which never actually displayed. #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. Long-pressing a cart row produced a grey screen with no dialog.
+// The sheet now carries its own z-index above the backdrop and its own
+// .show rule. This is why manual quantity entry looked missing: the control
+// existed and worked, it was just rendering underneath its own backdrop.
+// Bumped because pos.html is served cache-first out of the shell.
+// v31 — cart quantity is now directly editable: the number in each cart line
+// is a real button that opens the existing Edit line sheet (previously only
+// reachable by an undiscoverable 600ms long-press), and holding +/- no
+// longer pops that sheet open mid-tap. The discount trigger moved out of its
+// own 54px footer row to ride inside the Subtotal row, so it costs no
+// vertical height while staying visible.
+// Bumped because pos-app.js and pos.html are both served cache-first out of
+// the shell; without a new CACHE name the tills keep the old ones.
+const CACHE = "pos-v33";
const APP_SHELL = [
'./',
'./index.html',
diff --git a/src/lib/pos-cart-quantity.test.ts b/src/lib/pos-cart-quantity.test.ts
new file mode 100644
index 0000000..36a2642
--- /dev/null
+++ b/src/lib/pos-cart-quantity.test.ts
@@ -0,0 +1,214 @@
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+/**
+ * POS cart quantity entry (`public/pos/pos-app.js` + `public/pos/pos.html`).
+ *
+ * Ringing up 800 of something means typing 800, and the only route to the
+ * quantity field used to be a 600ms long-press on the row. The number
+ * itself was a plain `
` — exactly the thing a cashier reaches
+ * for — so it rendered inert. The sheet it opens is unchanged and still
+ * reachable both ways; these guards exist because the *discoverability* is
+ * what regressed, and that is invisible in a code review: nothing throws,
+ * the build is green, and the control is simply dead to a first-time user.
+ *
+ * Same shape of problem as `pos-inline-handlers.test.ts`: assert the
+ * affordance exists rather than trusting that it was left in place.
+ */
+const POS_APP = readFileSync(join(process.cwd(), "public", "pos", "pos-app.js"), "utf8");
+const POS_HTML = readFileSync(join(process.cwd(), "public", "pos", "pos.html"), "utf8");
+
+describe("POS cart quantity is directly settable", () => {
+ it("renders the quantity as a button, not an inert div", () => {
+ expect(POS_APP).toMatch(/