RECORDGOD/site/pos.html
type-two ef4769784f feat: Woo customer sync (non-destructive, in refresh.sh) + AusPost rates mirror & quote calc
customer_woo_sync.py: WooCommerce online customers (wc_customer_lookup+billing) → RecordGod
customer CRM, links-by-email/refresh-by-wp_user_id/insert-new, never clobbers POS rows; added
as refresh.sh step 5 (after the --clean push). shipping_sync.py mirrors disc_post_flat_rates
(+zones) → post_flat_rate/post_zone. /sales/shipping/quote (units|weight_g → 280g/record →
≤5kg parcels → flat rate × parcels, Parcel Post + Express). POS Register '📦 post' quotes the
cart + adds a postage line. AusPost +5% lands 2026-07-01 — re-pull rates then.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:55:56 +10:00

541 lines
35 KiB
HTML
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.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RecordGod — Sales</title>
<script defer src="/nav.js?v=5"></script>
<style>
:root{--pink:#d10f7a;--btn:#e0117f;--bg:#f4f5f7;--pn:#fff;--line:#e2e2ea;--mut:#5f5f6c;--ok:#1a8f54;--warn:#b06a00}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:#1b1b22;font:14px/1.5 system-ui,sans-serif}
button{cursor:pointer;border:0;border-radius:8px;font:500 13px system-ui}
.btn{background:var(--btn);color:#fff;padding:10px 14px}
.ghost{background:#fff;color:#2a2a30;border:1px solid var(--line);padding:8px 11px}
.ghost.on{background:#fdeef6;color:var(--pink);border-color:var(--pink)}
.big{padding:13px 16px;font-size:15px;width:100%}
input,select,textarea{padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:#fff;color:#1b1b22;font:14px system-ui}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--pink)}
#gate{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg);z-index:50}
#gate .b{width:320px;text-align:center}#gate h1{color:var(--pink)}
.wrap{max-width:1180px;margin:0 auto;padding:16px}
.top{display:flex;align-items:center;gap:14px;margin-bottom:14px}
.top h1{font-size:20px;margin:0;font-weight:600}.top h1 b{color:var(--pink)}
.tabs{display:flex;gap:4px;flex:1}
.tabs button{background:none;color:var(--mut);padding:8px 16px;border-radius:8px;font-weight:600}
.tabs button.on{color:var(--pink);background:#fdeef6}
.tab{display:none}.tab.on{display:block}
.panel{background:var(--pn);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:12px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
.panel h2{font-size:13px;font-weight:600;margin:0 0 10px;color:#33333a}
.grid{display:grid;grid-template-columns:1.4fr 1fr;gap:14px;align-items:start}
@media(max-width:880px){.grid{grid-template-columns:1fr}}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.lbl{color:var(--mut);font-size:12px;min-width:80px}
.res{position:relative;flex:1}
.drop{position:absolute;left:0;right:0;top:calc(100% + 4px);background:#fff;border:1px solid var(--line);border-radius:9px;max-height:300px;overflow:auto;z-index:20;box-shadow:0 6px 20px rgba(0,0,0,.12)}
.ri{display:flex;gap:10px;align-items:center;padding:8px;cursor:pointer}.ri:hover{background:#f5f5f8}
.ri img,.ri .ph{width:38px;height:38px;border-radius:5px;object-fit:cover;background:#ececf0;flex-shrink:0}
.ri .t{flex:1;min-width:0}.ri .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.muted{color:var(--mut);font-size:12px}.pink{color:var(--pink)}.ok{color:var(--ok)}.warn{color:var(--warn)}
.chip{display:inline-flex;gap:8px;align-items:center;background:#fdeef6;color:var(--pink);padding:6px 11px;border-radius:20px;font-weight:600;cursor:pointer}
table{width:100%;border-collapse:collapse;font-size:13px}
td,th{padding:7px 6px;border-bottom:1px solid #ededf2;text-align:left}th{color:var(--mut);font-weight:500}
.qty{width:48px}.num{width:74px}
.tot{display:flex;justify-content:space-between;align-items:center;padding:3px 0;font-size:13px}
.tot.grand{font-size:15px;font-weight:600;border-top:1px solid var(--line);margin-top:6px;padding-top:10px}
.tot.grand b{color:var(--pink);font-size:24px}
.pm{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;margin:12px 0 4px}
.pm button{padding:11px 6px;background:#fff;border:1px solid var(--line);border-radius:8px;color:#33333a;font-weight:600}
.pm button.on{background:#fdeef6;color:var(--pink);border-color:var(--pink)}
.x{color:var(--mut);cursor:pointer}
.ovl{position:fixed;inset:0;background:rgba(0,0,0,.5);display:none;align-items:center;justify-content:center;z-index:60}
.ovl .card{background:#fff;border-radius:12px;padding:18px;width:360px;max-width:92vw}
#rcptCard{font:13px/1.6 ui-monospace,monospace}
</style>
</head>
<body>
<div id="gate"><div class="b"><h1>Record<b style="color:var(--pink)">God</b> · Sales</h1>
<div class="row"><input id="tok" type="password" placeholder="admin token" style="flex:1"><button class="btn" onclick="signin()">Enter</button></div>
<div id="gerr" class="muted" style="margin-top:8px"></div></div></div>
<div class="wrap" id="app" style="display:none">
<div class="top">
<h1>Record<b>God</b> Sales</h1>
<div class="tabs" id="tabs">
<button data-tab="register" class="on" onclick="tab('register')">🛒 Register</button>
<button data-tab="history" onclick="tab('history')">🧾 History</button>
<button data-tab="settings" onclick="tab('settings')">⚙️ Settings</button>
</div>
<span id="who" class="muted"></span>
</div>
<!-- ════ REGISTER ════ -->
<div id="tab-register" class="tab on">
<div class="grid">
<!-- cart side -->
<div>
<div class="panel">
<div class="row" style="justify-content:space-between">
<div class="row" style="flex:1">
<span id="custChip" class="chip" onclick="resetCust()">👤 Guest</span>
<div class="res"><input id="custQ" placeholder="attach customer…" autocomplete="off" style="width:100%">
<div id="custDrop" class="drop" style="display:none"></div></div>
</div>
<button class="ghost" onclick="newCustDlg()"> new</button>
</div>
</div>
<div class="panel">
<div class="row"><div class="res">
<input id="q" placeholder="🔍 scan barcode or search title / artist / SKU…" autocomplete="off" style="width:100%" autofocus>
<div id="res" class="drop" style="display:none"></div></div>
<button class="ghost" onclick="bulkDlg()">bulk</button>
<button class="ghost" onclick="manualAdd()">manual</button>
<button class="ghost" onclick="postageDlg()">📦 post</button>
</div>
</div>
<div class="panel">
<table><thead><tr><th>Item</th><th>Qty</th><th>Price</th><th>Disc</th><th>Line</th><th></th></tr></thead>
<tbody id="cart"></tbody></table>
<div id="empty" class="muted" style="padding:10px 0">cart is empty — scan or search to add items</div>
</div>
</div>
<!-- checkout / receipt side -->
<div>
<div class="panel" id="checkout">
<div class="tot"><span class="muted">Subtotal</span><span id="t-sub">$0.00</span></div>
<div id="promoLines"></div>
<div class="tot"><span class="muted">Manual discount</span><input id="cartDisc" class="num" type="number" step="0.01" value="0" oninput="render()"></div>
<div class="tot"><span class="muted">Trade-in credit</span><input id="trade" class="num" type="number" step="0.01" value="0" oninput="render()"></div>
<div class="tot"><span class="muted">Tax %</span><input id="tax" class="num" type="number" step="0.1" value="0" oninput="render()"></div>
<div class="tot grand"><span>Total</span><b id="t-total">$0.00</b></div>
<div class="pm">
<button data-m="cash" onclick="setMethod('cash')">💵 Cash</button>
<button data-m="card" onclick="setMethod('card')">💳 Card</button>
<button data-m="eftpos" onclick="setMethod('eftpos')">EFTPOS</button>
<button data-m="terminal" id="btnTerminal" onclick="setMethod('terminal')" style="display:none">📟 Terminal</button>
<button data-m="split" onclick="setMethod('split')">⵹ Split</button>
<button data-m="layby" onclick="setMethod('layby')">⏸ Layby</button>
</div>
<div id="pCash" class="pp" style="display:none">
<div class="tot"><span class="muted">Tendered</span><input id="tendered" class="num" type="number" step="0.01" oninput="render()"></div>
<div class="tot"><span class="muted">Change</span><b id="change" class="pink">$0.00</b></div>
</div>
<div id="pSplit" class="pp" style="display:none">
<div id="splitRows"></div>
<button class="ghost" onclick="addSplit()" style="margin-top:6px"> add tender</button>
<div class="muted" id="splitMsg" style="margin-top:4px"></div>
</div>
<div id="pLayby" class="pp" style="display:none">
<div class="tot"><span class="muted">Deposit</span><input id="dep" class="num" type="number" step="0.01" value="0"></div>
<div class="tot"><span class="muted">Due date</span><input id="due" type="date"></div>
</div>
<div id="pTerminal" class="pp" style="display:none">
<div class="muted">Press <b>Push to terminal</b> — the total goes to the Square Terminal for the customer to tap / insert. The sale finalises only when the terminal approves.</div>
</div>
<input id="saleNote" placeholder="note (optional, prints on receipt)" style="width:100%;margin-top:10px">
<button class="btn big" id="completeBtn" style="margin-top:10px" onclick="complete()">Complete sale</button>
<div id="msg" class="muted" style="margin-top:6px"></div>
</div>
<div class="panel" id="receiptPane" style="display:none">
<div id="rcptCard"></div>
<div class="row" style="margin-top:12px">
<button class="btn" style="flex:1" onclick="printReceipt()">🖨 Print</button>
<button class="ghost" onclick="emailDlg()">✉️ Email</button>
<button class="ghost pink" onclick="newSale()"> New sale</button>
</div>
<div id="rcptMsg" class="muted" style="margin-top:6px"></div>
</div>
<div class="panel"><h2>Promotions on now <span class="muted">— manage in Settings</span></h2>
<div id="activePromos" class="muted">none active</div></div>
</div>
</div>
</div>
<!-- ════ HISTORY ════ -->
<div id="tab-history" class="tab">
<div class="panel"><h2>📋 Open laybys &amp; holds</h2><div id="holds" class="muted">loading…</div></div>
<div class="panel"><h2>Past sales</h2>
<input id="pastQ" placeholder="search sale # or customer…" style="width:100%;margin-bottom:8px" oninput="loadPast()">
<table><thead><tr><th>Sale #</th><th>Date</th><th>Customer</th><th>Items</th><th>Total</th><th>Status</th><th></th></tr></thead>
<tbody id="pastRows"></tbody></table>
<div id="pastEmpty" class="muted" style="padding:10px 0"></div>
</div>
</div>
<!-- ════ SETTINGS ════ -->
<div id="tab-settings" class="tab">
<div class="panel"><h2>Receipt &amp; checkout</h2>
<div class="row" style="margin-bottom:8px"><span class="lbl">Store name</span><input id="setName" style="flex:1"></div>
<div class="row" style="margin-bottom:8px"><span class="lbl">Address</span><input id="setAddr" style="flex:1" placeholder="prints under the store name"></div>
<div class="row" style="margin-bottom:8px"><span class="lbl">Receipt footer</span><input id="setFooter" style="flex:1" placeholder="thanks for digging"></div>
<div class="row" style="margin-bottom:8px"><span class="lbl">Logo URL</span><input id="setLogo" style="flex:1" placeholder="https://recordgod.com/receipt-logo.webp"></div>
<div class="row" style="margin-bottom:8px"><span class="lbl">Currency</span><input id="setCur" style="width:60px">
<span class="lbl" style="min-width:40px">Tax %</span><input id="setTax" class="num" type="number" step="0.1">
<select id="setTaxType"><option value="exclusive">added on top</option><option value="inclusive">included (GST incl)</option></select></div>
<div class="row" style="margin-bottom:8px"><span class="lbl">Default disc %</span><input id="setDisc" class="num" type="number" step="0.1"></div>
<button class="btn" onclick="saveSettings()">Save</button><span id="setMsg" class="muted" style="margin-left:10px"></span>
</div>
<div class="panel"><h2>✉️ Receipt email — test</h2>
<div class="muted" style="margin-bottom:8px">SMTP credentials live in <a class="pink" href="/admin" target="_blank">admin → Connections</a> (admin-only). Send a sample to check they work.</div>
<div class="row"><input id="testEmail" type="email" placeholder="you@example.com" style="flex:1"><button class="btn" onclick="emailTest()">Send test</button></div>
<div id="testMsg" class="muted" style="margin-top:6px"></div>
</div>
<div class="panel"><h2>📟 Payment terminal (Square)</h2>
<div class="muted" style="margin-bottom:8px">Push card payments straight to a Square Terminal. Keys live in <a class="pink" href="/admin" target="_blank">admin → Connections</a>.</div>
<div id="termStatus" class="muted">checking…</div>
<div class="row" style="margin-top:8px"><button class="btn" onclick="pairTerminal()">Pair a terminal</button></div>
<div id="pairOut" style="margin-top:8px"></div>
</div>
<div class="panel"><h2>Promotions &amp; discounts <span class="muted">— switch on to apply at the counter</span></h2>
<div id="promoList" class="muted">loading…</div></div>
</div>
</div>
<!-- modals -->
<div id="dlg" class="ovl" onclick="if(event.target.id==='dlg')close_('dlg')"><div class="card" id="dlgCard"></div></div>
<script>
const $=s=>document.querySelector(s);
let TOKEN=localStorage.getItem('rg_token')||'', cart=[], customer={id:0,name:'Guest'};
let CUR='$', promos=[], method='cash', splits=[], lastSale=null, TS={}, TERM=null;
const hdr=()=>({'Authorization':'Bearer '+TOKEN,'Content-Type':'application/json'});
const money=n=>CUR+Number(n||0).toFixed(2);
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const get=u=>fetch(u,{headers:hdr()}).then(r=>r.json());
const post=(u,b)=>fetch(u,{method:'POST',headers:hdr(),body:JSON.stringify(b)}).then(r=>r.json());
async function signin(){
TOKEN=($('#tok')&&$('#tok').value.trim())||TOKEN;
const me=await fetch('/admin/me',{headers:hdr()}); if(!me.ok){ if($('#gerr'))$('#gerr').textContent='invalid token'; return; }
$('#who').textContent=(await me.json()).name||'';
localStorage.setItem('rg_token',TOKEN);
$('#gate').style.display='none'; $('#app').style.display='block';
loadSettings(); loadPromos(); loadTerminal(); setMethod('cash');
}
function tab(name){
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('on'));
document.querySelectorAll('#tabs button').forEach(b=>b.classList.toggle('on',b.dataset.tab===name));
$('#tab-'+name).classList.add('on');
if(name==='history'){ loadPast(); loadHolds(); }
if(name==='settings'){ loadPromos(true); }
}
function close_(id){ $('#'+id).style.display='none'; }
/* ── customer ── */
let cst;
$('#custQ').addEventListener('input',e=>{ clearTimeout(cst); cst=setTimeout(()=>custSearch(e.target.value),180); });
async function custSearch(q){
const d=await get('/sales/customers?q='+encodeURIComponent(q));
$('#custDrop').style.display='block';
$('#custDrop').innerHTML=(d.customers||[]).map(c=>`<div class="ri" onclick='pickCust(${JSON.stringify(c).replace(/'/g,"&#39;")})'>
<div class="t"><div class="nm">${esc(c.name)||'Guest'}</div><div class="muted">${esc(c.email||'')}</div></div></div>`).join('')||'<div class="muted" style="padding:8px">no match</div>';
}
function pickCust(c){ customer={id:c.id,name:c.name||'Guest',email:c.email||''}; $('#custChip').innerHTML='👤 '+esc(customer.name); $('#custDrop').style.display='none'; $('#custQ').value=''; }
function resetCust(){ customer={id:0,name:'Guest'}; $('#custChip').innerHTML='👤 Guest'; }
function newCustDlg(){
$('#dlgCard').innerHTML=`<h3 style="margin:0 0 10px">New customer</h3>
<input id="ncName" placeholder="Name" style="width:100%;margin-bottom:8px">
<input id="ncEmail" type="email" placeholder="Email" style="width:100%;margin-bottom:8px">
<input id="ncPhone" placeholder="Phone" style="width:100%;margin-bottom:12px">
<div class="row"><button class="btn" style="flex:1" onclick="addCustomer()">Add &amp; attach</button><button class="ghost" onclick="close_('dlg')">Cancel</button></div>`;
$('#dlg').style.display='flex'; setTimeout(()=>$('#ncName').focus(),50);
}
async function addCustomer(){
const name=$('#ncName').value.trim(); if(!name){ return; }
const [fn,...r]=name.split(' ');
const d=await post('/sales/customers',{first_name:fn,last_name:r.join(' '),email:$('#ncEmail').value.trim(),phone:$('#ncPhone').value.trim()});
if(d.ok){ pickCust({id:d.id,name:d.name,email:$('#ncEmail').value.trim()}); close_('dlg'); }
}
document.addEventListener('click',e=>{ if(!e.target.closest('.res')) document.querySelectorAll('.drop').forEach(d=>d.style.display='none'); });
/* ── cart ── */
let st;
$('#q').addEventListener('input',e=>{ clearTimeout(st); st=setTimeout(()=>doSearch(e.target.value),170); });
$('#q').addEventListener('keydown',e=>{ if(e.key==='Enter'){clearTimeout(st);scan();} });
async function doSearch(q){
if(!q.trim()){ $('#res').style.display='none'; return; }
const d=await get('/sales/search?q='+encodeURIComponent(q));
$('#res').style.display='block';
$('#res').innerHTML=(d.items||[]).map(it=>`<div class="ri" onclick='add(${JSON.stringify(it).replace(/'/g,"&#39;")})'>
${it.thumb?`<img src="${it.thumb}">`:'<span class=ph></span>'}
<div class="t"><div class="nm">${esc(it.title||it.sku)}</div><div class="muted">${esc(it.artist||'')} · ${it.condition||''}${it.crate?' · 📍 '+esc(it.crate):''}</div></div>
<div class="pink">${money(it.price)}</div></div>`).join('')||'<div class="muted" style="padding:8px">no in-stock match</div>';
}
async function scan(){
const q=$('#q').value.trim(); if(!q) return;
const d=await get('/sales/search?q='+encodeURIComponent(q));
if(d.items&&d.items.length) add(d.items[0]); else $('#msg').innerHTML='<span class="warn">no in-stock match for "'+esc(q)+'"</span>';
}
function manualAdd(){
const name=prompt('Item name:'); if(!name) return;
cart.push({sku:'MANUAL-'+Date.now(),title:name,qty:1,price:parseFloat(prompt('Price:','0'))||0,discount:0,manual:true}); $('#res').style.display='none'; render();
}
async function postageDlg(){
const units=cart.filter(c=>!/^POSTAGE-/.test(c.sku)).reduce((s,c)=>s+c.qty,0);
if(!units){ $('#msg').textContent='add items first'; return; }
const d=await get('/sales/shipping/quote?units='+units);
$('#dlgCard').innerHTML=`<h3 style="margin:0 0 6px">📦 AusPost postage</h3>
<div class="muted" style="margin-bottom:8px">${units} item${units>1?'s':''} · ${d.weight_g}g · ${d.parcels} parcel${d.parcels>1?'s':''}</div>
${(d.options||[]).map(o=>`<div class="row" style="justify-content:space-between;padding:8px 0;border-bottom:1px solid #ededf2">
<span>${esc(o.label)}</span><button class="btn" onclick='addPostage(${JSON.stringify(o.label)},${o.price})'>${money(o.price)}</button></div>`).join('')||'<div class="muted">no rate for this weight</div>'}
<div class="row" style="margin-top:12px"><button class="ghost" onclick="close_('dlg')">Cancel</button></div>`;
$('#dlg').style.display='flex';
}
function addPostage(label,price){
cart=cart.filter(c=>!/^POSTAGE-/.test(c.sku)); // one postage line at a time
cart.push({sku:'POSTAGE-'+Date.now(),title:'Postage — '+label,qty:1,price,discount:0,manual:true});
close_('dlg'); render();
}
function add(it){
const ex=cart.find(c=>c.sku===it.sku);
if(ex) ex.qty++; else cart.push({sku:it.sku,title:it.title||it.sku,qty:1,price:it.price||0,discount:0,crate:it.crate});
$('#q').value=''; $('#res').style.display='none'; $('#q').focus(); render();
}
function totals(){
const gross=cart.reduce((s,c)=>s+c.price*c.qty,0), ld=cart.reduce((s,c)=>s+(+c.discount||0),0), sub=gross-ld;
const active=promos.filter(p=>p.manual_active&&p.live);
const promoPct=Math.min(active.reduce((s,p)=>s+(+p.percentage||0),0),100), promoAmt=sub*promoPct/100;
const cd=+$('#cartDisc').value||0, tr=+$('#trade').value||0, tx=+$('#tax').value||0;
const after=Math.max(0,sub-promoAmt-cd), tax=after*tx/100, total=Math.max(0,after+tax-tr);
return {gross,ld,sub,active,promoAmt,cd,tr,tx,tax,total};
}
function render(){
$('#empty').style.display=cart.length?'none':'block';
$('#cart').innerHTML=cart.map((c,i)=>`<tr>
<td>${esc(c.title)}${c.manual?' <span class="muted">(manual)</span>':''}${c.crate?`<div class="muted">📍 ${esc(c.crate)}</div>`:''}</td>
<td><input class="qty" type="number" min="1" value="${c.qty}" oninput="upd(${i},'qty',this.value)"></td>
<td><input class="num" type="number" step="0.01" value="${c.price}" oninput="upd(${i},'price',this.value)"></td>
<td><input class="num" type="number" step="0.01" value="${c.discount}" oninput="upd(${i},'discount',this.value)"></td>
<td>${money(c.price*c.qty-c.discount)}</td><td><span class="x" onclick="rm(${i})">✕</span></td></tr>`).join('');
const t=totals();
$('#promoLines').innerHTML=t.active.map(p=>`<div class="tot"><span class="muted">${esc(p.name)} ${p.percentage}%</span><span>${money(t.sub*p.percentage/100)}</span></div>`).join('');
$('#t-sub').textContent=money(t.sub);
$('#t-total').textContent=money(t.total);
const tend=+$('#tendered').value||0; $('#change').textContent=money(Math.max(0,tend-t.total));
if(method==='split') renderSplit(t.total);
}
function upd(i,k,v){ cart[i][k]= k==='qty'?Math.max(1,+v|0):(+v||0); render(); }
function rm(i){ cart.splice(i,1); render(); }
/* ── checkout ── */
function setMethod(m){
method=m;
document.querySelectorAll('.pm button').forEach(b=>b.classList.toggle('on',b.dataset.m===m));
['Cash','Split','Layby','Terminal'].forEach(p=>$('#p'+p).style.display='none');
if(m==='cash'){ $('#pCash').style.display='block'; $('#tendered').focus&&setTimeout(()=>$('#tendered').focus(),30); }
if(m==='split'){ $('#pSplit').style.display='block'; if(!splits.length) splits=[{method:'cash',amount:0}]; renderSplit(totals().total); }
if(m==='layby'){ $('#pLayby').style.display='block'; }
if(m==='terminal'){ $('#pTerminal').style.display='block'; }
$('#completeBtn').textContent = m==='layby'?'Hold layby & take deposit':(m==='terminal'?'Push to terminal':'Complete '+m+' sale');
}
function addSplit(){ splits.push({method:'card',amount:0}); renderSplit(totals().total); }
function renderSplit(total){
$('#splitRows').innerHTML=splits.map((s,i)=>`<div class="row" style="margin-bottom:5px">
<select onchange="splits[${i}].method=this.value"><option ${s.method==='cash'?'selected':''}>cash</option><option ${s.method==='card'?'selected':''}>card</option><option ${s.method==='eftpos'?'selected':''}>eftpos</option></select>
<input class="num" type="number" step="0.01" value="${s.amount}" oninput="splits[${i}].amount=+this.value||0;splitSum()">
<span class="x" onclick="splits.splice(${i},1);renderSplit(totals().total)">✕</span></div>`).join('');
splitSum();
}
function splitSum(){
const sum=splits.reduce((s,x)=>s+(+x.amount||0),0), total=totals().total;
$('#splitMsg').innerHTML=`tendered ${money(sum)} of ${money(total)} · ${sum>=total-0.005?'<span class="ok">ok</span>':'<span class="warn">'+money(total-sum)+' short</span>'}`;
}
function payload(){
const t=totals();
return { items:cart.map(c=>({sku:c.sku,title:c.title,qty:c.qty,unit_price:c.price,discount:+c.discount||0})),
cart_discount:t.promoAmt+t.cd, trade_in:t.tr, tax_rate:t.tx, customer_id:customer.id,
notes:$('#saleNote').value.trim()||null };
}
async function complete(){
if(!cart.length){ $('#msg').textContent='cart is empty'; return; }
if(method==='terminal'){ runTerminal(); return; }
const t=totals(); let body={...payload()};
if(method==='layby'){
body.payment_method='layby'; body.hold={deposit:+$('#dep').value||0, due_date:$('#due').value||null};
} else if(method==='split'){
const sum=splits.reduce((s,x)=>s+(+x.amount||0),0);
if(sum<t.total-0.005){ $('#msg').innerHTML='<span class="warn">split is '+money(t.total-sum)+' short</span>'; return; }
body.payment_method='split'; body.split=splits.filter(s=>s.amount>0);
} else {
body.payment_method=method;
if(method==='cash'){ const tend=+$('#tendered').value||0;
if(tend && tend<t.total-0.005){ $('#msg').innerHTML='<span class="warn">tendered less than total</span>'; return; }
body.tendered=tend||t.total; }
}
$('#msg').textContent='processing…';
const d=await post('/sales',body);
if(d.ok){ lastSale=d.sale_id; showReceipt(d.sale_id); cart=[]; splits=[]; $('#saleNote').value=''; $('#tendered').value=''; $('#dep').value=0; render(); }
else $('#msg').innerHTML='<span class="warn">failed</span>';
}
/* ── Square Terminal ── */
async function loadTerminal(){
TS=await get('/sales/terminal/status').catch(()=>({}));
$('#btnTerminal').style.display = TS.paired ? '' : 'none';
renderTermSettings();
}
function renderTermSettings(){
const e=$('#termStatus'); if(!e) return;
if(!TS.configured){ e.innerHTML='<span class="warn">Square not set up — add the keys in admin → Connections.</span>'; return; }
e.innerHTML = TS.paired ? `<span class="ok">✓ Terminal paired</span> <span class="muted">device ${esc(TS.device_id||'')}</span>`
: 'Square connected — no terminal paired yet. Click “Pair a terminal”.';
}
async function pairTerminal(){
$('#pairOut').innerHTML='requesting a pairing code…';
const d=await post('/sales/terminal/pair',{});
if(!d.code){ $('#pairOut').innerHTML='<span class="warn">'+esc(d.detail||'failed')+'</span>'; return; }
$('#pairOut').innerHTML=`<div style="text-align:center;background:#fdeef6;border-radius:10px;padding:14px">
On the Square Terminal: <b>Sign in → Use a device code</b>, then enter
<div style="font-size:34px;letter-spacing:5px;font-weight:700;margin:8px 0">${esc(d.code)}</div>
<div class="muted" id="pairPoll">waiting for the terminal…</div></div>`;
const id=d.id, t=setInterval(async()=>{
const p=await get('/sales/terminal/pair/'+id).catch(()=>({}));
if(p.device_id){ clearInterval(t); $('#pairOut').innerHTML='<span class="ok">✓ Paired — device '+esc(p.device_id)+'</span>'; loadTerminal(); }
}, 3000);
setTimeout(()=>clearInterval(t), 300000);
}
async function runTerminal(){
const t=totals(); if(t.total<=0){ $('#msg').textContent='nothing to charge'; return; }
$('#msg').textContent='pushing to terminal…';
const d=await post('/sales/terminal/checkout',{amount:t.total,reference:'POS',note:cart.map(c=>c.title).join(', ').slice(0,400)});
if(!d.id){ $('#msg').innerHTML='<span class="warn">'+esc(d.detail||'terminal error')+'</span>'; return; }
$('#msg').textContent='';
const cid=d.id;
$('#dlgCard').innerHTML=`<h3 style="margin:0 0 8px">📟 Square Terminal</h3>
<div style="text-align:center;padding:10px 0"><div class="pink" style="font-size:30px;font-weight:700">${money(t.total)}</div>
<div class="muted" id="termMsg" style="margin-top:8px">waiting for customer to tap / insert…</div></div>
<button class="ghost" style="width:100%" onclick="cancelTerminal('${cid}')">Cancel</button>`;
$('#dlg').style.display='flex';
TERM=setInterval(async()=>{
const p=await get('/sales/terminal/checkout/'+cid).catch(()=>({}));
if(p.status==='COMPLETED'){ clearInterval(TERM); close_('dlg'); finalizeSale('square'); }
else if(p.status==='CANCELED'||p.status==='CANCEL_REQUESTED'){ clearInterval(TERM); close_('dlg'); $('#msg').innerHTML='<span class="warn">cancelled at terminal</span>'; }
else if(p.status){ const m=$('#termMsg'); if(m) m.textContent='status: '+String(p.status).toLowerCase().replace(/_/g,' ')+'…'; }
}, 2000);
setTimeout(()=>{ if(TERM){ clearInterval(TERM); } }, 180000);
}
function cancelTerminal(cid){ if(TERM) clearInterval(TERM); fetch('/sales/terminal/checkout/'+cid+'/cancel',{method:'POST',headers:hdr()}); close_('dlg'); $('#msg').textContent='cancelled'; }
async function finalizeSale(payMethod){
const d=await post('/sales',{...payload(),payment_method:payMethod,tendered:totals().total});
if(d.ok){ lastSale=d.sale_id; showReceipt(d.sale_id); cart=[]; splits=[]; $('#saleNote').value=''; render(); }
else $('#msg').innerHTML='<span class="warn">paid on terminal but sale-save failed — check History</span>';
}
/* ── receipt ── */
let rcptHtml='';
async function showReceipt(id){
const d=await get('/sales/'+id+'/receipt'); rcptHtml=d.html||''; lastSale=id;
$('#rcptCard').innerHTML=rcptHtml;
$('#checkout').style.display='none'; $('#receiptPane').style.display='block';
$('#receiptPane').dataset.email=d.customer_email||customer.email||'';
$('#rcptMsg').innerHTML='<span class="ok">✓ sale '+esc(d.sale_number||'')+' complete</span>';
}
function printReceipt(){
const w=window.open('','_blank','width=380,height=680'); if(!w){ alert('allow pop-ups to print the receipt'); return; }
w.document.write('<!doctype html><html><head><meta charset=utf-8><title>Receipt</title><style>body{margin:16px;background:#fff}</style></head><body>'+rcptHtml+'<scr'+'ipt>window.onload=function(){setTimeout(function(){window.print()},350)}</scr'+'ipt></body></html>');
w.document.close(); w.focus();
}
function newSale(){ $('#receiptPane').style.display='none'; $('#checkout').style.display='block'; resetCust(); $('#msg').textContent=''; setMethod('cash'); $('#q').focus(); }
function emailDlg(){
const def=$('#receiptPane').dataset.email||'';
$('#dlgCard').innerHTML=`<h3 style="margin:0 0 10px">Email receipt</h3>
<input id="emTo" type="email" value="${esc(def)}" placeholder="customer@example.com" style="width:100%;margin-bottom:12px">
<div class="row"><button class="btn" style="flex:1" onclick="sendEmail()">Send</button><button class="ghost" onclick="close_('dlg')">Cancel</button></div>
<div id="emMsg" class="muted" style="margin-top:8px"></div>`;
$('#dlg').style.display='flex'; setTimeout(()=>$('#emTo').focus(),50);
}
async function sendEmail(){
const to=$('#emTo').value.trim(); if(!to||to.indexOf('@')<0){ $('#emMsg').textContent='enter a valid email'; return; }
$('#emMsg').textContent='sending…';
const r=await fetch('/sales/'+lastSale+'/email',{method:'POST',headers:hdr(),body:JSON.stringify({email:to})});
const d=await r.json().catch(()=>({}));
if(r.ok&&d.ok){ close_('dlg'); $('#rcptMsg').innerHTML='<span class="ok">✓ emailed to '+esc(to)+'</span>'; }
else $('#emMsg').innerHTML='<span class="warn">'+esc(d.detail||'send failed — check SMTP in Connections')+'</span>';
}
/* ── layby / holds ── */
async function loadHolds(){
const d=await get('/sales?status=holds');
$('#holds').innerHTML=(d.sales&&d.sales.length)? d.sales.map(s=>`<div class="row" style="padding:6px 0;border-bottom:1px solid #ededf2">
<span style="flex:1">${esc(s.sale_number)} · bal <b class="pink">${money(s.balance)}</b>${s.hold_expires_at?' · due '+String(s.hold_expires_at).slice(0,10):''}</span>
<button class="ghost" onclick="collect(${s.id},${s.balance})">Collect</button></div>`).join('') : '<div class="muted">no open laybys</div>';
}
async function collect(id,bal){
const amt=parseFloat(prompt('Collect amount (balance '+money(bal)+'):', bal.toFixed(2))); if(!amt) return;
const d=await post('/sales/'+id+'/pay',{amount:amt,method:'cash'}); if(d.ok){ loadHolds();
if(d.completed){ showReceipt(id); tab('register'); } }
}
/* ── past sales ── */
let pst;
function loadPast(){ clearTimeout(pst); pst=setTimeout(async()=>{
const d=await get('/sales/past?q='+encodeURIComponent($('#pastQ').value||''));
$('#pastEmpty').textContent=(d.sales&&d.sales.length)?'':'no sales found';
$('#pastRows').innerHTML=(d.sales||[]).map(s=>`<tr>
<td class="pink">${esc(s.sale_number||('#'+s.id))}</td><td>${s.sale_date?String(s.sale_date).slice(0,10):'—'}</td>
<td>${esc(s.customer)}</td><td>${s.items}</td><td>${money(s.total)}</td>
<td><span class="muted">${esc(s.payment_status||s.status||'')}</span></td>
<td><button class="ghost" onclick="reprint(${s.id})">Receipt</button></td></tr>`).join('');
},150); }
async function reprint(id){ tab('register'); await showReceipt(id); }
/* ── promotions ── */
async function loadPromos(forList){
promos=(await get('/sales/discounts?active_only=false')).discounts||[];
const active=promos.filter(p=>p.manual_active&&p.live);
$('#activePromos').innerHTML=active.length? active.map(p=>`<span class="chip" style="margin:2px;cursor:default">${esc(p.name)} ${p.percentage}%</span>`).join('') : 'none active';
render();
if(forList) $('#promoList').innerHTML=promos.length? promos.map(p=>`<div class="row" style="justify-content:space-between;padding:6px 0;border-bottom:1px solid #ededf2">
<span><b>${esc(p.name)}</b> · ${p.percentage}% <span class="muted">${esc(p.discount_type||'')}${p.live?'':' · scheduled/expired'}</span></span>
<button class="ghost ${p.manual_active?'on':''}" onclick="togglePromo(${p.id})">${p.manual_active?'ON':'off'}</button></div>`).join('') : '<div class="muted">no discounts defined yet</div>';
}
async function togglePromo(id){ await fetch('/sales/discounts/'+id+'/toggle',{method:'POST',headers:hdr()}); loadPromos(true); }
/* ── bulk add ── */
function bulkDlg(){
$('#dlgCard').innerHTML=`<h3 style="margin:0 0 8px">Bulk add to cart</h3>
<div class="muted" style="margin-bottom:6px">One SKU / Release ID / barcode per line.</div>
<textarea id="bulkList" rows="7" style="width:100%;margin-bottom:8px" placeholder="12345&#10;ABC-001"></textarea>
<div class="row"><button class="btn" style="flex:1" onclick="runBulk()">Resolve &amp; add</button><button class="ghost" onclick="close_('dlg')">Cancel</button></div>
<div id="bulkMsg" class="muted" style="margin-top:6px"></div>`;
$('#dlg').style.display='flex';
}
async function runBulk(){
const lines=$('#bulkList').value.split('\n').map(s=>s.trim()).filter(Boolean); if(!lines.length) return;
$('#bulkMsg').textContent='resolving '+lines.length+'…'; let n=0, miss=[];
for(const ln of lines){ const d=await get('/sales/search?q='+encodeURIComponent(ln));
if(d.items&&d.items.length){ add(d.items[0]); n++; } else miss.push(ln); }
$('#bulkMsg').innerHTML=`<span class="ok">✓ added ${n}</span>`+(miss.length?` <span class="warn">· not found: ${miss.map(esc).join(', ')}</span>`:'');
if(n) setTimeout(()=>close_('dlg'),700);
}
/* ── settings ── */
async function loadSettings(){
const s=await get('/sales/settings'); CUR=s.currency_symbol||'$';
$('#setName').value=s.store_name||''; $('#setAddr').value=s.store_address||''; $('#setFooter').value=s.receipt_footer||''; $('#setLogo').value=s.store_logo||'';
$('#setCur').value=CUR; $('#setTax').value=s.tax_rate; $('#setDisc').value=s.discount_rate; $('#setTaxType').value=s.tax_type||'exclusive';
if(!(+$('#tax').value)) $('#tax').value=s.tax_rate||0;
render();
}
async function saveSettings(){
await post('/sales/settings',{currency_symbol:$('#setCur').value,tax_rate:$('#setTax').value,discount_rate:$('#setDisc').value,
tax_type:$('#setTaxType').value,store_name:$('#setName').value,store_address:$('#setAddr').value,receipt_footer:$('#setFooter').value,store_logo:$('#setLogo').value});
CUR=$('#setCur').value||'$'; $('#setMsg').textContent='saved'; setTimeout(()=>$('#setMsg').textContent='',1500); render();
}
async function emailTest(){
const to=$('#testEmail').value.trim(); if(!to||to.indexOf('@')<0){ $('#testMsg').textContent='enter a valid email'; return; }
$('#testMsg').textContent='sending…';
const r=await fetch('/sales/email-test',{method:'POST',headers:hdr(),body:JSON.stringify({email:to})});
const d=await r.json().catch(()=>({}));
$('#testMsg').innerHTML=(r.ok&&d.ok)?`<span class="ok">✓ test sent to ${esc(to)} from ${esc(d.from||'')}</span>`:`<span class="warn">${esc(d.detail||'failed — set smtp_* in admin → Connections')}</span>`;
}
if(TOKEN){ signin(); }
</script>
</body>
</html>