WFDASH-WEBAPP/public/pos/pos-app.js
Dave 8232a87f99 feat(pos): ex-VAT totals, ad-hoc custom lines, fixed discount tiers
Three changes to the POS sale pipeline, all in one commit because
pos-service.ts / types.ts / pos-app.js are each touched by all three and
splitting them would only produce intermediate broken states.

VAT is now charged on top, not backed out

Catalogue prices are ex-VAT: `selling_price` is derived as
`cost_price x markup` from the supplier's ex-VAT cost, with no VAT
uplift anywhere (verified — median selling/cost across the catalogue is
exactly 1.8). The till nevertheless treated them as VAT-inclusive and
recovered the net with `total / 1.15`, so every sale was recorded with
an understated net and an understated VAT line.

An R100 ex-VAT line now rings up as R115. The rate comes from the till's
letterhead, read server-side rather than accepted off the wire, and is
stored per sale so a later letterhead rate change cannot rewrite what
was charged. All 3,623 catalogue prices are untouched.

Ad-hoc "custom" lines

A cashier can now ring up something that was never a catalogue item —
"bolt cutting" at R20 agreed at the counter — via a new
"+ Add a custom item" composer. The line prints on the slip and lands in
pos_sale_items, but carries no stock_item relation and takes no stock
movement: no ledger row, no current_qty decrement, no catalogue entry.

pos_sale_items.stock_item is no longer required at the schema level and
an explicit `is_custom` flag marks the line, rather than inferring it
from a blank relation — so "how much did we make on bolt cutting this
month" is a plain query, and a future stock-less line type cannot be
confused with a corrupt one.

Custom lines are refused on account sales: those raise a delivery note
the customer signs for and feed their statement, so an ad-hoc charge
must not slip onto one. Enforced server-side, not just in the UI.

Discounts are now 20% or 40%, nothing else

The R-off / %-off pills and the free-typed number box are gone. The
server treats DISCOUNT_PERCENT_OPTIONS as an allowlist and refuses
anything outside it rather than coercing it, so a stale till cannot
invent a discount. The discount lands on the ex-VAT subtotal, making
"20% off" a true 20% off both the net and the VAT on it.

Two defects found while doing this

pos_sales was missing six fields that checkoutSale has been writing all
along (gross_subtotal, gross_vat, gross_total, discount_amount,
discount_kind, discount_input). PocketBase accepts unknown fields
without error and silently drops them, so all 192 historical sales lack
them and a discounted sale reprinted as though no discount had been
applied. Added, plus vat_percent.

History also passed gross_total (VAT-inclusive) where the receipt
derives the gross VAT from an ex-VAT base — a reprint double-counted
VAT. Corrected.

Verification: 3860 tests pass (14 new); typecheck, lint and production
build clean; live PocketBase probe confirms a pos_sale_items row with
stock_item="" and is_custom=true round-trips, with the probe removed
afterwards.
2026-10-12 22:54:09 +02:00

