- 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>
402 lines
25 KiB
HTML
402 lines
25 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;--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 & 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 & 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 & 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 ABC-001 0123456789012"></textarea>
|
||
<div class="row" style="margin-top:8px"><button class="btn" onclick="runImport()">Resolve & 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=>({'&':'&','<':'<','>':'>','"':'"'}[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,"'")})'>
|
||
<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,"'")})'>
|
||
${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>
|