diff --git a/app/admin_routes.py b/app/admin_routes.py
index 41ccb4a..0dc033f 100644
--- a/app/admin_routes.py
+++ b/app/admin_routes.py
@@ -30,8 +30,8 @@ async def stats(ident=Depends(require_token), db=Depends(get_db)):
@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)):
+async def inventory(q: str = Query(""), kind: str = Query(""), crate_id: int | None = Query(None),
+ 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}
@@ -41,6 +41,9 @@ async def inventory(q: str = Query(""), kind: str = Query(""), page: int = Query
if kind:
where.append("i.kind = :k")
params["k"] = kind
+ if crate_id:
+ where.append("i.crate_id = :cid")
+ params["cid"] = crate_id
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,
@@ -60,6 +63,38 @@ async def inventory(q: str = Query(""), kind: str = Query(""), page: int = Query
return {"ok": True, "items": items, "page": page, "per": per, "total": total}
+@router.get("/crates")
+async def crates(ident=Depends(require_token), db=Depends(get_db)):
+ rows = await db.execute(text("""
+ SELECT c.id, c.name, c.label_text, c.purpose,
+ count(i.sku) FILTER (WHERE i.in_stock) AS items,
+ coalesce(sum(i.price) FILTER (WHERE i.in_stock), 0)::float AS value
+ FROM crate c LEFT JOIN inventory i ON i.crate_id = c.id
+ GROUP BY c.id, c.name, c.label_text, c.purpose
+ ORDER BY items DESC NULLS LAST, c.id
+ """))
+ return {"ok": True, "crates": [dict(r) for r in rows.mappings()]}
+
+
+@router.get("/reports")
+async def reports(ident=Depends(require_token), db=Depends(get_db)):
+ summary = dict((await db.execute(text("""
+ SELECT count(*) AS orders, coalesce(sum(total),0)::float AS revenue,
+ coalesce(avg(total),0)::float AS avg_order
+ FROM sales
+ """))).mappings().first())
+ by_month = [dict(r) for r in (await db.execute(text("""
+ SELECT to_char(date_trunc('month', sale_date),'YYYY-MM') AS month,
+ count(*) AS orders, coalesce(sum(total),0)::float AS revenue
+ FROM sales WHERE sale_date IS NOT NULL GROUP BY 1 ORDER BY 1 DESC LIMIT 12
+ """))).mappings()]
+ top = [dict(r) for r in (await db.execute(text("""
+ SELECT item_name, count(*) AS qty, coalesce(sum(line_total),0)::float AS revenue
+ FROM sale_items WHERE item_name IS NOT NULL GROUP BY item_name ORDER BY revenue DESC LIMIT 10
+ """))).mappings()]
+ return {"ok": True, "summary": summary, "by_month": by_month, "top": top}
+
+
@router.get("/orders")
async def orders(ident=Depends(require_token), db=Depends(get_db)):
base = await vault.get_secret(db, "woo_base_url")
diff --git a/site/admin.html b/site/admin.html
index c329868..26106ce 100644
--- a/site/admin.html
+++ b/site/admin.html
@@ -96,7 +96,7 @@ async function signin(){
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:vDash, inventory:vInv, orders:vOrders, intake:vIntake, crates:vCrates, reports:vReports, connections:vConn}[v])();
}
// ---------- Dashboard ----------
@@ -123,7 +123,7 @@ async function vDash(){
const kpi = (n,l,pink) => `
`;
// ---------- Inventory ----------
-let invState = { q:'', kind:'', page:1 };
+let invState = { q:'', kind:'', crate_id:null, page:1 };
async function vInv(){
const m = $('#content');
m.innerHTML = 'Inventory
'
@@ -135,9 +135,10 @@ async function vInv(){
$('#ikind').value = invState.kind;
loadInv();
}
-function invSearch(){ invState.q = $('#iq').value.trim(); invState.kind = $('#ikind').value; invState.page = 1; loadInv(); }
+function invSearch(){ invState.q = $('#iq').value.trim(); invState.kind = $('#ikind').value; invState.crate_id = null; invState.page = 1; loadInv(); }
async function loadInv(){
- const d = await api(`/inventory?q=${encodeURIComponent(invState.q)}&kind=${invState.kind}&page=${invState.page}`);
+ 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 => `
${r.thumb?` `:''} |
${r.title?escapeH(r.title):''+r.sku+''} ${r.artist?escapeH(r.artist):''} |
@@ -195,6 +196,53 @@ async function testConn(svc){
else { el.textContent='✕ '+(d.detail||r.status); el.style.color='var(--warn)'; }
}
+// ---------- Intake ----------
+function vIntake(){
+ $('#content').innerHTML = 'Intake · stage new stock
'
+ + ''
+ + '
'
+ + '
'
+ + '
'
+ + '
'
+ + '
';
+}
+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 = `✓ staged ${d.sku} — ` + (d.enriched ? escapeH(d.title||'')+' · '+escapeH(d.artist||'') : 'not in catalog cache (un-enriched)'); $('#in_rid').value=''; $('#in_price').value=''; }
+ else { res.innerHTML = `${d.reason||'failed'}`; }
+}
+
+// ---------- Crates ----------
+async function vCrates(){
+ const m = $('#content'); m.innerHTML = 'Crates
loading…
';
+ const d = await api('/crates');
+ const cards = d.crates.map(c => `
+
${escapeH(c.label_text||c.name||('#'+c.id))}
+
${c.items||0} items · ${money(c.value)}
+
${escapeH(c.purpose||'stock')}
`).join('');
+ m.innerHTML = `Crates · ${d.crates.length} bins · click to see contents
${cards}
`;
+}
+function crateOpen(id){ invState = { q:'', kind:'', crate_id:id, page:1 }; go('inventory'); }
+
+// ---------- Reports ----------
+async function vReports(){
+ const m = $('#content'); m.innerHTML = 'Reports
loading…
';
+ const d = await api('/reports'), s = d.summary;
+ const months = d.by_month.map(x => `
| ${x.month} | ${x.orders} | ${money(x.revenue)} |
`).join('');
+ const top = d.top.map(x => `| ${escapeH(x.item_name||'—')} | ${x.qty} | ${money(x.revenue)} |
`).join('');
+ m.innerHTML = 'Reports
'
+ + kpi(s.orders.toLocaleString(),'total sales',true) + kpi(money(s.revenue),'revenue',true) + kpi(money(s.avg_order),'avg order')
+ + '
Revenue by month
| Month | Sales | Revenue |
'
+ + (months||'| no data |
') + '
'
+ + 'Top items
| Item | Qty | Revenue |
'
+ + (top||'| no data |
') + '
';
+}
+
// ---------- helpers ----------
function vSoon(title, body){ return () => { $('#content').innerHTML = `${title}
${body}
Coming next.
`; }; }
function escapeH(s){ return (s||'').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }