RECORDGOD/site/admin.html
type-two b6994a96e3 feat(admin): legacy-panel banner — new admin lives at robotmonster.party/admin + copy-my-token
The owner token is per-browser localStorage, so a sign-in here doesn't carry across domains;
the banner hands you the token to paste into the new panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 17:40:40 +10:00

1321 lines
106 KiB
HTML
Raw Permalink 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 class="brand">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" data-module="records">📝 Wantlist</a>
<div class="sec">Catalog</div>
<a data-v="identify" data-generic style="display:none">🔎 Identify</a>
<a data-v="melt" data-module="jewellery" style="display:none">💰 Melt</a>
<a data-v="priceguide" data-module="tools" style="display:none">🔧 Price guide</a>
<a data-v="intake" data-module="records">📥 Intake</a>
<a data-v="newstock" data-module="records">📦 New stock</a>
<a data-v="buylist" data-module="records">🎯 Buy list</a>
<a data-v="discogs" data-module="records">💿 Discogs</a>
<a data-v="heal" data-module="records">🩹 Heal</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 data-v="settings" data-admin>⚙️ Settings</a>
<a data-v="pages" data-admin>📄 Pages</a>
<a data-v="system" data-admin>🖥️ System</a>
<a href="/store/">🎧 Virtual store ↗</a>
<a onclick="changeMyPassword()" style="cursor:pointer">🔑 Change password</a>
<a onclick="signOut()" style="cursor:pointer">🚪 Sign out</a>
<a href="/">← Home</a>
</nav>
<main>
<div style="background:#fdeef6;border:1px solid #c2187e;border-radius:10px;padding:10px 14px;margin-bottom:14px;font-size:13px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<span>⚠️ <b>Legacy panel.</b> The store admin now lives at <a href="https://robotmonster.party/admin" style="color:#c2187e"><b>robotmonster.party/admin</b></a> — your sign-in token here works there too.</span>
<button onclick="legacyCopyToken()" style="cursor:pointer">📋 Copy my token</button>
<span id="legacyMsg" style="color:#666;font-size:12px"></span>
</div>
<div id="content"></div>
</main>
</div>
<script>
async function legacyCopyToken(){
const t=localStorage.getItem('rg_token')||'', m=document.getElementById('legacyMsg');
try{ await navigator.clipboard.writeText(t); if(m) m.textContent='copied — paste it into the sign-in at robotmonster.party/admin'; }
catch(e){ if(m) m.textContent='clipboard blocked — token: '+t; }
}
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') && $('#tok').value.trim()) || TOKEN; // keep the stored token if the box is empty
let me; try { me = await api('/me'); } catch(e){ localStorage.removeItem('rg_token'); location.replace('/login'); return; }
ROLE = me.role || 'staff';
localStorage.setItem('rg_token', TOKEN);
window.ME = me;
applyStore(me); // brand + show/hide specialist nav per the unlocked modules
// 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==='system')) 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, newstock:vNewstock, buylist:vBuylist, heal:vHeal, storemap:vStoreMap, identify:vIdentify, melt:vMelt, priceguide:vPriceguide, discogs:vDiscogs, crates:vCrates, reports:vReports, connections:vConn, settings:vSettings, pages:vPages, staff:vStaff, system:vSystem}[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 oninput=invMargin()')}
${fld('Cost','eCost',r.cost_price,'type=number step=0.01 oninput=invMargin()')}
<div class="bar" style="margin:-2px 0 8px"><span class=muted style="width:90px">Margin</span><span id="eMargin" class="muted">—</span></div>
<div class="bar" style="margin-bottom:6px"><span class=muted style="width:90px">New / used</span><select id="eCondType"><option value="used"${r.condition_type!=='new'?' selected':''}>Used</option><option value="new"${r.condition_type==='new'?' selected':''}>New / sealed</option></select></div>
${fld('Target','eTarget',r.target,'type=number step=0.01')}
<div id="dgVal" style="margin:2px 0 8px;font-size:13px"><button class="ghost" type="button" onclick="dgValue('${sku}')">💎 DealGod value</button></div>
${r.release_id?`<div class="bar" style="margin:0 0 8px;font-size:13px"><span class=muted style="width:90px">Discogs</span><button class="ghost" type="button" onclick="dgListItem('${sku}')">💿 List</button><button class="ghost" type="button" onclick="dgDelistItem('${sku}')">Delist</button><span id="dgItemMsg" class="muted" style="margin-left:8px"></span></div>`:''}
${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>`);
invMargin();
}
function invMargin(){
const p=parseFloat($('#ePrice').value), c=parseFloat($('#eCost').value), m=$('#eMargin');
if(!m) return;
if(isNaN(p)||isNaN(c)){ m.textContent='—'; return; }
const d=p-c, pct=p>0?Math.round(d/p*100):0;
m.innerHTML=`<b style="color:${d>=0?'var(--ok)':'var(--warn)'}">${money(d)}</b> (${pct}%)`;
}
async function dgValue(sku){
const el=$('#dgVal'); if(!el) return;
el.innerHTML='<span class=muted>checking DealGod…</span>';
try{
const d=await api('/value/'+encodeURIComponent(sku));
if(!d.ok){ el.innerHTML='<span class=muted>'+escapeH(d.reason||'unavailable')+'</span>'; return; }
const v=d.value;
if(!v){ el.innerHTML='<span class=muted>'+escapeH(d.note||'no value')+'</span>'; return; }
const sup=v.supply?` · ${v.supply.stores||0} stores/${v.supply.sellers||0} sellers`:'';
const fresh=v.as_of?` · ${v.as_of}${v.n?` (n=${v.n})`:''}`:'';
const ready=v.value_ready===false?' <span style="color:var(--warn)">· identify-only</span>':'';
let html=`💎 <b>${money(v.typ)}</b> <span class=muted>(${money(v.low)}${money(v.high)} ${escapeH(v.currency||'')}${fresh}${sup})</span>${ready} `
+`<button class="ghost" type="button" onclick="document.getElementById('eTarget').value=${Number(v.typ)||0}">→ Target</button>`;
const lo=d.lore;
if(lo && (lo.tier || (lo.faults||[]).length)){
html+=`<div class=muted style="margin-top:4px">${lo.tier?`tier <b>${escapeH(lo.tier)}</b>`:''}`
+`${(lo.faults||[]).length?` · watch: ${escapeH((lo.faults||[]).slice(0,2).join('; '))}`:''}</div>`;
}
el.innerHTML=html;
}catch(e){ el.innerHTML='<span class=muted>lookup failed</span>'; }
}
async function invSave(sku){
const v = id => { const x = $('#'+id).value.trim(); return x===''?null:x; };
const body = { title:v('eTitle'), price:numOrNull('ePrice'), cost_price:numOrNull('eCost'),
target_price:numOrNull('eTarget'), condition_type:$('#eCondType').value,
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">Staff sign in at <b>recordgod.com/login</b> with their email + password. They 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" style="flex-wrap:wrap;gap:8px"><input id="stName" placeholder="name"><input id="stEmail" type="email" placeholder="email (their login)"><input id="stPhone" placeholder="phone"><input id="stPay" type="number" step="0.01" placeholder="$/hr" style="width:90px"><input id="stPw" type="text" placeholder="set password" style="width:130px"><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><td class="price">${t.pay_rate!=null?money(t.pay):'<span class=muted>—</span>'}</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><th>Pay</th></tr></thead><tbody>${tot||'<tr><td colspan=4 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 class="muted">${escapeH(s.email||'—')}${s.email?(s.has_password?' <span style="color:var(--ok)">●</span>':' <span style="color:var(--warn)" title="no password set">○</span>'):''}</td>
<td>${s.pay_rate!=null?money(s.pay_rate)+'/hr':'<span class=muted>—</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="staffEditDetails(${s.id},${JSON.stringify({email:s.email||'',phone:s.phone||'',pay_rate:s.pay_rate}).replace(/"/g,'&quot;')})">✏️ details</button>
<button class="ghost" onclick="staffSetPw(${s.id},'${escapeH(s.name)}')">🔒 set password</button>
<button class="ghost" onclick="staffReset(${s.id})">🔑 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>Email</th><th>Pay</th><th>Role</th><th>Last seen</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan=6 class=muted>no staff yet — add one above</td></tr>'}</tbody></table>`;
}
async function staffEditDetails(id,cur){
const email=prompt('Email (their login):',cur.email||''); if(email===null) return;
const phone=prompt('Phone:',cur.phone||'')||null;
const pay=prompt('Pay rate ($/hr):',cur.pay_rate!=null?cur.pay_rate:'');
const body={email:email.trim()||null,phone:phone,pay_rate:pay===null||pay===''?null:parseFloat(pay)};
const r=await fetch('/admin/staff/'+id,{method:'POST',headers:hdr(),body:JSON.stringify(body)});
if(r.ok) loadStaff(); else alert('Failed: '+((await r.json().catch(()=>({}))).detail||r.status));
}
async function staffSetPw(id,name){
const pw = prompt('Set a password for '+name+' (min 6 characters):'); if(pw===null) return;
if(pw.length<6){ alert('Too short — min 6 characters.'); return; }
const r = await fetch('/admin/staff/'+id+'/password',{method:'POST',headers:hdr(),body:JSON.stringify({password:pw})});
if(r.ok){ alert(name+' can now sign in with their email + this password.'); loadStaff(); }
else alert('Failed: '+((await r.json().catch(()=>({}))).detail||r.status));
}
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 body={name, role:$('#stRole').value, email:$('#stEmail').value.trim()||null,
phone:$('#stPhone').value.trim()||null, pay_rate:parseFloat($('#stPay').value)||null, password:$('#stPw').value||null};
const d=await fetch('/admin/staff',{method:'POST',headers:hdr(),body:JSON.stringify(body)}).then(r=>r.json());
['stName','stEmail','stPhone','stPay','stPw'].forEach(i=>$('#'+i).value='');
$('#stNew').innerHTML = body.email && body.password
? `<div style="background:#e9f9ee;border:1px solid var(--ok);border-radius:8px;padding:11px"><b>${escapeH(name)}</b> is ready — they sign in at <b>recordgod.com/login</b> with <b>${escapeH(body.email)}</b> + the password you set.</div>`
: 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); }
// ---------- System (read-only Postgres health) ----------
function fmtBytes(b){ b=+b||0; const u=['B','KB','MB','GB','TB']; let i=0; while(b>=1024&&i<u.length-1){b/=1024;i++;} return b.toFixed(b<10&&i>0?1:0)+' '+u[i]; }
function fmtUptime(s){ s=+s||0; const d=Math.floor(s/86400), h=Math.floor(s%86400/3600), m=Math.floor(s%3600/60); return d?`${d}d ${h}h`:(h?`${h}h ${m}m`:`${m}m`); }
function ago(t){ if(!t) return '—'; const s=(Date.now()-new Date(t).getTime())/1000; if(s<60)return 'just now'; if(s<3600)return Math.floor(s/60)+'m ago'; if(s<86400)return Math.floor(s/3600)+'h ago'; return Math.floor(s/86400)+'d ago'; }
async function vSystem(){
$('#content').innerHTML = '<h2>System</h2><div class="muted">Read-only Postgres health · maintenance is handled by infra (autovacuum + scheduled backups)</div><div id="sys" style="margin-top:10px"><div class="muted">loading…</div></div>';
const d = await api('/system'), db = d.db, f = d.fresh;
const tiles = kpi(fmtBytes(db.db_bytes),'database size',true) + kpi('PG '+escapeH(db.pg_version||'?'),'postgres')
+ kpi(fmtUptime(db.uptime_s),'uptime') + kpi(db.active+' / '+db.conns,'active / conns')
+ kpi(db.cache_hit==null?'—':db.cache_hit+'%','cache hit',true)
+ kpi(Number(f.inventory_rows).toLocaleString(),'inventory rows') + kpi(Number(f.releases).toLocaleString(),'releases');
const rows = (d.tables||[]).map(t=>`<tr><td>${escapeH(t.name)}</td><td class="price">${fmtBytes(t.bytes)}</td><td>${Number(t.rows).toLocaleString()}</td></tr>`).join('');
$('#sys').innerHTML = '<div class="kpis">'+tiles+'</div>'
+ '<div class="card"><h3>Data freshness</h3><table><tbody>'
+ `<tr><td>Last sale</td><td class="price">${ago(f.last_sale)}</td><td class="muted">${f.last_sale?String(f.last_sale).slice(0,16).replace('T',' '):'—'}</td></tr>`
+ `<tr><td>Inventory last updated</td><td class="price">${ago(f.inventory_updated)}</td><td class="muted">${f.inventory_updated?String(f.inventory_updated).slice(0,16).replace('T',' '):'—'}</td></tr>`
+ '</tbody></table></div>'
+ '<div class="card"><h3>Largest tables</h3><table><thead><tr><th>Table</th><th>Size</th><th>Rows (est.)</th></tr></thead><tbody>'+(rows||'<tr><td colspan=3 class=muted>—</td></tr>')+'</tbody></table></div>'
+ '<div class="card"><h3>Slow queries <button class="ghost" style="float:right;font-size:12px" onclick="sqReset()">↺ reset stats</button></h3><div class="muted" style="font-size:12px;margin-bottom:6px">slowest SQL by mean time · pg_stat_statements</div><div id="sqOut"><div class="muted">loading…</div></div></div>';
loadSlowQueries();
}
async function loadSlowQueries(){
const el=$('#sqOut'); if(!el) return;
const d=await api('/slow-queries');
if(!d.ok){ el.innerHTML='<div class="muted">'+escapeH(d.reason||'unavailable')+'</div>'; return; }
if(!(d.queries||[]).length){ el.innerHTML='<div class="muted">no stats yet — they accumulate as the app runs</div>'; return; }
el.innerHTML='<table><thead><tr><th>Query</th><th>Calls</th><th>Mean ms</th><th>Max ms</th><th>Total ms</th></tr></thead><tbody>'
+ d.queries.map(q=>`<tr><td style="font-family:ui-monospace,monospace;font-size:11px;max-width:430px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escapeH(q.query)}">${escapeH(q.query)}</td>`
+`<td>${Number(q.calls).toLocaleString()}</td><td class="price"><b style="color:${q.mean_ms>50?'var(--warn)':'inherit'}">${q.mean_ms}</b></td><td>${q.max_ms}</td><td>${Number(q.total_ms).toLocaleString()}</td></tr>`).join('')
+ '</tbody></table>';
}
async function sqReset(){
await fetch('/admin/slow-queries/reset',{method:'POST',headers:hdr()});
loadSlowQueries();
}
// ---------- Store settings (WordPress General/Reading replacement) ----------
const SETTING_KEYS=['store_name','store_tagline','store_logo','store_url','contact_email','contact_phone','store_address','abn','timezone','currency_symbol','tax_rate','tax_type','homepage','seo_title','seo_description','social_instagram','social_facebook','policy_returns','policy_shipping','policy_privacy','receipt_footer','meta_pixel_id','umami_website_id','umami_src','ga4_id'];
async function vSettings(){
const s=await (await fetch('/sales/settings',{headers:hdr()})).json();
const f=(label,key,attr)=>`<div class="bar" style="margin-bottom:6px"><span class=muted style="width:130px">${label}</span><input id="set_${key}" ${attr||''} value="${(s[key]==null?'':String(s[key])).replace(/"/g,'&quot;')}" style="flex:1"></div>`;
const ta=(label,key)=>`<div style="margin-bottom:8px"><div class=muted style="margin-bottom:3px">${label}</div><textarea id="set_${key}" style="width:100%;min-height:56px;padding:6px;border:1px solid var(--line);border-radius:6px;font:inherit">${escapeH(s[key]||'')}</textarea></div>`;
$('#content').innerHTML=`<h2>Store settings <span class="muted" style="font-size:13px">· the WordPress General/Reading replacement</span></h2>
<div style="display:flex;gap:16px;flex-wrap:wrap;align-items:flex-start">
<div class="card" style="flex:1;min-width:300px"><h3>Identity &amp; contact</h3>
${f('Store name','store_name')}${f('Tagline','store_tagline')}${f('Logo URL','store_logo')}${f('Store URL','store_url')}
${f('Email','contact_email','type=email')}${f('Phone','contact_phone')}${f('Address','store_address')}${f('ABN','abn')}${f('Timezone','timezone')}
${f('Instagram','social_instagram')}${f('Facebook','social_facebook')}</div>
<div class="card" style="flex:1;min-width:300px"><h3>Tax &amp; currency</h3>
${f('Currency symbol','currency_symbol')}${f('GST rate %','tax_rate','type=number step=0.1')}
<div class="bar" style="margin-bottom:6px"><span class=muted style="width:130px">Tax type</span><select id="set_tax_type"><option value="inclusive">Inclusive (AU)</option><option value="exclusive">Exclusive</option></select></div>
<h3 style="margin-top:14px">Homepage &amp; SEO</h3>
<div class="bar" style="margin-bottom:6px"><span class=muted style="width:130px">Homepage</span><select id="set_homepage"><option value="storefront">Storefront</option><option value="landing">Custom landing</option><option value="3d">3D store</option></select></div>
${f('SEO title','seo_title')}${ta('SEO description','seo_description')}
<h3 style="margin-top:14px">Analytics &amp; tracking</h3>
${f('Meta Pixel ID','meta_pixel_id')}${f('Umami website ID','umami_website_id')}${f('Umami src','umami_src','placeholder="https://cloud.umami.is/script.js"')}${f('GA4 ID','ga4_id','placeholder="G-XXXX"')}</div>
<div class="card" style="flex:1;min-width:300px"><h3>Policies</h3>
${ta('Returns','policy_returns')}${ta('Shipping','policy_shipping')}${ta('Privacy','policy_privacy')}${f('Receipt footer','receipt_footer')}</div>
</div>
<div class="bar" style="margin-top:12px"><button onclick="saveSettings()">Save settings</button><span id="setMsg" class="muted" style="margin-left:10px"></span></div>`;
if($('#set_tax_type')) $('#set_tax_type').value=s.tax_type||'exclusive';
if($('#set_homepage')) $('#set_homepage').value=s.homepage||'storefront';
}
async function saveSettings(){
const body={}; SETTING_KEYS.forEach(k=>{ const el=$('#set_'+k); if(el) body[k]=el.value; });
const r=await fetch('/sales/settings',{method:'POST',headers:hdr(),body:JSON.stringify(body)});
$('#setMsg').textContent = r.ok?'✓ saved':'save failed'; setTimeout(()=>{const m=$('#setMsg');if(m)m.textContent='';},2000);
}
// ---------- Pages CMS (WordPress Pages replacement) ----------
async function vPages(){
const d=await api('/pages');
const list=(d.pages||[]).map(p=>`<div class="ri" style="cursor:pointer" onclick="pageEdit('${escapeH(p.slug)}')"><div style="flex:1"><b>${escapeH(p.title||p.slug)}</b> <span class=muted>/${escapeH(p.slug)}</span>${p.published?'':' <span style="color:var(--warn)">draft</span>'}</div><span class=muted style="font-size:12px">${p.body_len} ch</span></div>`).join('');
$('#content').innerHTML=`<h2>Pages <span class="muted" style="font-size:13px">· About / Shipping / Returns / Privacy — the WordPress Pages replacement</span></h2>
<div class="bar"><button onclick="pageEdit('')">+ New page</button></div>
<div style="display:flex;gap:18px;align-items:flex-start;margin-top:10px">
<div style="width:300px;flex-shrink:0"><div class="card">${list||'<div class=muted>no pages yet</div>'}</div></div>
<div id="pageEd" style="flex:1;min-width:0"><div class="card soon">pick a page or create one</div></div></div>
<div class="card" style="margin-top:16px"><h3>🔀 URL redirects <span class="muted" style="font-size:12px;font-weight:400">· 301s so old WowPlatter links don't break at cutover</span></h3>
<div class=muted style="font-size:12px;margin-bottom:4px">paste one per line — <code>/old/path , /new/path</code></div>
<textarea id="rdIn" placeholder="/product/some-old-record , /release/12345&#10;/shop-page , /shop" style="width:100%;min-height:80px;padding:8px;border:1px solid var(--line);border-radius:6px;font:12px ui-monospace,monospace"></textarea>
<div class="bar" style="margin-top:6px"><button onclick="importRedirects()">Import redirects</button><span id="rdMsg" class="muted" style="margin-left:10px"></span></div>
<div id="rdList" style="margin-top:10px"></div></div>`;
loadRedirects();
}
async function loadRedirects(){
const el=$('#rdList'); if(!el) return;
const d=await api('/redirects'); const rs=d.redirects||[];
el.innerHTML = rs.length ? '<table><thead><tr><th>From</th><th>→ To</th><th>Code</th><th>Hits</th><th></th></tr></thead><tbody>'
+ rs.map(r=>`<tr><td style="font-family:ui-monospace,monospace;font-size:12px">${escapeH(r.from_path)}</td><td style="font-family:ui-monospace,monospace;font-size:12px">${escapeH(r.to_path)}</td><td>${r.code}</td><td>${r.hits}</td><td><span style="cursor:pointer;color:#e66" onclick="delRedirect('${escapeH(r.from_path)}')">✕</span></td></tr>`).join('')
+ '</tbody></table>' : '<div class=muted style="font-size:12px">no redirects yet</div>';
}
async function importRedirects(){
const lines=$('#rdIn').value.split('\n').map(l=>l.trim()).filter(Boolean);
const redirects=[]; lines.forEach(l=>{ const m=l.split(/\s*[,→]\s*|\s+->\s+/); if(m.length>=2 && m[0] && m[1]) redirects.push({from_path:m[0],to_path:m[1],code:301}); });
if(!redirects.length){ $('#rdMsg').textContent='nothing to import'; return; }
const r=await fetch('/admin/redirects',{method:'POST',headers:hdr(),body:JSON.stringify({redirects})}).then(x=>x.json());
$('#rdMsg').textContent='✓ '+(r.saved||0)+' saved'; $('#rdIn').value=''; loadRedirects();
}
async function delRedirect(fp){
await fetch('/admin/redirects?from_path='+encodeURIComponent(fp),{method:'DELETE',headers:hdr()});
loadRedirects();
}
function pgFld(l,id,v){ return `<div class="bar" style="margin-bottom:6px"><span class=muted style="width:90px">${l}</span><input id="${id}" value="${(v==null?'':String(v)).replace(/"/g,'&quot;')}" style="flex:1"></div>`; }
async function pageEdit(slug){
let p={slug:'',title:'',body:'',published:true,seo_title:'',seo_description:''};
if(slug){ const d=await api('/pages/'+encodeURIComponent(slug)); if(d.page) p=d.page; }
$('#pageEd').innerHTML=`<div class="card">
${pgFld('Title','pgTitle',p.title)}
<div class="bar" style="margin-bottom:6px"><span class=muted style="width:90px">Slug</span><input id="pgSlug" value="${escapeH(p.slug)}" ${slug?'readonly':''} placeholder="about" style="flex:1"><span class=muted style="font-size:11px;margin-left:6px">/shop/page/<b id="pgSlugPrev">${escapeH(p.slug||'…')}</b></span></div>
<div style="margin-bottom:6px"><div class=muted style="margin-bottom:3px">Body (HTML)</div><textarea id="pgBody" style="width:100%;min-height:200px;padding:8px;border:1px solid var(--line);border-radius:6px;font:13px ui-monospace,monospace">${escapeH(p.body)}</textarea></div>
${pgFld('SEO title','pgSeoT',p.seo_title)}${pgFld('SEO desc','pgSeoD',p.seo_description)}
<div class="bar" style="margin-top:6px"><label class=muted style="display:flex;align-items:center;gap:5px"><input type=checkbox id="pgPub" ${p.published?'checked':''}> published</label>
<span style="flex:1"></span><button onclick="pageSave('${slug}')">Save</button>
${p.slug?`<a class="ghost" href="/shop/page/${escapeH(p.slug)}" target="_blank" style="padding:8px 12px">View ↗</a>`:''}
${slug?`<button class="ghost" style="color:#e66" onclick="pageDelete('${escapeH(slug)}')">Delete</button>`:''}</div>
<div id="pgMsg" class="muted" style="margin-top:6px"></div></div>`;
const sl=$('#pgSlug'); if(sl && !slug) sl.addEventListener('input',()=>{$('#pgSlugPrev').textContent=sl.value.toLowerCase().replace(/[^a-z0-9-]+/g,'-')||'…';});
}
async function pageSave(slug){
const body={slug: slug || $('#pgSlug').value, title:$('#pgTitle').value, body:$('#pgBody').value, published:$('#pgPub').checked, seo_title:$('#pgSeoT').value||null, seo_description:$('#pgSeoD').value||null};
if(!body.slug && !body.title){ $('#pgMsg').textContent='need a title or slug'; return; }
const r=await fetch('/admin/pages',{method:'POST',headers:hdr(),body:JSON.stringify(body)}).then(x=>x.json());
if(r.ok) vPages(); else $('#pgMsg').textContent='save failed';
}
async function pageDelete(slug){
if(!confirm('Delete page /'+slug+'?')) return;
await fetch('/admin/pages/'+encodeURIComponent(slug),{method:'DELETE',headers:hdr()});
vPages();
}
// ---------- Discogs marketplace cockpit ----------
async function vDiscogs(){
$('#content').innerHTML='<h2>Discogs marketplace <span class="muted" style="font-size:13px">· your listings + orders, synced cross-channel (sell anywhere → off everywhere)</span></h2><div id="dgBody"><div class="muted">connecting to Discogs…</div></div>';
const s=await api('/discogs');
if(!s.ok){ $('#dgBody').innerHTML='<div class="card" style="color:var(--warn)">'+escapeH(s.reason||'Discogs not connected')+' — add your token in Connections</div>'; return; }
$('#dgBody').innerHTML=`<div class="kpis">${kpi(escapeH(s.seller),'seller')}${kpi(s.listings==null?'—':s.listings,'listings for sale')}${kpi(s.orders==null?'—':s.orders,'orders')}</div>
<div class="bar" style="margin:10px 0"><button onclick="dgSync()">↺ Sync orders</button><span id="dgSyncMsg" class="muted" style="margin-left:10px"></span></div>
<div class="card"><h3>Recent orders</h3><div id="dgOrders" class="muted">loading…</div></div>
<div class="card soon" style="margin-top:12px">To list a record: open it in <b>Inventory</b> → <b>💿 List</b>. Selling it anywhere — POS, web checkout, or Discogs — auto-delists it everywhere (no overselling).</div>`;
loadDgOrders();
}
async function loadDgOrders(){
const el=$('#dgOrders'); if(!el) return;
const d=await api('/discogs/orders');
if(!d.ok){ el.innerHTML='<span class=muted>'+escapeH(d.reason||'')+'</span>'; return; }
el.innerHTML = (d.orders||[]).length ? '<table><thead><tr><th>Order</th><th>Status</th><th>Buyer</th><th>Items</th><th>Total</th></tr></thead><tbody>'
+ d.orders.map(o=>`<tr><td>${escapeH(o.id)}</td><td>${escapeH(o.status||'')}</td><td>${escapeH(o.buyer||'')}</td><td>${o.items}</td><td class="price">${money(o.total)}</td></tr>`).join('')+'</tbody></table>'
: '<span class=muted>no orders yet</span>';
}
async function dgSync(){
$('#dgSyncMsg').textContent='syncing…';
const r=await fetch('/admin/discogs/sync',{method:'POST',headers:hdr()}).then(x=>x.json());
$('#dgSyncMsg').textContent = r.ok ? `✓ imported ${r.count||0} order(s)` : ('failed: '+escapeH(r.reason||''));
if(r.ok) setTimeout(vDiscogs,800);
}
async function dgListItem(sku){
const m=$('#dgItemMsg'); if(m) m.textContent='listing on Discogs…';
const r=await fetch('/admin/discogs/list/'+encodeURIComponent(sku),{method:'POST',headers:hdr(),body:JSON.stringify({status:'For Sale'})}).then(x=>x.json());
if(m) m.innerHTML = r.ok ? '✓ listed #'+r.listing_id : '<span style="color:var(--warn)">'+escapeH(r.error||'failed')+'</span>';
}
async function dgDelistItem(sku){
const m=$('#dgItemMsg'); if(m) m.textContent='delisting…';
const r=await fetch('/admin/discogs/list/'+encodeURIComponent(sku),{method:'DELETE',headers:hdr()}).then(x=>x.json());
if(m) m.textContent = r.ok ? '✓ delisted' : 'failed';
}
// ---------- 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','openrouter'].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 ----------
const CONDS = ['M','NM','VG+','VG','G+','G','F','P'];
const condSel = (sel,cls,attrs) => `<select class="${cls||''}" ${attrs||''}>`+CONDS.map(c=>`<option${c===(sel||'VG+')?' selected':''}>${c}</option>`).join('')+'</select>';
let inkTab = 'find';
function vIntake(){
$('#content').innerHTML = '<h2>Intake <span class="muted" style="font-size:13px">· stage new stock</span></h2>'
+ '<div class="tabrow" style="display:flex;gap:6px;margin-bottom:14px">'
+ ['find:🔎 Find &amp; stage','collection:💿 Discogs collection','sheet:📄 Google Sheet'].map(t=>{
const [k,l]=t.split(':'); return `<button class="tab${k===inkTab?' on':''}" onclick="inkGo('${k}')" style="padding:7px 13px;border-radius:8px;border:1px solid var(--line);background:${k===inkTab?'var(--accent,#c2185b)':'var(--panel)'};color:${k===inkTab?'#fff':'inherit'}">${l}</button>`;
}).join('')
+ '</div><div id="ink_body"></div>';
inkGo(inkTab);
}
function inkGo(t){ inkTab=t; if(t==='find')inkFind(); else if(t==='collection')inkColl(); else inkSheet();
document.querySelectorAll('.tabrow .tab').forEach(b=>{const on=b.getAttribute('onclick').includes("'"+t+"'");b.style.background=on?'var(--accent,#c2185b)':'var(--panel)';b.style.color=on?'#fff':'inherit';}); }
const inkBody = h => { if(inkTab&&$('#ink_body')) $('#ink_body').innerHTML = h; };
// --- 1. Find & stage (internal lookup + Discogs fallback) ---
function inkFind(){
inkBody('<div class="card"><div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">'
+ '<div style="flex:2;min-width:220px"><label>Artist / title</label><input id="ik_q" placeholder="e.g. aphex twin selected ambient" style="width:100%" onkeydown="if(event.key===\'Enter\')inkSearch()"></div>'
+ '<div><label>Label</label><input id="ik_label" style="width:120px"></div>'
+ '<div><label>Year</label><input id="ik_year" type="number" style="width:78px"></div>'
+ '<div><label>Country</label><input id="ik_country" style="width:90px"></div>'
+ '<div><label>Format</label><input id="ik_fmt" placeholder="Vinyl / CD" style="width:100px"></div>'
+ '<button onclick="inkSearch()">Search</button></div>'
+ '<div id="ik_hint" class="muted" style="margin-top:8px;font-size:12px">Searches your local catalog first — instant. New titles? Hit “Search Discogs”.</div></div>'
+ '<div id="ik_results"></div>');
}
async function inkSearch(remote){
const q=$('#ik_q').value.trim();
const p=new URLSearchParams({q, label:$('#ik_label').value.trim(), year:$('#ik_year').value.trim(),
country:$('#ik_country').value.trim(), fmt:$('#ik_fmt').value.trim()});
for(const[k,v]of[...p])if(!v)p.delete(k);
$('#ik_results').innerHTML='<div class="muted" style="padding:12px">searching…</div>';
const url = remote ? '/admin/intake/discogs/search?q='+encodeURIComponent(q) : '/admin/intake/search?'+p;
let d; try{ d=await(await fetch(url,{headers:hdr()})).json(); }catch(e){ $('#ik_results').innerHTML='<div class="card">search failed</div>'; return; }
inkRenderCards(d.items||[], d.source, d.error);
}
function inkRenderCards(items, source, err){
let h='';
if(source!=='discogs') h+='<div style="margin:6px 0 10px"><button onclick="inkSearch(true)" style="background:var(--panel);border:1px solid var(--line)">Search Discogs ↗ (new titles)</button> <span class="muted" style="font-size:12px">'+(items.length?items.length+' local matches':'no local matches')+'</span></div>';
if(err) h+='<div class="card" style="color:var(--warn)">'+escapeH(err)+'</div>';
if(!items.length && !err){ inkBody2(h+'<div class="card muted">Nothing found.</div>'); return; }
h+='<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px">';
items.forEach((it,i)=>{ window['_ik'+i]=it; h+=inkCard(it,'_ik'+i); });
h+='</div>';
inkBody2(h);
}
const inkBody2 = h => { $('#ik_results').innerHTML = h; };
function inkCard(it,ref){
return `<div class="card" style="display:flex;gap:10px;padding:12px">
${it.thumb?`<img src="${escapeH(it.thumb)}" style="width:64px;height:64px;object-fit:cover;border-radius:6px;flex-shrink:0">`:'<div style="width:64px;height:64px;background:var(--line);border-radius:6px;flex-shrink:0"></div>'}
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeH(it.title||'')}</div>
<div class="muted" style="font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeH(it.artist||'')}</div>
<div class="muted" style="font-size:11px">${[it.year,it.country,it.format].filter(Boolean).map(escapeH).join(' · ')}${it.in_stock?' · <span style="color:var(--ok)">in stock</span>':''}</div>
<div style="display:flex;gap:6px;margin-top:8px;align-items:center">
${condSel(it.media,'ik_c',`onchange="inkSuggest(this,${it.release_id})"`)}
<input class="ik_p" type="number" step="0.01" placeholder="$" style="width:70px" oninput="this.dataset.t=1" onfocus="if(!this.value)inkSuggest(this.closest('.card').querySelector('.ik_c'),${it.release_id})">
<button onclick="inkStage(${ref?`window.${ref}.release_id`:it.release_id},this)" style="padding:5px 12px">Stage</button>
</div>
<div class="ik_sugg muted" style="font-size:11px;margin-top:5px"></div>
</div></div>`;
}
// Condition dropdown → auto-fill the suggested price (DealGod value → release_market when live).
async function inkSuggest(sel, rid){
const card = sel.closest('.card'); if(!card || !rid) return;
const p = card.querySelector('.ik_p'), hint = card.querySelector('.ik_sugg');
if(hint) hint.textContent = '…';
try{
const d = await (await fetch(`/admin/intake/suggest?release_id=${rid}&condition=${encodeURIComponent(sel.value)}`,{headers:hdr()})).json();
if(!d.ok){ if(hint) hint.textContent = 'no market price yet'; return; }
if(p && (!p.value || p.dataset.t!=='1')) p.value = d.suggested; // fill unless operator typed
if(hint) hint.innerHTML = `💡 suggested <b>$${d.suggested}</b> · ${sel.value} · range $${d.low??'?'}$${d.high??'?'} <span style="opacity:.55">(${(d.source||[]).includes('lore')?'lore':'market'})</span>`;
}catch(e){ if(hint) hint.textContent = ''; }
}
async function inkStage(rid, btn){
const row=btn.parentElement, cond=row.querySelector('.ik_c').value, price=parseFloat(row.querySelector('.ik_p').value)||null;
btn.disabled=true; btn.textContent='…';
const d=await(await fetch('/admin/intake/stage',{method:'POST',headers:hdr(),
body:JSON.stringify({release_id:rid,condition:cond,price})})).json();
btn.outerHTML = d.ok ? `<span style="color:var(--ok)">✓ ${escapeH(d.sku)}</span>` : '<span style="color:var(--warn)">failed</span>';
}
// --- 2. Discogs collection folder ---
async function inkColl(){
inkBody('<div class="card"><label>Collection folder</label><div id="ik_folders" class="muted">loading folders…</div></div><div id="ik_folitems"></div>');
let d; try{ d=await(await fetch('/admin/intake/discogs/folders',{headers:hdr()})).json(); }catch(e){ $('#ik_folders').innerHTML='failed'; return; }
if(d.error){ $('#ik_folders').innerHTML='<span style="color:var(--warn)">'+escapeH(d.error)+'</span>'; return; }
$('#ik_folders').innerHTML = `<select id="ik_fsel" style="width:100%;max-width:480px" onchange="inkFolder(this.value)">`
+ '<option value="">— pick a folder —</option>'
+ d.folders.map(f=>`<option value="${f.id}">${escapeH(f.name)} (${f.count})</option>`).join('')+'</select>'
+ `<div class="muted" style="font-size:12px;margin-top:6px">Discogs user: ${escapeH(d.user)} · ${d.folders.length} folders with stock</div>`;
}
async function inkFolder(id,page){
if(!id) return; page=page||1;
$('#ik_folitems').innerHTML='<div class="muted" style="padding:12px">loading releases…</div>';
const d=await(await fetch(`/admin/intake/discogs/folder/${id}?page=${page}`,{headers:hdr()})).json();
if(d.error){ $('#ik_folitems').innerHTML='<div class="card" style="color:var(--warn)">'+escapeH(d.error)+'</div>'; return; }
let h='<div class="card"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">'
+`<strong>${d.items.length} releases</strong> <button onclick="inkStageAll('${id}')">Stage all on this page</button></div>`
+'<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px">';
d.items.forEach((it,i)=>{ window['_ikf'+i]=it; h+=inkCard(it,'_ikf'+i); });
h+='</div>';
if(d.pages>1) h+=`<div style="margin-top:12px;text-align:center" class="muted">page ${d.page} of ${d.pages} `
+(d.page>1?`<button onclick="inkFolder('${id}',${d.page-1})"></button> `:'')
+(d.page<d.pages?`<button onclick="inkFolder('${id}',${d.page+1})"></button>`:'')+'</div>';
h+='</div>';
$('#ik_folitems').innerHTML=h;
}
async function inkStageAll(id){
const sel=$('#ik_fsel'); if(!confirm('Stage all releases on this page with their collection condition/price?'))return;
const cards=[...document.querySelectorAll('#ik_folitems .card button')].filter(b=>b.textContent==='Stage');
for(const b of cards){ b.click(); await new Promise(r=>setTimeout(r,250)); }
}
// --- 3. Google Sheet (public CSV) ---
function inkSheet(){
inkBody('<div class="card"><div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">'
+'<div><strong>📋 Connected sheet</strong><div class="muted" style="font-size:12px">Your private intake sheet, read via the Google service account — uses the saved column map.</div></div>'
+'<button onclick="inkGPreview()">Preview connected sheet</button></div>'
+'<div id="ik_gsheet" style="margin-top:10px"></div></div>'
+'<div class="card"><label>…or paste a Google Sheet URL <span class="muted" style="font-weight:400">(Share → anyone with link can view)</span></label>'
+'<input id="ik_url" placeholder="https://docs.google.com/spreadsheets/d/…" style="width:100%">'
+'<div style="margin-top:10px"><button onclick="inkPreview()">Preview</button></div>'
+'<div class="muted" style="font-size:12px;margin-top:8px">Auto-detects columns: release id · media/sleeve condition · price · comment · timestamp (→ SKU).</div></div>'
+'<div id="ik_sheet"></div>');
}
function inkSampleTable(rows){
return '<table style="width:100%;font-size:12px;border-collapse:collapse"><tr style="text-align:left;color:var(--mut)"><th>release_id</th><th>sku</th><th>media</th><th>sleeve</th><th>price</th><th>notes</th></tr>'
+rows.map(r=>`<tr style="border-top:1px solid var(--line)"><td>${r.release_id}</td><td>${escapeH(r.sku||'auto')}</td><td>${escapeH(r.media||'')}</td><td>${escapeH(r.sleeve||'')}</td><td>${r.price??''}</td><td>${escapeH(r.notes||'')}</td></tr>`).join('')+'</table>';
}
async function inkGPreview(){
$('#ik_gsheet').innerHTML='<div class="muted">reading connected sheet…</div>';
const d=await(await fetch('/admin/intake/gsheet/preview',{headers:hdr()})).json();
if(!d.ok){ $('#ik_gsheet').innerHTML='<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>'; return; }
$('#ik_gsheet').innerHTML=`<div style="margin-bottom:8px"><strong>${d.new}</strong> new of ${d.total} rows in <em>${escapeH(d.sheet)}</em> <span class="muted">(already-stocked SKUs are skipped)</span></div>`
+inkSampleTable(d.sample)
+'<div style="margin-top:12px"><button onclick="inkGImport()">Import '+d.new+' new rows</button> <span id="ik_gimp" class="muted"></span></div>';
}
async function inkGImport(){
const m=$('#ik_gimp'); m.textContent='importing… (enriching new titles, please wait)';
const d=await(await fetch('/admin/intake/gsheet/import',{method:'POST',headers:hdr()})).json();
m.innerHTML=d.ok?`<span style="color:var(--ok)">✓ staged ${d.staged} new</span>${d.skipped?` · ${d.skipped} already stocked`:''}`:'<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>';
}
async function inkPreview(){
const url=$('#ik_url').value.trim(); if(!url)return;
$('#ik_sheet').innerHTML='<div class="muted" style="padding:12px">reading sheet…</div>';
const d=await(await fetch('/admin/intake/sheet/preview',{method:'POST',headers:hdr(),body:JSON.stringify({url})})).json();
if(!d.ok){ $('#ik_sheet').innerHTML='<div class="card" style="color:var(--warn)">'+escapeH(d.error||'failed')+'</div>'; return; }
const cols=Object.keys(d.columns).map(k=>`${k}→col ${d.columns[k]+1}`).join(' · ');
let h=`<div class="card"><div style="margin-bottom:8px"><strong>${d.total}</strong> stageable rows · <span class="muted">${escapeH(cols)}</span></div>`
+'<table style="width:100%;font-size:12px;border-collapse:collapse"><tr style="text-align:left;color:var(--mut)"><th>release_id</th><th>sku</th><th>media</th><th>sleeve</th><th>price</th><th>notes</th></tr>'
+d.sample.map(r=>`<tr style="border-top:1px solid var(--line)"><td>${r.release_id}</td><td>${escapeH(r.sku||'auto')}</td><td>${escapeH(r.media||'')}</td><td>${escapeH(r.sleeve||'')}</td><td>${r.price??''}</td><td>${escapeH(r.notes||'')}</td></tr>`).join('')
+'</table><div style="margin-top:12px"><button onclick="inkImport()">Import all '+d.total+' rows</button> <span id="ik_imp" class="muted"></span></div></div>';
$('#ik_sheet').innerHTML=h; $('#ik_sheet').dataset.url=$('#ik_url').value.trim();
}
async function inkImport(){
const url=$('#ik_sheet').dataset.url, m=$('#ik_imp'); m.textContent='importing… (enriching new titles, please wait)';
const d=await(await fetch('/admin/intake/sheet/import',{method:'POST',headers:hdr(),body:JSON.stringify({url})})).json();
m.innerHTML = d.ok ? `<span style="color:var(--ok)">✓ staged ${d.staged} new</span>${d.skipped?` · ${d.skipped} already stocked`:''}` : '<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>';
}
// ---------- Heal (backfill missing catalog metadata) ----------
function vHeal(){
$('#content').innerHTML = '<h2>Heal <span class="muted" style="font-size:13px">· backfill missing catalog metadata</span></h2>'
+ '<div class="card" style="max-width:680px">'
+ '<p class="muted" style="margin-top:0">Scans in-stock items for gaps — missing title, weight, or cover art — and backfills from your local Discogs mirror. Anything the mirror doesnt have is re-fetched from Discogs (rate-limited, so it heals in batches).</p>'
+ '<button onclick="healScan()">Scan for gaps</button> <button onclick="healRun()" id="heal_run" style="display:none">🩹 Heal now</button>'
+ '<div id="heal_out" style="margin-top:14px"></div></div>';
}
async function healScan(){
const o=$('#heal_out'); o.innerHTML='<span class="muted">scanning…</span>';
const d=await(await fetch('/admin/intake/heal/scan',{headers:hdr()})).json();
if(!d.ok){ o.innerHTML='<span style="color:var(--warn)">scan failed</span>'; return; }
const row=(label,n,note)=>`<tr style="border-top:1px solid var(--line)"><td style="padding:5px 10px 5px 0">${label}</td><td style="text-align:right;font-weight:600">${n}</td><td class="muted" style="padding-left:12px;font-size:12px">${note||''}</td></tr>`;
o.innerHTML='<table style="border-collapse:collapse">'
+ row('Missing title', d.missing_title, d.local_fixable?`${d.local_fixable} fixable instantly from the mirror`:'')
+ row('Missing weight', d.missing_weight, 'blocks shipping quotes')
+ row('Need Discogs re-fetch', d.api_needed, 'not in mirror / no cover art')
+ row('No release_id', d.no_release_id, 'cant auto-heal — needs matching')
+ '</table>';
const fixable = d.missing_title + d.missing_weight + d.api_needed;
$('#heal_run').style.display = fixable ? '' : 'none';
if(!fixable) o.innerHTML += '<div style="color:var(--ok);margin-top:10px">✓ Nothing to heal — catalog is clean.</div>';
}
async function healRun(){
const o=$('#heal_out'), b=$('#heal_run'); b.disabled=true; b.textContent='healing…';
const d=await(await fetch('/admin/intake/heal/run',{method:'POST',headers:hdr(),body:'{}'})).json();
b.disabled=false; b.textContent='🩹 Heal now';
if(!d.ok){ o.innerHTML='<span style="color:var(--warn)">heal failed</span>'; return; }
o.innerHTML=`<div style="color:var(--ok)">✓ ${d.local_healed} healed locally · ${d.api_enriched} re-fetched from Discogs</div>`
+ (d.remaining_api ? `<div style="margin-top:8px">${d.remaining_api} still need Discogs (rate-limited) — <button onclick="healRun()">Heal next batch</button></div>`
: '<div class="muted" style="margin-top:8px">All gaps closed. Re-scan to confirm.</div>');
}
// ---------- New stock (distro purchases → approve → publish) ----------
async function vNewstock(){
$('#content').innerHTML = '<h2>New stock <span class="muted" style="font-size:13px">· approve distro purchases, then publish</span></h2>'
+ '<div class="card"><h3>Import a distro order sheet <span class="muted" style="font-weight:400">· Inertia / Warner SOH (fill ORDER QTY, then upload)</span></h3>'
+ '<div class="bar"><select id="ns_sheet_src"><option value="inertia">Inertia</option><option value="bertus">Bertus</option><option value="rocket">Rocket</option><option value="other">Other</option></select>'
+ '<input id="ns_sheet_file" type="file" accept=".xlsx" onchange="nsPickSheet(this)"></div>'
+ '<div id="ns_sheet_out" class="muted" style="margin-top:8px"></div></div>'
+ '<div id="ns_bar" class="muted" style="margin:8px 0">loading…</div><div id="ns_body"></div>';
nsLoad();
}
function nsPickSheet(input){
const f=input.files[0]; if(!f) return;
const rd=new FileReader();
rd.onload=()=>{ window._nsSheet={b64:rd.result, filename:f.name}; nsSheetPreview(); };
rd.readAsDataURL(f);
}
async function nsSheetPreview(){
const s=window._nsSheet; if(!s) return;
$('#ns_sheet_out').innerHTML='reading sheet…';
const src=$('#ns_sheet_src').value;
const d=await fetch('/admin/intake/distro-sheet/preview',{method:'POST',headers:hdr(),body:JSON.stringify({source:src,filename:s.filename,xlsx_b64:s.b64})}).then(r=>r.json());
if(!d.ok){ $('#ns_sheet_out').innerHTML='<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>'; return; }
$('#ns_sheet_out').innerHTML=`<div style="margin-bottom:6px"><b>${d.rows}</b> ordered titles · <b>${d.copies}</b> copies in <em>${escapeH(s.filename)}</em></div>`
+'<table style="font-size:12px"><tr style="color:var(--mut);text-align:left"><th>barcode</th><th>title</th><th>fmt</th><th>qty</th><th>cost</th></tr>'
+(d.sample||[]).map(r=>`<tr><td>${escapeH(r.barcode||'')}</td><td>${escapeH((r.artist?r.artist+' ':'')+(r.title||''))}</td><td>${escapeH(r.kind||'')}</td><td>${r.qty}</td><td>${r.unit_cost!=null?money(r.unit_cost):''}</td></tr>`).join('')
+'</table><div style="margin-top:10px"><button onclick="nsSheetImport()">📥 Import '+d.copies+' copies</button> <span id="ns_sheet_msg" class="muted"></span></div>';
}
async function nsSheetImport(){
const s=window._nsSheet, src=$('#ns_sheet_src').value, m=$('#ns_sheet_msg');
m.textContent='importing… (resolving barcodes, please wait)';
const d=await fetch('/admin/intake/distro-sheet',{method:'POST',headers:hdr(),body:JSON.stringify({source:src,filename:s.filename,xlsx_b64:s.b64})}).then(r=>r.json());
if(d.ok&&d.already_ingested) m.innerHTML='<span class="muted">already imported ('+d.existing+' copies)</span>';
else if(d.ok){ m.innerHTML=`<span style="color:var(--ok)">✓ staged ${d.staged} · ${d.resolved} matched</span>`; nsLoad(); }
else m.innerHTML='<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>';
}
async function nsLoad(){
const d = await api('/newstock');
if(!d.items.length){ $('#ns_bar').innerHTML='<div class="card" style="color:var(--ok)">✓ Nothing pending — scrape a distro order with the PRICEGOD extension and it lands here.</div>'; $('#ns_body').innerHTML=''; return; }
const resolved = d.items.filter(i=>i.release_id).length, priced = d.items.filter(i=>i.price!=null).length;
$('#ns_bar').innerHTML = `<b>${d.count}</b> copies pending · ${resolved} matched · ${priced} priced `
+ `<button onclick="nsPublishAll()" style="margin-left:8px">🚀 Publish all matched &amp; priced</button>`;
const groups = {}; d.items.forEach(i=>{ (groups[i.cost_source]=groups[i.cost_source]||[]).push(i); });
$('#ns_body').innerHTML = Object.entries(groups).map(([src,items])=>
`<div class="card"><h3>${escapeH(src)} <span class="muted" style="font-weight:400">· ${items.length} copies</span></h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(310px,1fr));gap:10px">${items.map(nsCard).join('')}</div></div>`).join('');
}
function nsCard(it){
const cover = it.thumb ? `<img src="${escapeH(it.thumb)}" style="width:54px;height:54px;object-fit:cover;border-radius:5px;flex-shrink:0">`
: '<div style="width:54px;height:54px;background:var(--line);border-radius:5px;flex-shrink:0"></div>';
return `<div id="nsc_${it.sku}" style="display:flex;gap:9px;border:1px solid var(--line);border-radius:8px;padding:9px">${cover}
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeH(it.title||it.barcode||'?')}</div>
<div class="muted" style="font-size:11px">${escapeH(it.artist||'')}${it.year?' · '+it.year:''} · ${escapeH(it.barcode||'')}</div>
<div style="font-size:11px;margin:2px 0">${it.release_id?`<span style="color:var(--ok)">✓ rel ${it.release_id}</span>`:'<span style="color:var(--warn)">⚠ needs release</span>'} · cost ${money(it.cost_price)}</div>
<div style="display:flex;gap:5px;align-items:center;margin-top:4px">
<select class="ns_ct">${['new','used'].map(c=>`<option${c===it.condition_type?' selected':''}>${c}</option>`).join('')}</select>
<input class="ns_pr" type="number" step="0.01" placeholder="retail $" value="${it.price!=null?it.price:''}" style="width:78px">
<button onclick="nsPublish('${it.sku}')"${it.release_id?'':' disabled title="pick a release first"'}>✓ publish</button>
<button class="ghost" onclick="nsRepick('${it.sku}')">🔎</button>
</div>
<div class="ns_msg muted" style="font-size:11px;margin-top:3px"></div>
</div></div>`;
}
async function nsPublish(sku){
const c=$('#nsc_'+sku), price=parseFloat(c.querySelector('.ns_pr').value)||null, ct=c.querySelector('.ns_ct').value, msg=c.querySelector('.ns_msg');
if(price==null){ msg.innerHTML='<span style="color:var(--warn)">set a retail price first</span>'; return; }
msg.textContent='publishing…';
const r=await fetch('/admin/newstock/'+encodeURIComponent(sku),{method:'POST',headers:hdr(),body:JSON.stringify({price,condition_type:ct,publish:true})});
if(r.ok){ c.style.opacity=.4; msg.innerHTML='<span style="color:var(--ok)">✓ published & live</span>'; }
else msg.innerHTML='<span style="color:var(--warn)">failed</span>';
}
async function nsPublishAll(){
if(!confirm('Publish every matched copy that has a retail price?')) return;
const d=await fetch('/admin/newstock/publish-all',{method:'POST',headers:hdr()}).then(r=>r.json());
alert('Published '+d.published+' copies.'); nsLoad();
}
function nsRepick(sku){
const c=$('#nsc_'+sku);
c.innerHTML=`<div style="flex:1"><div style="display:flex;gap:5px"><input class="ns_q" placeholder="search artist / title" style="flex:1" onkeydown="if(event.key==='Enter')nsSearch('${sku}')"><button onclick="nsSearch('${sku}')">Search</button><button class="ghost" onclick="nsLoad()">✕</button></div><div class="ns_res" style="margin-top:6px"></div></div>`;
c.querySelector('.ns_q').focus();
}
async function nsSearch(sku){
const c=$('#nsc_'+sku), q=c.querySelector('.ns_q').value.trim(); if(!q) return;
const res=c.querySelector('.ns_res'); res.innerHTML='<span class="muted">searching…</span>';
const d=await api('/intake/search?q='+encodeURIComponent(q)); window['_nsres_'+sku]=d.items||[];
res.innerHTML=(d.items||[]).slice(0,8).map((r,idx)=>`<div onclick="nsSetRelease('${sku}',${idx})" style="cursor:pointer;padding:4px;border-bottom:1px solid var(--line);font-size:12px">${escapeH(r.artist||'')} ${escapeH(r.title||'')} <span class="muted">${escapeH(r.year||'')} ${escapeH(r.country||'')}</span></div>`).join('')||'<span class="muted">no matches — try the barcode or fewer words</span>';
}
async function nsSetRelease(sku,idx){
const r=(window['_nsres_'+sku]||[])[idx]; if(!r) return;
await fetch('/admin/newstock/'+encodeURIComponent(sku),{method:'POST',headers:hdr(),body:JSON.stringify({release_id:r.release_id,title:(r.artist?r.artist+' ':'')+(r.title||'')})});
nsLoad();
}
// ---------- Buy list (wishlist ranked by scarcity) ----------
async function vBuylist(){
$('#content').innerHTML = '<h2>Buy list <span class="muted" style="font-size:13px">· your wishlist, rarest first</span></h2>'
+ '<div class="muted" style="margin:6px 0">Scrape your RareWaves wishlist with the PRICEGOD extension → it ranks here by scarcity. <b>Fewer AU stores = rarer = buy it first.</b></div>'
+ '<div id="bl_bar" class="muted" style="margin:8px 0">loading…</div><div id="bl_body"></div>';
blLoad();
}
async function blLoad(){
const d = await api('/buylist');
if(!d.count){ $('#bl_bar').innerHTML='<div class="card muted">Empty — scrape a wishlist with the extension and it lands here ranked.</div>'; $('#bl_body').innerHTML=''; return; }
$('#bl_bar').innerHTML = `<b>${d.count}</b> items · rarest first <button class="ghost" onclick="blClear()" style="margin-left:8px">Clear</button>`;
const scarce = n => n==null?'<span class="muted">?</span>' : `<b style="color:${n<=1?'#e0457b':n<=3?'#e08a00':'var(--mut)'}">${n}</b>`;
const rows = d.items.map(b=>`<tr style="border-top:1px solid var(--line)${b.store_count<=1?';background:rgba(224,69,123,.06)':''}">
<td>${b.thumb?`<img src="${escapeH(b.thumb)}" style="width:36px;height:36px;object-fit:cover;border-radius:4px">`:''}</td>
<td><div style="font-weight:600;font-size:13px">${escapeH(b.title||b.barcode||'?')}</div><div class="muted" style="font-size:11px">${escapeH(b.artist||'')}${b.colour&&b.colour!=='black'?' · <span style="color:#e08a00">'+escapeH(b.colour)+'</span>':''}</div></td>
<td style="text-align:center">${scarce(b.store_count)}<div class="muted" style="font-size:10px">AU stores</div></td>
<td style="text-align:center">${scarce(b.discogs_seller_count)}<div class="muted" style="font-size:10px">Discogs</div></td>
<td class="price">${b.lowest_au!=null?money(b.lowest_au):'<span class=muted>—</span>'}<div class="muted" style="font-size:10px">AU low</div></td>
<td class="price">${b.discogs_lowest!=null?money(b.discogs_lowest):'<span class=muted>—</span>'}<div class="muted" style="font-size:10px">Disc low</div></td>
<td>${b.already_stocked?'<span class="pill in">✓ in stock</span>':''}</td></tr>`).join('');
$('#bl_body').innerHTML = `<table style="width:100%"><thead><tr style="color:var(--mut);text-align:left;font-size:11px"><th></th><th>Title</th><th>Stores</th><th>Sellers</th><th>AU</th><th>Discogs</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
}
async function blClear(){
if(!confirm('Clear the buy list?')) return;
await fetch('/admin/buylist/clear',{method:'POST',headers:hdr()}); blLoad();
}
// ---------- 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==null?'':String(s)).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
// ---------- skin / vertical pack ----------
function brandHtml(brand){ brand=brand||'StoreGod'; return /God$/.test(brand) ? `${escapeH(brand.slice(0,-3))}<b>God</b>` : escapeH(brand); }
function applyStore(s){ // StoreGod base + unlocked modules drive brand + which specialist nav shows
s = s || {brand:'StoreGod', modules:[]};
document.querySelectorAll('.brand').forEach(el=>el.innerHTML=brandHtml(s.brand||'StoreGod'));
document.title = (s.brand||'StoreGod')+' · admin';
const mods = s.modules || [];
document.querySelectorAll('[data-module]').forEach(el=>el.style.display = mods.includes(el.dataset.module) ? '' : 'none');
document.querySelectorAll('[data-generic]').forEach(el=>el.style.display = ''); // Identify = StoreGod base, always on
}
let idCands=[];
function vIdentify(){
const scope=(window.ME||{}).scope||[];
$('#content').innerHTML = `<h2>Identify &amp; add <span class="muted" style="font-size:13px">· ${escapeH(scope.join(', ')||'any')} · via DealGod</span></h2>
<div class="bar"><input id="idQ" placeholder="barcode / ISBN / type a title…" style="flex:1"><button onclick="idRun()">🔎 Identify</button></div>
<div id="idOut" style="margin-top:10px"><div class="card soon">scan or type — DealGod identifies it, you stage it</div></div>`;
$('#idQ').addEventListener('keydown',e=>{ if(e.key==='Enter') idRun(); });
$('#idQ').focus();
}
async function idRun(){
const q=$('#idQ').value.trim(); if(!q) return;
const out=$('#idOut'); out.innerHTML='<div class=muted>asking DealGod…</div>';
const digits=q.replace(/\D/g,'');
const clue = /^[0-9\s-]{8,}$/.test(q) ? (digits.length>=12?{barcode:digits}:{isbn:digits}) : {text:q};
let r; try{ r=await fetch('/admin/identify',{method:'POST',headers:hdr(),body:JSON.stringify({clues:clue})}).then(x=>x.json()); }
catch(e){ out.innerHTML='<div class="card muted">lookup failed</div>'; return; }
idCands=r.candidates||[];
if(!idCands.length){ out.innerHTML='<div class="card muted">No match — '+escapeH(r.reason||'the brain may not cover this category yet')+'.</div>'; return; }
out.innerHTML = idCands.slice(0,6).map((c,i)=>`<div class="card" style="display:flex;justify-content:space-between;align-items:center;gap:10px">
<div><b>${escapeH(c.display_name||('#'+c.ref_id))}</b> <span class=muted>· ${escapeH(c.category||'')} · ${Math.round((c.confidence||0)*100)}%</span>
${(c.id_keys||[]).length?`<div class="muted" style="font-size:12px">look for: ${escapeH((c.id_keys||[]).slice(0,3).join(', '))}</div>`:''}</div>
<button onclick="idAdd(${i})">+ Add</button></div>`).join('');
}
async function idAdd(i){
const c=idCands[i]; if(!c) return;
const KMAP={books:'book',comics:'comic',games:'game',records:'vinyl'};
const mods=(window.ME||{}).modules||[];
const body={ ref_type:c.ref_type, ref_id:c.ref_id, title:c.display_name, kind:(mods.length===1?(KMAP[mods[0]]||'other'):'other') };
const r=await fetch('/admin/intake/identify',{method:'POST',headers:hdr(),body:JSON.stringify(body)}).then(x=>x.json()).catch(()=>({}));
$('#idOut').innerHTML = r.ok ? '<div class="card" style="color:var(--ok)">✓ staged <b>'+escapeH(r.title||r.sku)+'</b> — find it in Inventory (staged).</div>' : '<div class="card" style="color:var(--warn)">add failed</div>';
}
// ---------- Price guide (tools module — value + variants + faults + availability) ----------
function vPriceguide(){
$('#content').innerHTML = `<h2>Price guide <span class="muted" style="font-size:13px">· what it's worth + what to watch for · via DealGod</span></h2>
<div class="bar"><input id="pgQ" placeholder="model / barcode — e.g. Makita DHP484…" style="flex:1"><button onclick="pgRun()">🔧 Look up</button></div>
<div id="pgOut" style="margin-top:10px"><div class="card soon">look up any item — value, variants, common faults, where else it's stocked</div></div>`;
$('#pgQ').addEventListener('keydown',e=>{ if(e.key==='Enter') pgRun(); });
$('#pgQ').focus();
}
async function pgRun(){
const q=$('#pgQ').value.trim(); if(!q) return;
const out=$('#pgOut'); out.innerHTML='<div class=muted>asking DealGod…</div>';
const d=await fetch('/admin/priceguide',{method:'POST',headers:hdr(),body:JSON.stringify({query:q})}).then(x=>x.json()).catch(()=>({}));
if(!d.ok || !d.candidate){ out.innerHTML='<div class="card muted">No match'+(d.reason?' — '+escapeH(String(d.reason)):'')+'.</div>'; return; }
const c=d.candidate, v=d.value||{}, lo=d.lore||{}, sup=d.supply;
const idk=(c.id_keys&&c.id_keys.length?c.id_keys:lo.id_keys)||[];
let h=`<div class="card"><div style="display:flex;justify-content:space-between;align-items:baseline;gap:10px">
<h3 style="margin:0">${escapeH(c.display_name||('#'+c.ref_id))}</h3>
<div style="text-align:right">${v.typ!=null?`<div style="font-size:22px"><b>${money(v.typ)}</b></div><div class=muted style="font-size:12px">${money(v.low)}${money(v.high)} ${escapeH(v.currency||'')}${v.as_of?` · ${escapeH(v.as_of)}`:''}${v.n?` · n=${v.n}`:''}</div>`:'<span class=muted>no value yet</span>'}</div></div>
<div class=muted style="font-size:12px;margin:2px 0 8px">${escapeH(c.category||'')}${lo.tier?` · tier <b>${escapeH(lo.tier)}</b>`:''}${v.source?` · ${escapeH((v.source||[]).join('+'))}`:''}${sup?` · ${sup.store_count||0} stores / ${sup.discogs_seller_count||sup.sellers||0} sellers`:''}</div>`;
if(lo.description) h+=`<div style="font-size:13px;margin-bottom:8px">${escapeH(lo.description)}</div>`;
if((lo.variants||[]).length) h+=`<div style="margin-bottom:6px"><b style="font-size:13px">Variants</b><div class=muted style="font-size:12px">${lo.variants.map(x=>escapeH((x.sku?x.sku+' — ':'')+(x.note||''))).join(' · ')}</div></div>`;
if((lo.faults||[]).length) h+=`<div style="margin-bottom:6px"><b style="font-size:13px;color:var(--warn)">Common faults</b><div class=muted style="font-size:12px">${escapeH(lo.faults.join('; '))}</div></div>`;
if(idk.length) h+=`<div><b style="font-size:13px">Spot it</b><div class=muted style="font-size:12px">${escapeH(idk.join(' · '))}</div></div>`;
h+='</div>';
if((d.others||[]).length) h+=`<div class=muted style="font-size:12px;margin-top:6px">also: ${d.others.map(o=>escapeH(o.display_name||('#'+o.ref_id))).join(' · ')}</div>`;
out.innerHTML=h;
}
// ---------- Melt calculator (jewellery module) ----------
let meltT;
function vMelt(){
$('#content').innerHTML = `<h2>Melt calculator <span class="muted" style="font-size:13px">· live AU spot · the floor you never sell below</span></h2>
<div class="card" style="max-width:480px">
<div class="bar" style="margin-bottom:8px"><span class=muted style="width:96px">Metal</span>
<select id="mMetal" onchange="meltCalc()"><option value="gold">Gold</option><option value="silver">Silver</option><option value="platinum">Platinum</option></select></div>
<div class="bar" style="margin-bottom:8px"><span class=muted style="width:96px">Purity</span>
<input id="mKarat" type="number" step="0.5" value="18" oninput="meltCalc()" style="width:110px">
<span class="muted" style="margin-left:8px;font-size:12px">karat for gold (9/14/18/22/24) · fineness for silver/plat (925, 950)</span></div>
<div class="bar" style="margin-bottom:8px"><span class=muted style="width:96px">Weight (g)</span><input id="mGrams" type="number" step="0.01" value="0" oninput="meltCalc()" style="width:110px"></div>
<div class="bar" style="margin-bottom:8px"><span class=muted style="width:96px">Asking $</span><input id="mAsk" type="number" step="0.01" placeholder="optional — is it below melt?" oninput="meltCalc()" style="width:200px"></div>
<div id="meltOut" style="margin-top:6px"></div>
</div>`;
meltCalc();
}
function meltCalc(){
clearTimeout(meltT); meltT=setTimeout(async()=>{
const m=$('#mMetal').value, k=parseFloat($('#mKarat').value)||0, g=parseFloat($('#mGrams').value)||0;
const d=await api(`/melt?metal=${m}&karat=${k}&grams=${g}`);
const o=$('#meltOut'); if(!o) return;
if(!d.ok){ o.innerHTML='<span class=muted>'+escapeH(d.reason||'')+'</span>'; return; }
let h=`<div style="font-size:24px"><b>${money(d.melt)}</b> <span class=muted style="font-size:13px">melt floor</span></div>
<div class=muted style="font-size:12px">${money(d.per_gram)}/g · ${escapeH(m)} ${k} (${Math.round(d.purity*100)}%) · spot ${escapeH((d.captured_at||'').slice(0,10))}</div>`;
const ask=parseFloat($('#mAsk').value);
if(!isNaN(ask)){ const diff=ask-d.melt;
h += diff<0
? `<div style="margin-top:8px"><b style="color:var(--warn)">⚠ ${money(ask)} is ${money(-diff)} BELOW melt</b> — scrap/buy signal</div>`
: `<div style="margin-top:8px"><b style="color:var(--ok)">${money(ask)} is ${money(diff)} above melt</b></div>`; }
o.innerHTML=h;
},150);
}
function signOut(){ localStorage.removeItem('rg_token'); localStorage.removeItem('rg_name'); localStorage.removeItem('rg_role'); location.replace('/login'); }
async function changeMyPassword(){
const np = prompt('New password (min 6 characters):'); if(np===null) return;
if(np.length<6){ alert('Too short — min 6 characters.'); return; }
const cur = prompt('Your current password (leave blank if none set yet):') || '';
const r = await fetch('/auth/change-password',{method:'POST',headers:hdr(),body:JSON.stringify({current_password:cur,new_password:np})});
alert(r.ok ? 'Password updated.' : 'Failed: ' + ((await r.json().catch(()=>({}))).detail || r.status));
}
fetch('/pack').then(r=>r.json()).then(applyStore).catch(()=>{}); // brand the login/gate before auth
if(TOKEN){ signin(); } else { location.replace('/login'); }
</script>
</body>
</html>