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