feat(admin): Store Map + Intake/Crates/Reports

This commit is contained in:
type-two 2026-06-21 20:03:08 +10:00
parent 3732dfa325
commit b4cdc8aee4
2 changed files with 77 additions and 1 deletions

View File

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

View File

@ -67,6 +67,7 @@
<a data-v="orders">🛒 Orders</a>
<div class="sec">Catalog</div>
<a data-v="intake">📥 Intake</a>
<a data-v="storemap">🗺️ Store map</a>
<a data-v="crates">🗄️ Crates</a>
<a data-v="reports">📈 Reports</a>
<div class="sec">System</div>
@ -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||'<tr><td colspan=3 class=muted>no data</td></tr>') + '</tbody></table></div>';
}
// ---------- Store map ----------
let mapData = null;
async function vStoreMap(){
const m = $('#content');
m.innerHTML = '<h2>Store map <span class="muted" style="font-size:13px">· click a bin for contents · brighter = fuller</span></h2>'
+ '<div class="bar"><input id="mq" placeholder="find a record → locate its bin…"><button onclick="mapFind()">Locate</button></div>'
+ '<div style="display:flex;gap:18px;align-items:flex-start">'
+ '<div id="mapwrap" style="flex:1;min-width:0;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px;overflow:auto">loading…</div>'
+ '<div id="cratepane" style="width:300px;flex-shrink:0"><div class="card soon">click a bin on the map</div></div></div>';
$('#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 = '<div class="muted">no crate positions in this space</div>'; 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 = `<svg viewBox="0 0 ${(W*scale).toFixed(0)} ${(D*scale).toFixed(0)}" style="width:100%;height:auto;background:#0c0c0e;border-radius:8px">`;
racks.forEach(r => { const w=(r.w||0.6)*scale, d=(r.d||0.4)*scale;
svg += `<g transform="translate(${sx(r.x)},${sy(r.z)}) rotate(${r.rot||0})"><rect x="${(-w/2).toFixed(1)}" y="${(-d/2).toFixed(1)}" width="${w.toFixed(1)}" height="${d.toFixed(1)}" rx="2" fill="#16161b" stroke="#2a2a30" stroke-width="0.5"/></g>`; });
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 += `<g transform="translate(${sx(c.x)},${sy(c.z)}) rotate(${c.rot||0})" style="cursor:pointer" onclick="openCrate(${c.id})"><title>${escapeH(c.label_text||c.name||('#'+c.id))} · ${c.items||0} items</title><rect x="${(-w/2).toFixed(1)}" y="${(-d/2).toFixed(1)}" width="${w.toFixed(1)}" height="${d.toFixed(1)}" rx="1.5" fill="${fill}" ${hl}/></g>`; });
$('#mapwrap').innerHTML = svg + '</svg>';
}
async function openCrate(id){
renderMap(id);
const pane = $('#cratepane'); pane.innerHTML = '<div class="card muted">loading…</div>';
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 => `<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid #1b1b20">
${r.thumb?`<img class=thumb src="${r.thumb}">`:'<span class=thumb style="display:inline-block"></span>'}
<div style="min-width:0"><div style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeH(r.title||r.sku)}</div>
<div class="muted" style="font-size:12px">${escapeH(r.artist||'')} · ${money(r.price)}</div></div></div>`).join('');
pane.innerHTML = `<div class="card"><h3>${escapeH(c.label_text||c.name||('Crate #'+id))} <span class="muted" style="font-weight:400">· ${d.total} items</span></h3>${rows||'<div class=muted>empty</div>'}</div>`;
}
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 = `<div class="card" style="color:var(--warn)">no in-store match for “${escapeH(q)}”</div>`; return; }
openCrate(hit.crate_id);
}
// ---------- 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])); }