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>
459 lines
29 KiB
HTML
459 lines
29 KiB
HTML
<!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 & 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 & 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 & 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=>({'&':'&','<':'<','>':'>','"':'"'}[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,"'")})'>
|
||
<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 & 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,"'")})'>
|
||
${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 ABC-001"></textarea>
|
||
<div class="row"><button class="btn" style="flex:1" onclick="runBulk()">Resolve & 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>
|