WFDASH-WEBAPP/public/pos/pos-app.js
Dave e8e65aabb7 feat(pos): checkout via /api/v1/pos/checkout, drop orders/order_items/delivery_notes writes
Sends client_ref (the till's localId) so the offline queue can retry a
sale that already landed without recording it twice. Customer search
moves to /api/v1/pos/customers and now carries the company name for the
delivery-note slip.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-31 01:44:31 +02:00

600 lines
22 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: '',
cart: new Map(), // id -> {p, qty}
customer: null,
paymentMethod: null,
cashGiven: 0,
letterhead: null
};
// ---------------- 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 ----------------
async function loadProducts(){
// Try IDB first
try{
const cached = await dbAll(STORE_PRODUCTS);
if(cached.length){
state.products = cached;
state.productsById = new Map(cached.map(p=>[p.id,p]));
buildCategories();
render();
}
}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,
}));
state.products = all;
state.productsById = new Map(all.map(p=>[p.id,p]));
buildCategories();
render();
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(){
// Letterhead comes from the main app in a later task if needed;
// the receipt already falls back to "WFSALES POS" when absent.
}
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();
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(){
const q = state.search.trim().toLowerCase();
return state.products.filter(p=>{
if(state.activeCat==='__uncat'){
if(p.category) return false;
} else if(state.activeCat!=='all' && p.category!==state.activeCat){
return false;
}
if(!q) return true;
return (p.sku||'').toLowerCase().includes(q)
|| (p.sku_normalised||'').toLowerCase().includes(q)
|| (p.description||'').toLowerCase().includes(q)
|| (p.brand||'').toLowerCase().includes(q)
|| (p.search_text||'').toLowerCase().includes(q);
});
}
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;
}
cat.innerHTML = list.map(p=>{
const price = p.selling_price ?? p.cost_price ?? 0;
const inCart = state.cart.get(p.id)?.qty || 0;
return `<button class="prod" data-id="${p.id}" data-od-id="prod-${p.id}">
<div class="ic">${initials(p.category||p.brand||'?')}</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('');
cat.querySelectorAll('.prod').forEach(b=>{
b.addEventListener('click', ()=>addToCart(b.dataset.id));
});
}
function initials(s){
const parts = s.trim().split(/\s+/).slice(0,2);
return parts.map(p=>p[0]||'').join('').toUpperCase().slice(0,2);
}
// ---------------- 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, total=0;
for(const {p,qty} of state.cart.values()){
count += qty;
total += (p.selling_price ?? p.cost_price ?? 0) * qty;
}
// VAT-inclusive model: selling_price is the shelf price (incl. VAT).
// Subtotal excluding VAT = total / 1.15, VAT = total - sub
const sub = total / 1.15;
const vat = total - sub;
return {count, sub, vat, total};
}
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();
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('grandTotal').textContent = fmt(0);
return;
}
body.innerHTML = Array.from(state.cart.values()).map(({p,qty})=>{
const price = p.selling_price ?? p.cost_price ?? 0;
return `<div class="line" data-od-id="line-${p.id}">
<div class="nm">${escapeHTML(p.description||p.sku)}<small>${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}</small></div>
<div class="qty">
<button onclick="window.__pos.changeQty('${p.id}',-1)" aria-label="Decrease">−</button>
<div class="v">${qty}</div>
<button onclick="window.__pos.changeQty('${p.id}',1)" aria-label="Increase">+</button>
</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);
}
// ---------------- 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();
box.innerHTML = (j.items||[]).map(c=>`<div 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('');
box.querySelectorAll('div').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 ----------------
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();
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,
sub, vat, total,
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: p.selling_price, 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: p.id, qty, unit_price: p.selling_price ?? p.cost_price ?? 0,
sku: p.sku, description: p.description,
})),
payment_method: state.paymentMethod,
cash_given: state.paymentMethod==='cash' ? state.cashGiven : undefined,
customer_id: state.customer ? state.customer.id : undefined,
client_ref: localId,
};
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;
} else {
throw new Error('server returned ' + r.status);
}
}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(_){}
}
}
// 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;
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'){
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 };
// 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;
// ---------------- 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(()=>{});
}
})();
})();