From 79cc7d1f0aac13865487391d7fb8db876ac089f0 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 4 Sep 2026 10:37:00 +0200 Subject: [PATCH] fix(pos): keep edit-line modal from getting stuck on invalid qty; show clear 409 stock-shortage error instead of silently queuing offline --- public/pos/pos-app.js | 64 +++++++++++++++++++++++++++++++++++++++++-- public/pos/sw.js | 2 +- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/public/pos/pos-app.js b/public/pos/pos-app.js index 02b6a3e..0f1f097 100644 --- a/public/pos/pos-app.js +++ b/public/pos/pos-app.js @@ -618,7 +618,20 @@ function editSave(){ if (!line) { closeEditLine(); return; } const qty = Number((document.getElementById('editQty').value || '').replace(/[^0-9.]/g, '')); const price = Number((document.getElementById('editPrice').value || '').replace(/[^0-9.]/g, '')); - if (!Number.isFinite(qty) || qty <= 0) { return; } + const hint = document.getElementById('editHint'); + // Bad qty — show an error in the hint, keep the modal open so the + // operator can fix it. (Previously the modal stayed open without + // any feedback, so a cleared/0 qty field left the screen grey and + // the operator had to long-press another line to escape.) + if (!Number.isFinite(qty) || qty <= 0) { + if (hint) { + hint.textContent = 'Enter a quantity of 1 or more.'; + hint.style.color = '#c92a2a'; + } + const qIn = document.getElementById('editQty'); + if (qIn) { qIn.focus(); qIn.select(); } + return; + } // price === 0 is allowed (the operator can comp a line) but // negative isn't. If the input is empty, leave the price alone. const shelf = Number(line.p.selling_price ?? line.p.cost_price ?? 0) || 0; @@ -627,6 +640,9 @@ function editSave(){ else delete line.unitPriceOverride; // back to default } line.qty = Math.floor(qty); + // Reset the hint to its neutral default before closing so the next + // openEditLine() doesn't show a stale error from this round. + if (hint) { hint.textContent = 'Hold to edit again, or change a value and tap Save.'; hint.style.color = ''; } persistCart(); render(); renderCartDrawer(); @@ -831,6 +847,14 @@ async function confirmSale(){ discount_input: state.discount && state.discount.kind !== 'none' ? state.discount.input : undefined, }; + // Distinguish a permanent server-side rejection (insufficient + // stock, bad input) from a transient failure (server down, network + // blip). The former must NOT advance to the receipt page or get + // queued for offline retry — it's a real, human-fixable problem + // the operator needs to see. The latter is what the offline queue + // is for. + let serverError = null; + try{ const r = await window.posFetch('/checkout', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(checkoutBody), @@ -840,7 +864,18 @@ async function confirmSale(){ sale.synced = true; sale.serverRef = j.sale && j.sale.id; } else { - throw new Error('server returned ' + r.status); + // Try to read the body so we can tell apart stock-shortage + // (409) from generic 4xx/5xx. The route returns JSON on every + // non-2xx path, so this should always parse. + let body = null; + try { body = await r.json(); } catch (_) { /* not json */ } + if (body && body.reason === 'insufficient_stock' && Array.isArray(body.shortages) && body.shortages.length) { + serverError = { kind: 'insufficient_stock', shortages: body.shortages }; + } else { + const msg = body && body.message ? body.message : ('server returned ' + r.status); + serverError = { kind: 'other', message: msg }; + throw new Error(msg); + } } }catch(e){ console.warn('Sale queued for sync', e); @@ -856,6 +891,31 @@ async function confirmSale(){ } } + // Permanent server-side rejection — stop here, do NOT navigate to + // the receipt page, do NOT queue for retry. Show a clear error, + // re-enable the Complete button so the operator can fix the + // cart (remove a line, lower the qty) and try again. The cart + // stays open. + if (serverError) { + if (serverError.kind === 'insufficient_stock') { + const lines = serverError.shortages.map(s => { + const item = state.cart.get(s.stock_item_id); + const name = item && item.p + ? (item.p.description || item.p.sku || s.stock_item_id) + : s.stock_item_id; + return `${name}: only ${s.available} in stock (you asked for ${s.requested})`; + }); + toast.error('Not enough stock — ' + lines.join('; ')); + } else { + toast.error('Sale rejected: ' + (serverError.kind === 'other' ? serverError.message : 'Unknown error')); + } + if (btn) { + btn.disabled = false; + btn.innerHTML = 'Complete sale'; + } + return; + } + // ALWAYS navigate to receipt — never leave the user stuck on "Saving…" try{ sessionStorage.setItem('pos.lastSale', JSON.stringify(sale)); }catch(_){} state.cart.clear(); persistCart(); paintCart(); render(); diff --git a/public/pos/sw.js b/public/pos/sw.js index 47654b8..618009e 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-v18"; +const CACHE = "pos-v19"; const APP_SHELL = [ './', './index.html',