feat(admin): MEGA admin dash + /admin endpoints (stats/inventory/orders)
This commit is contained in:
parent
0e8c84b0e7
commit
1f388f241c
86
app/admin_routes.py
Normal file
86
app/admin_routes.py
Normal 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}
|
||||
@ -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
205
site/admin.html
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||
|
||||
if(TOKEN){ $('#tok').value = TOKEN; signin(); }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user