feat(admin): Intake, Crates, Reports views + endpoints

This commit is contained in:
type-two 2026-06-21 19:50:47 +10:00
parent 1f388f241c
commit 3732dfa325
2 changed files with 89 additions and 6 deletions

View File

@ -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")

View File

@ -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) => `<div class="kpi"><div class="n${pink?' pink':''}">${n}</div><div class="l">${l}</div></div>`;
// ---------- Inventory ----------
let invState = { q:'', kind:'', page:1 };
let invState = { q:'', kind:'', crate_id:null, page:1 };
async function vInv(){
const m = $('#content');
m.innerHTML = '<h2>Inventory</h2>'
@ -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 => `<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>
@ -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 = '<h2>Intake <span class="muted" style="font-size:13px">· stage new stock</span></h2>'
+ '<div class="card" style="max-width:460px">'
+ '<label>Discogs release id</label><input id="in_rid" placeholder="e.g. 8949699" style="width:100%">'
+ '<label>Condition</label><select id="in_cond" style="width:100%"><option>M</option><option>NM</option><option selected>VG+</option><option>VG</option><option>G+</option><option>G</option></select>'
+ '<label>Price (AUD)</label><input id="in_price" type="number" step="0.01" placeholder="0.00" style="width:100%">'
+ '<div style="margin-top:14px"><button onclick="doIntake()">Stage item</button></div>'
+ '<div id="in_res" class="muted" style="margin-top:14px"></div></div>';
}
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 = `<span style="color:var(--ok)">✓ staged ${d.sku}</span> — ` + (d.enriched ? escapeH(d.title||'')+' · '+escapeH(d.artist||'') : '<span style="color:var(--warn)">not in catalog cache (un-enriched)</span>'); $('#in_rid').value=''; $('#in_price').value=''; }
else { res.innerHTML = `<span style="color:var(--warn)">${d.reason||'failed'}</span>`; }
}
// ---------- Crates ----------
async function vCrates(){
const m = $('#content'); m.innerHTML = '<h2>Crates</h2><div class="muted">loading…</div>';
const d = await api('/crates');
const cards = d.crates.map(c => `<div class="kpi" style="cursor:pointer" onclick="crateOpen(${c.id})">
<div class="n pink" style="font-size:17px">${escapeH(c.label_text||c.name||('#'+c.id))}</div>
<div class="l">${c.items||0} items · ${money(c.value)}</div>
<div class="soon">${escapeH(c.purpose||'stock')}</div></div>`).join('');
m.innerHTML = `<h2>Crates <span class="muted" style="font-size:13px">· ${d.crates.length} bins · click to see contents</span></h2><div class="kpis">${cards}</div>`;
}
function crateOpen(id){ invState = { q:'', kind:'', crate_id:id, page:1 }; go('inventory'); }
// ---------- Reports ----------
async function vReports(){
const m = $('#content'); m.innerHTML = '<h2>Reports</h2><div class="muted">loading…</div>';
const d = await api('/reports'), s = d.summary;
const months = d.by_month.map(x => `<tr><td>${x.month}</td><td>${x.orders}</td><td class="price">${money(x.revenue)}</td></tr>`).join('');
const top = d.top.map(x => `<tr><td>${escapeH(x.item_name||'—')}</td><td>${x.qty}</td><td class="price">${money(x.revenue)}</td></tr>`).join('');
m.innerHTML = '<h2>Reports</h2><div class="kpis">'
+ kpi(s.orders.toLocaleString(),'total sales',true) + kpi(money(s.revenue),'revenue',true) + kpi(money(s.avg_order),'avg order')
+ '</div><div class="card"><h3>Revenue by month</h3><table><thead><tr><th>Month</th><th>Sales</th><th>Revenue</th></tr></thead><tbody>'
+ (months||'<tr><td colspan=3 class=muted>no data</td></tr>') + '</tbody></table></div>'
+ '<div class="card"><h3>Top items</h3><table><thead><tr><th>Item</th><th>Qty</th><th>Revenue</th></tr></thead><tbody>'
+ (top||'<tr><td colspan=3 class=muted>no data</td></tr>') + '</tbody></table></div>';
}
// ---------- 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])); }