feat(admin): MEGA admin dash + /admin endpoints (stats/inventory/orders)

This commit is contained in:
type-two 2026-06-21 19:19:56 +10:00
parent 0e8c84b0e7
commit 1f388f241c
3 changed files with 293 additions and 0 deletions

86
app/admin_routes.py Normal file
View File

@ -0,0 +1,86 @@
import httpx
from fastapi import APIRouter, Depends, Query
from sqlalchemy import text
from . import vault
from .auth import require_token
from .db import get_db
# The MEGA admin — back-office cockpit, all admin-gated. Inspired by WowPlatter's wp-admin nav
# (Dashboard / Inventory / Orders / Import / Sales / Reports), but on RecordGod's own data.
router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/stats")
async def stats(ident=Depends(require_token), db=Depends(get_db)):
s = ident["store_id"]
row = (await db.execute(text("""
SELECT
(SELECT count(*) FROM inventory WHERE store_id=:s) AS items,
(SELECT count(*) FROM inventory WHERE store_id=:s AND in_stock) AS in_stock,
(SELECT count(*) FROM inventory WHERE store_id=:s AND kind='vinyl') AS vinyl,
(SELECT count(*) FROM inventory WHERE store_id=:s AND kind<>'vinyl') AS other_goods,
(SELECT coalesce(sum(price),0)::float FROM inventory WHERE store_id=:s AND in_stock) AS stock_value,
(SELECT count(*) FROM inventory WHERE staged) AS staged,
(SELECT count(*) FROM crate) AS crates,
(SELECT count(*) FROM sales) AS sales,
(SELECT coalesce(sum(total),0)::float FROM sales) AS sales_total
"""), {"s": s})).mappings().first()
return {"ok": True, **dict(row)}
@router.get("/inventory")
async def inventory(q: str = Query(""), kind: str = Query(""), page: int = Query(1, ge=1),
ident=Depends(require_token), db=Depends(get_db)):
per = 50
where = ["i.store_id = :s"]
params = {"s": ident["store_id"], "per": per, "off": (page - 1) * per}
if q:
where.append("(i.sku ILIKE :q OR coalesce(i.title,dc.title) ILIKE :q OR dc.artist ILIKE :q)")
params["q"] = f"%{q}%"
if kind:
where.append("i.kind = :k")
params["k"] = kind
w = " AND ".join(where)
items = [dict(r) for r in (await db.execute(text(f"""
SELECT i.sku, i.kind, i.release_id, coalesce(i.title, dc.title) AS title, dc.artist,
i.price::float AS price, i.condition, i.in_stock, i.crate_id,
c.label_text AS crate, dc.thumb, i.target_price::float AS target
FROM inventory i
LEFT JOIN disc_cache dc ON dc.release_id = i.release_id
LEFT JOIN crate c ON c.id = i.crate_id
WHERE {w}
ORDER BY i.updated_at DESC NULLS LAST
LIMIT :per OFFSET :off
"""), params)).mappings()]
cparams = {k: v for k, v in params.items() if k not in ("per", "off")}
total = (await db.execute(text(
f"SELECT count(*) FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id=i.release_id WHERE {w}"
), cparams)).scalar()
return {"ok": True, "items": items, "page": page, "per": per, "total": total}
@router.get("/orders")
async def orders(ident=Depends(require_token), db=Depends(get_db)):
base = await vault.get_secret(db, "woo_base_url")
ck = await vault.get_secret(db, "woo_consumer_key")
cs = await vault.get_secret(db, "woo_consumer_secret")
if not (base and ck and cs):
return {"ok": False, "reason": "WooCommerce not connected — add keys in Connections"}
try:
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as c:
r = await c.get(base.rstrip("/") + "/wp-json/wc/v3/orders",
params={"per_page": 20, "orderby": "date", "order": "desc"}, auth=(ck, cs))
except Exception as e:
return {"ok": False, "reason": f"connection error: {e}"}
if r.status_code != 200:
return {"ok": False, "reason": f"Woo API HTTP {r.status_code}"}
out = []
for o in r.json():
b = o.get("billing", {})
name = (b.get("first_name", "") + " " + b.get("last_name", "")).strip() or b.get("email", "")
out.append({"id": o["id"], "number": o.get("number"), "status": o.get("status"),
"total": o.get("total"), "currency": o.get("currency"),
"date": (o.get("date_created") or "")[:10], "customer": name,
"items": len(o.get("line_items", []))})
return {"ok": True, "orders": out}

View File

@ -17,11 +17,13 @@ from . import __version__ # noqa: E402
from .wowplatter_v1 import router as wowplatter_v1_router # noqa: E402
from .virtual import router as virtual_router # noqa: E402
from .settings_routes import router as settings_router # noqa: E402
from .admin_routes import router as admin_router # noqa: E402
app = FastAPI(title="RECORDGOD", version=__version__)
app.include_router(wowplatter_v1_router)
app.include_router(virtual_router)
app.include_router(settings_router)
app.include_router(admin_router)
@app.get("/healthz")

205
site/admin.html Normal file
View File

@ -0,0 +1,205 @@
<!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>