+
+
+
+
+
+
+
+
+
+
+ Sale complete
+ Saved to register
+
+
+ Ready to print.
+
+
+
+
+
+
+
+
+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 @@ + + +
+ + + +Start a sale, reprint a slip, or close out the till. Works offline — your sales sync when you reconnect.
+ +— 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,", "sizes": "192x192", "type": "image/svg+xml" }, + { "src": "data:image/svg+xml;utf8,", "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 = '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 = 'Try a different search or category.
Add products from the catalog.
Fetching products from the register.
+