Each stock item now renders a Font Awesome icon in the catalog grid
in place of the generic '?' initials. The icon comes from the item's
category, set on the categories collection's new 'icon' field (e.g.
'fa-solid fa-bolt' for Fasteners). All 10 active categories seeded
with sensible defaults; can be edited per-row in PB admin.
Match logic lives in pos-service.listActiveStockCatalog: it pulls the
categories matcher table, then assigns each item by:
1. category.match_brands (against item text or supplier's supply_tags)
2. category.match_supplier_tags
3. category.match_keywords (longest first so 'hydraulic hose' beats 'hose')
4. the 'Unknown / Other' catch-all is excluded from matching — anything
with no match simply gets the initials fallback
The matcher table is in PB (the existing match_keywords/match_supplier_tags
/match_brands fields on categories), so admins can tune coverage without
a deploy. Two keyword additions to Fasteners (allen cap, set screw) and
Cutting Tools (tap, drill, chuck) lifted the live match rate from 67% to
71% (298/418 items across 8 categories).
FA 6.5.2 Free, solid only, bundled under public/pos/fa/ (256KB total:
100KB CSS + 156KB solid woff2). Both the CSS and the woff2 are in the
service worker APP_SHELL so icons render offline. No CDN dependency.
Co-Authored-By: Claude <noreply@anthropic.com>
651 lines
24 KiB
JavaScript
651 lines
24 KiB
JavaScript
// 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,
|
||
// 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 ----------------
|
||
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();
|
||
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(){
|
||
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);
|
||
});
|
||
}
|
||
|
||
/** Call whenever the filter changes, so a new result set starts at the top. */
|
||
function resetPaging(){
|
||
state.limit = PAGE;
|
||
}
|
||
|
||
function render(){
|
||
const list = filteredProducts();
|
||
const cat = document.getElementById('catalog');
|
||
if(!list.length){
|
||
cat.innerHTML = '<div class="empty"><h3>No matches</h3><p>Try a different search or category.</p></div>';
|
||
return;
|
||
}
|
||
if(!state.limit) state.limit = PAGE;
|
||
const shown = list.slice(0, state.limit);
|
||
const more = list.length - shown.length;
|
||
// Keep the scroll position across re-renders (adding to the cart calls
|
||
// render()), otherwise the till jumps back to the top on every tap.
|
||
const scrollTop = cat.scrollTop;
|
||
|
||
const browsing = !state.search.trim() && state.activeCat === 'all';
|
||
const heading = browsing ? 'Recently added' : 'Results';
|
||
|
||
cat.innerHTML =
|
||
`<div class="meta"><span>${heading}</span><span>${shown.length} of ${list.length}</span></div>` +
|
||
shown.map(p=>{
|
||
const price = p.selling_price ?? p.cost_price ?? 0;
|
||
const inCart = state.cart.get(p.id)?.qty || 0;
|
||
// FA icon when the category row carries one; fall back to initials
|
||
// so an unconfigured category still renders something recognisable.
|
||
const icon = p.category_icon
|
||
? `<i class="ic-fa ${escapeAttr(p.category_icon)}" aria-hidden="true"></i>`
|
||
: initials(p.category||p.brand||'?');
|
||
return `<button class="prod" data-id="${p.id}" data-od-id="prod-${p.id}">
|
||
<div class="ic">${icon}</div>
|
||
<div class="body">
|
||
<div class="name">${escapeHTML(p.description||p.sku||'—')}</div>
|
||
<div class="sub">${escapeHTML(p.sku||'')}${p.brand?' · '+escapeHTML(p.brand):''}${p.unit?' · '+escapeHTML(p.unit):''}${inCart?' · '+inCart+' in cart':''}</div>
|
||
</div>
|
||
<div class="price">${fmt(price)}<small>/${escapeHTML(p.unit||'ea')}</small></div>
|
||
</button>`;
|
||
}).join('') +
|
||
(more > 0
|
||
? `<button class="more" id="showMore">Show ${Math.min(more, PAGE)} more <small>${more} remaining</small></button>`
|
||
: '');
|
||
|
||
cat.querySelectorAll('.prod').forEach(b=>{
|
||
b.addEventListener('click', ()=>addToCart(b.dataset.id));
|
||
});
|
||
cat.scrollTop = scrollTop;
|
||
|
||
const moreBtn = document.getElementById('showMore');
|
||
if(moreBtn){
|
||
const loadMore = ()=>{ state.limit += PAGE; render(); };
|
||
moreBtn.addEventListener('click', loadMore);
|
||
// Auto-load as it scrolls into view; the button stays as the fallback
|
||
// for browsers without IntersectionObserver.
|
||
if('IntersectionObserver' in window){
|
||
const io = new IntersectionObserver(entries=>{
|
||
if(entries.some(e=>e.isIntersecting)){ io.disconnect(); loadMore(); }
|
||
}, {root: cat, rootMargin: '200px'});
|
||
io.observe(moreBtn);
|
||
}
|
||
}
|
||
}
|
||
|
||
function initials(s){
|
||
const parts = s.trim().split(/\s+/).slice(0,2);
|
||
return parts.map(p=>p[0]||'').join('').toUpperCase().slice(0,2);
|
||
}
|
||
|
||
// ---------------- 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;
|
||
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'){
|
||
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 };
|
||
// 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(()=>{});
|
||
}
|
||
})();
|
||
|
||
})();
|