WFDASH-WEBAPP/public/pos/pos-app.js

1021 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// POS app — catalog, cart, payment, offline queue, slip generation.
// All server access goes through /api/v1/pos/* via window.posFetch
// (see pos-config.js) — this app never talks to PocketBase directly.
(function(){
'use strict';
const CACHE = 'pos-v1';
const LS_CART = 'pos.cart';
const LS_QUEUE = 'pos.queue';
// ---------------- state ----------------
const state = {
products: [],
productsById: new Map(),
categories: [],
activeCat: 'all',
search: '',
cart: new Map(), // id -> {p, qty, 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'})[c]); }
function escapeAttr(s){ return escapeHTML(s).replace(/`/g,'&#96;'); }
// expose for inline handlers
window.__pos = { changeQty, removeFromCart, openDrawer, closeDrawer, openPayment, closePayment, pickMethod, confirmSale, 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(()=>{});
}
})();
})();