RECORDGOD/site/pos.html
type-two 53b1a64a48 feat(nav): light/dark switch every page + store→rack→crate navigator
- nav.js owns a light/dark toggle (persisted, default light) across all owner pages
- /nav API: spaces, store-layout, rack/{id}, crate/{id} over migrated virtual_* geometry
- /search rebuilt as Store→Rack→Crate drill-down (true rack rotation, per-level slot grid,
  crate facing arrows + BACK/FRONT/L/R, search→locate)
- facing convention (directionYaw, slot-grid, rotatedArrows, world composition) in RECORDGOD_NAVIGATOR_PLAN.md
- no-cache HTML routes so deploys show instantly

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

402 lines
25 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;--ink:#f4f5f7;--pn:#ffffff;--pn2:#ffffff;--line:#e2e2ea;--mut:#5f5f6c;--ok:#1a8f54;--warn:#b06a00}
*{box-sizing:border-box}
html,body{margin:0;background:var(--ink);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)}
input,select,textarea{padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:var(--pn2);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(--ink);z-index:50}
#gate .b{width:320px;text-align:center}#gate h1{color:var(--pink)}
.wrap{max-width:1240px;margin:0 auto;padding:16px}
h1.pg{font-size:24px;margin:4px 0 14px;font-weight:600}
.tabs{display:flex;gap:6px;flex-wrap:wrap;border-bottom:1px solid var(--line);margin-bottom:16px}
.tabs button{background:none;color:var(--mut);padding:9px 14px;border-radius:8px 8px 0 0;font-weight:600;font-size:13px}
.tabs button.on{color:var(--pink);background:var(--pn);border:1px solid var(--line);border-bottom-color:var(--pn)}
.tab{display:none}.tab.on{display:block}
.panel{background:var(--pn);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:14px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
.panel h2{font-size:14px;font-weight:600;margin:0 0 10px;color:#33333a}
.grid{display:grid;grid-template-columns:1.35fr 1fr;gap:16px}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.lbl{color:var(--mut);font-size:12px;min-width:74px}
.res{position:relative}
.drop{position:absolute;left:0;right:0;top:calc(100% + 4px);background:#fff;border:1px solid var(--line);border-radius:9px;max-height:280px;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 10px;border-radius:20px;font-weight:600}
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:46px}.disc{width:66px}
.tot{display:flex;justify-content:space-between;align-items:center;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:10px 0}.pillbtns button{flex:1}
.promo{display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid #ededf2}
.x{color:var(--mut);cursor:pointer}
.hold{display:flex;gap:8px;align-items:center;padding:8px 0;border-bottom:1px solid #ededf2;font-size:13px}
iframe{width:100%;height:72vh;border:1px solid var(--line);border-radius:12px;background:#fff}
#rcpt{position:fixed;inset:0;background:rgba(0,0,0,.6);display:none;align-items:center;justify-content:center;z-index:60}
#rcpt .card{background:#fff;color:#111;width:340px;max-height:86vh;overflow:auto;border-radius:10px;padding:20px;font:13px/1.5 ui-monospace,monospace}
#rcpt h3{margin:0 0 2px;text-align:center}#rcpt .rt{display:flex;justify-content:space-between}
@media print{body *{visibility:hidden}#rcpt,#rcpt *{visibility:visible}#rcpt{position:absolute;inset:0;background:#fff}#rcpt .noprint{display:none}}
</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">
<h1 class="pg">Sales Management</h1>
<div class="tabs" id="tabs">
<button data-tab="sale" class="on" onclick="tab('sale')">Sales</button>
<button data-tab="plans" onclick="tab('plans')">Payment Plans</button>
<button data-tab="past" onclick="tab('past')">Past Sales</button>
<button data-tab="locate" onclick="tab('locate')">Locate</button>
<button data-tab="promo" onclick="tab('promo')">Promotions</button>
<button data-tab="import" onclick="tab('import')">Import</button>
<button data-tab="settings" onclick="tab('settings')">Settings</button>
</div>
<!-- ── SALES ─────────────────────────────────────────── -->
<div id="tab-sale" class="tab on">
<div class="panel">
<div class="row" style="justify-content:space-between">
<div class="row res" style="flex:1;min-width:280px">
<span class="lbl">Customer</span>
<span id="custChip" class="chip">👤 Guest Sale</span>
<input id="custQ" placeholder="search customers…" style="flex:1" autocomplete="off">
<div id="custDrop" class="drop" style="display:none"></div>
</div>
<div class="row">
<span class="muted">or</span>
<input id="newName" placeholder="Name" style="width:130px">
<input id="newEmail" placeholder="Email" style="width:150px">
<button class="ghost" onclick="addCustomer()">Add</button>
</div>
</div>
</div>
<div class="grid">
<div>
<div class="panel"><h2>Scan / enter SKU · Release ID · Barcode</h2>
<div class="row res">
<input id="q" placeholder="scan barcode or enter SKU / Release ID…" style="flex:1" autocomplete="off">
<button class="btn" onclick="scan()">Scan</button>
<button class="ghost" onclick="manualAdd()">Manual Add</button>
<div id="res" class="drop" style="display:none"></div>
</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 — scan or search 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 id="promoLines"></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>Promotions on now <span class="muted">— toggle in Promotions tab</span></h2>
<div id="activePromos" class="muted">none active</div>
</div>
</div>
</div>
</div>
<!-- ── PAYMENT PLANS ─────────────────────────────────── -->
<div id="tab-plans" class="tab">
<div class="panel"><h2>💳 Put current cart on a payment plan</h2>
<div class="row"><span class="lbl">Deposit</span><input id="dep" class="disc" type="number" step="0.01" value="0">
<span class="lbl">Due date</span><input id="due" type="date">
<button class="btn" onclick="hold()">Hold cart &amp; take deposit</button></div>
<div class="muted" style="margin-top:6px">Builds from the Sales-tab cart: reserves the items, banks the deposit, balance due by the date.</div>
<div id="planMsg" class="muted" style="margin-top:6px"></div>
</div>
<div class="panel"><h2>📋 Open plans &amp; holds</h2><div id="holds" class="muted">loading…</div></div>
</div>
<!-- ── PAST SALES ────────────────────────────────────── -->
<div id="tab-past" class="tab">
<div class="panel"><h2>Past sales</h2>
<div class="row"><input id="pastQ" placeholder="search sale # or customer…" style="flex:1" oninput="loadPast()"></div>
<table style="margin-top:10px"><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>
<!-- ── LOCATE (the search workstation, embedded) ─────── -->
<div id="tab-locate" class="tab">
<div class="muted" style="margin-bottom:8px">Find any item and light up its bin on the store map. <a class="pink" href="/search" target="_blank">open full screen ↗</a></div>
<iframe id="locFrame" data-src="/search" title="search and locate"></iframe>
</div>
<!-- ── PROMOTIONS ────────────────────────────────────── -->
<div id="tab-promo" class="tab">
<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>
<!-- ── IMPORT ────────────────────────────────────────── -->
<div id="tab-import" class="tab">
<div class="panel"><h2>Bulk import sale</h2>
<div class="muted" style="margin-bottom:8px">One SKU / Release ID / barcode per line — resolves each to in-stock inventory, rings them up as one sale and marks them sold.</div>
<textarea id="impList" rows="8" style="width:100%" placeholder="12345&#10;ABC-001&#10;0123456789012"></textarea>
<div class="row" style="margin-top:8px"><button class="btn" onclick="runImport()">Resolve &amp; ring up</button>
<span id="impMsg" class="muted"></span></div>
<div id="impOut" class="muted" style="margin-top:8px"></div>
</div>
</div>
<!-- ── SETTINGS ──────────────────────────────────────── -->
<div id="tab-settings" class="tab">
<div class="panel"><h2>Sales settings</h2>
<div class="row" style="margin-bottom:8px"><span class="lbl">Currency</span><input id="setCur" style="width:60px"></div>
<div class="row" style="margin-bottom:8px"><span class="lbl">Tax %</span><input id="setTax" class="disc" type="number" step="0.1">
<select id="setTaxType"><option value="exclusive">added on top (exclusive)</option><option value="inclusive">included in price (inclusive)</option></select></div>
<div class="row" style="margin-bottom:8px"><span class="lbl">Default discount %</span><input id="setDisc" class="disc" type="number" step="0.1"></div>
<button class="btn" onclick="saveSettings()">Save settings</button>
<span id="setMsg" class="muted" style="margin-left:10px"></span>
</div>
</div>
</div>
<!-- printable receipt -->
<div id="rcpt" onclick="if(event.target.id==='rcpt')closeRcpt()"><div class="card" id="rcptCard"></div></div>
<script>
const $=s=>document.querySelector(s);
let TOKEN=localStorage.getItem('rg_token')||'', cart=[], customer={id:0,name:'Guest Sale'};
let CUR='$', promos=[]; // promos = all discounts; manual_active+live drive the calc
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());
async function signin(){
TOKEN=($('#tok')&&$('#tok').value.trim())||TOKEN;
const r=await fetch('/admin/stats',{headers:hdr()});
if(!r.ok){ if($('#gerr'))$('#gerr').textContent='invalid token'; return; }
localStorage.setItem('rg_token',TOKEN);
$('#gate').style.display='none'; $('#app').style.display='block';
loadSettings(); loadPromos(); loadHolds();
}
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==='past') loadPast();
if(name==='promo') loadPromos(true);
if(name==='sale') loadPromos();
if(name==='locate'){ const f=$('#locFrame'); if(!f.src) f.src=f.dataset.src; }
}
/* ── 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));
const list=[{id:0,name:'Guest Sale',email:''}].concat(d.customers||[]);
$('#custDrop').style.display='block';
$('#custDrop').innerHTML=list.map(c=>`<div class="ri" onclick='pickCust(${JSON.stringify(c).replace(/'/g,"&#39;")})'>
<div class="t"><div class="nm">${esc(c.name)||'Guest Sale'}</div><div class="muted">${esc(c.email||'')}</div></div></div>`).join('');
}
function pickCust(c){ customer={id:c.id,name:c.name||'Guest Sale'}; $('#custChip').innerHTML='👤 '+esc(customer.name); $('#custDrop').style.display='none'; $('#custQ').value=''; }
async function addCustomer(){
const name=$('#newName').value.trim(), email=$('#newEmail').value.trim();
if(!name){ alert('name required'); return; }
const [fn,...rest]=name.split(' ');
const d=await fetch('/sales/customers',{method:'POST',headers:hdr(),body:JSON.stringify({first_name:fn,last_name:rest.join(' '),email})}).then(r=>r.json());
if(d.ok){ pickCust({id:d.id,name:d.name}); $('#newName').value='';$('#newEmail').value=''; }
}
document.addEventListener('click',e=>{ if(!e.target.closest('.res')) document.querySelectorAll('.drop').forEach(d=>d.style.display='none'); });
/* ── ring up ── */
let st;
$('#q').addEventListener('input',e=>{ clearTimeout(st); st=setTimeout(()=>doSearch(e.target.value),180); });
$('#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(){ // exact resolve on Enter / Scan: first match straight into the cart
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 (manual line):'); if(!name) return;
const price=parseFloat(prompt('Price:','0'))||0;
cart.push({sku:'MANUAL-'+Date.now(),title:name,qty:1,price,discount:0,crate:null,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 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="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 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);
const promoAmt=sub*promoPct/100;
$('#promoLines').innerHTML=active.map(p=>`<div class="tot"><span class="muted">${esc(p.name)} ${p.percentage}%</span><span>${money(sub*p.percentage/100)}</span></div>`).join('');
const cd=+$('#cartDisc').value||0, tr=+$('#trade').value||0, tx=+$('#tax').value||0;
const after=sub-promoAmt-cd, tax=after*tx/100, total=after+tax-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(){
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 promoAmt=sub*Math.min(active.reduce((s,p)=>s+(+p.percentage||0),0),100)/100;
return { items:cart.map(c=>({sku:c.sku,title:c.title,qty:c.qty,unit_price:c.price,discount:+c.discount||0})),
cart_discount:promoAmt+(+$('#cartDisc').value||0), trade_in:+$('#trade').value||0,
tax_rate:+$('#tax').value||0, customer_id:customer.id };
}
async function complete(method){
if(!cart.length){ $('#msg').textContent='cart is empty'; return; }
$('#msg').textContent='processing…';
const d=await fetch('/sales',{method:'POST',headers:hdr(),body:JSON.stringify({...payload(),payment_method:method})}).then(r=>r.json());
if(d.ok){ $('#msg').innerHTML=`<span class="ok">✓ sale ${d.sale_number} · ${money(d.total)} ${method} · ${esc(customer.name)}</span>`;
cart=[]; render(); loadHolds(); } else $('#msg').textContent='failed';
}
async function hold(){
if(!cart.length){ $('#planMsg').textContent='cart is empty — add items on the Sales tab first'; return; }
const dep=+$('#dep').value||0, due=$('#due').value||null;
const d=await fetch('/sales',{method:'POST',headers:hdr(),body:JSON.stringify({...payload(),payment_method:'hold',hold:{deposit:dep,due_date:due}})}).then(r=>r.json());
if(d.ok){ $('#planMsg').innerHTML=`<span class="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 get('/sales?status=holds');
$('#holds').innerHTML=(d.sales&&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 plans</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();
}
/* ── past sales + receipt ── */
let pst;
async 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="receipt(${s.id})">Receipt</button></td></tr>`).join('');
},150);
}
async function receipt(id){
const d=await get('/sales/'+id);
const s=d.sale, it=d.items||[];
$('#rcptCard').innerHTML=`<h3>RecordGod</h3><div style="text-align:center" class="muted">${esc(s.sale_number||'')}</div>
<div class="muted" style="text-align:center;margin-bottom:8px">${s.sale_date?String(s.sale_date).slice(0,16).replace('T',' '):''}</div><hr>
${it.map(i=>`<div class="rt"><span>${i.qty}× ${esc(i.item_name||i.sku)}</span><span>${money(i.line_total)}</span></div>`).join('')}
<hr><div class="rt"><b>Total</b><b>${money(s.total)}</b></div>
<div class="rt muted"><span>${esc(s.payment_method||'')}</span><span>${esc(s.payment_status||s.status||'')}</span></div>
<div style="text-align:center;margin-top:10px" class="muted">thanks for digging 🎶</div>
<div class="noprint" style="display:flex;gap:8px;margin-top:14px"><button class="btn" style="flex:1" onclick="window.print()">Print</button><button class="ghost" onclick="closeRcpt()">Close</button></div>`;
$('#rcpt').style.display='flex';
}
function closeRcpt(){ $('#rcpt').style.display='none'; }
/* ── 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">${esc(p.name)} ${p.percentage}%</span>`).join('') : 'none active';
render();
if(forList) $('#promoList').innerHTML=promos.length? promos.map(p=>`<div class="promo">
<span><b>${esc(p.name)}</b> · ${p.percentage}% <span class="muted">${esc(p.discount_type||'')}${p.description?' · '+esc(p.description):''}</span>${p.live?'':' <span class="warn">(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 — they migrate from WowPlatter</div>';
}
async function togglePromo(id){ await fetch('/sales/discounts/'+id+'/toggle',{method:'POST',headers:hdr()}); loadPromos(true); }
/* ── import ── */
async function runImport(){
const lines=$('#impList').value.split('\n').map(s=>s.trim()).filter(Boolean);
if(!lines.length) return;
$('#impMsg').textContent='resolving '+lines.length+'…';
const found=[], miss=[];
for(const ln of lines){ const d=await get('/sales/search?q='+encodeURIComponent(ln));
if(d.items&&d.items.length) found.push(d.items[0]); else miss.push(ln); }
if(!found.length){ $('#impMsg').textContent=''; $('#impOut').innerHTML='<span class="warn">nothing resolved</span>'; return; }
const body={items:found.map(it=>({sku:it.sku,title:it.title,qty:1,unit_price:it.price||0,discount:0})),
cart_discount:0,trade_in:0,tax_rate:0,payment_method:'import',customer_id:customer.id};
const d=await fetch('/sales',{method:'POST',headers:hdr(),body:JSON.stringify(body)}).then(r=>r.json());
$('#impMsg').textContent='';
$('#impOut').innerHTML=`<span class="ok">✓ ${found.length} sold as ${d.sale_number} · ${money(d.total)}</span>`+
(miss.length?`<div class="warn">not found: ${miss.map(esc).join(', ')}</div>`:'');
loadHolds();
}
/* ── settings ── */
async function loadSettings(){
const s=await get('/sales/settings');
CUR=s.currency_symbol||'$';
$('#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(){
const body={currency_symbol:$('#setCur').value,tax_rate:$('#setTax').value,discount_rate:$('#setDisc').value,tax_type:$('#setTaxType').value};
await fetch('/sales/settings',{method:'POST',headers:hdr(),body:JSON.stringify(body)});
CUR=body.currency_symbol||'$'; $('#setMsg').textContent='saved'; setTimeout(()=>$('#setMsg').textContent='',1500); render();
}
if(TOKEN){ signin(); }
</script>
</body>
</html>