RECORDGOD/site/admin.html

306 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RecordGod — admin</title>
<script defer src="/nav.js?v=3"></script>
<style>
:root{--pink:#ff5db1;--ink:#0b0b0d;--panel:#141418;--line:#26262c;--mut:#9a9aa6;--ok:#46d18a;--warn:#e6a046}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--ink);color:#f0f0f2;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:#0e0e11;color:#eee;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:#1d1d22;color:#ccc;border:1px solid var(--line)}
/* layout */
#app{display:none;grid-template-columns:212px 1fr;height:calc(100vh - 44px)}
nav{background:#0e0e11;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:#cfcfd6;padding:9px 12px;border-radius:8px;margin:2px 0;cursor:pointer}
nav a:hover{background:#17171c}
nav a.on{background:#221a20;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:#141418}
.thumb{width:34px;height:34px;border-radius:4px;object-fit:cover;background:#222;vertical-align:middle}
.pill{padding:2px 8px;border-radius:20px;font-size:11px;background:#1d1d22;color:#bbb}
.pill.in{background:#15291f;color:var(--ok)} .pill.out{background:#2a1a1a;color:#e66}
.price{font-weight:500}
.muted{color:var(--mut)} .soon{color:#55555f}
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px;margin-bottom:16px}
.card h3{margin:0 0 12px;font-weight:500;font-size:15px}
label{display:block;color:var(--mut);font-size:13px;margin:10px 0 4px}
.field{display:flex;gap:8px;align-items:center} .field input{flex:1}
.conn{display:flex;gap:10px;align-items:center;margin:9px 0}
.pg{display:flex;gap:8px;align-items:center;margin-top:14px}
</style>
</head>
<body>
<div id="gate"><div class="box">
<h1>Record<b>God</b></h1>
<p class="muted">admin sign-in</p>
<div class="field"><input id="tok" type="password" placeholder="admin token" style="flex:1"><button onclick="signin()">Enter</button></div>
<div id="gerr" class="soon" style="margin-top:10px"></div>
</div></div>
<div id="app">
<nav>
<div class="brand">Record<b>God</b></div>
<a data-v="dashboard" class="on">📊 Dashboard</a>
<a data-v="inventory">📦 Inventory</a>
<a data-v="orders">🛒 Orders</a>
<div class="sec">Catalog</div>
<a data-v="intake">📥 Intake</a>
<a data-v="storemap">🗺️ Store map</a>
<a data-v="crates">🗄️ Crates</a>
<a data-v="reports">📈 Reports</a>
<div class="sec">System</div>
<a data-v="connections">🔌 Connections</a>
<a href="/store/">🎧 Virtual store ↗</a>
<a href="/">← Home</a>
</nav>
<main id="content"></main>
</div>
<script>
const $ = s => document.querySelector(s);
let TOKEN = localStorage.getItem('rg_token') || '';
const hdr = () => ({ 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' });
const money = n => n==null ? '—' : '$' + Number(n).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
async function api(path){ const r = await fetch('/admin'+path, { headers: hdr() }); if(!r.ok) throw new Error(r.status); return r.json(); }
async function signin(){
TOKEN = $('#tok').value.trim();
try { await api('/stats'); } catch(e){ $('#gerr').textContent = 'invalid token'; return; }
localStorage.setItem('rg_token', TOKEN);
$('#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){
document.querySelectorAll('nav a[data-v]').forEach(a => a.classList.toggle('on', a.dataset.v===v));
({dashboard:vDash, inventory:vInv, orders:vOrders, intake:vIntake, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn}[v])();
}
// ---------- Dashboard ----------
async function vDash(){
const m = $('#content'); m.innerHTML = '<h2>Dashboard</h2><div class="muted">loading…</div>';
const s = await api('/stats');
m.innerHTML = '<h2>Dashboard</h2><div class="kpis">'
+ kpi(s.items.toLocaleString(),'items in catalog', true)
+ kpi(s.in_stock.toLocaleString(),'in stock')
+ kpi(money(s.stock_value),'stock value', true)
+ kpi(s.vinyl.toLocaleString(),'vinyl')
+ kpi(s.other_goods.toLocaleString(),'other goods')
+ kpi(s.crates.toLocaleString(),'crates')
+ kpi(s.sales.toLocaleString(),'sales')
+ kpi(money(s.sales_total),'sales total')
+ (s.staged ? kpi(s.staged.toLocaleString(),'staged (un-published)') : '')
+ '</div>'
+ '<div class="card"><h3>Quick actions</h3><div class="bar">'
+ '<button class="ghost" onclick="go(\'inventory\')">Browse inventory</button>'
+ '<button class="ghost" onclick="go(\'orders\')">Live orders</button>'
+ '<button class="ghost" onclick="window.open(\'/store/\')">Open the 3D store</button>'
+ '</div></div>';
}
const kpi = (n,l,pink) => `<div class="kpi"><div class="n${pink?' pink':''}">${n}</div><div class="l">${l}</div></div>`;
// ---------- Inventory ----------
let invState = { q:'', kind:'', crate_id:null, page:1 };
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></div>'
+ '<div id="invrows" class="muted">loading…</div>';
$('#iq').addEventListener('keydown', e => { if(e.key==='Enter') invSearch(); });
$('#ikind').value = invState.kind;
loadInv();
}
function invSearch(){ invState.q = $('#iq').value.trim(); invState.kind = $('#ikind').value; invState.crate_id = null; invState.page = 1; 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}`);
const rows = d.items.map(r => `<tr>
<td>${r.thumb?`<img class=thumb src="${r.thumb}">`:'<span class=thumb style="display:inline-block"></span>'}</td>
<td>${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</div>`
+ `<table><thead><tr><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=8 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>`;
}
function invPage(n){ invState.page += n; 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>`;
}
// ---------- Connections ----------
async function vConn(){
const m = $('#content');
m.innerHTML = '<h2>Connections</h2><div class="card"><h3>Credentials <span class="soon">— encrypted at rest, never shown back</span></h3><div id="secrets" class="muted">loading…</div></div>'
+ '<div class="card"><h3>Test connections</h3>'
+ ['discogs','woo','dealgod'].map(s=>`<div class="conn"><button class="ghost" onclick="testConn('${s}')">Test ${s}</button><span id="c-${s}" class="soon"></span></div>`).join('')
+ '</div>';
const d = await fetch('/settings/secrets', { headers: hdr() }).then(r=>r.json());
$('#secrets').innerHTML = d.fields.map(f => {
const st = f.set ? `<span style="color:var(--ok);font-size:12px">✓ set ${(f.updated_at||'').slice(0,10)}</span>` : '';
return `<label>${f.label} ${st}</label><div class="field"><input type="password" placeholder="${f.set?'•••••• (replace to update)':'paste value'}" data-name="${f.name}"><button class="ghost" data-save="${f.name}">Save</button></div>`;
}).join('');
document.querySelectorAll('[data-save]').forEach(b => b.onclick = () => saveSecret(b.dataset.save, b));
}
async function saveSecret(name, btn){
const i = document.querySelector(`input[data-name="${name}"]`); if(!i.value.trim()) return;
btn.textContent='…';
const r = await fetch('/settings/secrets', { method:'POST', headers:hdr(), body: JSON.stringify({name, value:i.value.trim()}) });
btn.textContent = r.ok ? 'Saved' : 'Error'; if(r.ok){ i.value=''; i.placeholder='•••••• (replace to update)'; } setTimeout(()=>btn.textContent='Save',1500);
}
async function testConn(svc){
const el = document.getElementById('c-'+svc); el.textContent='testing…'; el.style.color='var(--mut)';
let r,d; try { r = await fetch('/settings/test/'+svc,{method:'POST',headers:hdr()}); d = await r.json(); } catch(e){ el.textContent='✕ network'; return; }
if(d.connected){ el.textContent='✓ connected'+(d.as?' as '+d.as:''); el.style.color='var(--ok)'; }
else { el.textContent='✕ '+(d.detail||r.status); el.style.color='var(--warn)'; }
}
// ---------- Intake ----------
function vIntake(){
$('#content').innerHTML = '<h2>Intake <span class="muted" style="font-size:13px">· stage new stock</span></h2>'
+ '<div class="card" style="max-width:460px">'
+ '<label>Discogs release id</label><input id="in_rid" placeholder="e.g. 8949699" style="width:100%">'
+ '<label>Condition</label><select id="in_cond" style="width:100%"><option>M</option><option>NM</option><option selected>VG+</option><option>VG</option><option>G+</option><option>G</option></select>'
+ '<label>Price (AUD)</label><input id="in_price" type="number" step="0.01" placeholder="0.00" style="width:100%">'
+ '<div style="margin-top:14px"><button onclick="doIntake()">Stage item</button></div>'
+ '<div id="in_res" class="muted" style="margin-top:14px"></div></div>';
}
async function doIntake(){
const rid = parseInt($('#in_rid').value, 10), res = $('#in_res');
if(!rid){ res.textContent = 'enter a Discogs release id'; return; }
res.textContent = 'enriching + staging…';
const r = await fetch('/wowplatter/v1/inventory/intake', { method:'POST', headers:hdr(),
body: JSON.stringify({ release_id: rid, condition: $('#in_cond').value, price: parseFloat($('#in_price').value)||null }) });
const d = await r.json();
if(d.ok){ res.innerHTML = `<span style="color:var(--ok)">✓ staged ${d.sku}</span> — ` + (d.enriched ? escapeH(d.title||'')+' · '+escapeH(d.artist||'') : '<span style="color:var(--warn)">not in catalog cache (un-enriched)</span>'); $('#in_rid').value=''; $('#in_price').value=''; }
else { res.innerHTML = `<span style="color:var(--warn)">${d.reason||'failed'}</span>`; }
}
// ---------- Crates ----------
async function vCrates(){
const m = $('#content'); m.innerHTML = '<h2>Crates</h2><div class="muted">loading…</div>';
const d = await api('/crates');
const cards = d.crates.map(c => `<div class="kpi" style="cursor:pointer" onclick="crateOpen(${c.id})">
<div class="n pink" style="font-size:17px">${escapeH(c.label_text||c.name||('#'+c.id))}</div>
<div class="l">${c.items||0} items · ${money(c.value)}</div>
<div class="soon">${escapeH(c.purpose||'stock')}</div></div>`).join('');
m.innerHTML = `<h2>Crates <span class="muted" style="font-size:13px">· ${d.crates.length} bins · click to see contents</span></h2><div class="kpis">${cards}</div>`;
}
function crateOpen(id){ invState = { q:'', kind:'', crate_id:id, page:1 }; go('inventory'); }
// ---------- Reports ----------
async function vReports(){
const m = $('#content'); m.innerHTML = '<h2>Reports</h2><div class="muted">loading…</div>';
const d = await api('/reports'), s = d.summary;
const months = d.by_month.map(x => `<tr><td>${x.month}</td><td>${x.orders}</td><td class="price">${money(x.revenue)}</td></tr>`).join('');
const top = d.top.map(x => `<tr><td>${escapeH(x.item_name||'—')}</td><td>${x.qty}</td><td class="price">${money(x.revenue)}</td></tr>`).join('');
m.innerHTML = '<h2>Reports</h2><div class="kpis">'
+ kpi(s.orders.toLocaleString(),'total sales',true) + kpi(money(s.revenue),'revenue',true) + kpi(money(s.avg_order),'avg order')
+ '</div><div class="card"><h3>Revenue by month</h3><table><thead><tr><th>Month</th><th>Sales</th><th>Revenue</th></tr></thead><tbody>'
+ (months||'<tr><td colspan=3 class=muted>no data</td></tr>') + '</tbody></table></div>'
+ '<div class="card"><h3>Top items</h3><table><thead><tr><th>Item</th><th>Qty</th><th>Revenue</th></tr></thead><tbody>'
+ (top||'<tr><td colspan=3 class=muted>no data</td></tr>') + '</tbody></table></div>';
}
// ---------- 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:#0c0c0e;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="#0a0a0c" stroke-width="0.5"';
svg += `<g transform="translate(${sx(c.x)},${sy(c.z)}) rotate(${c.rot||0})" style="cursor:pointer" onclick="openCrate(${c.id})"><title>${escapeH(c.label_text||c.name||('#'+c.id))} · ${c.items||0} items</title><rect x="${(-w/2).toFixed(1)}" y="${(-d/2).toFixed(1)}" width="${w.toFixed(1)}" height="${d.toFixed(1)}" rx="1.5" fill="${fill}" ${hl}/></g>`; });
$('#mapwrap').innerHTML = svg + '</svg>';
}
async function openCrate(id){
renderMap(id);
const pane = $('#cratepane'); pane.innerHTML = '<div class="card muted">loading…</div>';
const d = await api(`/inventory?crate_id=${id}&page=1`);
const c = mapData.crates.find(x => x.id===id) || {};
const rows = d.items.map(r => `<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid #1b1b20">
${r.thumb?`<img class=thumb src="${r.thumb}">`:'<span class=thumb style="display:inline-block"></span>'}
<div style="min-width:0"><div style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeH(r.title||r.sku)}</div>
<div class="muted" style="font-size:12px">${escapeH(r.artist||'')} · ${money(r.price)}</div></div></div>`).join('');
pane.innerHTML = `<div class="card"><h3>${escapeH(c.label_text||c.name||('Crate #'+id))} <span class="muted" style="font-weight:400">· ${d.total} items</span></h3>${rows||'<div class=muted>empty</div>'}</div>`;
}
async function mapFind(){
const q = $('#mq').value.trim(); if(!q) return;
const d = await api(`/inventory?q=${encodeURIComponent(q)}&page=1`);
const hit = d.items.find(i => i.crate_id);
if(!hit){ $('#cratepane').innerHTML = `<div class="card" style="color:var(--warn)">no in-store match for “${escapeH(q)}”</div>`; return; }
openCrate(hit.crate_id);
}
// ---------- helpers ----------
function vSoon(title, body){ return () => { $('#content').innerHTML = `<h2>${title}</h2><div class="card soon">${body}<br><br>Coming next.</div>`; }; }
function escapeH(s){ return (s||'').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
if(TOKEN){ $('#tok').value = TOKEN; signin(); }
</script>
</body>
</html>