RECORDGOD/site/pos.html
type-two bf08cd9b35 feat(pos): receipt logo + Monster Robot template (store name/address/footer)
receipts.py renders store_logo (img) above the title; store_logo added to sales
settings (get/save) + a Logo URL field in POS Settings. Logo hosted at
site/receipt-logo.webp (Monster Robot, from WowPlatter receipt_templates). Print
window now waits for window.onload so the logo prints. Deploy via /opt/recordgod/deploy.sh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:33:08 +10:00

459 lines
29 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>
</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="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>
<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>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;
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(); 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();
}
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'].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'; }
$('#completeBtn').textContent = m==='layby'?'Hold layby & take deposit':'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; }
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>';
}
/* ── 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>