RECORDGOD/site/admin.html
type-two 336d525585 feat(admin): comprehensive lazy dashboard + reports workbench (Chart.js)
Dashboard: fast KPI tiles (instant /stats) + lazy 30-day revenue line + stock-by-format
donut. Reports rebuilt into a parameterized workbench — date presets (7d/30d/90d/12mo/all
+ custom from/to) × dimension (trend/genre/style/format/condition/payment/weekday/hour +
top releases/artists/labels/customers) × metric (revenue/count), each chart fetched ON
DEMAND. New /admin/report/{kpis,series,breakdown,top,stock} (whitelisted dims, bound dates).

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

658 lines
48 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 — admin</title>
<script defer src="/nav.js?v=5"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
<style>
:root{--pink:#d10f7a;--ink:#f4f5f7;--panel:#ffffff;--line:#e2e2ea;--mut:#5f5f6c;--ok:#1a8f54;--warn:#b06a00}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--ink);color:#1b1b22;font:14px/1.5 system-ui,sans-serif}
a{color:var(--pink);text-decoration:none}
/* gate */
#gate{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--ink);z-index:10}
#gate .box{width:340px;text-align:center}
#gate h1{color:var(--pink);font-weight:600}
input,select{padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:#ffffff;color:#1b1b22;font:14px system-ui}
button{padding:9px 13px;border:0;border-radius:8px;background:var(--pink);color:#1a0a14;font:500 14px system-ui;cursor:pointer}
button.ghost{background:#f3f3f6;color:#2a2a30;border:1px solid var(--line)}
/* layout */
#app{display:none;grid-template-columns:212px 1fr;height:calc(100vh - 44px)}
nav{background:#ffffff;border-right:1px solid var(--line);padding:14px 10px;overflow:auto}
nav .brand{font-weight:600;font-size:18px;padding:6px 10px 14px}
nav .brand b{color:var(--pink)}
nav a{display:block;color:#2a2a30;padding:9px 12px;border-radius:8px;margin:2px 0;cursor:pointer}
nav a:hover{background:#f3f3f6}
nav a.on{background:#fdeef6;color:var(--pink)}
nav .sec{color:#55555f;font-size:11px;text-transform:uppercase;letter-spacing:.1em;padding:14px 12px 4px}
main{overflow:auto;padding:22px 26px}
h2{font-weight:600;font-size:20px;margin:0 0 16px}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin-bottom:24px}
.kpi{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px}
.kpi .n{font-size:28px;font-weight:600}
.kpi .l{color:var(--mut);font-size:13px;margin-top:2px}
.kpi .n.pink{color:var(--pink)}
.bar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
.bar input{flex:1;min-width:200px}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;color:var(--mut);font-weight:500;padding:8px 10px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--ink)}
td{padding:8px 10px;border-bottom:1px solid #1b1b20}
tr:hover td{background:#ffffff}
.thumb{width:34px;height:34px;border-radius:4px;object-fit:cover;background:#ececf0;vertical-align:middle}
.pill{padding:2px 8px;border-radius:20px;font-size:11px;background:#f3f3f6;color:#bbb}
.pill.in{background:#15291f;color:var(--ok)} .pill.out{background:#2a1a1a;color:#e66}
.price{font-weight:500}
.muted{color:var(--mut)} .soon{color:#55555f}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px;margin-bottom:16px}
.card h3{margin:0 0 12px;font-weight:500;font-size:15px}
label{display:block;color:var(--mut);font-size:13px;margin:10px 0 4px}
.field{display:flex;gap:8px;align-items:center} .field input{flex:1}
.conn{display:flex;gap:10px;align-items:center;margin:9px 0}
.pg{display:flex;gap:8px;align-items:center;margin-top:14px}
</style>
</head>
<body>
<div id="gate"><div class="box">
<h1>Record<b>God</b></h1>
<p class="muted">admin sign-in</p>
<div class="field"><input id="tok" type="password" placeholder="admin token" style="flex:1"><button onclick="signin()">Enter</button></div>
<div id="gerr" class="soon" style="margin-top:10px"></div>
</div></div>
<div id="app">
<nav>
<div class="brand">Record<b>God</b></div>
<a data-v="dashboard" class="on">📊 Dashboard</a>
<a data-v="inventory">📦 Inventory</a>
<a data-v="orders">🛒 Orders</a>
<a data-v="customers">👤 Customers</a>
<a data-v="wantlist">📝 Wantlist</a>
<div class="sec">Catalog</div>
<a data-v="intake">📥 Intake</a>
<a data-v="storemap">🗺️ Store map</a>
<a data-v="crates">🗄️ Crates</a>
<a data-v="reports">📈 Reports</a>
<div class="sec" data-admin>System</div>
<a data-v="staff" data-admin>🧑‍💼 Staff</a>
<a data-v="connections" data-admin>🔌 Connections</a>
<a href="/store/">🎧 Virtual store ↗</a>
<a href="/">← Home</a>
</nav>
<main id="content"></main>
</div>
<script>
const $ = s => document.querySelector(s);
let TOKEN = localStorage.getItem('rg_token') || '';
const hdr = () => ({ 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' });
const money = n => n==null ? '—' : '$' + Number(n).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
async function api(path){ const r = await fetch('/admin'+path, { headers: hdr() }); if(!r.ok) throw new Error(r.status); return r.json(); }
let ROLE = 'staff';
async function signin(){
TOKEN = $('#tok').value.trim();
let me; try { me = await api('/me'); } catch(e){ $('#gerr').textContent = 'invalid token'; return; }
ROLE = me.role || 'staff';
localStorage.setItem('rg_token', TOKEN);
// staff don't see admin-only nav (API keys / staff admin); the backend enforces it too
document.querySelectorAll('[data-admin]').forEach(el => el.style.display = ROLE==='admin' ? '' : 'none');
$('#gate').style.display='none'; $('#app').style.display='grid';
go('dashboard');
}
document.querySelectorAll('nav a[data-v]').forEach(a => a.onclick = () => go(a.dataset.v));
function go(v){
if(ROLE!=='admin' && (v==='connections'||v==='staff')) v='dashboard'; // staff can't reach admin views
document.querySelectorAll('nav a[data-v]').forEach(a => a.classList.toggle('on', a.dataset.v===v));
({dashboard:vDash, inventory:vInv, orders:vOrders, customers:vCustomers, wantlist:vWantlist, intake:vIntake, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn, staff:vStaff}[v])();
}
// ---------- Dashboard (fast tiles first, charts lazy) ----------
async function vDash(){
$('#content').innerHTML = '<h2>Dashboard</h2>'
+ '<div class="kpis" id="dashKpis"><div class="muted">loading…</div></div>'
+ '<div style="display:grid;grid-template-columns:2fr 1fr;gap:14px;margin-top:4px">'
+ '<div class="card"><h3>Revenue — last 30 days</h3><div style="height:150px"><canvas id="dashRev"></canvas></div></div>'
+ '<div class="card"><h3>Stock by format</h3><div style="height:150px"><canvas id="dashFmt"></canvas></div></div></div>'
+ '<div class="card" style="margin-top:14px"><h3>Quick actions</h3><div class="bar">'
+ '<button class="ghost" onclick="go(\'inventory\')">Browse inventory</button>'
+ '<button class="ghost" onclick="go(\'reports\')">Full reports</button>'
+ '<button class="ghost" onclick="window.open(\'/store/\')">Open the 3D store</button>'
+ '</div></div>';
const s = await api('/stats');
$('#dashKpis').innerHTML = kpi(s.in_stock.toLocaleString(),'in stock',true) + kpi(money(s.stock_value),'stock value',true)
+ kpi(s.sales.toLocaleString(),'sales') + kpi(money(s.sales_total),'sales total')
+ kpi(s.items.toLocaleString(),'catalog') + kpi(s.vinyl.toLocaleString(),'vinyl') + kpi(s.crates.toLocaleString(),'crates')
+ (s.staged ? kpi(s.staged.toLocaleString(),'staged') : '');
api('/report/series?days=30&bucket=day').then(d => lineChart('dashRev', d.series.map(x=>x.bucket), d.series.map(x=>x.revenue), 'Revenue', true));
api('/report/stock?dim=format').then(d => donutChart('dashFmt', d.rows.map(x=>x.name), d.rows.map(x=>x.count)));
}
const kpi = (n,l,pink) => `<div class="kpi"><div class="n${pink?' pink':''}">${n}</div><div class="l">${l}</div></div>`;
// ---------- Chart.js helpers ----------
const CHARTS = {};
const PINK = '#d10f7a';
const PALETTE = ['#d10f7a','#46d18a','#1e90ff','#ffb020','#9b59b6','#ff6b6b','#19c3c3','#e67e22','#2ecc71','#8e44ad','#34495e','#888'];
function mkChart(id, cfg){ if(CHARTS[id]) CHARTS[id].destroy(); const c = $('#'+id); if(!c || !window.Chart) return; CHARTS[id] = new Chart(c, cfg); }
function chopts(isMoney){ return {maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{
x:{ticks:{font:{size:10},maxRotation:45,autoSkip:true}},
y:{beginAtZero:true,ticks:{font:{size:10},callback:v=>isMoney?'$'+Number(v).toLocaleString():v}}}}; }
function lineChart(id,labels,data,label,isMoney){ mkChart(id,{type:'line',data:{labels,datasets:[{label,data,borderColor:PINK,backgroundColor:'rgba(209,15,122,.12)',fill:true,tension:.3,pointRadius:2}]},options:chopts(isMoney)}); }
function barChart(id,labels,data,label,isMoney){ mkChart(id,{type:'bar',data:{labels,datasets:[{label,data,backgroundColor:PINK,borderRadius:4}]},options:chopts(isMoney)}); }
function donutChart(id,labels,data){ mkChart(id,{type:'doughnut',data:{labels,datasets:[{data,backgroundColor:PALETTE,borderWidth:0}]},options:{maintainAspectRatio:false,plugins:{legend:{position:'right',labels:{boxWidth:12,font:{size:11}}}}}}); }
// ---------- Inventory ----------
let invState = { q:'', kind:'', crate_id:null, page:1 };
let invItems = {}; // sku -> row (for the edit modal)
let invSel = new Set(); // selected skus (bulk)
async function vInv(){
const m = $('#content');
m.innerHTML = '<h2>Inventory</h2>'
+ '<div class="bar"><input id="iq" placeholder="search title / artist / sku…" value="'+invState.q+'">'
+ '<select id="ikind"><option value="">all kinds</option><option>vinyl</option><option>book</option><option>magazine</option><option>cd</option></select>'
+ '<button onclick="invSearch()">Search</button>'
+ '<button class="ghost" onclick="invAdd()"> Add item</button>'
+ '<button class="ghost" onclick="invImport()">⇪ Import list</button></div>'
+ '<div id="invbulk"></div>'
+ '<div id="invrows" class="muted">loading…</div>';
$('#iq').addEventListener('keydown', e => { if(e.key==='Enter') invSearch(); });
$('#ikind').value = invState.kind;
invSel.clear(); loadInv();
}
function invSearch(){ invState.q = $('#iq').value.trim(); invState.kind = $('#ikind').value; invState.crate_id = null; invState.page = 1; invSel.clear(); loadInv(); }
async function loadInv(){
const cr = invState.crate_id ? `&crate_id=${invState.crate_id}` : '';
const d = await api(`/inventory?q=${encodeURIComponent(invState.q)}&kind=${invState.kind}&page=${invState.page}${cr}`);
invItems = {}; d.items.forEach(r => invItems[r.sku] = r);
const rows = d.items.map(r => `<tr>
<td><input type=checkbox ${invSel.has(r.sku)?'checked':''} onclick="event.stopPropagation();invSelTog('${r.sku}')"></td>
<td onclick="invEdit('${r.sku}')" style="cursor:pointer">${r.release_id?`<img class=thumb src="/img/r/${r.release_id}" onerror="this.style.visibility='hidden'">`:'<span class=thumb style="display:inline-block"></span>'}</td>
<td onclick="invEdit('${r.sku}')" style="cursor:pointer">${r.title?escapeH(r.title):'<span class=muted>'+r.sku+'</span>'}<div class="muted">${r.artist?escapeH(r.artist):''}</div></td>
<td><span class="pill">${r.kind}</span></td>
<td>${r.condition||'—'}</td>
<td class="price">${money(r.price)}</td>
<td>${r.target?'<span class=muted>'+money(r.target)+'</span>':'—'}</td>
<td>${r.crate?escapeH(r.crate):(r.crate_id||'—')}</td>
<td>${r.in_stock?'<span class="pill in">in</span>':'<span class="pill out">sold</span>'}</td>
</tr>`).join('');
const pages = Math.max(1, Math.ceil(d.total/d.per));
$('#invrows').innerHTML = `<div class="muted" style="margin-bottom:8px">${d.total.toLocaleString()} items · click a row to edit</div>`
+ `<table><thead><tr><th><input type=checkbox onclick="invSelAll(this.checked)"></th><th></th><th>Title / artist</th><th>Kind</th><th>Cond</th><th>Price</th><th>Target</th><th>Crate</th><th>Stock</th></tr></thead><tbody>${rows||'<tr><td colspan=9 class=muted>nothing found</td></tr>'}</tbody></table>`
+ `<div class="pg"><button class="ghost" onclick="invPage(-1)" ${d.page<=1?'disabled':''}> prev</button><span class="muted">page ${d.page} / ${pages}</span><button class="ghost" onclick="invPage(1)" ${d.page>=pages?'disabled':''}>next </button></div>`;
invBulkBar();
}
function invPage(n){ invState.page += n; loadInv(); }
function invSelTog(sku){ invSel.has(sku)?invSel.delete(sku):invSel.add(sku); invBulkBar(); }
function invSelAll(on){ if(on) Object.keys(invItems).forEach(s=>invSel.add(s)); else invSel.clear(); loadInv(); }
function invBulkBar(){
const b = $('#invbulk'); if(!b) return; const n = invSel.size;
b.innerHTML = n ? `<div class="bar" style="background:rgba(255,93,177,.08);border:1px solid #3a2230;border-radius:8px;padding:8px;margin:8px 0">
<b>${n} selected</b>
<select id="bAct"><option value="set_price">set price $</option><option value="adjust_pct">adjust %</option><option value="set_crate">set crate id</option><option value="mark_sold">mark sold</option><option value="restock">restock</option><option value="delete">delete</option></select>
<input id="bVal" placeholder="value" style="width:90px">
<button onclick="invBulk()">Apply</button><button class="ghost" onclick="invSel.clear();loadInv()">clear</button></div>` : '';
}
async function invBulk(){
const action = $('#bAct').value; let value = $('#bVal').value;
if(action==='delete' && !confirm(`Delete ${invSel.size} items? This can't be undone.`)) return;
if(['set_price','adjust_pct','set_crate'].includes(action)) value = parseFloat(value);
await fetch('/admin/inventory/bulk',{method:'POST',headers:hdr(),body:JSON.stringify({skus:[...invSel],action,value})});
invSel.clear(); loadInv();
}
function invModalBox(html){
let o = $('#invModal');
if(!o){ o = document.createElement('div'); o.id='invModal'; o.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:flex-start;justify-content:center;z-index:200;padding-top:6vh'; document.body.appendChild(o); o.addEventListener('mousedown',e=>{ if(e.target===o) o.remove(); }); }
o.innerHTML = '<div style="background:#141418;border:1px solid #26262c;border-radius:12px;padding:18px;width:400px;max-height:86vh;overflow:auto">'+html+'</div>';
}
function invModalClose(){ const o = $('#invModal'); if(o) o.remove(); }
function numOrNull(id){ const x = $('#'+id).value; return x===''?null:parseFloat(x); }
function fld(label,id,val,attr){ return `<div class="bar" style="margin-bottom:6px"><span class=muted style="width:90px">${label}</span><input id="${id}" ${attr||''} value="${val==null?'':String(val).replace(/"/g,'&quot;')}" style="flex:1"></div>`; }
function invEdit(sku){
const r = invItems[sku] || {};
invModalBox(`<h3 style="margin:0 0 12px">${escapeH(r.title||sku)}</h3>
${fld('Title','eTitle',r.title)}
${fld('Price','ePrice',r.price,'type=number step=0.01')}
${fld('Target','eTarget',r.target,'type=number step=0.01')}
${fld('Condition','eCond',r.condition)}
${fld('Sleeve','eSleeve',r.sleeve_cond)}
${fld('Crate id','eCrate',r.crate_id,'type=number')}
${fld('Slot','eSlot',r.slot_number,'type=number')}
${fld('Kind','eKind',r.kind)}
<div class="bar" style="margin-bottom:6px"><span class=muted style="width:90px">In stock</span><input id="eStock" type=checkbox ${r.in_stock?'checked':''}></div>
${fld('Notes','eNotes',r.notes)}
<div class="bar" style="margin-top:8px"><button onclick="invSave('${sku}')" style="flex:1">Save</button>
<button class="ghost" onclick="invLabel('${sku}')">🏷 Label</button>
<button class="ghost" onclick="invDelete('${sku}')" style="color:#e66">Delete</button>
<button class="ghost" onclick="invModalClose()">Cancel</button></div>`);
}
async function invSave(sku){
const v = id => { const x = $('#'+id).value.trim(); return x===''?null:x; };
const body = { title:v('eTitle'), price:numOrNull('ePrice'), target_price:numOrNull('eTarget'),
condition:v('eCond'), sleeve_cond:v('eSleeve'), crate_id:numOrNull('eCrate'), slot_number:numOrNull('eSlot'),
kind:v('eKind'), notes:v('eNotes'), in_stock:$('#eStock').checked };
await fetch('/admin/inventory/'+encodeURIComponent(sku)+'/edit',{method:'POST',headers:hdr(),body:JSON.stringify(body)});
invModalClose(); loadInv();
}
async function invDelete(sku){
if(!confirm('Delete this item?')) return;
await fetch('/admin/inventory/'+encodeURIComponent(sku)+'/delete',{method:'POST',headers:hdr()});
invModalClose(); loadInv();
}
function invAdd(){
invModalBox(`<h3 style="margin:0 0 12px">Add item</h3>
${fld('Title','aTitle','')}
${fld('Release ID','aRid','','type=number')}
${fld('Barcode/UPC','aIdent','')}
${fld('Price','aPrice','','type=number step=0.01')}
${fld('Condition','aCond','')}
${fld('Kind','aKind','vinyl')}
${fld('Crate id','aCrate','','type=number')}
${fld('SKU (blank=auto)','aSku','')}
<div class="muted" style="margin:4px 0 8px">Tip: enter a Release ID and the title fills from the catalog.</div>
<div class="bar"><button onclick="invDoAdd()" style="flex:1">Add item</button><button class="ghost" onclick="invModalClose()">Cancel</button></div>`);
}
async function invDoAdd(){
const v = id => { const x = $('#'+id).value.trim(); return x===''?null:x; };
const body = { sku:v('aSku'), title:v('aTitle'), release_id:numOrNull('aRid'), identifier:v('aIdent'),
price:numOrNull('aPrice'), condition:v('aCond'), kind:v('aKind')||'vinyl', crate_id:numOrNull('aCrate') };
await fetch('/admin/inventory/add',{method:'POST',headers:hdr(),body:JSON.stringify(body)});
invModalClose(); loadInv();
}
// ---------- Label print + RFID (drives the local rfid-daemon :7790, Chafon H102) ----------
const RFID_DAEMON = 'http://127.0.0.1:7790'; // localhost is a secure context — HTTPS page may call it
function invLabel(sku){
const r = invItems[sku] || { sku };
const qr = 'https://api.qrserver.com/v1/create-qr-code/?size=120x120&margin=0&data=' + encodeURIComponent(r.sku);
invModalBox(`<h3 style="margin:0 0 10px">🏷 Label — ${escapeH(r.title||sku)}</h3>
<div style="display:flex;gap:10px;background:#fff;color:#111;border-radius:8px;padding:8px;align-items:center">
<div style="flex:1;min-width:0">
<div style="font-weight:700;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeH(r.artist||'')}</div>
<div style="font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeH(r.title||'')}</div>
<div style="font-size:22px;font-weight:800;margin-top:2px">${money(r.price)}</div>
<div style="font-size:9px;color:#555">${escapeH(r.sku)}${r.condition?' · '+escapeH(r.condition):''}</div>
</div>
<img src="${qr}" style="width:64px;height:64px">
</div>
<label class="bar" style="margin:10px 0 4px;cursor:pointer"><input type="checkbox" id="lblRfid"> <span>⚡ Program RFID chip at the same time (UHF H102)</span></label>
<div id="lblDaemon" class="muted" style="font-size:11px">checking tag reader…</div>
<div class="bar" style="margin-top:10px"><button onclick='invDoPrint(${JSON.stringify(r).replace(/'/g,"&#39;")})' style="flex:1">🖨 Print label</button>
<button class="ghost" onclick="invEdit('${sku}')"> back</button></div>
<div id="lblMsg" class="muted" style="margin-top:8px"></div>`);
daemonPing();
}
async function daemonPing(){
const el = $('#lblDaemon'); if(!el) return;
try { const r = await fetch(RFID_DAEMON+'/status', {signal:AbortSignal.timeout(2500)}).then(x=>x.json());
el.innerHTML = '<span style="color:#6ee7b7">● tag reader connected</span>'; }
catch(e){ el.innerHTML = '<span style="color:#e6a046">● rfid-daemon not running on :7790 — RFID disabled (label still prints)</span>'; }
}
function invDoPrint(r){
const qr = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&margin=0&data=' + encodeURIComponent(r.sku);
const html = `<html><head><meta charset=utf-8><style>
@page{size:54mm 25mm;margin:0}
*{box-sizing:border-box}body{margin:0;font-family:system-ui,sans-serif}
.lbl{width:54mm;height:25mm;padding:2mm;display:flex;gap:2mm;align-items:center}
.info{flex:1;min-width:0}
.a{font-weight:700;font-size:10pt;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.t{font-size:7.5pt;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.p{font-size:17pt;font-weight:800;line-height:1.1;margin-top:0.5mm}
.s{font-size:5.5pt;color:#444}.qr{width:18mm;height:18mm}
</style></head><body onload="window.print();setTimeout(()=>window.close(),300)">
<div class="lbl"><div class="info"><div class="a">${escapeH(r.artist||'')}</div><div class="t">${escapeH(r.title||'')}</div>
<div class="p">${money(r.price)}</div><div class="s">${escapeH(r.sku)}</div></div>
<img class="qr" src="${qr}"></div></body></html>`;
const w = window.open('', '_blank', 'width=420,height=240');
if(w){ w.document.open(); w.document.write(html); w.document.close(); }
if($('#lblRfid') && $('#lblRfid').checked) rfidWrite(r.sku, r.release_id);
}
async function rfidWrite(sku, releaseId){
const msg = $('#lblMsg'); if(msg) msg.innerHTML = '⚡ writing tag (present the chip to the reader)…';
try {
await fetch(RFID_DAEMON+'/clear-mask', {method:'POST', signal:AbortSignal.timeout(3000)}).catch(()=>{});
const body = { sku }; if(releaseId) body.releaseId = String(releaseId);
const d = await fetch(RFID_DAEMON+'/write-tag', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify(body), signal:AbortSignal.timeout(120000)}).then(x=>x.json());
if(msg) msg.innerHTML = d.ok ? `<span style="color:#6ee7b7">✓ RFID tag programmed (${escapeH(sku)})</span>`
: `<span style="color:#e66">RFID write failed: ${escapeH(d.error||'?')}</span>`;
} catch(e){ if(msg) msg.innerHTML = '<span style="color:#e6a046">RFID daemon unreachable (:7790)</span>'; }
}
// ---------- Customers (CRM) ----------
let custState = { q:'' };
async function vCustomers(){
const m = $('#content');
m.innerHTML = '<h2>Customers</h2>'
+ '<div class="bar"><input id="cq" placeholder="search name / email…" value="'+custState.q+'"><button onclick="custSearch()">Search</button></div>'
+ '<div id="custrows" class="muted">loading…</div>';
$('#cq').addEventListener('keydown', e=>{ if(e.key==='Enter') custSearch(); });
loadCust();
}
function custSearch(){ custState.q = $('#cq').value.trim(); loadCust(); }
async function loadCust(){
const d = await api('/customers?q='+encodeURIComponent(custState.q));
const list = (d.customers||[]).filter(c=>c.id!==0);
const rows = list.map(c=>`<tr onclick="custOpen(${c.id})" style="cursor:pointer">
<td>${escapeH(c.name||'—')}${c.mailing_optin?' <span class="pill in">✉</span>':''}</td>
<td class="muted">${escapeH(c.email||'')}</td><td>${escapeH(c.phone||'')}</td>
<td>${c.orders}</td><td class="price">${money(c.spent)}</td>
<td class="muted">${c.last_order?String(c.last_order).slice(0,10):'—'}</td></tr>`).join('');
$('#custrows').innerHTML = `<div class="muted" style="margin-bottom:8px">${list.length} customers · click to open · sorted by spend</div>`
+ `<table><thead><tr><th>Name</th><th>Email</th><th>Phone</th><th>Orders</th><th>Spent</th><th>Last</th></tr></thead><tbody>${rows||'<tr><td colspan=6 class=muted>none</td></tr>'}</tbody></table>`;
}
async function custOpen(id){
const d = await api('/customers/'+id); const c = d.customer, st = d.stats;
const card = (label,val) => `<div style="background:rgba(255,255,255,0.04);border:1px solid #26262c;border-radius:10px;padding:10px 14px;min-width:110px"><div class="muted">${label}</div><div style="font-size:20px;font-weight:700">${val}</div></div>`;
const sales = (d.sales||[]).map(s=>`<tr><td class="muted">${s.sale_date?String(s.sale_date).slice(0,10):'—'}</td>
<td>${escapeH(s.sale_number||('#'+s.id))}</td><td>${s.items}</td><td class="price">${money(s.total)}</td>
<td class="muted">${escapeH(s.payment_method||'')} ${escapeH(s.status||'')}</td></tr>`).join('');
$('#content').innerHTML = `<div class="bar"><button class="ghost" onclick="vCustomers()"> Customers</button><h2 style="margin:0">${escapeH(((c.first_name||'')+' '+(c.last_name||'')).trim()||('Customer '+id))}</h2></div>
<div class="bar" style="gap:12px;margin:12px 0">${card('Orders',st.orders)}${card('Total spent','<span class=price>'+money(st.spent)+'</span>')}${card('Last order',st.last_order?String(st.last_order).slice(0,10):'—')}</div>
<h3>Info</h3>
${fld('First name','cFirst',c.first_name)}${fld('Last name','cLast',c.last_name)}
${fld('Email','cEmail',c.email)}${fld('Phone','cPhone',c.phone)}${fld('Address','cAddr',c.address)}${fld('Notes','cNotes',c.notes)}
<label class="bar" style="margin:4px 0 8px;cursor:pointer"><input type="checkbox" id="cOptin" ${c.mailing_optin?'checked':''}> <span>Mailing opt-in</span></label>
<div class="bar"><button onclick="custSave(${id})">Save info</button><span id="cMsg" class="muted"></span></div>
<h3 style="margin-top:18px">Sales history</h3>
<table><thead><tr><th>Date</th><th>Sale</th><th>Items</th><th>Total</th><th>Payment</th></tr></thead><tbody>${sales||'<tr><td colspan=5 class=muted>no sales yet</td></tr>'}</tbody></table>`;
}
async function custSave(id){
const v = i => { const x=$('#'+i).value.trim(); return x===''?null:x; };
const body = { first_name:v('cFirst'), last_name:v('cLast'), email:v('cEmail'), phone:v('cPhone'), address:v('cAddr'), notes:v('cNotes'), mailing_optin:$('#cOptin').checked };
await fetch('/admin/customers/'+id,{method:'POST',headers:hdr(),body:JSON.stringify(body)});
const e=$('#cMsg'); if(e){ e.textContent='✓ saved'; setTimeout(()=>{e.textContent='';},1500); }
}
// ---------- Wantlist (customer record-requests from the storefront) ----------
let wantState = { status:'pending' };
async function vWantlist(){
$('#content').innerHTML = '<h2>Wantlist</h2><div class="muted">Records customers asked us to find via the storefront request form.</div>'
+ '<div class="bar" id="wantTabs" style="margin:12px 0"></div><div id="wantrows" class="muted">loading…</div>';
loadWant();
}
async function loadWant(){
const d = await api('/wantlist?status='+wantState.status);
const c = d.counts||{};
const tab = (k,lbl)=>`<button class="${wantState.status===k?'':'ghost'}" onclick="wantState.status='${k}';loadWant()">${lbl}${c[k]?` (${c[k]})`:''}</button>`;
$('#wantTabs').innerHTML = tab('pending','Pending')+tab('found','Found')+tab('fulfilled','Fulfilled')+tab('cancelled','Cancelled')+tab('all','All');
const next = { pending:['found','Mark found'], found:['fulfilled','Mark fulfilled'] };
const rows = (d.requests||[]).map(w=>{
const what = (escapeH(w.artist||'')+(w.title?' '+escapeH(w.title):'')) || '<span class=muted>—</span>';
const meta = [w.format, w.max_price!=null?('≤ '+money(w.max_price)):'', w.delivery_preference, w.postcode].filter(Boolean).map(escapeH).join(' · ');
const act = next[w.status] ? `<button onclick="wantSet(${w.id},'${next[w.status][0]}')">${next[w.status][1]}</button> ` : '';
const link = w.release_id ? ` <a href="/release/${w.release_id}" target="_blank" class="muted">↗</a>` : '';
return `<tr><td class="muted">${w.created_at?String(w.created_at).slice(0,10):''}</td>
<td><b>${what}</b>${link}${meta?`<div class="muted">${meta}</div>`:''}${w.notes?`<div class="muted">“${escapeH(w.notes)}”</div>`:''}</td>
<td>${escapeH(w.name||'')}<div class="muted">${escapeH(w.email||'')}${w.phone?' · '+escapeH(w.phone):''}</div></td>
<td><span class="pill ${w.status==='pending'?'':w.status==='cancelled'?'out':'in'}">${w.status}</span></td>
<td style="white-space:nowrap">${act}${w.status!=='cancelled'&&w.status!=='fulfilled'?`<button class="ghost" onclick="wantSet(${w.id},'cancelled')">✕</button>`:''}</td></tr>`;
}).join('');
$('#wantrows').innerHTML = `<table><thead><tr><th>Date</th><th>Wanted</th><th>Customer</th><th>Status</th><th></th></tr></thead>`
+ `<tbody>${rows||'<tr><td colspan=5 class=muted>no requests</td></tr>'}</tbody></table>`;
}
async function wantSet(id, status){
await fetch('/admin/wantlist/'+id,{method:'POST',headers:hdr(),body:JSON.stringify({status})});
loadWant();
}
function invImport(){
invModalBox(`<h3 style="margin:0 0 6px">Import list (bulk stock-in)</h3>
<div class="muted" style="margin-bottom:8px">One per line — Release ID / barcode / catalogue no. Resolved against the catalog, each becomes an in-stock item.</div>
<textarea id="impLines" rows="8" style="width:100%;margin-bottom:6px" placeholder="12345&#10;0123456789012&#10;ABC-001"></textarea>
${fld('Default price','impPrice','','type=number step=0.01')}
${fld('Condition','impCond','')}
${fld('Crate id','impCrate','','type=number')}
${fld('Kind','impKind','vinyl')}
<div class="bar" style="margin-top:8px"><button onclick="invDoImport()" style="flex:1">Resolve &amp; import</button><button class="ghost" onclick="invModalClose()">Cancel</button></div>
<div id="impOut" class="muted" style="margin-top:8px"></div>`);
}
async function invDoImport(){
const lines = $('#impLines').value.split('\n').map(s=>s.trim()).filter(Boolean);
if(!lines.length) return;
$('#impOut').textContent = 'resolving '+lines.length+'…';
const body = { lines, price:numOrNull('impPrice'), condition:($('#impCond').value.trim()||null),
crate_id:numOrNull('impCrate'), kind:($('#impKind').value.trim()||'vinyl') };
const d = await fetch('/admin/inventory/import',{method:'POST',headers:hdr(),body:JSON.stringify(body)}).then(x=>x.json());
$('#impOut').innerHTML = `<span style="color:#6ee7b7">✓ ${d.added} imported</span>`
+ (d.not_found.length?`<div style="color:#e6a046">not found (${d.not_found.length}): ${d.not_found.map(escapeH).join(', ')}</div>`:'');
loadInv();
}
// ---------- Orders ----------
async function vOrders(){
const m = $('#content'); m.innerHTML = '<h2>Orders <span class="muted" style="font-size:13px">· live from WooCommerce</span></h2><div class="muted">loading…</div>';
const d = await api('/orders');
if(!d.ok){ m.innerHTML += `<div class="card warn">${d.reason} — <a onclick="go('connections')">go to Connections</a></div>`; return; }
const rows = d.orders.map(o => `<tr>
<td>#${o.number}</td><td>${o.date}</td><td>${escapeH(o.customer||'—')}</td>
<td>${o.items} item${o.items==1?'':'s'}</td><td class="price">${o.currency||''} ${o.total}</td>
<td><span class="pill">${o.status}</span></td></tr>`).join('');
m.innerHTML = '<h2>Orders <span class="muted" style="font-size:13px">· live from WooCommerce</span></h2>'
+ `<table><thead><tr><th>Order</th><th>Date</th><th>Customer</th><th>Items</th><th>Total</th><th>Status</th></tr></thead><tbody>${rows||'<tr><td colspan=6 class=muted>no orders</td></tr>'}</tbody></table>`;
}
// ---------- Staff (admin only) ----------
async function vStaff(){
$('#content').innerHTML = '<h2>Staff</h2>'
+ '<div class="muted">Operators get a sign-in token + role. Staff can run the store (search, scan, inventory, prices, labels) but can\'t see API keys / connections.</div>'
+ '<div class="card" style="margin-top:12px"><h3>Add staff</h3><div class="bar"><input id="stName" placeholder="name"><select id="stRole"><option value="staff">Staff</option><option value="admin">Admin</option></select><button onclick="staffAdd()">Add</button></div><div id="stNew" style="margin-top:10px"></div></div>'
+ '<div id="staffRows" class="muted" style="margin-top:12px">loading…</div>'
+ '<div id="timecards" class="muted" style="margin-top:18px">loading hours…</div>';
loadStaff(); loadTimecards();
}
const fmtDT = s => s ? new Date(s).toLocaleString(undefined,{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : '';
async function loadTimecards(){
const d = await api('/timecards?days=14');
const tot = (d.totals||[]).map(t=>`<tr><td>${escapeH(t.name)}</td><td>${t.shifts}</td><td class="price"><b>${t.hours}</b> h</td></tr>`).join('');
const sh = (d.shifts||[]).map(s=>`<tr><td>${escapeH(s.name)}</td><td class="muted">${fmtDT(s.clock_in)}</td><td>${s.clock_out?'<span class="muted">'+fmtDT(s.clock_out)+'</span>':'<span style="color:var(--ok)">● on shift</span>'}</td><td>${s.hours} h</td></tr>`).join('');
$('#timecards').innerHTML = '<h3>Timecards — last 14 days</h3>'
+ `<table><thead><tr><th>Staff</th><th>Shifts</th><th>Hours</th></tr></thead><tbody>${tot||'<tr><td colspan=3 class=muted>no shifts yet</td></tr>'}</tbody></table>`
+ '<h3 style="margin-top:14px">Recent shifts</h3>'
+ `<table><thead><tr><th>Staff</th><th>Clock in</th><th>Clock out</th><th>Hours</th></tr></thead><tbody>${sh||'<tr><td colspan=4 class=muted>none</td></tr>'}</tbody></table>`;
}
async function loadStaff(){
const d = await api('/staff');
const rows = (d.staff||[]).map(s=>`<tr>
<td>${escapeH(s.name)}${s.active?'':' <span class="pill out">inactive</span>'}</td>
<td><select onchange="staffRole(${s.id},this.value)"><option value="staff"${s.role==='staff'?' selected':''}>Staff</option><option value="admin"${s.role==='admin'?' selected':''}>Admin</option></select></td>
<td class="muted">${s.last_seen?String(s.last_seen).slice(0,10):'never'}</td>
<td style="white-space:nowrap"><button class="ghost" onclick="staffReset(${s.id})">🔑 reset token</button>
<button class="ghost" onclick="staffActive(${s.id},${s.active?'false':'true'})">${s.active?'Deactivate':'Reactivate'}</button></td></tr>`).join('');
$('#staffRows').innerHTML = `<table><thead><tr><th>Name</th><th>Role</th><th>Last seen</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan=4 class=muted>no staff yet — add one above</td></tr>'}</tbody></table>`;
}
function staffToken(name,token){ return `<div style="background:#fdeef6;border:1px solid var(--pink);border-radius:8px;padding:11px"><b>${escapeH(name)}</b> — sign-in token (shown once, copy it now):<div style="margin:6px 0"><code style="font-size:14px;user-select:all;word-break:break-all">${escapeH(token)}</code></div><div class="muted">They paste this at <b>recordgod.com/search</b> to sign in.</div></div>`; }
async function staffAdd(){
const name=$('#stName').value.trim(); if(!name){ return; }
const d=await fetch('/admin/staff',{method:'POST',headers:hdr(),body:JSON.stringify({name,role:$('#stRole').value})}).then(r=>r.json());
$('#stName').value=''; $('#stNew').innerHTML=staffToken(name,d.token); loadStaff();
}
async function staffRole(id,role){ await fetch('/admin/staff/'+id,{method:'POST',headers:hdr(),body:JSON.stringify({role})}); }
async function staffActive(id,active){ await fetch('/admin/staff/'+id,{method:'POST',headers:hdr(),body:JSON.stringify({active})}); loadStaff(); }
async function staffReset(id){ if(!confirm('Reset this token? Their current one stops working immediately.')) return;
const d=await fetch('/admin/staff/'+id+'/token',{method:'POST',headers:hdr()}).then(r=>r.json()); $('#stNew').innerHTML=staffToken('Reset',d.token); }
// ---------- Connections ----------
async function vConn(){
const m = $('#content');
m.innerHTML = '<h2>Connections</h2><div class="card"><h3>Credentials <span class="soon">— encrypted at rest, never shown back</span></h3><div id="secrets" class="muted">loading…</div></div>'
+ '<div class="card"><h3>Test connections</h3>'
+ ['discogs','woo','dealgod'].map(s=>`<div class="conn"><button class="ghost" onclick="testConn('${s}')">Test ${s}</button><span id="c-${s}" class="soon"></span></div>`).join('')
+ '</div>';
const d = await fetch('/settings/secrets', { headers: hdr() }).then(r=>r.json());
$('#secrets').innerHTML = d.fields.map(f => {
const st = f.set ? `<span style="color:var(--ok);font-size:12px">✓ set ${(f.updated_at||'').slice(0,10)}</span>` : '';
return `<label>${f.label} ${st}</label><div class="field"><input type="password" placeholder="${f.set?'•••••• (replace to update)':'paste value'}" data-name="${f.name}"><button class="ghost" data-save="${f.name}">Save</button></div>`;
}).join('');
document.querySelectorAll('[data-save]').forEach(b => b.onclick = () => saveSecret(b.dataset.save, b));
}
async function saveSecret(name, btn){
const i = document.querySelector(`input[data-name="${name}"]`); if(!i.value.trim()) return;
btn.textContent='…';
const r = await fetch('/settings/secrets', { method:'POST', headers:hdr(), body: JSON.stringify({name, value:i.value.trim()}) });
btn.textContent = r.ok ? 'Saved' : 'Error'; if(r.ok){ i.value=''; i.placeholder='•••••• (replace to update)'; } setTimeout(()=>btn.textContent='Save',1500);
}
async function testConn(svc){
const el = document.getElementById('c-'+svc); el.textContent='testing…'; el.style.color='var(--mut)';
let r,d; try { r = await fetch('/settings/test/'+svc,{method:'POST',headers:hdr()}); d = await r.json(); } catch(e){ el.textContent='✕ network'; return; }
if(d.connected){ el.textContent='✓ connected'+(d.as?' as '+d.as:''); el.style.color='var(--ok)'; }
else { el.textContent='✕ '+(d.detail||r.status); el.style.color='var(--warn)'; }
}
// ---------- Intake ----------
function vIntake(){
$('#content').innerHTML = '<h2>Intake <span class="muted" style="font-size:13px">· stage new stock</span></h2>'
+ '<div class="card" style="max-width:460px">'
+ '<label>Discogs release id</label><input id="in_rid" placeholder="e.g. 8949699" style="width:100%">'
+ '<label>Condition</label><select id="in_cond" style="width:100%"><option>M</option><option>NM</option><option selected>VG+</option><option>VG</option><option>G+</option><option>G</option></select>'
+ '<label>Price (AUD)</label><input id="in_price" type="number" step="0.01" placeholder="0.00" style="width:100%">'
+ '<div style="margin-top:14px"><button onclick="doIntake()">Stage item</button></div>'
+ '<div id="in_res" class="muted" style="margin-top:14px"></div></div>';
}
async function doIntake(){
const rid = parseInt($('#in_rid').value, 10), res = $('#in_res');
if(!rid){ res.textContent = 'enter a Discogs release id'; return; }
res.textContent = 'enriching + staging…';
const r = await fetch('/wowplatter/v1/inventory/intake', { method:'POST', headers:hdr(),
body: JSON.stringify({ release_id: rid, condition: $('#in_cond').value, price: parseFloat($('#in_price').value)||null }) });
const d = await r.json();
if(d.ok){ res.innerHTML = `<span style="color:var(--ok)">✓ staged ${d.sku}</span> — ` + (d.enriched ? escapeH(d.title||'')+' · '+escapeH(d.artist||'') : '<span style="color:var(--warn)">not in catalog cache (un-enriched)</span>'); $('#in_rid').value=''; $('#in_price').value=''; }
else { res.innerHTML = `<span style="color:var(--warn)">${d.reason||'failed'}</span>`; }
}
// ---------- Crates ----------
async function vCrates(){
const m = $('#content'); m.innerHTML = '<h2>Crates</h2><div class="muted">loading…</div>';
const d = await api('/crates');
const cards = d.crates.map(c => `<div class="kpi" style="cursor:pointer" onclick="crateOpen(${c.id})">
<div class="n pink" style="font-size:17px">${escapeH(c.label_text||c.name||('#'+c.id))}</div>
<div class="l">${c.items||0} items · ${money(c.value)}</div>
<div class="soon">${escapeH(c.purpose||'stock')}</div></div>`).join('');
m.innerHTML = `<h2>Crates <span class="muted" style="font-size:13px">· ${d.crates.length} bins · click to see contents</span></h2><div class="kpis">${cards}</div>`;
}
function crateOpen(id){ invState = { q:'', kind:'', crate_id:id, page:1 }; go('inventory'); }
// ---------- Reports ----------
// ---------- Reports workbench (date range + dimension + metric, all lazy) ----------
let RP = {days:30, from:null, to:null, metric:'revenue', report:'trend'};
const RP_TABS = [{r:'trend',l:'Revenue trend'},{r:'genre',l:'By genre'},{r:'style',l:'By style'},{r:'format',l:'By format'},
{r:'condition',l:'By condition'},{r:'payment',l:'By payment'},{r:'weekday',l:'By weekday'},{r:'hour',l:'By hour'},
{r:'top:release',l:'Top releases'},{r:'top:artist',l:'Top artists'},{r:'top:label',l:'Top labels'},{r:'top:customer',l:'Top customers'}];
function vReports(){
$('#content').innerHTML = '<h2>Reports</h2>'
+ '<div class="card"><div class="bar" style="flex-wrap:wrap;gap:8px;align-items:center">'
+ '<span class="muted">Period</span>'
+ [['7','7d'],['30','30d'],['90','90d'],['365','12mo'],['0','All']].map(x=>`<button class="ghost rpd" data-d="${x[0]}" onclick="rpDays(${x[0]})">${x[1]}</button>`).join('')
+ '<span class="muted">·</span><input type="date" id="rpFrom" onchange="rpCustom()" style="width:140px"><input type="date" id="rpTo" onchange="rpCustom()" style="width:140px">'
+ '<span style="flex:1"></span><span class="muted">Metric</span>'
+ '<button class="ghost" id="rpRev" onclick="rpMetric(\'revenue\')">Revenue</button><button class="ghost" id="rpCnt" onclick="rpMetric(\'count\')">Count</button>'
+ '</div></div>'
+ '<div class="kpis" id="rpKpis"><div class="muted">loading…</div></div>'
+ '<div class="card"><div class="bar" id="rpTabs" style="flex-wrap:wrap;gap:6px"></div></div>'
+ '<div class="card"><div style="height:300px"><canvas id="rpChart"></canvas></div></div>'
+ '<div class="card"><div id="rpTable"></div></div>';
$('#rpTabs').innerHTML = RP_TABS.map(t=>`<button class="ghost rpt" data-r="${t.r}" onclick="rpReport('${t.r}')">${t.l}</button>`).join('');
rpSync(); loadRpKpis(); rpReport(RP.report);
}
function pq(){ return (RP.from && RP.to) ? ('from='+RP.from+'&to='+RP.to) : ('days='+RP.days); }
function rpDays(d){ RP.days=d; RP.from=RP.to=null; $('#rpFrom').value=''; $('#rpTo').value=''; rpSync(); loadRpKpis(); rpReport(RP.report); }
function rpCustom(){ const f=$('#rpFrom').value, t=$('#rpTo').value; if(f&&t){ RP.from=f; RP.to=t; rpSync(); loadRpKpis(); rpReport(RP.report); } }
function rpMetric(mt){ RP.metric=mt; rpSync(); rpReport(RP.report); }
function rpReport(r){ RP.report=r; rpSync(); if(r==='trend') rpTrend(); else if(r.startsWith('top:')) rpTop(r.slice(4)); else rpBreakdown(r); }
function rpSync(){
document.querySelectorAll('.rpd').forEach(b=>b.classList.toggle('on', !RP.from && +b.dataset.d===+RP.days));
document.querySelectorAll('.rpt').forEach(b=>b.classList.toggle('on', b.dataset.r===RP.report));
if($('#rpRev')){ $('#rpRev').classList.toggle('on',RP.metric==='revenue'); $('#rpCnt').classList.toggle('on',RP.metric==='count'); }
}
async function loadRpKpis(){
const k = await api('/report/kpis?'+pq());
$('#rpKpis').innerHTML = kpi(k.sales.toLocaleString(),'sales',true) + kpi(money(k.revenue),'revenue',true)
+ kpi(money(k.avg_sale),'avg sale') + kpi(Number(k.units||0).toLocaleString(),'units')
+ kpi(k.customers.toLocaleString(),'customers') + kpi(money(k.discounts),'discounts given')
+ kpi(k.in_stock.toLocaleString(),'in stock now') + (k.open_laybys?kpi(k.open_laybys,'open laybys'):'');
}
function rpTable(head, rows){
$('#rpTable').innerHTML = '<table><thead><tr>'+head.map(h=>`<th>${escapeH(h)}</th>`).join('')+'</tr></thead><tbody>'
+ (rows.map(r=>'<tr>'+r.map((c,i)=>`<td${i>0?' class="price"':''}>${escapeH(String(c))}</td>`).join('')+'</tr>').join('') || '<tr><td class=muted colspan=9>no data in this period</td></tr>')
+ '</tbody></table>';
}
async function rpTrend(){
const bucket = (RP.from||RP.days===0||RP.days>120)?'month':(RP.days>31?'week':'day');
const d = await api('/report/series?'+pq()+'&bucket='+bucket);
const isRev = RP.metric==='revenue';
lineChart('rpChart', d.series.map(x=>x.bucket), d.series.map(x=>isRev?x.revenue:x.sales), isRev?'Revenue':'Sales', isRev);
rpTable(['Period','Sales','Revenue'], d.series.map(x=>[x.bucket, x.sales, money(x.revenue)]));
}
async function rpBreakdown(dim){
const d = await api('/report/breakdown?dim='+dim+'&metric='+RP.metric+'&'+pq()+'&limit=15');
const isRev = RP.metric==='revenue', vals = d.rows.map(x=>isRev?x.revenue:x.count);
if(dim!=='weekday' && dim!=='hour' && d.rows.length<=8) donutChart('rpChart', d.rows.map(x=>x.name), vals);
else barChart('rpChart', d.rows.map(x=>x.name), vals, isRev?'Revenue':'Count', isRev);
rpTable(['Name','Units','Revenue'], d.rows.map(x=>[x.name, x.count, money(x.revenue)]));
}
async function rpTop(dim){
const d = await api('/report/top?dim='+dim+'&'+pq()+'&limit=15');
barChart('rpChart', d.rows.map(x=>x.name), d.rows.map(x=>x.revenue), 'Revenue', true);
rpTable([dim.charAt(0).toUpperCase()+dim.slice(1),'Units','Revenue'], d.rows.map(x=>[x.name, x.count, money(x.revenue)]));
}
// ---------- Store map ----------
let mapData = null;
async function vStoreMap(){
const m = $('#content');
m.innerHTML = '<h2>Store map <span class="muted" style="font-size:13px">· click a bin for contents · brighter = fuller</span></h2>'
+ '<div class="bar"><input id="mq" placeholder="find a record → locate its bin…"><button onclick="mapFind()">Locate</button></div>'
+ '<div style="display:flex;gap:18px;align-items:flex-start">'
+ '<div id="mapwrap" style="flex:1;min-width:0;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px;overflow:auto">loading…</div>'
+ '<div id="cratepane" style="width:300px;flex-shrink:0"><div class="card soon">click a bin on the map</div></div></div>';
$('#mq').addEventListener('keydown', e => { if(e.key==='Enter') mapFind(); });
mapData = await api('/storemap');
renderMap();
}
function renderMap(highlight){
const { crates, racks, crate_types:ct } = mapData;
const pts = crates.map(c=>[c.x,c.z]).concat(racks.map(r=>[r.x,r.z]));
if(!pts.length){ $('#mapwrap').innerHTML = '<div class="muted">no crate positions in this space</div>'; return; }
const minX=Math.min(...pts.map(p=>p[0]))-1, maxX=Math.max(...pts.map(p=>p[0]))+1;
const minZ=Math.min(...pts.map(p=>p[1]))-1, maxZ=Math.max(...pts.map(p=>p[1]))+1;
const W=maxX-minX, D=maxZ-minZ, scale=Math.min(820/W, 560/D);
const sx=x=>((x-minX)*scale).toFixed(1), sy=z=>((z-minZ)*scale).toFixed(1);
const maxItems=Math.max(1, ...crates.map(c=>c.items||0));
let svg = `<svg viewBox="0 0 ${(W*scale).toFixed(0)} ${(D*scale).toFixed(0)}" style="width:100%;height:auto;background:#f4f5f7;border-radius:8px">`;
racks.forEach(r => { const w=(r.w||0.6)*scale, d=(r.d||0.4)*scale;
svg += `<g transform="translate(${sx(r.x)},${sy(r.z)}) rotate(${r.rot||0})"><rect x="${(-w/2).toFixed(1)}" y="${(-d/2).toFixed(1)}" width="${w.toFixed(1)}" height="${d.toFixed(1)}" rx="2" fill="#16161b" stroke="#2a2a30" stroke-width="0.5"/></g>`; });
crates.forEach(c => { const t=ct[c.crate_type_id]||{w:0.35,d:0.5}; const w=Math.max(7,t.w*scale), d=Math.max(7,t.d*scale);
const fill = c.items ? `rgba(255,93,177,${(0.22+0.65*(c.items/maxItems)).toFixed(2)})` : '#22222a';
const hl = highlight===c.id ? 'stroke="#fff" stroke-width="2.5"' : 'stroke="#ffffff" stroke-width="0.5"';
svg += `<g transform="translate(${sx(c.x)},${sy(c.z)}) rotate(${c.rot||0})" style="cursor:pointer" onclick="openCrate(${c.id})"><title>${escapeH(c.label_text||c.name||('#'+c.id))} · ${c.items||0} items</title><rect x="${(-w/2).toFixed(1)}" y="${(-d/2).toFixed(1)}" width="${w.toFixed(1)}" height="${d.toFixed(1)}" rx="1.5" fill="${fill}" ${hl}/></g>`; });
$('#mapwrap').innerHTML = svg + '</svg>';
}
async function openCrate(id){
renderMap(id);
const pane = $('#cratepane'); pane.innerHTML = '<div class="card muted">loading…</div>';
const d = await api(`/inventory?crate_id=${id}&page=1`);
const c = mapData.crates.find(x => x.id===id) || {};
const rows = d.items.map(r => `<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid #1b1b20">
${r.thumb?`<img class=thumb src="${r.thumb}">`:'<span class=thumb style="display:inline-block"></span>'}
<div style="min-width:0"><div style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeH(r.title||r.sku)}</div>
<div class="muted" style="font-size:12px">${escapeH(r.artist||'')} · ${money(r.price)}</div></div></div>`).join('');
pane.innerHTML = `<div class="card"><h3>${escapeH(c.label_text||c.name||('Crate #'+id))} <span class="muted" style="font-weight:400">· ${d.total} items</span></h3>${rows||'<div class=muted>empty</div>'}</div>`;
}
async function mapFind(){
const q = $('#mq').value.trim(); if(!q) return;
const d = await api(`/inventory?q=${encodeURIComponent(q)}&page=1`);
const hit = d.items.find(i => i.crate_id);
if(!hit){ $('#cratepane').innerHTML = `<div class="card" style="color:var(--warn)">no in-store match for “${escapeH(q)}”</div>`; return; }
openCrate(hit.crate_id);
}
// ---------- helpers ----------
function vSoon(title, body){ return () => { $('#content').innerHTML = `<h2>${title}</h2><div class="card soon">${body}<br><br>Coming next.</div>`; }; }
function escapeH(s){ return (s||'').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
if(TOKEN){ $('#tok').value = TOKEN; signin(); }
</script>
</body>
</html>