diff --git a/public/pos/pos-app.js b/public/pos/pos-app.js index 670aaa7..e4907d4 100644 --- a/public/pos/pos-app.js +++ b/public/pos/pos-app.js @@ -1,9 +1,9 @@ // POS app — catalog, cart, payment, offline queue, slip generation. -// All routes: /api/collections/{name}/records +// 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 cfg = window.PB_CONFIG; const CACHE = 'pos-v1'; const LS_CART = 'pos.cart'; const LS_QUEUE = 'pos.queue'; @@ -417,16 +417,15 @@ async function searchCustomers(q){ const box = document.getElementById('custResults'); if(!q){ box.innerHTML=''; return; } try{ - const f = `(customer~'${q}' || email~'${q}' || number~'${q}')`; - const r = await window.pbFetch(`/api/collections/${cfg.collections.customers}/records?perPage=8&filter=${encodeURIComponent(f)}`); + const r = await window.posFetch(`/customers?q=${encodeURIComponent(q)}`); const j = await r.json(); - box.innerHTML = j.items.map(c=>`
- ${escapeHTML(c.customer||c.email||'—')} - ${escapeHTML(c.number||c.email||'')} + box.innerHTML = (j.items||[]).map(c=>`
+ ${escapeHTML(c.name)} + ${escapeHTML(c.company || '')}
`).join(''); box.querySelectorAll('div').forEach(d=>{ d.addEventListener('click',()=>{ - state.customer = {id:d.dataset.id, name:d.dataset.name}; + 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; @@ -448,84 +447,60 @@ async function confirmSale(){ const btn = document.getElementById('confirmBtn'); if(btn.disabled) return; btn.disabled = true; btn.innerHTML = 'Saving…'; - const items = Array.from(state.cart.values()).map(({p,qty})=>({ - sku: p.sku, description: p.description, unit: p.unit, brand: p.brand, - category: p.category, subcategory: p.subcategory, supplier: p.supplier, - cost_price: p.cost_price, selling_price: p.selling_price, qty, product_id: p.id - })); const totals = cartTotals(); const sub = totals.sub, vat = totals.vat, total = totals.total; + + // 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(); + const sale = { - localId: uid(), + localId, createdAt: new Date().toISOString(), payment: state.paymentMethod, cashGiven: state.cashGiven || null, change: state.paymentMethod==='cash' ? Math.max(0, state.cashGiven - total) : 0, - sub, vat, total, items, + sub, vat, total, + 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: p.selling_price, qty, product_id: p.id, + })), customer: state.customer || null, - letterhead: state.letterhead ? {name: state.letterhead.display_name, address: state.letterhead.address, phone: state.letterhead.phone, email: state.letterhead.email, vat_number: state.letterhead.vat_number, registration_number: state.letterhead.registration_number, footer_text: state.letterhead.footer_text, logo_url: state.letterhead.logo} : null, - synced: false + synced: false, + }; + + const checkoutBody = { + items: Array.from(state.cart.values()).map(({p,qty})=>({ + stock_item_id: p.id, qty, unit_price: p.selling_price ?? p.cost_price ?? 0, + 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, }; - // Try to write to PocketBase now; if offline or fails, queue. - let serverRef = null; - let serverError = null; try{ - const orderBody = { - order_number: 'S-' + Date.now().toString(36).toUpperCase(), - notes: 'POS sale · ' + sale.payment + (sale.customer?` · ${sale.customer.name}`:''), - invoiced: sale.payment==='account' ? false : true, - }; - if(sale.customer) orderBody.customer = sale.customer.id; - const r = await window.pbFetch('/api/collections/'+cfg.collections.orders+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(orderBody)}); + 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(); - serverRef = j.id; - // Line items - for(const it of sale.items){ - try{ - await window.pbFetch('/api/collections/'+cfg.collections.orderItems+'/records',{ - method:'POST',headers:{'Content-Type':'application/json'}, - body: JSON.stringify({order: j.id, description: it.description, quantity: it.qty, cost_price: it.cost_price, selling_price: it.selling_price, item_status:'invoiced'}) - }); - }catch(liErr){ console.warn('line item write failed', liErr); } - } - // If account sale, create delivery note - if(sale.payment==='account'){ - try{ - const dn = await window.pbFetch('/api/collections/'+cfg.collections.deliveryNotes+'/records',{ - method:'POST',headers:{'Content-Type':'application/json'}, - body: JSON.stringify({delivery_note_number: 'DN-'+Date.now().toString(36).toUpperCase(), order: j.id, customer: sale.customer?.id, status:'pending', notes:'Account sale — deliver to customer.'}) - }); - if(dn.ok){ - const dnJ = await dn.json(); - for(const it of sale.items){ - try{ - await window.pbFetch('/api/collections/'+cfg.collections.deliveryNoteItems+'/records',{ - method:'POST',headers:{'Content-Type':'application/json'}, - body: JSON.stringify({delivery_note: dnJ.id, description: it.description, quantity: it.qty}) - }); - }catch(_){} - } - } - }catch(dnErr){ console.warn('delivery note write failed', dnErr); } - } sale.synced = true; - sale.serverRef = serverRef; + sale.serverRef = j.sale && j.sale.id; } else { - serverError = 'server returned ' + r.status; - throw new Error(serverError); + throw new Error('server returned ' + r.status); } }catch(e){ console.warn('Sale queued for sync', e); - // Queue locally — never let this throw escape try{ - await dbPut(STORE_QUEUE, sale); + 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); + arr.push({...sale, checkoutBody}); localStorage.setItem('wfsales.queue', JSON.stringify(arr)); }catch(_){} } @@ -553,17 +528,16 @@ async function flushQueue(){ const q = await dbAll(STORE_QUEUE); for(const s of q){ try{ - const r = await window.pbFetch('/api/collections/'+cfg.collections.orders+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({order_number:'S-'+Date.now().toString(36).toUpperCase(), notes:'POS sale (offline) · '+s.payment, invoiced: s.payment!=='account'})}); + const r = await window.posFetch('/checkout', { + method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(s.checkoutBody), + }); if(r.ok){ - const j = await r.json(); - for(const it of s.items){ - await window.pbFetch('/api/collections/'+cfg.collections.orderItems+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({order:j.id, description:it.description, quantity:it.qty, cost_price:it.cost_price, selling_price:it.selling_price, item_status:'invoiced'})}); - } - if(s.payment==='account' && s.customer){ - const dn = await window.pbFetch('/api/collections/'+cfg.collections.deliveryNotes+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delivery_note_number:'DN-'+Date.now().toString(36).toUpperCase(), order:j.id, customer:s.customer.id, status:'pending'})}); - if(dn.ok){ const dnJ = await dn.json(); for(const it of s.items){ await window.pbFetch('/api/collections/'+cfg.collections.deliveryNoteItems+'/records',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({delivery_note:dnJ.id, description:it.description, quantity:it.qty})}); } } - } + // 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; } }