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; }
}