RECORDGOD/site/pos.html

164 lines
9.9 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=3"></script>
<style>
:root{--pink:#ff5db1;--ink:#0b0b0d;--pn:#141418;--line:#26262c;--mut:#9a9aa6;--ok:#46d18a;--warn:#e6a046}
*{box-sizing:border-box}
html,body{margin:0;background:var(--ink);color:#f0f0f2;font:14px/1.5 system-ui,sans-serif}
button{cursor:pointer;border:0;border-radius:8px;font:500 13px system-ui}
.btn{background:var(--pink);color:#1a0a14;padding:10px 14px}
.ghost{background:#1d1d22;color:#ccc;border:1px solid var(--line);padding:8px 11px}
input,select{padding:8px 9px;border:1px solid var(--line);border-radius:7px;background:#0e0e11;color:#eee;font:14px system-ui}
#gate{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--ink);z-index:50}
#gate .b{width:320px;text-align:center}#gate h1{color:var(--pink)}
#app{display:none;grid-template-columns:1.3fr 1fr;gap:18px;padding:18px;max-width:1200px;margin:0 auto}
.panel{background:var(--pn);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:14px}
.panel h2{font-size:14px;font-weight:600;margin:0 0 10px}
.row{display:flex;gap:8px;align-items:center}
.res{max-height:240px;overflow:auto;margin-top:8px}
.ri{display:flex;gap:10px;align-items:center;padding:7px;border-radius:8px;cursor:pointer}
.ri:hover{background:#17171c}.ri img,.ri .ph{width:38px;height:38px;border-radius:5px;object-fit:cover;background:#222;flex-shrink:0}
.ri .t{flex:1;min-width:0}.ri .t .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.muted{color:var(--mut);font-size:12px}.pink{color:var(--pink)}
table{width:100%;border-collapse:collapse;font-size:13px}
td,th{padding:6px 5px;border-bottom:1px solid #1b1b20;text-align:left}
th{color:var(--mut);font-weight:500}
.qty{width:46px}.disc{width:62px}
.tot{display:flex;justify-content:space-between;padding:4px 0}
.tot.big{font-size:22px;font-weight:600;border-top:1px solid var(--line);margin-top:6px;padding-top:10px}
.tot.big b{color:var(--pink)}
.pillbtns{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}
.pillbtns button{flex:1}
.hold{display:flex;gap:8px;align-items:center;padding:8px 0;border-bottom:1px solid #1b1b20;font-size:13px}
.x{color:var(--mut);cursor:pointer}
</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 id="app">
<div>
<div class="panel"><h2>🔍 Ring up <span class="muted">— search title / artist / SKU / scan</span></h2>
<div class="row"><input id="q" placeholder="search or scan to add…" style="flex:1" autocomplete="off"></div>
<div id="res" class="res"></div></div>
<div class="panel"><h2>🛒 Cart</h2>
<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 — search above to add items</div></div>
</div>
<div>
<div class="panel"><h2>Total</h2>
<div class="tot"><span class="muted">Subtotal</span><span id="t-sub">$0.00</span></div>
<div class="tot"><span class="muted">Cart discount</span><input id="cartDisc" class="disc" type="number" step="0.01" value="0" oninput="render()"></div>
<div class="tot"><span class="muted">Trade-in credit</span><input id="trade" class="disc" type="number" step="0.01" value="0" oninput="render()"></div>
<div class="tot"><span class="muted">Tax %</span><input id="tax" class="disc" type="number" step="0.1" value="0" oninput="render()"></div>
<div class="tot big"><span>Total</span><b id="t-total">$0.00</b></div>
<div class="pillbtns">
<button class="btn" onclick="complete('cash')">Cash</button>
<button class="btn" onclick="complete('card')">Card</button>
<button class="btn" onclick="complete('eftpos')">EFTPOS</button>
</div>
<div id="msg" class="muted"></div></div>
<div class="panel"><h2>💳 Payment plan / hold</h2>
<div class="row"><span class="muted">Deposit</span><input id="dep" class="disc" type="number" step="0.01" value="0">
<span class="muted">Due</span><input id="due" type="date"><button class="ghost" onclick="hold()">Put on hold</button></div>
<div class="muted" style="margin-top:6px">Reserves the items, takes the deposit, balance due by the date.</div></div>
<div class="panel"><h2>📋 Open holds <span class="muted">— collect balance</span></h2>
<div id="holds" class="muted">loading…</div></div>
</div>
</div>
<script>
const $=s=>document.querySelector(s);
let TOKEN=localStorage.getItem('rg_token')||'', cart=[];
const hdr=()=>({'Authorization':'Bearer '+TOKEN,'Content-Type':'application/json'});
const money=n=>'$'+Number(n||0).toFixed(2);
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
async function signin(){
TOKEN=$('#tok').value.trim();
const r=await fetch('/sales?status=holds',{headers:hdr()});
if(!r.ok){$('#gerr').textContent='invalid token';return;}
localStorage.setItem('rg_token',TOKEN);
$('#gate').style.display='none'; $('#app').style.display='grid';
$('#q').focus(); loadHolds();
}
let st;
$('#q')&&($('#q').addEventListener('input',e=>{ clearTimeout(st); st=setTimeout(()=>doSearch(e.target.value),180); }));
async function doSearch(q){
if(!q.trim()){ $('#res').innerHTML=''; return; }
const d=await fetch('/sales/search?q='+encodeURIComponent(q),{headers:hdr()}).then(r=>r.json());
$('#res').innerHTML=d.items.map((it,i)=>`<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>';
}
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').innerHTML=''; $('#q').focus(); render();
}
function render(){
$('#empty').style.display=cart.length?'none':'block';
$('#cart').innerHTML=cart.map((c,i)=>`<tr>
<td>${esc(c.title)}${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="disc" type="number" step="0.01" value="${c.price}" oninput="upd(${i},'price',this.value)"></td>
<td><input class="disc" 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 gross=cart.reduce((s,c)=>s+c.price*c.qty,0), ld=cart.reduce((s,c)=>s+(+c.discount||0),0);
const cd=+$('#cartDisc').value||0, tr=+$('#trade').value||0, tx=+$('#tax').value||0;
const sub=gross-ld, after=sub-cd, total=after+after*tx/100-tr;
$('#t-sub').textContent=money(sub);
$('#t-total').textContent=money(Math.max(0,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(); }
function payload(){
return { items:cart.map(c=>({sku:c.sku,title:c.title,qty:c.qty,unit_price:c.price,discount:+c.discount||0})),
cart_discount:+$('#cartDisc').value||0, trade_in:+$('#trade').value||0, tax_rate:+$('#tax').value||0 };
}
async function complete(method){
if(!cart.length){ $('#msg').textContent='cart is empty'; return; }
$('#msg').textContent='processing…';
const r=await fetch('/sales',{method:'POST',headers:hdr(),body:JSON.stringify({...payload(),payment_method:method})});
const d=await r.json();
if(d.ok){ $('#msg').innerHTML=`<span style="color:var(--ok)">✓ sale ${d.sale_number} · ${money(d.total)} ${method}</span>`; cart=[]; render(); loadHolds(); }
else $('#msg').textContent='failed';
}
async function hold(){
if(!cart.length){ $('#msg').textContent='cart is empty'; return; }
const dep=+$('#dep').value||0, due=$('#due').value||null;
const r=await fetch('/sales',{method:'POST',headers:hdr(),body:JSON.stringify({...payload(),payment_method:'hold',hold:{deposit:dep,due_date:due}})});
const d=await r.json();
if(d.ok){ $('#msg').innerHTML=`<span style="color:var(--ok)">✓ held ${d.sale_number} · deposit ${money(d.amount_paid)} · balance ${money(d.balance)}</span>`; cart=[]; $('#dep').value=0; render(); loadHolds(); }
}
async function loadHolds(){
const d=await fetch('/sales?status=holds',{headers:hdr()}).then(r=>r.json());
$('#holds').innerHTML=d.sales.length? d.sales.map(s=>`<div class="hold">
<span style="flex:1">${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="payHold(${s.id},${s.balance})">Collect</button></div>`).join('')
: '<div class="muted">no open holds</div>';
}
async function payHold(id,bal){
const amt=parseFloat(prompt('Amount to collect (balance '+money(bal)+'):', bal.toFixed(2)));
if(!amt) return;
const d=await fetch('/sales/'+id+'/pay',{method:'POST',headers:hdr(),body:JSON.stringify({amount:amt,method:'cash'})}).then(r=>r.json());
if(d.ok){ loadHolds(); $('#msg').innerHTML=d.completed?`<span style="color:var(--ok)">✓ paid off</span>`:`<span class="muted">balance ${money(d.balance)}</span>`; }
}
if(TOKEN){ $('#tok').value=TOKEN; signin(); }
</script>
</body>
</html>