// 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
`).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(()=>{});
}
})();
})();