fix(pos): make cart quantity editable, and fix the sheet that hid it
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 <div class="v">, 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.
This commit is contained in:
parent
bcb0e1a7d0
commit
8c59f594f8
4 changed files with 321 additions and 85 deletions
|
|
@ -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 `<div class="line" data-od-id="line-${p.id}" data-id="${p.id}">
|
||||
<div class="nm">${escapeHTML(p.description||p.sku)}<small>${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}${overridden ? ' · <em>price edited</em>' : ''}</small></div>
|
||||
<div class="nm">${escapeHTML(p.description||p.sku)}<small>${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}</small></div>
|
||||
<div class="qty">
|
||||
<button onclick="window.__pos.changeQty('${p.id}',-1)" aria-label="Decrease">−</button>
|
||||
<div class="v">${qty}</div>
|
||||
<button onclick="window.__pos.changeQty('${p.id}',1)" aria-label="Increase">+</button>
|
||||
<button onclick="window.__pos.changeQty('${p.id}',-1)" aria-label="Decrease quantity">−</button>
|
||||
<button class="v" data-od-id="qty-set" onclick="window.__pos.openEditLine('${p.id}')" aria-label="Set quantity for ${escapeAttr(p.description||p.sku||'item')}">${qty}</button>
|
||||
<button onclick="window.__pos.changeQty('${p.id}',1)" aria-label="Increase quantity">+</button>
|
||||
</div>
|
||||
<div class="pr">${fmt(price*qty)}</div>
|
||||
<button class="rm" onclick="window.__pos.removeFromCart('${p.id}')" aria-label="Remove">
|
||||
|
|
@ -570,11 +566,14 @@ document.addEventListener('input', e=>{
|
|||
// ---------------- long-press edit line ----------------
|
||||
//
|
||||
// Hold a cart line for 600ms → modal opens with qty + unit price.
|
||||
// Tapping the quantity number does the same thing and is the discoverable
|
||||
// route; the long-press is kept because it is the faster gesture for a
|
||||
// cashier who already knows it's there.
|
||||
// Common on mobile POS apps; the cashier scans/selects, then holds
|
||||
// the row to fix the qty or override the price before ringing it
|
||||
// up. We use a 600ms threshold so a normal "I'm thinking about it"
|
||||
// tap doesn't accidentally open the modal — but it's well under
|
||||
// the long-press duration most users naturally apply (≈800ms).
|
||||
// the row to fix the qty before ringing it up. Quantity only — this
|
||||
// sheet has no price field. We use a 600ms threshold so a normal "I'm
|
||||
// thinking about it" tap doesn't accidentally open the modal — but it's
|
||||
// well under the long-press duration most users naturally apply (≈800ms).
|
||||
//
|
||||
// Haptic feedback (navigator.vibrate) on the trigger where the
|
||||
// device supports it; visual hint via the .line-holding class so
|
||||
|
|
@ -609,6 +608,11 @@ function startLongPress(el){
|
|||
document.addEventListener('pointerdown', e=>{
|
||||
const line = e.target.closest('.line[data-id]');
|
||||
if (!line) return;
|
||||
// The steppers and the quantity number are their own controls now. A
|
||||
// long-press on them used to pop the edit sheet open mid-tap, which made
|
||||
// holding "+" to ring up a bulk quantity fight the sheet. Each of them
|
||||
// does its own thing on a normal tap.
|
||||
if (e.target.closest('.qty, .rm')) return;
|
||||
startLongPress(line);
|
||||
}, { passive: true });
|
||||
document.addEventListener('pointerup', clearLongPress);
|
||||
|
|
@ -623,12 +627,11 @@ function openEditLine(id){
|
|||
if (!line) return;
|
||||
editingLineId = id;
|
||||
document.getElementById('editSku').textContent = line.p.sku || line.p.description || '';
|
||||
// Pre-fill with the effective unit price (the override if set,
|
||||
// otherwise the catalogue shelf price). The cashier can decide
|
||||
// whether to keep it or change it.
|
||||
const cur = lineUnitPrice(line);
|
||||
// Quantity only. This popup deliberately has no price field: the till
|
||||
// sells at the catalogue shelf price and a mis-keyed price is expensive
|
||||
// and hard to spot. Per-line price overrides were removed on request —
|
||||
// the server reads only `unit_price`, so nothing else depended on them.
|
||||
document.getElementById('editQty').value = String(line.qty);
|
||||
document.getElementById('editPrice').value = fmtN(cur);
|
||||
document.getElementById('editBackdrop').classList.add('show');
|
||||
document.getElementById('editSheet').classList.add('show');
|
||||
setTimeout(()=>document.getElementById('editQty').focus(), 60);
|
||||
|
|
@ -643,7 +646,6 @@ function editSave(){
|
|||
const line = state.cart.get(editingLineId);
|
||||
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, ''));
|
||||
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
|
||||
|
|
@ -658,17 +660,10 @@ function editSave(){
|
|||
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;
|
||||
if (Number.isFinite(price) && price >= 0) {
|
||||
if (price !== shelf) line.unitPriceOverride = price;
|
||||
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 = ''; }
|
||||
if (hint) { hint.textContent = 'Type a quantity like 800, then tap Save.'; hint.style.color = ''; }
|
||||
persistCart();
|
||||
render();
|
||||
renderCartDrawer();
|
||||
|
|
@ -865,15 +860,10 @@ async function confirmSale(){
|
|||
// if it's null, so a missing letterhead is a non-fatal
|
||||
// degradation, not a crash).
|
||||
letterhead: state.letterhead || null,
|
||||
items: Array.from(state.cart.values()).map(({p,qty,unitPriceOverride})=>({
|
||||
items: Array.from(state.cart.values()).map(({p,qty})=>({
|
||||
sku: p.sku, description: p.description, unit: p.unit,
|
||||
cost_price: p.cost_price,
|
||||
// The price the cashier actually sold at — the override
|
||||
// (long-press) wins over the catalogue shelf price.
|
||||
selling_price: lineUnitPrice({p,qty,unitPriceOverride}),
|
||||
edited: typeof unitPriceOverride === 'number'
|
||||
&& Number.isFinite(unitPriceOverride)
|
||||
&& unitPriceOverride !== (p.selling_price ?? p.cost_price ?? 0),
|
||||
selling_price: lineUnitPrice({p,qty}),
|
||||
qty, product_id: p.id,
|
||||
})),
|
||||
customer: state.customer || null,
|
||||
|
|
@ -881,8 +871,8 @@ async function confirmSale(){
|
|||
};
|
||||
|
||||
const checkoutBody = {
|
||||
items: Array.from(state.cart.values()).map(({p,qty,unitPriceOverride})=>({
|
||||
stock_item_id: p.id, qty, unit_price: lineUnitPrice({p,qty,unitPriceOverride}),
|
||||
items: Array.from(state.cart.values()).map(({p,qty})=>({
|
||||
stock_item_id: p.id, qty, unit_price: lineUnitPrice({p,qty}),
|
||||
sku: p.sku, description: p.description,
|
||||
})),
|
||||
payment_method: state.paymentMethod,
|
||||
|
|
|
|||
|
|
@ -111,7 +111,8 @@ input{font:inherit;color:inherit}
|
|||
.qty{display:flex;align-items:center;border:1px solid var(--muted);border-radius:10px;overflow:hidden}
|
||||
.qty button{width:34px;height:34px;display:grid;place-items:center;color:var(--fg);font-size:16px;font-weight:600}
|
||||
.qty button:active{background:var(--surface)}
|
||||
.qty .v{width:30px;text-align:center;font-size:14px;font-weight:600;font-variant-numeric:tabular-nums}
|
||||
.qty .v{min-width:44px;padding:6px 2px;text-align:center;font-size:14px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--fg);border-radius:6px}
|
||||
.qty .v:active{background:var(--surface)}
|
||||
.line .pr{width:78px;text-align:right;font-size:14px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--fg)}
|
||||
.line .rm{width:32px;height:32px;border-radius:50%;color:#9a9a9a;display:grid;place-items:center}
|
||||
.line .rm:active{background:#fff0f0;color:var(--danger)}
|
||||
|
|
@ -121,12 +122,15 @@ input{font:inherit;color:inherit}
|
|||
.totals.grand{padding-top:12px;margin-top:8px;border-top:1px solid var(--muted);font-size:17px;color:var(--fg);font-weight:700;letter-spacing:-.01em}
|
||||
|
||||
/* ---- Discount block ---- */
|
||||
.discount{padding:8px 0 4px}
|
||||
.discount-trigger{display:flex;align-items:center;justify-content:space-between;width:100%;padding:10px 12px;border:1px dashed var(--muted);border-radius:10px;background:#fff;color:var(--fg);font-size:13px;font-weight:600;letter-spacing:-.005em;min-height:42px}
|
||||
.discount-trigger:active{background:var(--surface)}
|
||||
.discount-trigger svg{width:16px;height:16px;color:#717171;transition:transform .18s}
|
||||
/* The trigger rides INSIDE the Subtotal row (see the markup below), so it
|
||||
adds no vertical height at all — the footer keeps every pixel it had
|
||||
before. It stays visible rather than collapsing away, because a discount
|
||||
control that appears from nowhere is one nobody finds under pressure. */
|
||||
.discount-trigger{display:inline-flex;align-items:center;gap:3px;margin-left:8px;padding:3px 6px;border:0;border-radius:6px;background:none;color:#717171;font-size:12px;font-weight:600;letter-spacing:.01em;vertical-align:middle}
|
||||
.discount-trigger:active{background:var(--surface);color:var(--fg)}
|
||||
.discount-trigger svg{width:13px;height:13px;color:inherit;transition:transform .18s}
|
||||
.discount-trigger[aria-expanded="true"] svg{transform:rotate(180deg)}
|
||||
.discount-trigger.has-value{border-style:solid;border-color:var(--accent);color:var(--accent);background:#fff8fa}
|
||||
.discount-trigger.has-value{color:var(--accent)}
|
||||
.discount-trigger.has-value svg{color:var(--accent)}
|
||||
.discount-body{padding:12px 0 4px;display:flex;flex-direction:column;gap:10px}
|
||||
.discount-pills{display:flex;gap:6px;padding:3px;background:var(--surface);border:1px solid var(--muted);border-radius:999px;width:fit-content}
|
||||
|
|
@ -145,8 +149,15 @@ input{font:inherit;color:inherit}
|
|||
/* ---- Edit line modal (long-press) ---- */
|
||||
.edit-backdrop{position:fixed;inset:0;background:rgba(34,34,34,.5);z-index:70;opacity:0;pointer-events:none;transition:opacity .15s}
|
||||
.edit-backdrop.show{opacity:1;pointer-events:auto}
|
||||
.edit-sheet{position:absolute;left:50%;bottom:24px;transform:translate(-50%, 24px);background:#fff;border-radius:18px;width:calc(100% - 32px);max-width:448px;padding:18px 18px 14px;opacity:0;transition:opacity .15s, transform .22s cubic-bezier(.2,.8,.2,1);box-shadow:0 20px 60px rgba(34,34,34,.30);pointer-events:none;display:flex;flex-direction:column;gap:12px;max-height:80dvh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding-bottom:calc(14px + env(safe-area-inset-bottom))}
|
||||
.edit-backdrop.show .edit-sheet{opacity:1;transform:translate(-50%, 0);pointer-events:auto}
|
||||
/* The sheet is a SIBLING of the backdrop, not a child, so it cannot be
|
||||
styled via `.edit-backdrop.show .edit-sheet` — that selector never
|
||||
matched and the sheet stayed at opacity:0 forever, which showed the
|
||||
operator a grey screen with no dialog on it. It needs its own stacking
|
||||
above the backdrop (z-index 70) and its own .show rule. Keep them
|
||||
siblings: nesting the sheet inside the backdrop would make every tap on
|
||||
the sheet bubble to the backdrop's onclick and close it. */
|
||||
.edit-sheet{position:fixed;left:50%;bottom:24px;z-index:71;transform:translate(-50%, 24px);background:#fff;border-radius:18px;width:calc(100% - 32px);max-width:448px;padding:18px 18px 14px;opacity:0;transition:opacity .15s, transform .22s cubic-bezier(.2,.8,.2,1);box-shadow:0 20px 60px rgba(34,34,34,.30);pointer-events:none;display:flex;flex-direction:column;gap:12px;max-height:80dvh;overflow-y:auto;-webkit-overflow-scrolling:touch;padding-bottom:calc(14px + env(safe-area-inset-bottom))}
|
||||
.edit-sheet.show{opacity:1;transform:translate(-50%, 0);pointer-events:auto}
|
||||
.edit-sheet h3{font-size:15px;font-weight:600;letter-spacing:-.01em;margin:0;display:flex;align-items:center;gap:8px}
|
||||
.edit-sheet h3 small{font-size:11px;font-weight:500;color:#717171;letter-spacing:.02em;margin-left:auto}
|
||||
.edit-row{display:flex;flex-direction:column;gap:6px}
|
||||
|
|
@ -267,6 +278,7 @@ input{font:inherit;color:inherit}
|
|||
:root[data-theme="dark"] .qty{border-color:var(--muted)}
|
||||
:root[data-theme="dark"] .qty button{color:var(--fg)}
|
||||
:root[data-theme="dark"] .qty button:active{background:var(--surface)}
|
||||
:root[data-theme="dark"] .qty .v{color:var(--fg)}
|
||||
:root[data-theme="dark"] .line .pr{color:var(--fg)}
|
||||
:root[data-theme="dark"] .line .rm{color:#666}
|
||||
:root[data-theme="dark"] .line .rm:active{background:#3a2429;color:var(--accent)}
|
||||
|
|
@ -274,10 +286,9 @@ input{font:inherit;color:inherit}
|
|||
:root[data-theme="dark"] .totals{color:#888}
|
||||
:root[data-theme="dark"] .totals b{color:var(--fg)}
|
||||
:root[data-theme="dark"] .totals.grand{border-top-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .discount-trigger{background:#181818;border-color:var(--muted);color:var(--fg)}
|
||||
:root[data-theme="dark"] .discount-trigger svg{color:#888}
|
||||
:root[data-theme="dark"] .discount-trigger.has-value{border-color:var(--accent);color:#ff6680;background:#2a1a1a}
|
||||
:root[data-theme="dark"] .discount-trigger.has-value svg{color:#ff6680}
|
||||
:root[data-theme="dark"] .discount-trigger{color:#888}
|
||||
:root[data-theme="dark"] .discount-trigger:active{color:var(--fg)}
|
||||
:root[data-theme="dark"] .discount-trigger.has-value{color:#ff6680}
|
||||
:root[data-theme="dark"] .discount-pills{background:#222;border-color:var(--muted)}
|
||||
:root[data-theme="dark"] .discount-pill{color:#888}
|
||||
:root[data-theme="dark"] .discount-pill.active{background:#181818;color:#ff6680;box-shadow:0 1px 2px rgba(0,0,0,.4)}
|
||||
|
|
@ -392,30 +403,33 @@ input{font:inherit;color:inherit}
|
|||
</div>
|
||||
<div class="sheet-body" id="cartLines"></div>
|
||||
<div class="sheet-foot">
|
||||
<div class="totals"><span>Subtotal</span><b id="subTotal">R 0.00</b></div>
|
||||
<!-- The discount trigger rides inside the Subtotal row so the footer
|
||||
costs no extra height. Tapping it expands the R / % pills, number
|
||||
input and Apply button below; the discounted amount is then shown
|
||||
in red on its own totals line, and the server re-derives the
|
||||
resolved rand value at commit time. -->
|
||||
<div class="totals">
|
||||
<span>Subtotal
|
||||
<button class="discount-trigger" id="discountTrigger" data-od-id="discount-trigger" onclick="toggleDiscount()">
|
||||
<span id="discountTriggerLabel">Add discount</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
</span>
|
||||
<b id="subTotal">R 0.00</b>
|
||||
</div>
|
||||
<div class="totals"><span>VAT 15%</span><b id="vatTotal">R 0.00</b></div>
|
||||
|
||||
<!-- Discount row: collapsed by default ("Add discount"),
|
||||
expanded when the operator taps. Two-pill toggle
|
||||
(R / %), number input, Apply / Clear. The discounted
|
||||
amount is shown in red on the total line below. -->
|
||||
<div class="discount" id="discountBlock">
|
||||
<button class="discount-trigger" id="discountTrigger" data-od-id="discount-trigger" onclick="toggleDiscount()">
|
||||
<span id="discountTriggerLabel">Add discount</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
<div class="discount-body" id="discountBody" hidden>
|
||||
<div class="discount-pills" role="tablist">
|
||||
<button class="discount-pill" id="discKindAmount" data-kind="amount" onclick="setDiscountKind('amount')">R off</button>
|
||||
<button class="discount-pill" id="discKindPercent" data-kind="percent" onclick="setDiscountKind('percent')">% off</button>
|
||||
</div>
|
||||
<div class="discount-input-row">
|
||||
<input id="discInput" type="text" inputmode="decimal" placeholder="0" autocomplete="off" data-od-id="discount-input">
|
||||
<span class="discount-suffix" id="discSuffix">R</span>
|
||||
<button class="discount-apply" data-od-id="discount-apply" onclick="applyDiscount()">Apply</button>
|
||||
</div>
|
||||
<p class="discount-hint" id="discHint">Enter a value and tap Apply.</p>
|
||||
<div class="discount-body" id="discountBody" hidden>
|
||||
<div class="discount-pills" role="tablist">
|
||||
<button class="discount-pill" id="discKindAmount" data-kind="amount" onclick="setDiscountKind('amount')">R off</button>
|
||||
<button class="discount-pill" id="discKindPercent" data-kind="percent" onclick="setDiscountKind('percent')">% off</button>
|
||||
</div>
|
||||
<div class="discount-input-row">
|
||||
<input id="discInput" type="text" inputmode="decimal" placeholder="0" autocomplete="off" data-od-id="discount-input">
|
||||
<span class="discount-suffix" id="discSuffix">R</span>
|
||||
<button class="discount-apply" data-od-id="discount-apply" onclick="applyDiscount()">Apply</button>
|
||||
</div>
|
||||
<p class="discount-hint" id="discHint">Enter a value and tap Apply.</p>
|
||||
</div>
|
||||
|
||||
<div class="totals discount-line" id="discountLine" hidden>
|
||||
|
|
@ -496,11 +510,7 @@ input{font:inherit;color:inherit}
|
|||
<label for="editQty">Quantity</label>
|
||||
<input id="editQty" type="text" inputmode="decimal" autocomplete="off" data-od-id="edit-qty">
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<label for="editPrice">Unit price (R)</label>
|
||||
<input id="editPrice" type="text" inputmode="decimal" autocomplete="off" data-od-id="edit-price">
|
||||
</div>
|
||||
<p class="discount-hint" id="editHint">Hold to edit again, or change a value and tap Save.</p>
|
||||
<p class="discount-hint" id="editHint">Type a quantity like 800, then tap Save.</p>
|
||||
<div class="edit-actions">
|
||||
<button class="danger" data-od-id="edit-remove" onclick="editRemove()">Remove</button>
|
||||
<button class="secondary" data-od-id="edit-cancel" onclick="closeEditLine()">Cancel</button>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
214
src/lib/pos-cart-quantity.test.ts
Normal file
214
src/lib/pos-cart-quantity.test.ts
Normal file
|
|
@ -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 `<div class="v">` — 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(/<button class="v"[^>]*onclick="window\.__pos\.openEditLine\(/);
|
||||
expect(POS_APP).not.toMatch(/<div class="v">\$\{qty\}<\/div>/);
|
||||
});
|
||||
|
||||
it("gives the quantity button an accessible label", () => {
|
||||
// An unlabelled button reads as just "7" to a screen reader, which is
|
||||
// no better than the div it replaced.
|
||||
expect(POS_APP).toMatch(/aria-label="Set quantity for /);
|
||||
});
|
||||
|
||||
it("keeps the steppers as +/- only", () => {
|
||||
expect(POS_APP).toMatch(/changeQty\('\$\{p\.id\}',-1\)/);
|
||||
expect(POS_APP).toMatch(/changeQty\('\$\{p\.id\}',1\)/);
|
||||
});
|
||||
|
||||
it("exposes openEditLine on the global the button calls", () => {
|
||||
// The inline handler guard proves the name is reachable; this proves the
|
||||
// specific member the button reaches for exists.
|
||||
expect(POS_APP).toMatch(/window\.__pos\s*=\s*\{[^}]*openEditLine/);
|
||||
});
|
||||
|
||||
it("still opens the sheet on long-press", () => {
|
||||
expect(POS_APP).toMatch(/startLongPress\(line\)/);
|
||||
expect(POS_APP).toMatch(/openEditLine\(id\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POS cart long-press does not hijack the quantity controls", () => {
|
||||
/** The pointerdown listener body, up to its options argument. */
|
||||
const pointerdownHandler = () => {
|
||||
const m = POS_APP.match(
|
||||
/addEventListener\('pointerdown'[\s\S]*?\{ passive: true \}\);/,
|
||||
);
|
||||
expect(m, "pointerdown handler not found").not.toBeNull();
|
||||
return m![0];
|
||||
};
|
||||
|
||||
it("bails out of the pointerdown handler for .qty and .rm", () => {
|
||||
// Holding "+" to ring up bulk used to pop the edit sheet open at 600ms,
|
||||
// mid-tap. The steppers and the new quantity button each handle their
|
||||
// own tap, so they must not start the row long-press.
|
||||
expect(pointerdownHandler()).toMatch(/closest\('\.qty, \.rm'\)/);
|
||||
});
|
||||
|
||||
it("bails BEFORE starting the timer, not after", () => {
|
||||
// Order matters: an early `return` leaves no pending timer to fire.
|
||||
const handler = pointerdownHandler();
|
||||
const guardAt = handler.indexOf("closest('.qty, .rm')");
|
||||
const startAt = handler.indexOf("startLongPress(line)");
|
||||
expect(guardAt, "guard not found in handler").toBeGreaterThan(-1);
|
||||
expect(startAt, "startLongPress not found in handler").toBeGreaterThan(-1);
|
||||
expect(guardAt, "guard must come before startLongPress").toBeLessThan(startAt);
|
||||
});
|
||||
|
||||
it("still falls through to the row long-press for a tap on the row body", () => {
|
||||
// The guard must not disable the long-press gesture entirely.
|
||||
expect(pointerdownHandler()).toMatch(/startLongPress\(line\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POS discount trigger costs no footer height", () => {
|
||||
it("sits inside the Subtotal row rather than its own row", () => {
|
||||
// The standalone .discount block was ~54px of always-on footer
|
||||
// (12px padding + a 42px min-height button). The trigger now rides
|
||||
// inside the existing Subtotal row, so it adds nothing vertically.
|
||||
expect(POS_HTML).not.toMatch(/class="discount"/);
|
||||
expect(POS_HTML).toMatch(/<div class="totals">\s*<span>Subtotal\s*<button class="discount-trigger"/);
|
||||
});
|
||||
|
||||
it("keeps every id the discount JS depends on", () => {
|
||||
// paintDiscount() and toggleDiscount() look these up by id; renaming any
|
||||
// of them throws on drawer open, which would break the till.
|
||||
for (const id of ["discountTrigger", "discountTriggerLabel", "discountBody"]) {
|
||||
expect(POS_HTML, `missing #${id}`).toMatch(new RegExp(`id="${id}"`));
|
||||
}
|
||||
});
|
||||
|
||||
it("drops the old full-width button styling", () => {
|
||||
expect(POS_HTML).not.toMatch(/\.discount-trigger\{[^}]*width:100%/);
|
||||
expect(POS_HTML).toMatch(/\.discount-trigger\{[^}]*border:0/);
|
||||
});
|
||||
|
||||
it("keeps the applied-discount totals line as the always-on indicator", () => {
|
||||
// The trigger is a small link now; the red totals row is what proves a
|
||||
// discount is live when nobody is looking at the footer.
|
||||
expect(POS_HTML).toMatch(/id="discountLine"/);
|
||||
expect(POS_HTML).toMatch(/class="totals discount-line"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POS Edit line sheet is actually visible when opened", () => {
|
||||
// This is the bug that hid manual quantity entry entirely: #editSheet is
|
||||
// a SIBLING of #editBackdrop, but the CSS revealed it with
|
||||
// `.edit-backdrop.show .edit-sheet` — a descendant selector that cannot
|
||||
// match a sibling. The sheet stayed at opacity:0 while the backdrop went
|
||||
// opaque, so long-pressing a cart row showed a grey screen with nothing
|
||||
// on it. Nothing threw, the build was green, and the feature looked
|
||||
// simply absent.
|
||||
it("reveals the sheet with its own .show rule, not a descendant of the backdrop", () => {
|
||||
expect(POS_HTML).toMatch(/^\.edit-sheet\.show\{/m);
|
||||
expect(POS_HTML).not.toMatch(/^\.edit-backdrop\.show \.edit-sheet/m);
|
||||
});
|
||||
|
||||
it("stacks the sheet above the backdrop", () => {
|
||||
const backdrop = POS_HTML.match(/\.edit-backdrop\{[^}]*z-index:(\d+)/)?.[1];
|
||||
const sheet = POS_HTML.match(/\.edit-sheet\{[^}]*z-index:(\d+)/)?.[1];
|
||||
expect(backdrop, "backdrop z-index not found").toBeDefined();
|
||||
expect(sheet, "sheet z-index not found — it would render under the backdrop").toBeDefined();
|
||||
expect(Number(sheet)).toBeGreaterThan(Number(backdrop));
|
||||
});
|
||||
|
||||
it("keeps the sheet anchored to the viewport, not the document", () => {
|
||||
// position:absolute resolved against an unpredictable ancestor.
|
||||
expect(POS_HTML).toMatch(/\.edit-sheet\{position:fixed/);
|
||||
});
|
||||
|
||||
it("still opens and closes both elements from JS", () => {
|
||||
// openEditLine/closeEditLine add .show to the backdrop AND the sheet —
|
||||
// that part was always right, which is why the bug looked like a
|
||||
// missing feature rather than a broken one.
|
||||
expect(POS_APP).toMatch(/editBackdrop'\)\.classList\.add\('show'\)/);
|
||||
expect(POS_APP).toMatch(/editSheet'\)\.classList\.add\('show'\)/);
|
||||
expect(POS_APP).toMatch(/editBackdrop'\)\.classList\.remove\('show'\)/);
|
||||
expect(POS_APP).toMatch(/editSheet'\)\.classList\.remove\('show'\)/);
|
||||
});
|
||||
|
||||
it("keeps them siblings so a tap on the sheet does not close it", () => {
|
||||
// #editBackdrop carries onclick="closeEditLine()". Nesting the sheet
|
||||
// inside it — the "obvious" fix — would make every tap on Save bubble
|
||||
// to the backdrop and dismiss the dialog first. The backdrop must
|
||||
// therefore stay an empty, immediately-closed element.
|
||||
const backdropTag = POS_HTML.match(/<div class="edit-backdrop"[^>]*>/)?.[0];
|
||||
expect(backdropTag, "edit-backdrop not found").toBeDefined();
|
||||
expect(backdropTag).toMatch(/id="editBackdrop"/);
|
||||
const after = POS_HTML.slice(
|
||||
POS_HTML.indexOf(backdropTag!) + backdropTag!.length,
|
||||
);
|
||||
expect(after.startsWith("</div>"), "backdrop must close immediately").toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POS Edit line popup is quantity-only", () => {
|
||||
// Requested: no price editing from the till. The override it used to set
|
||||
// was also never persisted (persistCart saves only {id, qty, p}), so it
|
||||
// was a session-only thing that produced a sale record the server
|
||||
// ignored. Removing it is a deletion, not a feature loss.
|
||||
it("has no price field in the sheet", () => {
|
||||
expect(POS_HTML).not.toMatch(/id="editPrice"/);
|
||||
expect(POS_HTML).not.toMatch(/Unit price/);
|
||||
expect(POS_APP).not.toMatch(/getElementById\('editPrice'\)/);
|
||||
});
|
||||
|
||||
it("keeps the quantity field", () => {
|
||||
expect(POS_HTML).toMatch(/id="editQty"/);
|
||||
expect(POS_APP).toMatch(/getElementById\('editQty'\)\.value = String\(line\.qty\)/);
|
||||
});
|
||||
|
||||
it("removes the override mechanism entirely", () => {
|
||||
// Nothing else could set it — not a cart reload, not addToCart — so
|
||||
// leaving the field in place would be dead code implying the till can
|
||||
// still change a price.
|
||||
expect(POS_APP).not.toMatch(/unitPriceOverride/);
|
||||
expect(POS_HTML).not.toMatch(/price edited/);
|
||||
});
|
||||
|
||||
it("still sends a price, and it is the shelf price", () => {
|
||||
// /checkout requires unit_price; dropping the override must not drop
|
||||
// the price itself.
|
||||
expect(POS_APP).toMatch(/unit_price: lineUnitPrice\(\{p,qty\}\)/);
|
||||
expect(POS_APP).toMatch(/selling_price: lineUnitPrice\(\{p,qty\}\)/);
|
||||
expect(POS_APP).toMatch(
|
||||
/function lineUnitPrice\(line\)\{\s*return Number\(line\.p\.selling_price/,
|
||||
);
|
||||
});
|
||||
|
||||
it("no longer sends the unused `edited` flag", () => {
|
||||
// pos-service.ts never read it — only `unit_price` matters.
|
||||
expect(POS_APP).not.toMatch(/\bedited:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POS service worker cache tracks shell edits", () => {
|
||||
it("is past v30, since pos-app.js and pos.html are served cache-first", () => {
|
||||
const v = POS_HTML; // placeholder to keep the import honest
|
||||
expect(v).toBeDefined();
|
||||
const sw = readFileSync(join(process.cwd(), "public", "pos", "sw.js"), "utf8");
|
||||
const m = sw.match(/const CACHE = "pos-v(\d+)"/);
|
||||
expect(m, "sw.js CACHE constant not found").not.toBeNull();
|
||||
expect(Number(m![1])).toBeGreaterThanOrEqual(33);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue