Three changes to the POS sale pipeline, all in one commit because pos-service.ts / types.ts / pos-app.js are each touched by all three and splitting them would only produce intermediate broken states. VAT is now charged on top, not backed out Catalogue prices are ex-VAT: `selling_price` is derived as `cost_price x markup` from the supplier's ex-VAT cost, with no VAT uplift anywhere (verified — median selling/cost across the catalogue is exactly 1.8). The till nevertheless treated them as VAT-inclusive and recovered the net with `total / 1.15`, so every sale was recorded with an understated net and an understated VAT line. An R100 ex-VAT line now rings up as R115. The rate comes from the till's letterhead, read server-side rather than accepted off the wire, and is stored per sale so a later letterhead rate change cannot rewrite what was charged. All 3,623 catalogue prices are untouched. Ad-hoc "custom" lines A cashier can now ring up something that was never a catalogue item — "bolt cutting" at R20 agreed at the counter — via a new "+ Add a custom item" composer. The line prints on the slip and lands in pos_sale_items, but carries no stock_item relation and takes no stock movement: no ledger row, no current_qty decrement, no catalogue entry. pos_sale_items.stock_item is no longer required at the schema level and an explicit `is_custom` flag marks the line, rather than inferring it from a blank relation — so "how much did we make on bolt cutting this month" is a plain query, and a future stock-less line type cannot be confused with a corrupt one. Custom lines are refused on account sales: those raise a delivery note the customer signs for and feed their statement, so an ad-hoc charge must not slip onto one. Enforced server-side, not just in the UI. Discounts are now 20% or 40%, nothing else The R-off / %-off pills and the free-typed number box are gone. The server treats DISCOUNT_PERCENT_OPTIONS as an allowlist and refuses anything outside it rather than coercing it, so a stale till cannot invent a discount. The discount lands on the ex-VAT subtotal, making "20% off" a true 20% off both the net and the VAT on it. Two defects found while doing this pos_sales was missing six fields that checkoutSale has been writing all along (gross_subtotal, gross_vat, gross_total, discount_amount, discount_kind, discount_input). PocketBase accepts unknown fields without error and silently drops them, so all 192 historical sales lack them and a discounted sale reprinted as though no discount had been applied. Added, plus vat_percent. History also passed gross_total (VAT-inclusive) where the receipt derives the gross VAT from an ex-VAT base — a reprint double-counted VAT. Corrected. Verification: 3860 tests pass (14 new); typecheck, lint and production build clean; live PocketBase probe confirms a pos_sale_items row with stock_item="" and is_custom=true round-trips, with the probe removed afterwards.
1117 lines
45 KiB
JavaScript
1117 lines
45 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: '',
|
||
// id -> {hay, squish, words}. Built by PosSearch.buildIndex on every
|
||
// catalog load (both the IndexedDB and the network path) so the
|
||
// per-keystroke filter only does `indexOf`, never normalisation.
|
||
searchIndex: new Map(),
|
||
cart: new Map(), // id -> {p, qty}
|
||
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 ----------------
|
||
/**
|
||
* Adopt a catalog array: wire up the id map, rebuild the search index,
|
||
* then repaint. Called from BOTH load paths — the IndexedDB cache and the
|
||
* network refresh — because a till that booted offline has no index yet
|
||
* and would otherwise filter against an empty one.
|
||
*/
|
||
function adoptCatalog(list){
|
||
state.products = list;
|
||
state.productsById = new Map(list.map(p=>[p.id,p]));
|
||
// See filteredProducts() — an empty index is inert there via fallbackSearch.
|
||
state.searchIndex = window.PosSearch ? window.PosSearch.buildIndex(list) : new Map();
|
||
buildCategories();
|
||
render();
|
||
}
|
||
|
||
async function loadProducts(){
|
||
// Try IDB first
|
||
try{
|
||
const cached = await dbAll(STORE_PRODUCTS);
|
||
if(cached.length){
|
||
adoptCatalog(cached);
|
||
}
|
||
}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,
|
||
}));
|
||
adoptCatalog(all);
|
||
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(){
|
||
// Category first — it's a plain equality test and prunes most of the
|
||
// catalog before any string work happens.
|
||
let list = state.products;
|
||
if(state.activeCat==='__uncat'){
|
||
list = list.filter(p=>!p.category);
|
||
} else if(state.activeCat!=='all'){
|
||
list = list.filter(p=>p.category===state.activeCat);
|
||
}
|
||
// …then rank by text. PosSearch owns the tiers; an empty query returns
|
||
// the list untouched, which keeps the default "recently added" view.
|
||
//
|
||
// The fallback is for a half-updated till: pos.html loads pos-search.js
|
||
// ahead of this file, but a service worker can still be serving a stale
|
||
// mix of the two. A blank catalog is a shop that cannot sell anything, so
|
||
// degrade to the old substring filter rather than throwing. Only reachable
|
||
// on a skewed cache — both files ship together.
|
||
if(!window.PosSearch) return fallbackSearch(list);
|
||
return window.PosSearch.search(list, state.searchIndex, state.search);
|
||
}
|
||
|
||
/** The pre-2026-10-06 filter: case-insensitive substring over sku + description. */
|
||
function fallbackSearch(list){
|
||
const q = state.search.trim().toLowerCase();
|
||
if(!q) return list;
|
||
return list.filter(p=>
|
||
(p.sku||'').toLowerCase().includes(q)
|
||
|| (p.description||'').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);
|
||
}
|
||
|
||
// ---------------- custom (ad-hoc) lines ----------------
|
||
//
|
||
// A line the cashier types by hand — "Bolt cutting" at R20 — that
|
||
// belongs on this slip and nowhere else. It is NOT a catalogue
|
||
// item: no `stock_items` row, no stock decrement, no ledger entry.
|
||
// It carries a synthetic `custom:` id purely so it can live in the
|
||
// same cart Map and flow through the same totals, slip and checkout
|
||
// path as everything else.
|
||
//
|
||
// The prefix matters: it's how the checkout payload marks the line
|
||
// `is_custom`, and it's how the cart drawer badges it so the
|
||
// cashier can see at a glance which lines are not real stock.
|
||
const CUSTOM_ID_PREFIX = 'custom:';
|
||
|
||
function addCustomItem(description, price){
|
||
const desc = (description||'').trim();
|
||
const amount = Number(price);
|
||
if(!desc){ alert('Give the item a name.'); return null; }
|
||
if(!Number.isFinite(amount) || amount <= 0){ alert('Enter a price greater than zero.'); return null; }
|
||
const p = {
|
||
id: CUSTOM_ID_PREFIX + uid(),
|
||
name: desc,
|
||
description: desc,
|
||
sku: '',
|
||
unit: '',
|
||
// EX-VAT, same basis as the catalogue — VAT is added on top in
|
||
// cartTotals(). A service the shop performs has no cost of goods.
|
||
selling_price: amount,
|
||
cost_price: 0,
|
||
is_custom: true,
|
||
};
|
||
state.cart.set(p.id, {p, qty: 1});
|
||
persistCart();
|
||
render();
|
||
paintCart();
|
||
renderCartDrawer();
|
||
return p;
|
||
}
|
||
function isCustomLine(p){ return !!(p && p.is_custom); }
|
||
|
||
function openCustomItem(){
|
||
document.getElementById('customDesc').value = '';
|
||
document.getElementById('customPrice').value = '';
|
||
document.getElementById('customSheet').classList.add('show');
|
||
document.getElementById('customBackdrop').classList.add('show');
|
||
setTimeout(()=>document.getElementById('customDesc').focus(), 60);
|
||
}
|
||
function closeCustomItem(){
|
||
document.getElementById('customSheet').classList.remove('show');
|
||
document.getElementById('customBackdrop').classList.remove('show');
|
||
}
|
||
function saveCustomItem(){
|
||
const desc = document.getElementById('customDesc').value;
|
||
const price = document.getElementById('customPrice').value.replace(/[^0-9.]/g,'');
|
||
const p = addCustomItem(desc, Number(price));
|
||
if(p) closeCustomItem();
|
||
}
|
||
|
||
// ---------------- 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;
|
||
// Catalogue shelf price — the till never edits a line's price.
|
||
// Prices are EX-VAT (see pos-service.ts): derived as
|
||
// cost × markup from the supplier's ex-VAT cost, with no VAT
|
||
// uplift. VAT is added on top below.
|
||
const unit = lineUnitPrice({p,qty});
|
||
gross += unit * qty;
|
||
}
|
||
// Discount (off the ex-VAT gross, so it's a true % off both the
|
||
// net and the VAT on it). Percentage-only and capped at the
|
||
// gross so the total can never go negative.
|
||
const d = state.discount || { kind: 'none', input: 0 };
|
||
let discountAmount = 0;
|
||
if (d.kind === 'percent' && d.input > 0) {
|
||
discountAmount = Math.min((gross * d.input) / 100, gross);
|
||
}
|
||
// The VAT rate comes from the active letterhead — each shop may
|
||
// sit at a different rate. Falls back to the SA standard rate
|
||
// when no letterhead has loaded yet (a fresh till on a flaky
|
||
// network still has to be able to take money).
|
||
const vatPct = Number(state.letterhead && state.letterhead.vat_percent);
|
||
const pct = Number.isFinite(vatPct) && vatPct >= 0 ? vatPct : 15;
|
||
const net = Math.max(0, gross - discountAmount);
|
||
const vat = (net * pct) / 100;
|
||
const total = net + vat;
|
||
return {count, gross, discountAmount, sub: net, vat, total, vatPct: pct};
|
||
}
|
||
|
||
/**
|
||
* Effective unit price for a cart line — always the catalogue's selling
|
||
* price. It used to prefer a per-line override set by the long-press edit
|
||
* sheet, but that sheet is quantity-only now, so no override can exist.
|
||
* Kept as a named helper because the price feeds four places (line render,
|
||
* cart totals, the sale record and the checkout payload) and they must not
|
||
* drift apart. Returns 0 when neither price is set.
|
||
*/
|
||
function lineUnitPrice(line){
|
||
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})=>{
|
||
const price = lineUnitPrice({p,qty});
|
||
const custom = isCustomLine(p);
|
||
// Custom lines carry no SKU and no unit — badge them so the
|
||
// cashier can see at a glance that this isn't stock, and it
|
||
// won't show up on a stocktake or reorder list.
|
||
const sub = custom
|
||
? `${fmt(price)} each · custom item`
|
||
: `${fmt(price)} / ${escapeHTML(p.unit||'ea')} · ${escapeHTML(p.sku||'')}`;
|
||
return `<div class="line${custom?' custom-line':''}" data-od-id="line-${p.id}" data-id="${p.id}">
|
||
<div class="nm">${escapeHTML(p.description||p.name||p.sku)}<small>${sub}</small></div>
|
||
<div class="qty">
|
||
<button onclick="window.__pos.changeQty('${p.id}',-1)" aria-label="Decrease quantity">−</button>
|
||
<button class="v" data-od-id="qty-set" onclick="window.__pos.openEditLine('${p.id}')" aria-label="Set quantity for ${escapeAttr(p.description||p.sku||'item')}">${qty}</button>
|
||
<button onclick="window.__pos.changeQty('${p.id}',1)" aria-label="Increase quantity">+</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);
|
||
// The VAT line label follows the letterhead's rate rather than
|
||
// hardcoding 15%, so a shop at a different rate doesn't print a
|
||
// wrong label over the right number.
|
||
const vatLabel = document.getElementById('vatLabel');
|
||
if (vatLabel) vatLabel.textContent = t.vatPct > 0 ? `VAT ${t.vatPct}%` : 'VAT';
|
||
// 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;
|
||
const d = state.discount || {};
|
||
const pctLabel = d.kind === 'percent' && d.input ? ` (${d.input}%)` : '';
|
||
document.getElementById('discountLineAmount').textContent =
|
||
`− ${fmt(t.discountAmount)}${pctLabel} ex VAT`;
|
||
} else {
|
||
dl.hidden = true;
|
||
}
|
||
}
|
||
|
||
// ---------------- discount ----------------
|
||
// Two fixed tiers, 20% and 40%. Deliberately not a free-typed
|
||
// field: the whole point is that every discount on every slip is
|
||
// visibly one of two known-good choices, so nobody can ring up a
|
||
// hand-typed 90% (or a random rand amount) and nobody has to guess
|
||
// afterwards whether it was deliberate.
|
||
const DISCOUNT_TIERS = [20, 40];
|
||
|
||
function paintDiscount(){
|
||
const d = state.discount || { kind: 'none', input: 0 };
|
||
const trig = document.getElementById('discountTrigger');
|
||
const lbl = document.getElementById('discountTriggerLabel');
|
||
const body = document.getElementById('discountBody');
|
||
const hint = document.getElementById('discHint');
|
||
|
||
const active = d.kind === 'percent' && DISCOUNT_TIERS.includes(Number(d.input))
|
||
? Number(d.input) : 0;
|
||
|
||
for (const tier of DISCOUNT_TIERS){
|
||
const btn = document.getElementById('discTier'+tier);
|
||
if (btn) btn.classList.toggle('active', tier === active);
|
||
}
|
||
if (active){
|
||
trig.classList.add('has-value');
|
||
lbl.textContent = `Discount: ${active}%`;
|
||
body.hidden = false;
|
||
trig.setAttribute('aria-expanded','true');
|
||
} else {
|
||
trig.classList.remove('has-value');
|
||
lbl.textContent = 'Add discount';
|
||
body.hidden = true;
|
||
trig.setAttribute('aria-expanded','false');
|
||
}
|
||
hint.textContent = 'Applied off the ex-VAT total — VAT is charged on the discounted amount.';
|
||
}
|
||
function toggleDiscount(){
|
||
// The trigger only opens/closes the tier buttons — it never clears
|
||
// the discount. Clearing is tapping the active tier again, so
|
||
// there's no way to lose a discount by mis-tapping the chevron.
|
||
const body = document.getElementById('discountBody');
|
||
const trig = document.getElementById('discountTrigger');
|
||
const open = body.hidden;
|
||
// Don't let the operator dismiss the panel out from under an
|
||
// applied discount — that would hide the reason for the total.
|
||
if (!open && state.discount && state.discount.kind === 'percent') return;
|
||
body.hidden = !open;
|
||
trig.setAttribute('aria-expanded', String(open));
|
||
}
|
||
function setDiscountTier(pct){
|
||
const tier = Number(pct);
|
||
if(!DISCOUNT_TIERS.includes(tier)) return;
|
||
// Tapping the active tier clears it — one control, no separate
|
||
// "remove" affordance to forget.
|
||
if(state.discount && state.discount.kind==='percent' && Number(state.discount.input)===tier){
|
||
clearDiscount();
|
||
return;
|
||
}
|
||
state.discount = { kind:'percent', input: tier };
|
||
paintDiscount();
|
||
renderCartDrawer();
|
||
}
|
||
function clearDiscount(){
|
||
state.discount = { kind:'none', input:0 };
|
||
paintDiscount();
|
||
renderCartDrawer();
|
||
}
|
||
// Discounts are two fixed buttons now, so there's no free-text
|
||
// input to live-listen to.
|
||
|
||
// ---------------- long-press edit line ----------------
|
||
//
|
||
// Hold a cart line for 600ms → modal opens with qty + unit price.
|
||
// Tapping the quantity number does the same thing and is the discoverable
|
||
// route; the long-press is kept because it is the faster gesture for a
|
||
// cashier who already knows it's there.
|
||
// Common on mobile POS apps; the cashier scans/selects, then holds
|
||
// the row to fix the qty before ringing it up. Quantity only — this
|
||
// sheet has no price field. 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;
|
||
// The steppers and the quantity number are their own controls now. A
|
||
// long-press on them used to pop the edit sheet open mid-tap, which made
|
||
// holding "+" to ring up a bulk quantity fight the sheet. Each of them
|
||
// does its own thing on a normal tap.
|
||
if (e.target.closest('.qty, .rm')) 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 || '';
|
||
// Quantity only. This popup deliberately has no price field: the till
|
||
// sells at the catalogue shelf price and a mis-keyed price is expensive
|
||
// and hard to spot. Per-line price overrides were removed on request —
|
||
// the server reads only `unit_price`, so nothing else depended on them.
|
||
document.getElementById('editQty').value = String(line.qty);
|
||
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 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;
|
||
}
|
||
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 = 'Type a quantity like 800, then 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();
|
||
const items = j.items || [];
|
||
// `total` is every match before the server's display cap. Without it a
|
||
// 52-person company would render 8 rows and read as the whole team.
|
||
const total = Number.isFinite(j.total) ? j.total : items.length;
|
||
if(!items.length){
|
||
box.innerHTML = `<div class="cust-none">No match for “${escapeHTML(q)}”</div>`;
|
||
return;
|
||
}
|
||
const head = total > items.length
|
||
? `<div class="cust-count">Showing ${items.length} of ${total} — keep typing to narrow</div>`
|
||
: `<div class="cust-count">${total} ${total===1?'match':'matches'}</div>`;
|
||
box.innerHTML = head + items.map(c=>`<div class="cust-row" 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('');
|
||
// Scoped to .cust-row on purpose: binding every div would make the
|
||
// count header above selectable, picking a customer with an empty id.
|
||
box.querySelectorAll('.cust-row').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 ----------------
|
||
// Minimal, self-contained error toast. `toast.error(...)` below used
|
||
// to reference a `toast` global that was never defined anywhere in
|
||
// this app (no library included, no local implementation) — every
|
||
// checkout rejection (e.g. insufficient stock) threw a
|
||
// ReferenceError instead of showing the operator anything, so the
|
||
// cart just sat there with no feedback.
|
||
function showToast(message){
|
||
let el = document.getElementById('posToast');
|
||
if(!el){
|
||
el = document.createElement('div');
|
||
el.id = 'posToast';
|
||
el.style.cssText = 'position:fixed;left:16px;right:16px;bottom:calc(96px + env(safe-area-inset-bottom));max-width:448px;margin:0 auto;background:#c92a2a;color:#fff;padding:14px 16px;border-radius:12px;font-size:14px;font-weight:600;line-height:1.4;box-shadow:0 8px 24px rgba(0,0,0,.25);z-index:999;opacity:0;transition:opacity .15s ease';
|
||
document.body.appendChild(el);
|
||
}
|
||
el.textContent = message;
|
||
el.style.opacity = '1';
|
||
clearTimeout(el._hideTimer);
|
||
el._hideTimer = setTimeout(()=>{ el.style.opacity = '0'; }, 5000);
|
||
}
|
||
|
||
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,
|
||
// Which VAT rate produced those numbers — the receipt labels
|
||
// its VAT line from this rather than hardcoding 15%.
|
||
vatPct: totals.vatPct,
|
||
// 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})=>({
|
||
sku: p.sku, description: p.description, unit: p.unit,
|
||
cost_price: p.cost_price,
|
||
selling_price: lineUnitPrice({p,qty}),
|
||
qty, product_id: p.id,
|
||
})),
|
||
customer: state.customer || null,
|
||
synced: false,
|
||
};
|
||
|
||
const checkoutBody = {
|
||
items: Array.from(state.cart.values()).map(({p,qty})=>({
|
||
stock_item_id: isCustomLine(p) ? undefined : p.id,
|
||
is_custom: isCustomLine(p),
|
||
qty, unit_price: lineUnitPrice({p,qty}),
|
||
sku: p.sku, description: isCustomLine(p) ? p.description : (p.description || p.sku),
|
||
})),
|
||
payment_method: state.paymentMethod,
|
||
cash_given: state.paymentMethod==='cash' ? state.cashGiven : undefined,
|
||
customer_id: state.customer ? state.customer.id : undefined,
|
||
client_ref: localId,
|
||
// The server reads the VAT rate off this letterhead rather than
|
||
// trusting a rate off the wire.
|
||
letterhead_id: state.letterhead ? state.letterhead.id : undefined,
|
||
discount_kind: state.discount && state.discount.kind === 'percent' ? 'percent' : undefined,
|
||
discount_input: state.discount && state.discount.kind === 'percent' ? 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;
|
||
// The server raises the delivery note for an `account` sale and
|
||
// hands it back here. Carried onto the local sale so
|
||
// receipt.html can print the A4 original instead of the old
|
||
// 80mm thermal slip. Absent for cash / card / EFT, and absent
|
||
// for an account sale whose DN creation failed — in which case
|
||
// the operator is told to raise one by hand.
|
||
if (j.deliveryNote) sale.deliveryNote = j.deliveryNote;
|
||
if (j.deliveryNoteError) sale.deliveryNoteError = j.deliveryNoteError;
|
||
} else {
|
||
// The route returns JSON on every non-2xx path, so this should
|
||
// always parse. Overselling is allowed on purpose (no stock
|
||
// check blocks the sale) — every rejection here is a real
|
||
// validation/server failure, not a stock shortage.
|
||
let body = null;
|
||
try { body = await r.json(); } catch (_) { /* not json */ }
|
||
const msg = body && body.message ? body.message : ('server returned ' + r.status);
|
||
serverError = { 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) {
|
||
showToast('Sale rejected: ' + serverError.message);
|
||
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, addCustomItem, openCustomItem, closeCustomItem, saveCustomItem, setDiscountTier, clearDiscount, toggleDiscount };
|
||
// 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;
|
||
// The edit-line modal could be opened but not dismissed or committed:
|
||
// its Save / Cancel / Remove buttons (and the customer "Remove" chip)
|
||
// call these from inline onclick, so they need to be global too.
|
||
window.closeEditLine = closeEditLine;
|
||
window.editSave = editSave;
|
||
window.editRemove = editRemove;
|
||
window.clearCust = clearCust;
|
||
// Discount tiers + the ad-hoc line composer are driven from inline
|
||
// onclick in pos.html, so they have to be globals too.
|
||
window.setDiscountTier = setDiscountTier;
|
||
window.toggleDiscount = toggleDiscount;
|
||
window.clearDiscount = clearDiscount;
|
||
window.addCustomItem = addCustomItem;
|
||
window.openCustomItem = openCustomItem;
|
||
window.closeCustomItem = closeCustomItem;
|
||
window.saveCustomItem = saveCustomItem;
|
||
|
||
// ---------------- 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(()=>{});
|
||
}
|
||
})();
|
||
|
||
})();
|