1021 lines
40 KiB
JavaScript
1021 lines
40 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, unitPriceOverride?}
|
||
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 ----------------
|
||
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(){
|
||
// 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 =
|
||
`<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, gross=0;
|
||
for(const {p,qty} of state.cart.values()){
|
||
count += qty;
|
||
// Use the per-line override if the cashier edited the price
|
||
// via long-press; otherwise the catalogue shelf price.
|
||
const unit = lineUnitPrice({p,qty});
|
||
gross += unit * qty;
|
||
}
|
||
// Discount (off the gross, post-VAT — same convention the
|
||
// server uses). Cap at the gross total so a fat-finger %
|
||
// can't drive the post-discount total negative.
|
||
const d = state.discount || { kind: 'none', input: 0 };
|
||
let discountAmount = 0;
|
||
if (d.kind === 'amount' && d.input > 0) {
|
||
discountAmount = Math.min(d.input, gross);
|
||
} else if (d.kind === 'percent' && d.input > 0) {
|
||
discountAmount = Math.min((gross * d.input) / 100, gross);
|
||
}
|
||
// VAT-inclusive model: selling_price is the shelf price (incl. VAT).
|
||
// The VAT rate is driven by the active letterhead (default 15% if
|
||
// no letterhead is loaded yet — same as the previous hardcoded
|
||
// behaviour, so a fresh till on a flaky network still works).
|
||
const vatPct = Number(state.letterhead && state.letterhead.vat_percent) || 15;
|
||
const total = Math.max(0, gross - discountAmount);
|
||
let sub, vat;
|
||
if (vatPct > 0) {
|
||
const factor = 1 + vatPct / 100;
|
||
sub = total / factor;
|
||
vat = total - sub;
|
||
} else {
|
||
// 0% VAT (e.g. CNL / ISHERWOOD) — no VAT line, sub == total.
|
||
sub = total;
|
||
vat = 0;
|
||
}
|
||
return {count, gross, discountAmount, sub, vat, total, vatPct};
|
||
}
|
||
|
||
/**
|
||
* Effective unit price for a cart line — the per-line override
|
||
* (set by long-press edit) or the catalogue's selling price.
|
||
* Returns 0 for the corner case where neither is set.
|
||
*/
|
||
function lineUnitPrice(line){
|
||
if (typeof line.unitPriceOverride === 'number' && Number.isFinite(line.unitPriceOverride)) {
|
||
return line.unitPriceOverride;
|
||
}
|
||
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 = '<div class="empty"><h3>Empty cart</h3><p>Add products from the catalog.</p></div>';
|
||
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,unitPriceOverride})=>{
|
||
const price = lineUnitPrice({p,qty,unitPriceOverride});
|
||
const overridden = typeof unitPriceOverride === 'number'
|
||
&& Number.isFinite(unitPriceOverride)
|
||
&& unitPriceOverride !== (p.selling_price ?? p.cost_price ?? 0);
|
||
return `<div class="line" data-od-id="line-${p.id}" data-id="${p.id}">
|
||
<div class="nm">${escapeHTML(p.description||p.sku)}<small>${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}${overridden ? ' · <em>price edited</em>' : ''}</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);
|
||
// 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;
|
||
document.getElementById('discountLineAmount').textContent = '− ' + fmt(t.discountAmount);
|
||
} else {
|
||
dl.hidden = true;
|
||
}
|
||
}
|
||
|
||
// ---------------- discount ----------------
|
||
function paintDiscount(){
|
||
// Reset the pill state + visibility based on current state.
|
||
const d = state.discount || { kind: 'none', input: 0 };
|
||
const trig = document.getElementById('discountTrigger');
|
||
const lbl = document.getElementById('discountTriggerLabel');
|
||
const amount = document.getElementById('discKindAmount');
|
||
const percent = document.getElementById('discKindPercent');
|
||
const input = document.getElementById('discInput');
|
||
const suffix = document.getElementById('discSuffix');
|
||
const hint = document.getElementById('discHint');
|
||
|
||
// Default the kind pill to "amount" if the user has never
|
||
// picked one — most "5 rand off" discounts are R off, not %.
|
||
const activeKind = d.kind === 'percent' ? 'percent' : 'amount';
|
||
amount.classList.toggle('active', activeKind === 'amount');
|
||
percent.classList.toggle('active', activeKind === 'percent');
|
||
suffix.textContent = activeKind === 'percent' ? '%' : 'R';
|
||
if (d.kind !== 'none' && d.input > 0) {
|
||
trig.classList.add('has-value');
|
||
if (d.kind === 'percent') {
|
||
lbl.textContent = `Discount: ${d.input}%`;
|
||
} else {
|
||
lbl.textContent = `Discount: R ${d.input.toFixed(2)}`;
|
||
}
|
||
document.getElementById('discountBody').hidden = false;
|
||
trig.setAttribute('aria-expanded', 'true');
|
||
if (input.value === '') input.value = String(d.input);
|
||
} else {
|
||
trig.classList.remove('has-value');
|
||
lbl.textContent = 'Add discount';
|
||
document.getElementById('discountBody').hidden = true;
|
||
trig.setAttribute('aria-expanded', 'false');
|
||
input.value = '';
|
||
}
|
||
hint.textContent = d.kind === 'percent'
|
||
? 'A percentage off the cart total (post-VAT).'
|
||
: 'A rand amount off the cart total (post-VAT).';
|
||
}
|
||
function toggleDiscount(){
|
||
// If the body is hidden, show it; if it's already open, close
|
||
// it (and clear the current discount if the operator hits the
|
||
// "X" — i.e. the chevron). For v1 the chevron toggles open
|
||
// state only; clearing happens via the explicit Apply/Clear
|
||
// buttons.
|
||
const body = document.getElementById('discountBody');
|
||
const trig = document.getElementById('discountTrigger');
|
||
if (body.hidden) {
|
||
body.hidden = false;
|
||
trig.setAttribute('aria-expanded', 'true');
|
||
setTimeout(()=>document.getElementById('discInput').focus(), 60);
|
||
} else {
|
||
body.hidden = true;
|
||
trig.setAttribute('aria-expanded', 'false');
|
||
}
|
||
}
|
||
function setDiscountKind(k){
|
||
if (k !== 'amount' && k !== 'percent') return;
|
||
// Switching kind while a value is present: keep the value,
|
||
// re-label, re-render so the suffix and totals update. The
|
||
// operator can then tap Apply to confirm or just watch the
|
||
// cart total change live.
|
||
state.discount = { ...(state.discount || { input: 0 }), kind: k };
|
||
paintDiscount();
|
||
renderCartDrawer();
|
||
}
|
||
function applyDiscount(){
|
||
const raw = (document.getElementById('discInput').value || '').replace(/[^0-9.]/g, '');
|
||
const v = Number(raw);
|
||
if (!Number.isFinite(v) || v <= 0) {
|
||
// Empty / 0 / non-numeric clears the discount. Same effect
|
||
// as the explicit clearDiscount() — kept as a single path
|
||
// so the totals always reflect what the input box says.
|
||
clearDiscount();
|
||
return;
|
||
}
|
||
state.discount = { kind: state.discount.kind === 'percent' ? 'percent' : 'amount', input: v };
|
||
paintDiscount();
|
||
renderCartDrawer();
|
||
}
|
||
function clearDiscount(){
|
||
state.discount = { kind: 'none', input: 0 };
|
||
document.getElementById('discInput').value = '';
|
||
paintDiscount();
|
||
renderCartDrawer();
|
||
}
|
||
// Live-update the input as the cashier types so the totals move
|
||
// with the digits (server re-derives the final number at commit
|
||
// time — this is just the preview).
|
||
document.addEventListener('input', e=>{
|
||
if (e.target.id !== 'discInput') return;
|
||
const raw = e.target.value.replace(/[^0-9.]/g, '');
|
||
e.target.value = raw;
|
||
const v = Number(raw);
|
||
if (!Number.isFinite(v) || v <= 0) {
|
||
state.discount = { kind: 'none', input: 0 };
|
||
} else {
|
||
const kind = state.discount.kind === 'percent' ? 'percent' : 'amount';
|
||
state.discount = { kind, input: v };
|
||
}
|
||
renderCartDrawer();
|
||
});
|
||
|
||
// ---------------- long-press edit line ----------------
|
||
//
|
||
// Hold a cart line for 600ms → modal opens with qty + unit price.
|
||
// Common on mobile POS apps; the cashier scans/selects, then holds
|
||
// the row to fix the qty or override the price before ringing it
|
||
// up. 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;
|
||
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 || '';
|
||
// Pre-fill with the effective unit price (the override if set,
|
||
// otherwise the catalogue shelf price). The cashier can decide
|
||
// whether to keep it or change it.
|
||
const cur = lineUnitPrice(line);
|
||
document.getElementById('editQty').value = String(line.qty);
|
||
document.getElementById('editPrice').value = fmtN(cur);
|
||
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 price = Number((document.getElementById('editPrice').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;
|
||
}
|
||
// price === 0 is allowed (the operator can comp a line) but
|
||
// negative isn't. If the input is empty, leave the price alone.
|
||
const shelf = Number(line.p.selling_price ?? line.p.cost_price ?? 0) || 0;
|
||
if (Number.isFinite(price) && price >= 0) {
|
||
if (price !== shelf) line.unitPriceOverride = price;
|
||
else delete line.unitPriceOverride; // back to default
|
||
}
|
||
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 = 'Hold to edit again, or change a value and 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=>`<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();
|
||
|
||
// 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,
|
||
// 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,unitPriceOverride})=>({
|
||
sku: p.sku, description: p.description, unit: p.unit,
|
||
cost_price: p.cost_price,
|
||
// The price the cashier actually sold at — the override
|
||
// (long-press) wins over the catalogue shelf price.
|
||
selling_price: lineUnitPrice({p,qty,unitPriceOverride}),
|
||
edited: typeof unitPriceOverride === 'number'
|
||
&& Number.isFinite(unitPriceOverride)
|
||
&& unitPriceOverride !== (p.selling_price ?? p.cost_price ?? 0),
|
||
qty, product_id: p.id,
|
||
})),
|
||
customer: state.customer || null,
|
||
synced: false,
|
||
};
|
||
|
||
const checkoutBody = {
|
||
items: Array.from(state.cart.values()).map(({p,qty,unitPriceOverride})=>({
|
||
stock_item_id: p.id, qty, unit_price: lineUnitPrice({p,qty,unitPriceOverride}),
|
||
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,
|
||
discount_kind: state.discount && state.discount.kind !== 'none' ? state.discount.kind : undefined,
|
||
discount_input: state.discount && state.discount.kind !== 'none' ? 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;
|
||
} else {
|
||
// Try to read the body so we can tell apart stock-shortage
|
||
// (409) from generic 4xx/5xx. The route returns JSON on every
|
||
// non-2xx path, so this should always parse.
|
||
let body = null;
|
||
try { body = await r.json(); } catch (_) { /* not json */ }
|
||
if (body && body.reason === 'insufficient_stock' && Array.isArray(body.shortages) && body.shortages.length) {
|
||
serverError = { kind: 'insufficient_stock', shortages: body.shortages };
|
||
} else {
|
||
const msg = body && body.message ? body.message : ('server returned ' + r.status);
|
||
serverError = { kind: 'other', 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) {
|
||
if (serverError.kind === 'insufficient_stock') {
|
||
const lines = serverError.shortages.map(s => {
|
||
const item = state.cart.get(s.stock_item_id);
|
||
const name = item && item.p
|
||
? (item.p.description || item.p.sku || s.stock_item_id)
|
||
: s.stock_item_id;
|
||
return `${name}: only ${s.available} in stock (you asked for ${s.requested})`;
|
||
});
|
||
toast.error('Not enough stock — ' + lines.join('; '));
|
||
} else {
|
||
toast.error('Sale rejected: ' + (serverError.kind === 'other' ? serverError.message : 'Unknown error'));
|
||
}
|
||
if (btn) {
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12l5 5 9-11"/></svg>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, toggleDiscount, setDiscountKind, applyDiscount, clearDiscount };
|
||
// 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;
|
||
window.toggleDiscount = toggleDiscount;
|
||
window.setDiscountKind = setDiscountKind;
|
||
window.applyDiscount = applyDiscount;
|
||
window.clearDiscount = clearDiscount;
|
||
|
||
// ---------------- 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(()=>{});
|
||
}
|
||
})();
|
||
|
||
})();
|