feat(admin): Intake, Crates, Reports views + endpoints
This commit is contained in:
parent
1f388f241c
commit
3732dfa325
@ -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")
|
||||
|
||||
@ -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 => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user