// 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 = '

Offline

No cached catalog. Reconnect and reload.

'; } } } 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 = `` + (totalUncategorized ? `` : '') + state.categories.map(([c,n])=>``).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 = '

No matches

Try a different search or category.

'; 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 = `
${heading}${shown.length} of ${list.length}
` + 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 ? `` : initials(p.category||p.brand||'?'); return ``; }).join('') + (more > 0 ? `` : ''); 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 = '

Empty cart

Add products from the catalog.

'; 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 `
${escapeHTML(p.description||p.name||p.sku)}${sub}
${fmt(price*qty)}
`; }).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; // Blank the figure as well as hiding the row. A discount that has // been cleared must not leave its old amount sitting on screen — // an operator seeing "− R 98.37" under a total that no longer // contains it has no way to tell stale text from a live one. document.getElementById('discountLineAmount').textContent = ''; } } // ---------------- 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=>``).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 = `
No match for “${escapeHTML(q)}”
`; return; } const head = total > items.length ? `
Showing ${items.length} of ${total} — keep typing to narrow
` : `
${total} ${total===1?'match':'matches'}
`; box.innerHTML = head + items.map(c=>`
${escapeHTML(c.name)} ${escapeHTML(c.company || '')}
`).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 = '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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[c]); } function escapeAttr(s){ return escapeHTML(s).replace(/`/g,'`'); } // 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(()=>{}); } })(); })();