RECORDGOD/site/admin.html

206 lines
12 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>
<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:100vh}
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="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:vSoon('Intake','New-stock intake — scan a Discogs release_id, enrich, stage. Wired to /wowplatter/v1/inventory/intake.'), crates:vSoon('Crates','Crate + slot map of the physical store. Reads the virtual store tables.'), reports:vSoon('Reports','Sales, sell-through, and arbitrage reporting.'), 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:'', 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.page = 1; loadInv(); }
async function loadInv(){
const d = await api(`/inventory?q=${encodeURIComponent(invState.q)}&kind=${invState.kind}&page=${invState.page}`);
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)'; }
}
// ---------- 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>