From b4cdc8aee47a3f88c14c6d8404715902bca26ebf Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 21 Jun 2026 20:03:08 +1000 Subject: [PATCH] feat(admin): Store Map + Intake/Crates/Reports --- app/admin_routes.py | 25 +++++++++++++++++++++ site/admin.html | 53 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/app/admin_routes.py b/app/admin_routes.py index 0dc033f..eb3b20f 100644 --- a/app/admin_routes.py +++ b/app/admin_routes.py @@ -76,6 +76,31 @@ async def crates(ident=Depends(require_token), db=Depends(get_db)): return {"ok": True, "crates": [dict(r) for r in rows.mappings()]} +@router.get("/storemap") +async def storemap(ident=Depends(require_token), db=Depends(get_db)): + """Top-down store map: room + racks + crates at their real positions, with live item counts. + Powers the clickable store-navigator (the WowPlatter 'Search' workstation, RecordGod-side).""" + sp = (await db.execute(text( + "SELECT * FROM virtual_space WHERE visible='y' ORDER BY (is_default='y') DESC LIMIT 1" + ))).mappings().first() + space = {"room_width": float(sp["room_width"] or 12), "room_depth": float(sp["room_depth"] or 12)} if sp else {"room_width": 12, "room_depth": 12} + ctypes = {r["id"]: {"w": float(r["width"] or 0.35), "d": float(r["depth"] or 0.5)} + for r in (await db.execute(text("SELECT id, width, depth FROM virtual_crate_type"))).mappings()} + crates = [dict(r) for r in (await db.execute(text(""" + SELECT c.id, c.name, c.label_text, c.crate_purpose, c.crate_type_id, + c.pos_x::float AS x, c.pos_z::float AS z, c.rotation_y::float AS rot, + (SELECT count(*) FROM inventory i WHERE i.crate_id = c.id AND i.in_stock) AS items + FROM virtual_crate c WHERE c.visible='y' + """))).mappings()] + racks = [dict(r) for r in (await db.execute(text(""" + SELECT r.pos_x::float AS x, r.pos_z::float AS z, r.rotation_y::float AS rot, + rt.width::float AS w, rt.depth::float AS d + FROM virtual_rack r LEFT JOIN virtual_rack_type rt ON rt.id = r.rack_type_id + WHERE r.visible='y' + """))).mappings()] + return {"ok": True, "space": space, "crate_types": ctypes, "crates": crates, "racks": racks} + + @router.get("/reports") async def reports(ident=Depends(require_token), db=Depends(get_db)): summary = dict((await db.execute(text(""" diff --git a/site/admin.html b/site/admin.html index 26106ce..bdfcf2c 100644 --- a/site/admin.html +++ b/site/admin.html @@ -67,6 +67,7 @@ 🛒 Orders
Catalog
📥 Intake + 🗺️ Store map 🗄️ Crates 📈 Reports
System
@@ -96,7 +97,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:vIntake, crates:vCrates, reports:vReports, connections:vConn}[v])(); + ({dashboard:vDash, inventory:vInv, orders:vOrders, intake:vIntake, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn}[v])(); } // ---------- Dashboard ---------- @@ -243,6 +244,56 @@ async function vReports(){ + (top||'no data') + ''; } +// ---------- Store map ---------- +let mapData = null; +async function vStoreMap(){ + const m = $('#content'); + m.innerHTML = '

Store map · click a bin for contents · brighter = fuller

' + + '
' + + '
' + + '
loading…
' + + '
click a bin on the map
'; + $('#mq').addEventListener('keydown', e => { if(e.key==='Enter') mapFind(); }); + mapData = await api('/storemap'); + renderMap(); +} +function renderMap(highlight){ + const { crates, racks, crate_types:ct } = mapData; + const pts = crates.map(c=>[c.x,c.z]).concat(racks.map(r=>[r.x,r.z])); + if(!pts.length){ $('#mapwrap').innerHTML = '
no crate positions in this space
'; return; } + const minX=Math.min(...pts.map(p=>p[0]))-1, maxX=Math.max(...pts.map(p=>p[0]))+1; + const minZ=Math.min(...pts.map(p=>p[1]))-1, maxZ=Math.max(...pts.map(p=>p[1]))+1; + const W=maxX-minX, D=maxZ-minZ, scale=Math.min(820/W, 560/D); + const sx=x=>((x-minX)*scale).toFixed(1), sy=z=>((z-minZ)*scale).toFixed(1); + const maxItems=Math.max(1, ...crates.map(c=>c.items||0)); + let svg = ``; + racks.forEach(r => { const w=(r.w||0.6)*scale, d=(r.d||0.4)*scale; + svg += ``; }); + crates.forEach(c => { const t=ct[c.crate_type_id]||{w:0.35,d:0.5}; const w=Math.max(7,t.w*scale), d=Math.max(7,t.d*scale); + const fill = c.items ? `rgba(255,93,177,${(0.22+0.65*(c.items/maxItems)).toFixed(2)})` : '#22222a'; + const hl = highlight===c.id ? 'stroke="#fff" stroke-width="2.5"' : 'stroke="#0a0a0c" stroke-width="0.5"'; + svg += `${escapeH(c.label_text||c.name||('#'+c.id))} · ${c.items||0} items`; }); + $('#mapwrap').innerHTML = svg + ''; +} +async function openCrate(id){ + renderMap(id); + const pane = $('#cratepane'); pane.innerHTML = '
loading…
'; + const d = await api(`/inventory?crate_id=${id}&page=1`); + const c = mapData.crates.find(x => x.id===id) || {}; + const rows = d.items.map(r => `
+ ${r.thumb?``:''} +
${escapeH(r.title||r.sku)}
+
${escapeH(r.artist||'')} · ${money(r.price)}
`).join(''); + pane.innerHTML = `

${escapeH(c.label_text||c.name||('Crate #'+id))} · ${d.total} items

${rows||'
empty
'}
`; +} +async function mapFind(){ + const q = $('#mq').value.trim(); if(!q) return; + const d = await api(`/inventory?q=${encodeURIComponent(q)}&page=1`); + const hit = d.items.find(i => i.crate_id); + if(!hit){ $('#cratepane').innerHTML = `
no in-store match for “${escapeH(q)}”
`; return; } + openCrate(hit.crate_id); +} + // ---------- helpers ---------- function vSoon(title, body){ return () => { $('#content').innerHTML = `

${title}

${body}

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