diff --git a/public/pos/cashup.html b/public/pos/cashup.html new file mode 100644 index 0000000..17fc731 --- /dev/null +++ b/public/pos/cashup.html @@ -0,0 +1,388 @@ + + + + + + +Cash-up + + + + + + +
+ +

Cash-up

+ … + +
+ +
+ + +
+ +
+
Sales
—
—
+
Takings
R 0
All methods
+
+ +
+ +
+

Cash counted in drawer

+
+
+ +
+
Cash salesR 0.00
+
Cash countedR 0.00
+
VarianceR 0.00
+
TakingsR 0.00
+
+ +
+ + +
+ + + + + + + diff --git a/public/pos/history.html b/public/pos/history.html new file mode 100644 index 0000000..a6cc3ab --- /dev/null +++ b/public/pos/history.html @@ -0,0 +1,257 @@ + + + + + + +Sales history + + + + + + +
+ +

Sales history

+ … + +
+ +
+
+
Sales
—
+
Total
R 0
+
+
+

Loading…

+
+ + + + + + + diff --git a/public/pos/index.html b/public/pos/index.html new file mode 100644 index 0000000..24fe66a --- /dev/null +++ b/public/pos/index.html @@ -0,0 +1,172 @@ + + + + + + +WFSales — Register + + + + + + +
+ +
+ WFSALES POS + Point of sale +
+ Online + +
+ +

Register

+

Start a sale, reprint a slip, or close out the till. Works offline — your sales sync when you reconnect.

+ +
+ + + + New sale + Browse catalog, build cart, take payment + + Open › + + + + + Sales history + Reprint slip, view today's sales + + Open › + + + + + Cash-up + End-of-day takings report + + Open › + +
+ +
Till
+
+ + Connecting to register… +
+ +

— products · cache pending

+ + + + + + diff --git a/public/pos/manifest.json b/public/pos/manifest.json new file mode 100644 index 0000000..fdf4fa8 --- /dev/null +++ b/public/pos/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "WFSALES POS", + "short_name": "WFSALES POS", + "description": "WFSALES POS — point of sale", + "start_url": "index.html", + "scope": ".", + "display": "standalone", + "orientation": "portrait", + "background_color": "#ffffff", + "theme_color": "#ff385c", + "icons": [ + { "src": "data:image/svg+xml;utf8,W", "sizes": "192x192", "type": "image/svg+xml" }, + { "src": "data:image/svg+xml;utf8,W", "sizes": "512x512", "type": "image/svg+xml" } + ] +} diff --git a/public/pos/pos-app.js b/public/pos/pos-app.js new file mode 100644 index 0000000..3690f31 --- /dev/null +++ b/public/pos/pos-app.js @@ -0,0 +1,633 @@ +// POS app — catalog, cart, payment, offline queue, slip generation. +// All routes: /api/collections/{name}/records +(function(){ +'use strict'; + +const cfg = window.PB_CONFIG; +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 + try{ + await window.pbAuth(); + const all = []; + let page = 1; + while(true){ + const r = await window.pbFetch(`/api/collections/${cfg.collections.products}/records?perPage=200&page=${page}&sort=description`); + const j = await r.json(); + all.push(...j.items); + if(j.page >= j.totalPages) break; + page++; + if(page > 30) break; // hard cap + } + state.products = all; + state.productsById = new Map(all.map(p=>[p.id,p])); + buildCategories(); + render(); + // Persist + 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(){ + try{ + const r = await window.pbFetch(`/api/collections/${cfg.collections.letterhead}/records?perPage=1&filter=is_active=true`); + const j = await r.json(); + if(j.items && j.items[0]) state.letterhead = j.items[0]; + }catch{} +} + +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(); + 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 = '

No matches

Try a different search or category.

'; + 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 ``; + }).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 = '

Empty cart

Add products from the catalog.

