From 3732dfa325cbff63363082994984d2d465b9cda4 Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 21 Jun 2026 19:50:47 +1000 Subject: [PATCH] feat(admin): Intake, Crates, Reports views + endpoints --- app/admin_routes.py | 39 +++++++++++++++++++++++++++++-- site/admin.html | 56 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 89 insertions(+), 6 deletions(-) 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) => `
${n}
${l}
`; // ---------- 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

' + + (months||'') + '
MonthSalesRevenue
no data
' + + '

Top items

' + + (top||'') + '
ItemQtyRevenue
no data
'; +} + // ---------- helpers ---------- function vSoon(title, body){ return () => { $('#content').innerHTML = `

${title}

${body}

Coming next.
`; }; } function escapeH(s){ return (s||'').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }