fix(pos): keep edit-line modal from getting stuck on invalid qty; show clear 409 stock-shortage error instead of silently queuing offline

This commit is contained in:
Dave 2026-09-04 10:37:00 +02:00
parent 46d5d540a0
commit 79cc7d1f0a
2 changed files with 63 additions and 3 deletions

View file

@ -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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12l5 5 9-11"/></svg>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();

View file

@ -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',