'; + 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 `
+
${escapeHTML(p.description||p.sku)}${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}
+
+ +
${qty}
+ +
+
${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); +} + +// ---------------- 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 f = `(customer~'${q}' || email~'${q}' || number~'${q}')`; + const r = await window.pbFetch(`/api/collections/${cfg.collections.customers}/records?perPage=8&filter=${encodeURIComponent(f)}`); + const j = await r.json(); + box.innerHTML = j.items.map(c=>`
+ ${escapeHTML(c.customer||c.email||'—')} + ${escapeHTML(c.number||c.email||'')} +
`).join(''); + box.querySelectorAll('div').forEach(d=>{ + d.addEventListener('click',()=>{ + state.customer = {id:d.dataset.id, name:d.dataset.name}; + 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 items = Array.from(state.cart.values()).map(({p,qty})=>({ + sku: p.sku, description: p.description, unit: p.unit, brand: p.brand, + category: p.category, subcategory: p.subcategory, supplier: p.supplier, + cost_price: p.cost_price, selling_price: p.selling_price, qty, product_id: p.id + })); + const totals = cartTotals(); + const sub = totals.sub, vat = totals.vat, total = totals.total; + const sale = { + localId: uid(), + 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, + customer: state.customer || null, + letterhead: state.letterhead ? {name: state.letterhead.display_name, address: state.letterhead.address, phone: state.letterhead.phone, email: state.letterhead.email, vat_number: state.letterhead.vat_number, registration_number: state.letterhead.registration_number, footer_text: state.letterhead.footer_text, logo_url: state.letterhead.logo} : null, + synced: false + }; + + // Try to write to PocketBase now; if offline or fails, queue. + let serverRef = null; + let serverError = null; + try{ + const orderBody = { + order_number: 'S-' + Date.now().toString(36).toUpperCase(), + notes: 'POS sale · ' + sale.payment + (sale.customer?` · ${sale.customer.name}`:''), + invoiced: sale.payment==='account' ? false : true, + }; + if(sale.customer) orderBody.customer = sale.customer.id; + const r = await window.pbFetch('/api/collections/'+cfg.collections.orders+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(orderBody)}); + if(r.ok){ + const j = await r.json(); + serverRef = j.id; + // Line items + for(const it of sale.items){ + try{ + await window.pbFetch('/api/collections/'+cfg.collections.orderItems+'/records',{ + method:'POST',headers:{'Content-Type':'application/json'}, + body: JSON.stringify({order: j.id, description: it.description, quantity: it.qty, cost_price: it.cost_price, selling_price: it.selling_price, item_status:'invoiced'}) + }); + }catch(liErr){ console.warn('line item write failed', liErr); } + } + // If account sale, create delivery note + if(sale.payment==='account'){ + try{ + const dn = await window.pbFetch('/api/collections/'+cfg.collections.deliveryNotes+'/records',{ + method:'POST',headers:{'Content-Type':'application/json'}, + body: JSON.stringify({delivery_note_number: 'DN-'+Date.now().toString(36).toUpperCase(), order: j.id, customer: sale.customer?.id, status:'pending', notes:'Account sale — deliver to customer.'}) + }); + if(dn.ok){ + const dnJ = await dn.json(); + for(const it of sale.items){ + try{ + await window.pbFetch('/api/collections/'+cfg.collections.deliveryNoteItems+'/records',{ + method:'POST',headers:{'Content-Type':'application/json'}, + body: JSON.stringify({delivery_note: dnJ.id, description: it.description, quantity: it.qty}) + }); + }catch(_){} + } + } + }catch(dnErr){ console.warn('delivery note write failed', dnErr); } + } + sale.synced = true; + sale.serverRef = serverRef; + } else { + serverError = 'server returned ' + r.status; + throw new Error(serverError); + } + }catch(e){ + console.warn('Sale queued for sync', e); + // Queue locally — never let this throw escape + try{ + await dbPut(STORE_QUEUE, sale); + }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); + 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.pbFetch('/api/collections/'+cfg.collections.orders+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({order_number:'S-'+Date.now().toString(36).toUpperCase(), notes:'POS sale (offline) · '+s.payment, invoiced: s.payment!=='account'})}); + if(r.ok){ + const j = await r.json(); + for(const it of s.items){ + await window.pbFetch('/api/collections/'+cfg.collections.orderItems+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({order:j.id, description:it.description, quantity:it.qty, cost_price:it.cost_price, selling_price:it.selling_price, item_status:'invoiced'})}); + } + if(s.payment==='account' && s.customer){ + const dn = await window.pbFetch('/api/collections/'+cfg.collections.deliveryNotes+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delivery_note_number:'DN-'+Date.now().toString(36).toUpperCase(), order:j.id, customer:s.customer.id, status:'pending'})}); + if(dn.ok){ const dnJ = await dn.json(); for(const it of s.items){ await window.pbFetch('/api/collections/'+cfg.collections.deliveryNoteItems+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delivery_note:dnJ.id, description:it.description, quantity:it.qty})}); } } + } + await dbDel(STORE_QUEUE, s.localId); + } + }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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''})[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(()=>{}); + } +})(); + +})(); diff --git a/public/pos/pos.html b/public/pos/pos.html new file mode 100644 index 0000000..7e36d0a --- /dev/null +++ b/public/pos/pos.html @@ -0,0 +1,393 @@ + + + + + + +New sale + + + + + + +
+ +

New sale

+ … + +
+ + + +
+ + — products +
+ + + + + +
+
+

Loading catalog…

+

Fetching products from the register.

+
+
+ +
+ +
0items
+
R 0.00
+ +
+ + +
+
+
+

Cart

+ +
+
+
+
SubtotalR 0.00
+
VAT 15%R 0.00
+
Total (incl. VAT)R 0.00
+ +
+
+
+ + +
+
+
+

Take payment

+ +
+
+
SubtotalR 0.00
+
VAT 15%R 0.00
+
Total dueR 0.00
+
+ + + + +
+ +
+
+
+
+
+
+
+
Change to give
+
R 0.00
+
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+ + + + + + + diff --git a/public/pos/receipt.html b/public/pos/receipt.html new file mode 100644 index 0000000..52730e0 --- /dev/null +++ b/public/pos/receipt.html @@ -0,0 +1,600 @@ + + + + + + +Receipt + + + + + + +
+ +

Receipt

+ + … +
+ +
+ +
+
+
+
Sale complete
+
Saved to register
+
+ +
+ +
+ + + +
+ + Ready to print. +
+ +
+ + + +
+ +
+ + + + + + + diff --git a/public/pos/sw.js b/public/pos/sw.js new file mode 100644 index 0000000..c84cd26 --- /dev/null +++ b/public/pos/sw.js @@ -0,0 +1,76 @@ +// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static. +const CACHE = "pos-v12"; +const APP_SHELL = [ + './', + './index.html', + './pos.html', + './receipt.html', + './history.html', + './cashup.html', + './pb-config.js', + './pos-app.js', + './theme.js', + './manifest.json' +]; + +// Files that must always be fresh (network-first, fallback to cache) +const NETWORK_FIRST = new Set([ + 'theme.js' +]); + +self.addEventListener('install', e => { + e.waitUntil(caches.open(CACHE).then(c => c.addAll(APP_SHELL)).then(()=>self.skipWaiting())); +}); + +self.addEventListener('activate', e => { + e.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))).then(()=>self.clients.claim())); +}); + +self.addEventListener('fetch', e => { + const url = new URL(e.request.url); + // Never cache PocketBase API + if(url.hostname.includes('pocketbase') || url.hostname.includes('sslip.io')) return; + // HTML navigations: always network-first so the user gets the latest page + if(e.request.mode === 'navigate'){ + e.respondWith( + fetch(e.request).then(res => { + if(res && res.ok) { + const clone = res.clone(); + caches.open(CACHE).then(c => c.put(e.request, clone)); + } + return res; + }).catch(() => caches.match(e.request).then(r => r || new Response('

Offline

', {status:503, headers:{'Content-Type':'text/html'}}))) + ); + return; + } + if(e.request.method !== 'GET') return; + + // theme.js (and other network-first assets) always go to network + const fname = url.pathname.split('/').pop(); + if(NETWORK_FIRST.has(fname)){ + e.respondWith( + fetch(e.request).then(res => { + if(res && res.ok){ + const clone = res.clone(); + caches.open(CACHE).then(c => c.put(e.request, clone)); + } + return res; + }).catch(() => caches.match(e.request).then(r => r || new Response('', {status:504}))) + ); + return; + } + + // Default: cache-first for static assets + e.respondWith( + caches.match(e.request).then(cached => { + const fetchP = fetch(e.request).then(res => { + if(res && res.ok && (e.request.url.startsWith('http'))) { + const clone = res.clone(); + caches.open(CACHE).then(c => c.put(e.request, clone)); + } + return res; + }).catch(() => cached); + return cached || fetchP; + }) + ); +}); diff --git a/public/pos/theme.js b/public/pos/theme.js new file mode 100644 index 0000000..78ebd40 --- /dev/null +++ b/public/pos/theme.js @@ -0,0 +1,91 @@ +// Shared dark-mode toggle. +// Usage: at the end of . +// Set . Default = light. +// +// Self-contained: the toggle works even if this file fails to load, +// because the button's inline onclick uses WFTheme.cycle() which is +// defined here AND duplicated as a fallback. We also listen for +// storage events so the theme stays in sync across tabs. +(function(){ + const KEY = 'wfsales.theme'; + const html = document.documentElement; + function systemPref(){ + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + function apply(){ + const saved = localStorage.getItem(KEY) || 'light'; + const v = saved === 'auto' ? systemPref() : saved; + html.setAttribute('data-theme', v); + document.querySelectorAll('[data-theme-toggle]').forEach(btn => { + btn.setAttribute('data-current', saved); + btn.setAttribute('aria-pressed', saved === 'dark' ? 'true' : 'false'); + }); + } + // API + function setTheme(v){ + if(!['light','dark','auto'].includes(v)) v = 'light'; + try{ localStorage.setItem(KEY, v); }catch(_){} + apply(); + } + function currentTheme(){ + try{ return localStorage.getItem(KEY) || 'light'; }catch(_){ return 'light'; } + } + function cycleTheme(){ + const cur = currentTheme(); + setTheme(cur === 'light' ? 'dark' : cur === 'dark' ? 'auto' : 'light'); + } + // Expose as early as possible so the button's inline onclick (which + // guards on window.WFTheme) never sees undefined. + window.WFTheme = { set: setTheme, current: currentTheme, cycle: cycleTheme }; + // Also: install a global fallback so clicking the button works even + // if this script never executes (network blocked, SW serving a stale + // version, etc.). The button's inline onclick calls + // (WFTheme && WFTheme.cycle || fallback)() — we register a global + // __wfCycle function so the click always works. + window.__wfCycle = cycleTheme; + + function onThemeTap(e){ + let el = e.target; + while(el && el !== document){ + if(el.matches && el.matches('[data-theme-toggle]')){ + e.preventDefault(); + e.stopPropagation(); + cycleTheme(); + return; + } + el = el.parentNode; + } + } + // Capture phase + both click and pointerup so the handler runs even + // if a deeper listener calls stopPropagation(), and so the toggle + // works on touch devices where the SVG is the actual target. + document.addEventListener('click', onThemeTap, true); + document.addEventListener('pointerup', onThemeTap, true); + // Also bind directly to any current or future theme buttons + function bindAll(){ + document.querySelectorAll('[data-theme-toggle]').forEach(btn=>{ + if(btn.__wfBound) return; + btn.__wfBound = true; + btn.addEventListener('click', e=>{ + e.preventDefault(); e.stopPropagation(); + cycleTheme(); + }); + }); + } + bindAll(); + // Watch for new buttons (e.g. drawer-injected HTML) and bind them + try{ new MutationObserver(bindAll).observe(document.body, {childList:true, subtree:true}); }catch(_){} + // React to system change when in auto mode + if(window.matchMedia){ + try{ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ()=>{ + if((currentTheme()) === 'auto') apply(); + }); }catch(_){} + } + // Apply the saved theme on load + apply(); + // Cross-tab sync: if the user toggles in one tab, other tabs update + window.addEventListener('storage', e=>{ + if(e.key === KEY) apply(); + }); +})(); +