1117 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// POS app — catalog, cart, payment, offline queue, slip generation.
// All server access goes through /api/v1/pos/* via window.posFetch
// (see pos-config.js) — this app never talks to PocketBase directly.
(function(){
'use strict';
const CACHE = 'pos-v1';
const LS_CART = 'pos.cart';
const LS_QUEUE = 'pos.queue';
// ---------------- state ----------------
const state = {
products: [],
productsById: new Map(),
categories: [],
activeCat: 'all',
search: '',
// id -> {hay, squish, words}. Built by PosSearch.buildIndex on every
// 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}
customer: null,
paymentMethod: null,
cashGiven: 0,
// Discount: { kind: "amount" | "percent" | "none", input: number }
// The "input" is the raw number the cashier typed (e.g. 10 for
// "10% off" or 50 for "R50 off"). The server resolves it to a
// rand value and applies it to the gross total post-VAT.
discount: { kind: 'none', input: 0 },
letterhead: null,
// How many rows of the current filter are rendered. The full catalog
// is several hundred items; painting them all makes the till sluggish
// and buries the stock staff actually reach for.
limit: 0
};
// Rows rendered per chunk. The catalog arrives newest-first, so the
// default view is effectively "recently added".
const PAGE = 50;
// ---------------- IndexedDB ----------------
const DB_NAME='pos', DB_VER=1, STORE_PRODUCTS='products', STORE_QUEUE='queue';
let dbPromise = null;
function openDB(){
if(dbPromise) return dbPromise;
dbPromise = new Promise((res,rej)=>{
const req = indexedDB.open(DB_NAME, DB_VER);
req.onupgradeneeded = e => {
const db = e.target.result;
if(!db.objectStoreNames.contains(STORE_PRODUCTS)) db.createObjectStore(STORE_PRODUCTS,{keyPath:'id'});
if(!db.objectStoreNames.contains(STORE_QUEUE)) db.createObjectStore(STORE_QUEUE,{keyPath:'localId'});
};
req.onsuccess = e => res(e.target.result);
req.onerror = e => rej(e.target.error);
});
return dbPromise;
}
async function dbPut(store, val){
const db = await openDB();
return new Promise((res,rej)=>{
const tx = db.transaction(store,'readwrite');
tx.objectStore(store).put(val);
tx.oncomplete = ()=>res();
tx.onerror = e=>rej(e.target.error);
});
}
async function dbAll(store){
const db = await openDB();
return new Promise((res,rej)=>{
const tx = db.transaction(store,'readonly');
const req = tx.objectStore(store).getAll();
req.onsuccess = ()=>res(req.result||[]);
req.onerror = e=>rej(e.target.error);
});
}
async function dbDel(store, key){
const db = await openDB();
return new Promise((res,rej)=>{
const tx = db.transaction(store,'readwrite');
tx.objectStore(store).delete(key);
tx.oncomplete = ()=>res();
tx.onerror = e=>rej(e.target.error);
});
}
// ---------------- formatting ----------------
const fmt = n => 'R ' + (Number(n)||0).toLocaleString('en-ZA',{minimumFractionDigits:2,maximumFractionDigits:2});
const fmtN = n => (Number(n)||0).toLocaleString('en-ZA',{minimumFractionDigits:2,maximumFractionDigits:2});
const uid = ()=> 'loc_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2,8);
// ---------------- catalog loading ----------------
/**
* Adopt a catalog array: wire up the id map, rebuild the search index,
* then repaint. Called from BOTH load paths — the IndexedDB cache and the
* network refresh — because a till that booted offline has no index yet
* and would otherwise filter against an empty one.
*/
function adoptCatalog(list){
state.products = list;
state.productsById = new Map(list.map(p=>[p.id,p]));
// See filteredProducts() — an empty index is inert there via fallbackSearch.
state.searchIndex = window.PosSearch ? window.PosSearch.buildIndex(list) : new Map();
buildCategories();
render();
}
async function loadProducts(){
// Try IDB first
try{
const cached = await dbAll(STORE_PRODUCTS);
if(cached.length){
adoptCatalog(cached);
}
}catch{}
// Then network refresh — single call, the API route returns the
// full active catalog (no pagination needed at this catalog size).
try{
const r = await window.posFetch('/catalog');
if(!r.ok) throw new Error('catalog returned ' + r.status);
const j = await r.json();
const all = (j.items || []).map(p => ({
...p,
description: p.description || p.name,
}));
adoptCatalog(all);
for(const p of all) await dbPut(STORE_PRODUCTS, p);
}catch(e){
console.warn('Network load failed', e);
if(!state.products.length){
const el = document.getElementById('empty');
el.innerHTML = '<h3>Offline</h3><p>No cached catalog. Reconnect and reload.</p>';
}
}
}
async function loadLetterhead(){
// Fetch + cache the active letterhead via letterhead-config.js.
// On a fresh till, this auto-picks Westcott Group (the only
// letterhead the POS uses per the 2026-09-04 operator decision).
// The fetched row drives:
// - state.letterhead.* → shown on the on-screen receipt
// - state.letterhead.vat_percent → cart VAT math (replaces the
// hardcoded 15% so a 0% letterhead shows no VAT line)
// - sale.letterhead.* → attached to the saved sale, consumed by
// receipt.html, epos-print.js (buildEposXml), and the
// history-reprint redirect.
try{
const item = await window.PosLetterhead.ensureActive();
state.letterhead = item;
}catch(e){
console.warn('Letterhead load failed; receipt will fall back to "WFSALES POS"', e);
state.letterhead = null;
}
}
function buildCategories(){
const set = new Map();
for(const p of state.products){
if(!p.category) continue;
set.set(p.category, (set.get(p.category)||0)+1);
}
state.categories = Array.from(set.entries()).sort((a,b)=>b[1]-a[1]);
// Label on the trigger
const lbl = document.getElementById('catLabel');
const cnt = document.getElementById('catCount');
lbl.textContent = state.activeCat === 'all' ? 'All categories' : state.activeCat;
cnt.textContent = state.products.length + ' products';
// Menu items
const head = document.getElementById('catMenuItems');
const total = state.products.length;
const totalUncategorized = state.products.filter(p => !p.category).length;
head.innerHTML =
`<button class="cat-item ${state.activeCat==='all'?'active':''}" data-cat="all" role="option">
<span class="nm">All categories</span><span class="ct">${total}</span>
</button>` +
(totalUncategorized ? `<button class="cat-item ${state.activeCat==='__uncat'?'active':''}" data-cat="__uncat" role="option">
<span class="nm">Uncategorised</span><span class="ct">${totalUncategorized}</span>
</button>` : '') +
state.categories.map(([c,n])=>`<button class="cat-item ${state.activeCat===c?'active':''}" data-cat="${escapeAttr(c)}" role="option">
<span class="nm">${escapeHTML(c)}</span><span class="ct">${n}</span>
</button>`).join('');
head.querySelectorAll('.cat-item').forEach(b=>{
b.addEventListener('click', ()=>{
const v = b.dataset.cat;
state.activeCat = v === '__uncat' ? '__uncat' : v;
closeCatMenu();
buildCategories();
resetPaging();
render();
});
});
}
function openCatMenu(){
const menu = document.getElementById('catMenu');
const back = document.getElementById('catBackdrop');
const trigger = document.getElementById('catTrigger');
// Position menu below the trigger
const r = trigger.getBoundingClientRect();
menu.style.top = (r.bottom + 6) + 'px';
menu.hidden = false;
back.hidden = false;
trigger.setAttribute('aria-expanded','true');
}
function closeCatMenu(){
const menu = document.getElementById('catMenu');
const back = document.getElementById('catBackdrop');
const trigger = document.getElementById('catTrigger');
menu.hidden = true;
back.hidden = true;
trigger.setAttribute('aria-expanded','false');
}
function filteredProducts(){
// Category first — it's a plain equality test and prunes most of the
// catalog before any string work happens.
let list = state.products;
if(state.activeCat==='__uncat'){
list = list.filter(p=>!p.category);
} else if(state.activeCat!=='all'){
list = list.filter(p=>p.category===state.activeCat);
}
// …then rank by text. PosSearch owns the tiers; an empty query returns
// the list untouched, which keeps the default "recently added" view.
//
// The fallback is for a half-updated till: pos.html loads pos-search.js
// ahead of this file, but a service worker can still be serving a stale
// mix of the two. A blank catalog is a shop that cannot sell anything, so
// degrade to the old substring filter rather than throwing. Only reachable
// on a skewed cache — both files ship together.
if(!window.PosSearch) return fallbackSearch(list);
return window.PosSearch.search(list, state.searchIndex, state.search);
}
/** The pre-2026-10-06 filter: case-insensitive substring over sku + description. */
function fallbackSearch(list){
const q = state.search.trim().toLowerCase();
if(!q) return list;
return list.filter(p=>
(p.sku||'').toLowerCase().includes(q)
|| (p.description||'').toLowerCase().includes(q));
}
/** Call whenever the filter changes, so a new result set starts at the top. */
function resetPaging(){
state.limit = PAGE;
}
function render(){
const list = filteredProducts();
const cat = document.getElementById('catalog');
if(!list.length){
cat.innerHTML = '<div class="empty"><h3>No matches</h3><p>Try a different search or category.</p></div>';
return;
}
if(!state.limit) state.limit = PAGE;
const shown = list.slice(0, state.limit);
const more = list.length - shown.length;
// Keep the scroll position across re-renders (adding to the cart calls
// render()), otherwise the till jumps back to the top on every tap.
const scrollTop = cat.scrollTop;
const browsing = !state.search.trim() && state.activeCat === 'all';
const heading = browsing ? 'Recently added' : 'Results';
cat.innerHTML =
`<div class="meta"><span>${heading}</span><span>${shown.length} of ${list.length}</span></div>` +
shown.map(p=>{
const price = p.selling_price ?? p.cost_price ?? 0;
const inCart = state.cart.get(p.id)?.qty || 0;
// FA icon when the category row carries one; fall back to initials
// so an unconfigured category still renders something recognisable.
const icon = p.category_icon
? `<i class="ic-fa ${escapeAttr(p.category_icon)}" aria-hidden="true"></i>`
: initials(p.category||p.brand||'?');
return `<button class="prod" data-id="${p.id}" data-od-id="prod-${p.id}">
<div class="ic">${icon}</div>
<div class="body">
<div class="name">${escapeHTML(p.description||p.sku||'—')}</div>
<div class="sub">${escapeHTML(p.sku||'')}${p.brand?' · '+escapeHTML(p.brand):''}${p.unit?' · '+escapeHTML(p.unit):''}${inCart?' · '+inCart+' in cart':''}</div>
</div>
<div class="price">${fmt(price)}<small>/${escapeHTML(p.unit||'ea')}</small></div>
</button>`;
}).join('') +
(more > 0
? `<button class="more" id="showMore">Show ${Math.min(more, PAGE)} more <small>${more} remaining</small></button>`
: '');
cat.querySelectorAll('.prod').forEach(b=>{
b.addEventListener('click', ()=>addToCart(b.dataset.id));
});
cat.scrollTop = scrollTop;
const moreBtn = document.getElementById('showMore');
if(moreBtn){
const loadMore = ()=>{ state.limit += PAGE; render(); };
moreBtn.addEventListener('click', loadMore);
// Auto-load as it scrolls into view; the button stays as the fallback
// for browsers without IntersectionObserver.
if('IntersectionObserver' in window){
const io = new IntersectionObserver(entries=>{
if(entries.some(e=>e.isIntersecting)){ io.disconnect(); loadMore(); }
}, {root: cat, rootMargin: '200px'});
io.observe(moreBtn);
}
}
}
function initials(s){
const parts = s.trim().split(/\s+/).slice(0,2);
return parts.map(p=>p[0]||'').join('').toUpperCase().slice(0,2);
}
// ---------------- custom (ad-hoc) lines ----------------
//
// A line the cashier types by hand — "Bolt cutting" at R20 — that
// belongs on this slip and nowhere else. It is NOT a catalogue
// item: no `stock_items` row, no stock decrement, no ledger entry.
// It carries a synthetic `custom:` id purely so it can live in the
// same cart Map and flow through the same totals, slip and checkout
// path as everything else.
//
// The prefix matters: it's how the checkout payload marks the line
// `is_custom`, and it's how the cart drawer badges it so the
// cashier can see at a glance which lines are not real stock.
const CUSTOM_ID_PREFIX = 'custom:';
function addCustomItem(description, price){
const desc = (description||'').trim();
const amount = Number(price);
if(!desc){ alert('Give the item a name.'); return null; }
if(!Number.isFinite(amount) || amount <= 0){ alert('Enter a price greater than zero.'); return null; }
const p = {
id: CUSTOM_ID_PREFIX + uid(),
name: desc,
description: desc,
sku: '',
unit: '',
// EX-VAT, same basis as the catalogue — VAT is added on top in
// cartTotals(). A service the shop performs has no cost of goods.
selling_price: amount,
cost_price: 0,
is_custom: true,
};
state.cart.set(p.id, {p, qty: 1});
persistCart();
render();
paintCart();
renderCartDrawer();
return p;
}
function isCustomLine(p){ return !!(p && p.is_custom); }
function openCustomItem(){
document.getElementById('customDesc').value = '';
document.getElementById('customPrice').value = '';
document.getElementById('customSheet').classList.add('show');
document.getElementById('customBackdrop').classList.add('show');
setTimeout(()=>document.getElementById('customDesc').focus(), 60);
}
function closeCustomItem(){
document.getElementById('customSheet').classList.remove('show');
document.getElementById('customBackdrop').classList.remove('show');
}
function saveCustomItem(){
const desc = document.getElementById('customDesc').value;
const price = document.getElementById('customPrice').value.replace(/[^0-9.]/g,'');
const p = addCustomItem(desc, Number(price));
if(p) closeCustomItem();
}
// ---------------- cart ----------------
function addToCart(id){
const p = state.productsById.get(id);
if(!p) return;
const cur = state.cart.get(id);
if(cur) cur.qty++;
else state.cart.set(id, {p, qty:1});
persistCart();
render();
paintCart();
// tactile on the bar
const bar = document.getElementById('cartBar');
bar.animate([{transform:'translateY(0) scale(1)'},{transform:'translateY(-2px) scale(1.01)'},{transform:'translateY(0) scale(1)'}],{duration:200});
}
function changeQty(id, delta){
const cur = state.cart.get(id);
if(!cur) return;
cur.qty += delta;
if(cur.qty<=0) state.cart.delete(id);
persistCart();
render();
paintCart();
if(document.getElementById('cartDrawer').classList.contains('show')) renderCartDrawer();
}
function removeFromCart(id){
state.cart.delete(id);
persistCart();
render();
paintCart();
renderCartDrawer();
}
function cartTotals(){
let count=0, gross=0;
for(const {p,qty} of state.cart.values()){
count += qty;
// Catalogue shelf price — the till never edits a line's price.
// Prices are EX-VAT (see pos-service.ts): derived as
// cost × markup from the supplier's ex-VAT cost, with no VAT
// uplift. VAT is added on top below.
const unit = lineUnitPrice({p,qty});
gross += unit * qty;
}
// Discount (off the ex-VAT gross, so it's a true % off both the
// net and the VAT on it). Percentage-only and capped at the
// gross so the total can never go negative.
const d = state.discount || { kind: 'none', input: 0 };
let discountAmount = 0;
if (d.kind === 'percent' && d.input > 0) {
discountAmount = Math.min((gross * d.input) / 100, gross);
}
// The VAT rate comes from the active letterhead — each shop may
// sit at a different rate. Falls back to the SA standard rate
// when no letterhead has loaded yet (a fresh till on a flaky
// network still has to be able to take money).
const vatPct = Number(state.letterhead && state.letterhead.vat_percent);
const pct = Number.isFinite(vatPct) && vatPct >= 0 ? vatPct : 15;
const net = Math.max(0, gross - discountAmount);
const vat = (net * pct) / 100;
const total = net + vat;
return {count, gross, discountAmount, sub: net, vat, total, vatPct: pct};
}
/**
* 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){
return Number(line.p.selling_price ?? line.p.cost_price ?? 0) || 0;
}
function persistCart(){
const arr = Array.from(state.cart.entries()).map(([id,{p,qty}])=>({id, qty, p}));
try{ localStorage.setItem(LS_CART, JSON.stringify(arr)); }catch{}
}
function loadCart(){
try{
const arr = JSON.parse(localStorage.getItem(LS_CART)||'[]');
for(const {id,p,qty} of arr) state.cart.set(id,{p,qty});
}catch{}
}
function paintCart(){
const t = cartTotals();
document.getElementById('cartCount').textContent = t.count;
document.getElementById('cartTotal').textContent = fmt(t.total);
document.getElementById('cartBar').classList.toggle('show', t.count>0);
}
function openDrawer(){
renderCartDrawer();
paintDiscount();
document.getElementById('cartDrawer').classList.add('show');
}
function closeDrawer(){
document.getElementById('cartDrawer').classList.remove('show');
}
function renderCartDrawer(){
const body = document.getElementById('cartLines');
if(state.cart.size===0){
body.innerHTML = '<div class="empty"><h3>Empty cart</h3><p>Add products from the catalog.</p></div>';
document.getElementById('subTotal').textContent = fmt(0);
document.getElementById('vatTotal').textContent = fmt(0);
document.getElementById('grandTotal').textContent = fmt(0);
document.getElementById('discountLine').hidden = true;
return;
}
body.innerHTML = Array.from(state.cart.values()).map(({p,qty})=>{
const price = lineUnitPrice({p,qty});
const custom = isCustomLine(p);
// Custom lines carry no SKU and no unit — badge them so the
// cashier can see at a glance that this isn't stock, and it
// won't show up on a stocktake or reorder list.
const sub = custom
? `${fmt(price)} each · custom item`
: `${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}`;
return `<div class="line${custom?' custom-line':''}" data-od-id="line-${p.id}" data-id="${p.id}">
<div class="nm">${escapeHTML(p.description||p.name||p.sku)}<small>${sub}</small></div>
<div class="qty">
<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">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>`;
}).join('');
const t = cartTotals();
document.getElementById('subTotal').textContent = fmt(t.sub);
document.getElementById('vatTotal').textContent = fmt(t.vat);
document.getElementById('grandTotal').textContent = fmt(t.total);
// The VAT line label follows the letterhead's rate rather than
// hardcoding 15%, so a shop at a different rate doesn't print a
// wrong label over the right number.
const vatLabel = document.getElementById('vatLabel');
if (vatLabel) vatLabel.textContent = t.vatPct > 0 ? `VAT ${t.vatPct}%` : 'VAT';
// Discount line: shown only when a discount is actually
// applied. The server re-derives the resolved rand value at
// commit time, so this number is a preview only.
const dl = document.getElementById('discountLine');
if (t.discountAmount > 0) {
dl.hidden = false;
const d = state.discount || {};
const pctLabel = d.kind === 'percent' && d.input ? ` (${d.input}%)` : '';
document.getElementById('discountLineAmount').textContent =
`− ${fmt(t.discountAmount)}${pctLabel} ex VAT`;
} else {
dl.hidden = true;
}
}
// ---------------- discount ----------------
// Two fixed tiers, 20% and 40%. Deliberately not a free-typed
// field: the whole point is that every discount on every slip is
// visibly one of two known-good choices, so nobody can ring up a
// hand-typed 90% (or a random rand amount) and nobody has to guess
// afterwards whether it was deliberate.
const DISCOUNT_TIERS = [20, 40];
function paintDiscount(){
const d = state.discount || { kind: 'none', input: 0 };
const trig = document.getElementById('discountTrigger');
const lbl = document.getElementById('discountTriggerLabel');
const body = document.getElementById('discountBody');
const hint = document.getElementById('discHint');
const active = d.kind === 'percent' && DISCOUNT_TIERS.includes(Number(d.input))
? Number(d.input) : 0;
for (const tier of DISCOUNT_TIERS){
const btn = document.getElementById('discTier'+tier);
if (btn) btn.classList.toggle('active', tier === active);
}
if (active){
trig.classList.add('has-value');
lbl.textContent = `Discount: ${active}%`;
body.hidden = false;
trig.setAttribute('aria-expanded','true');
} else {
trig.classList.remove('has-value');
lbl.textContent = 'Add discount';
body.hidden = true;
trig.setAttribute('aria-expanded','false');
}
hint.textContent = 'Applied off the ex-VAT total — VAT is charged on the discounted amount.';
}
function toggleDiscount(){
// The trigger only opens/closes the tier buttons — it never clears
// the discount. Clearing is tapping the active tier again, so
// there's no way to lose a discount by mis-tapping the chevron.
const body = document.getElementById('discountBody');
const trig = document.getElementById('discountTrigger');
const open = body.hidden;
// Don't let the operator dismiss the panel out from under an
// applied discount — that would hide the reason for the total.
if (!open && state.discount && state.discount.kind === 'percent') return;
body.hidden = !open;
trig.setAttribute('aria-expanded', String(open));
}
function setDiscountTier(pct){
const tier = Number(pct);
if(!DISCOUNT_TIERS.includes(tier)) return;
// Tapping the active tier clears it — one control, no separate
// "remove" affordance to forget.
if(state.discount && state.discount.kind==='percent' && Number(state.discount.input)===tier){
clearDiscount();
return;
}
state.discount = { kind:'percent', input: tier };
paintDiscount();
renderCartDrawer();
}
function clearDiscount(){
state.discount = { kind:'none', input:0 };
paintDiscount();
renderCartDrawer();
}
// Discounts are two fixed buttons now, so there's no free-text
// input to live-listen to.
// ---------------- 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 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
// the operator sees something happen during the press.
const LONG_PRESS_MS = 600;
let longPressTimer = null;
let longPressTarget = null;
function clearLongPress(){
if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
if (longPressTarget) {
longPressTarget.classList.remove('line-holding');
longPressTarget = null;
}
}
function startLongPress(el){
clearLongPress();
longPressTarget = el;
el.classList.add('line-holding');
// Best-effort haptic on the long-press fire. iOS Safari only
// honors navigator.vibrate inside a user-gesture handler; this
// is called from a pointerdown so the chain is intact. Android
// Chrome respects it always.
longPressTimer = setTimeout(()=>{
const id = el.dataset.id;
try { navigator.vibrate?.(12); } catch(_) {}
if (id) openEditLine(id);
clearLongPress();
}, LONG_PRESS_MS);
}
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);
document.addEventListener('pointercancel', clearLongPress);
document.addEventListener('pointerleave', e=>{
if (longPressTarget && e.target === longPressTarget) clearLongPress();
});
let editingLineId = null;
function openEditLine(id){
const line = state.cart.get(id);
if (!line) return;
editingLineId = id;
document.getElementById('editSku').textContent = line.p.sku || line.p.description || '';
// 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('editBackdrop').classList.add('show');
document.getElementById('editSheet').classList.add('show');
setTimeout(()=>document.getElementById('editQty').focus(), 60);
}
function closeEditLine(){
editingLineId = null;
document.getElementById('editBackdrop').classList.remove('show');
document.getElementById('editSheet').classList.remove('show');
}
function editSave(){
if (!editingLineId) return;
const line = state.cart.get(editingLineId);
if (!line) { closeEditLine(); return; }
const qty = Number((document.getElementById('editQty').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
// 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;
}
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 = 'Type a quantity like 800, then tap Save.'; hint.style.color = ''; }
persistCart();
render();
renderCartDrawer();
closeEditLine();
}
function editRemove(){
if (!editingLineId) return;
const id = editingLineId;
closeEditLine();
removeFromCart(id);
}
// ---------------- payment ----------------
function openPayment(){
if(state.cart.size===0) return;
const t = cartTotals();
state.paymentMethod = null;
state.cashGiven = 0;
state.customer = null;
document.getElementById('payTotal').textContent = fmt(t.total);
document.getElementById('paySub').textContent = fmt(t.sub);
document.getElementById('payVat').textContent = fmt(t.vat);
document.getElementById('dueIn').value = fmt(t.total);
document.getElementById('givenIn').value = '';
document.getElementById('changeOut').classList.remove('show','short');
document.getElementById('cashBlock').classList.remove('show');
document.getElementById('custBlock').classList.remove('show');
document.getElementById('custPicked').classList.remove('show');
document.getElementById('custResults').innerHTML = '';
document.getElementById('custQ').value = '';
document.querySelectorAll('.pay-tile').forEach(t=>t.classList.remove('selected'));
document.getElementById('confirmBtn').disabled = true;
buildQuickAmounts(t.total);
document.getElementById('payDrawer').classList.add('show');
}
function closePayment(){
document.getElementById('payDrawer').classList.remove('show');
}
function buildQuickAmounts(total){
// Rounded suggestions above the total
const base = Math.ceil(total/10)*10;
const suggestions = [];
if(base>total) suggestions.push(base);
for(const m of [50,100,200,500,1000]){
const v = Math.ceil(total/m)*m;
if(v>total && !suggestions.includes(v)) suggestions.push(v);
}
suggestions.sort((a,b)=>a-b);
document.getElementById('quick').innerHTML = suggestions.slice(0,5).map(v=>`<button data-v="${v}">${fmt(v)}</button>`).join('');
document.getElementById('quick').querySelectorAll('button').forEach(b=>{
b.addEventListener('click',()=>{
document.getElementById('givenIn').value = fmtN(b.dataset.v);
state.cashGiven = Number(b.dataset.v);
paintChange();
});
});
}
function pickMethod(m){
state.paymentMethod = m;
document.querySelectorAll('.pay-tile').forEach(t=>t.classList.toggle('selected', t.dataset.meth===m));
document.getElementById('cashBlock').classList.toggle('show', m==='cash');
document.getElementById('custBlock').classList.toggle('show', m==='account');
if(m!=='cash') document.getElementById('changeOut').classList.remove('show');
if(m!=='account') document.getElementById('custPicked').classList.remove('show');
validateConfirm();
if(m==='cash'){
setTimeout(()=>document.getElementById('givenIn').focus(), 250);
} else if(m==='account'){
setTimeout(()=>document.getElementById('custQ').focus(), 250);
}
}
function paintChange(){
const total = cartTotals().total;
const given = state.cashGiven || 0;
const change = given - total;
const out = document.getElementById('changeOut');
if(given<=0){ out.classList.remove('show','short'); return; }
out.classList.add('show');
out.classList.toggle('short', change<-0.005);
document.getElementById('changeAmt').textContent = (change>=0?'+ ':'− ') + fmt(Math.abs(change));
validateConfirm();
}
function validateConfirm(){
const m = state.paymentMethod;
const btn = document.getElementById('confirmBtn');
if(!m){ btn.disabled = true; return; }
if(m==='cash'){
btn.disabled = !(state.cashGiven >= cartTotals().total - 0.005);
} else if(m==='account'){
btn.disabled = !state.customer;
} else {
btn.disabled = false;
}
}
// ---------------- customer search ----------------
let custDebounce;
document.addEventListener('input', e=>{
if(e.target.id!=='custQ') return;
clearTimeout(custDebounce);
const q = e.target.value.trim();
custDebounce = setTimeout(()=>searchCustomers(q), 200);
});
document.addEventListener('input', e=>{
if(e.target.id!=='givenIn') return;
const raw = e.target.value.replace(/[^0-9.]/g,'');
e.target.value = raw;
state.cashGiven = Number(raw)||0;
paintChange();
});
async function searchCustomers(q){
const box = document.getElementById('custResults');
if(!q){ box.innerHTML=''; return; }
try{
const r = await window.posFetch(`/customers?q=${encodeURIComponent(q)}`);
const j = await r.json();
const items = j.items || [];
// `total` is every match before the server's display cap. Without it a
// 52-person company would render 8 rows and read as the whole team.
const total = Number.isFinite(j.total) ? j.total : items.length;
if(!items.length){
box.innerHTML = `<div class="cust-none">No match for “${escapeHTML(q)}”</div>`;
return;
}
const head = total > items.length
? `<div class="cust-count">Showing ${items.length} of ${total} — keep typing to narrow</div>`
: `<div class="cust-count">${total} ${total===1?'match':'matches'}</div>`;
box.innerHTML = head + items.map(c=>`<div class="cust-row" data-id="${c.id}" data-name="${escapeAttr(c.name)}" data-company="${escapeAttr(c.company||'')}">
<span>${escapeHTML(c.name)}</span>
<small>${escapeHTML(c.company || '')}</small>
</div>`).join('');
// Scoped to .cust-row on purpose: binding every div would make the
// count header above selectable, picking a customer with an empty id.
box.querySelectorAll('.cust-row').forEach(d=>{
d.addEventListener('click',()=>{
state.customer = {id:d.dataset.id, name:d.dataset.name, company:d.dataset.company};
const pk = document.getElementById('custPicked');
pk.classList.add('show');
pk.querySelector('b').textContent = d.dataset.name;
document.getElementById('custQ').value = '';
box.innerHTML = '';
validateConfirm();
});
});
}catch{}
}
function clearCust(){
state.customer = null;
document.getElementById('custPicked').classList.remove('show');
validateConfirm();
}
// ---------------- save sale ----------------
// Minimal, self-contained error toast. `toast.error(...)` below used
// to reference a `toast` global that was never defined anywhere in
// this app (no library included, no local implementation) — every
// checkout rejection (e.g. insufficient stock) threw a
// ReferenceError instead of showing the operator anything, so the
// cart just sat there with no feedback.
function showToast(message){
let el = document.getElementById('posToast');
if(!el){
el = document.createElement('div');
el.id = 'posToast';
el.style.cssText = 'position:fixed;left:16px;right:16px;bottom:calc(96px + env(safe-area-inset-bottom));max-width:448px;margin:0 auto;background:#c92a2a;color:#fff;padding:14px 16px;border-radius:12px;font-size:14px;font-weight:600;line-height:1.4;box-shadow:0 8px 24px rgba(0,0,0,.25);z-index:999;opacity:0;transition:opacity .15s ease';
document.body.appendChild(el);
}
el.textContent = message;
el.style.opacity = '1';
clearTimeout(el._hideTimer);
el._hideTimer = setTimeout(()=>{ el.style.opacity = '0'; }, 5000);
}
async function confirmSale(){
const btn = document.getElementById('confirmBtn');
if(btn.disabled) return;
btn.disabled = true; btn.innerHTML = 'Saving…';
const totals = cartTotals();
const sub = totals.sub, vat = totals.vat, total = totals.total;
// localId doubles as the server-side idempotency key (client_ref), so
// a queued sale that actually landed can be retried without being
// recorded — and stock decremented — a second time.
const localId = uid();
// Stash the resolved discount + the gross (pre-discount) totals
// so the receipt and the offline-queue replay both render the
// same numbers the cashier saw on the till. The server will
// re-derive its own gross_* / discount_* when the sale lands
// (it can't trust what the till says), but the till-rendered
// receipt uses these.
const d = state.discount || { kind: 'none', input: 0 };
const sale = {
localId,
createdAt: new Date().toISOString(),
payment: state.paymentMethod,
cashGiven: state.cashGiven || null,
change: state.paymentMethod==='cash' ? Math.max(0, state.cashGiven - total) : 0,
gross: totals.gross,
discount: { kind: d.kind, input: d.input, amount: totals.discountAmount },
sub, vat, total,
// Which VAT rate produced those numbers — the receipt labels
// its VAT line from this rather than hardcoding 15%.
vatPct: totals.vatPct,
// Attach the active letterhead so the on-screen receipt AND
// the printed slip can show the right shop name, address,
// phone, VAT number, footer, and logo. receipt.html's
// renderReceipt + epos-print.js's buildEposXml both read
// sale.letterhead.* (and fall back to "WFSALES POS" / blank
// 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})=>({
sku: p.sku, description: p.description, unit: p.unit,
cost_price: p.cost_price,
selling_price: lineUnitPrice({p,qty}),
qty, product_id: p.id,
})),
customer: state.customer || null,
synced: false,
};
const checkoutBody = {
items: Array.from(state.cart.values()).map(({p,qty})=>({
stock_item_id: isCustomLine(p) ? undefined : p.id,
is_custom: isCustomLine(p),
qty, unit_price: lineUnitPrice({p,qty}),
sku: p.sku, description: isCustomLine(p) ? p.description : (p.description || p.sku),
})),
payment_method: state.paymentMethod,
cash_given: state.paymentMethod==='cash' ? state.cashGiven : undefined,
customer_id: state.customer ? state.customer.id : undefined,
client_ref: localId,
// The server reads the VAT rate off this letterhead rather than
// trusting a rate off the wire.
letterhead_id: state.letterhead ? state.letterhead.id : undefined,
discount_kind: state.discount && state.discount.kind === 'percent' ? 'percent' : undefined,
discount_input: state.discount && state.discount.kind === 'percent' ? 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),
});
if(r.ok){
const j = await r.json();
sale.synced = true;
sale.serverRef = j.sale && j.sale.id;
// The server raises the delivery note for an `account` sale and
// hands it back here. Carried onto the local sale so
// receipt.html can print the A4 original instead of the old
// 80mm thermal slip. Absent for cash / card / EFT, and absent
// for an account sale whose DN creation failed — in which case
// the operator is told to raise one by hand.
if (j.deliveryNote) sale.deliveryNote = j.deliveryNote;
if (j.deliveryNoteError) sale.deliveryNoteError = j.deliveryNoteError;
} else {
// The route returns JSON on every non-2xx path, so this should
// always parse. Overselling is allowed on purpose (no stock
// check blocks the sale) — every rejection here is a real
// validation/server failure, not a stock shortage.
let body = null;
try { body = await r.json(); } catch (_) { /* not json */ }
const msg = body && body.message ? body.message : ('server returned ' + r.status);
serverError = { message: msg };
throw new Error(msg);
}
}catch(e){
console.warn('Sale queued for sync', e);
try{
await dbPut(STORE_QUEUE, {...sale, checkoutBody});
}catch(qErr){
console.error('IndexedDB queue failed too — saving to localStorage as fallback', qErr);
try{
const arr = JSON.parse(localStorage.getItem('wfsales.queue') || '[]');
arr.push({...sale, checkoutBody});
localStorage.setItem('wfsales.queue', JSON.stringify(arr));
}catch(_){}
}
}
// 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) {
showToast('Sale rejected: ' + serverError.message);
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();
closePayment();
// Use a small delay so the drawer close animation starts before navigation
setTimeout(()=>{ location.href = 'receipt.html?id=' + encodeURIComponent(sale.localId); }, 60);
}
// ---------------- network pill + boot ----------------
function paintNet(){
const p = document.getElementById('netpill');
p.textContent = navigator.onLine ? 'Online' : 'Offline';
p.classList.toggle('off', !navigator.onLine);
}
window.addEventListener('online', ()=>{ paintNet(); flushQueue(); });
window.addEventListener('offline', paintNet);
async function flushQueue(){
if(!navigator.onLine) return;
const q = await dbAll(STORE_QUEUE);
for(const s of q){
try{
const r = await window.posFetch('/checkout', {
method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(s.checkoutBody),
});
if(r.ok){
// Safe even if this sale already landed before — the server
// matches on client_ref and replays the original instead of
// decrementing stock again.
await dbDel(STORE_QUEUE, s.localId);
} else {
break; // stop on first failure, retry next time we come online
}
}catch(e){ console.warn('queue item failed', e); break; }
}
}
// ---------------- search input + topbar scroll ----------------
document.addEventListener('input', e=>{
if(e.target.id!=='q') return;
state.search = e.target.value;
resetPaging();
render();
});
const topbar = document.getElementById('topbar');
const cat = document.getElementById('catalog');
cat.addEventListener('scroll', ()=>topbar.classList.toggle('scrolled', cat.scrollTop>4));
// ---------------- category dropdown wiring ----------------
document.getElementById('catTrigger').addEventListener('click', e=>{
e.stopPropagation();
const t = e.currentTarget;
if(t.getAttribute('aria-expanded')==='true') closeCatMenu();
else openCatMenu();
});
document.getElementById('catBackdrop').addEventListener('click', closeCatMenu);
document.addEventListener('keydown', e=>{
if(e.key==='Escape'){
// Edit modal takes priority over the category menu.
if (document.getElementById('editSheet').classList.contains('show')) {
closeEditLine();
return;
}
const t = document.getElementById('catTrigger');
if(t && t.getAttribute('aria-expanded')==='true') closeCatMenu();
}
});
// ---------------- utils ----------------
function escapeHTML(s){ return String(s).replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]); }
function escapeAttr(s){ return escapeHTML(s).replace(/`/g,'&#96;'); }
// expose for inline handlers
window.__pos = { changeQty, removeFromCart, openDrawer, closeDrawer, openPayment, closePayment, pickMethod, confirmSale, openEditLine, addCustomItem, openCustomItem, closeCustomItem, saveCustomItem, setDiscountTier, clearDiscount, toggleDiscount };
// Also expose as bare globals so inline onclick="" in pos.html works
window.openDrawer = openDrawer;
window.closeDrawer = closeDrawer;
window.openPayment = openPayment;
window.closePayment = closePayment;
window.pickMethod = pickMethod;
window.confirmSale = confirmSale;
window.changeQty = changeQty;
window.removeFromCart = removeFromCart;
window.openEditLine = openEditLine;
// The edit-line modal could be opened but not dismissed or committed:
// its Save / Cancel / Remove buttons (and the customer "Remove" chip)
// call these from inline onclick, so they need to be global too.
window.closeEditLine = closeEditLine;
window.editSave = editSave;
window.editRemove = editRemove;
window.clearCust = clearCust;
// Discount tiers + the ad-hoc line composer are driven from inline
// onclick in pos.html, so they have to be globals too.
window.setDiscountTier = setDiscountTier;
window.toggleDiscount = toggleDiscount;
window.clearDiscount = clearDiscount;
window.addCustomItem = addCustomItem;
window.openCustomItem = openCustomItem;
window.closeCustomItem = closeCustomItem;
window.saveCustomItem = saveCustomItem;
// ---------------- boot ----------------
(async function boot(){
paintNet();
loadCart();
paintCart();
await Promise.all([loadLetterhead(), loadProducts()]);
flushQueue();
// register SW
if('serviceWorker' in navigator){
navigator.serviceWorker.register('sw.js').catch(()=>{});
}
})();
})();