feat(nav): unified tabbed Search & Inventory workstation (WowPlatter parity)
One persistent central store/rack map with a top tab strip (Scanner / Returns / Reorganize / Collections / Stock Finder) all driving the shared map, + Store/Rack/ Crate/Item view buttons and an omni scan bar (release/SKU/c12/r3). Reorganize: click crates IN ORDER (numbered pick badges feed the A–Z distribution planner) + drag racks to move them on the store map (persists pos_x/pos_z; RackEditIn gained position fields). Collections edit highlights picked crates on the map. Returns = stub (manual path via Scanner + Release Info for now). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
48f8579bff
commit
c4b544373d
@ -83,6 +83,10 @@ async def rack_detail(rack_id: int, ident=Depends(require_token), db=Depends(get
|
||||
|
||||
class RackEditIn(BaseModel):
|
||||
name: str | None = None
|
||||
pos_x: float | None = None # move the rack on the store map (metres)
|
||||
pos_z: float | None = None
|
||||
direction: str | None = None # forward | back | left | right
|
||||
rotation_y: float | None = None # fine rotation (degrees)
|
||||
|
||||
|
||||
@router.post("/rack/{rack_id}")
|
||||
|
||||
583
site/search.html
583
site/search.html
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RecordGod — search & locate</title>
|
||||
<title>RecordGod — Search & Inventory</title>
|
||||
<script defer src="/nav.js?v=5"></script>
|
||||
<style>
|
||||
:root{--pink:#d10f7a;--hot:#ff2e93;--bg:#f4f5f7;--card:#ffffff;--ink:#1b1b22;--line:#e2e2ea;--mut:#5f5f6c;--ok:#1a8f54}
|
||||
@ -13,103 +13,113 @@
|
||||
.ghost{background:#fff;color:#2a2a30;border:1px solid var(--line);padding:7px 10px}
|
||||
.ghost:hover{background:#f3f3f6}.ghost:disabled{opacity:.4;cursor:default}
|
||||
.ghost.on{background:#fdeef6;color:var(--pink);border-color:var(--pink)}
|
||||
input,select{padding:10px 12px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);font:15px system-ui}
|
||||
input:focus,select:focus{outline:none;border-color:var(--pink)}
|
||||
.prim{background:var(--pink);color:#fff;border:0;border-radius:8px;padding:9px 13px}
|
||||
input,select,textarea{padding:9px 11px;border:1px solid var(--line);border-radius:9px;background:#fff;color:var(--ink);font:14px system-ui}
|
||||
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--pink)}
|
||||
#gate{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg);z-index:50}
|
||||
#gate .b{width:320px;text-align:center}#gate h1{color:var(--pink)}
|
||||
#app{display:none;grid-template-columns:1.1fr 1.2fr;gap:16px;padding:16px;max-width:1340px;margin:0 auto}
|
||||
.panel{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;box-shadow:0 1px 3px rgba(0,0,0,.05);margin-bottom:14px}
|
||||
#app{display:none;max-width:1500px;margin:0 auto;padding:14px}
|
||||
.top{display:flex;align-items:center;gap:12px;margin-bottom:10px}
|
||||
.top h1{font-size:19px;margin:0;flex-shrink:0}.top h1 b{color:var(--pink)}
|
||||
.top .omni{flex:1;font-size:15px;padding:11px 14px}
|
||||
.tabs{display:flex;gap:4px;border-bottom:2px solid var(--line);margin-bottom:10px;flex-wrap:wrap}
|
||||
.tab{background:transparent;border:0;padding:9px 15px;font:600 13px system-ui;color:var(--mut);border-bottom:3px solid transparent;margin-bottom:-2px;border-radius:6px 6px 0 0}
|
||||
.tab:hover{color:var(--ink);background:#fafafc}
|
||||
.tab.on{color:var(--pink);border-bottom-color:var(--pink)}
|
||||
.tools{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:12px}
|
||||
.seg{display:inline-flex;border:1px solid var(--line);border-radius:9px;overflow:hidden}
|
||||
.seg button{background:#fff;border:0;border-right:1px solid var(--line);padding:7px 12px;color:#3a3a40}
|
||||
.seg button:last-child{border-right:0}.seg button.on{background:#fdeef6;color:var(--pink);font-weight:600}
|
||||
.seg button:disabled{opacity:.4;cursor:default}
|
||||
.lvl{display:inline-flex;align-items:center;gap:6px}
|
||||
.selmode{color:var(--pink);font-weight:600;font-size:12px}
|
||||
.work{display:grid;grid-template-columns:minmax(380px,1.15fr) minmax(280px,.9fr) 350px;gap:14px;align-items:start}
|
||||
@media(max-width:1200px){.work{grid-template-columns:1fr 1fr}.toolcol{grid-column:1/-1}}
|
||||
@media(max-width:820px){.work{grid-template-columns:1fr}}
|
||||
.panel{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:13px;box-shadow:0 1px 3px rgba(0,0,0,.05);margin-bottom:12px}
|
||||
.bar{display:flex;gap:8px;align-items:center}
|
||||
.res{display:flex;flex-direction:column;gap:6px;max-height:36vh;overflow:auto;margin-top:8px}
|
||||
#cv{width:100%;background:#fbfbfd;border:1px solid var(--line);border-radius:10px;display:block;cursor:pointer}
|
||||
.navhdr{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:8px}
|
||||
.crumb{font-weight:600;flex:1}.crumb b{color:var(--pink)}
|
||||
.res{display:flex;flex-direction:column;gap:6px;max-height:60vh;overflow:auto;margin-top:8px}
|
||||
.ri{display:flex;gap:11px;align-items:center;padding:9px;border-radius:9px;cursor:pointer;border:1px solid transparent}
|
||||
.ri:hover{background:#f5f5f8}.ri.sel{border-color:var(--pink);background:#fdeef6}
|
||||
.ri img,.ri .ph{width:42px;height:42px;border-radius:6px;object-fit:cover;background:#ececf0;flex-shrink:0}
|
||||
.ri .t{flex:1;min-width:0}.ri .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-weight:600}
|
||||
.muted{color:var(--mut);font-size:12px}.pink{color:var(--pink)}.ok{color:var(--ok)}.oos{color:#c0392b}
|
||||
.loc{display:inline-block;margin-top:2px;padding:1px 7px;border-radius:20px;background:#fdeef6;color:var(--pink);font-size:11px;font-weight:600}
|
||||
.navhdr{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px}
|
||||
.crumb{font-weight:600;flex:1}.crumb b{color:var(--pink)}
|
||||
.lvl{display:flex;align-items:center;gap:6px}
|
||||
#cv{width:100%;background:#fbfbfd;border:1px solid var(--line);border-radius:10px;display:block;cursor:pointer}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
td,th{padding:6px 5px;border-bottom:1px solid #eee;text-align:left}th{color:var(--mut);font-weight:500}
|
||||
.legend{display:flex;gap:14px;margin-top:8px;font-size:11px;color:var(--mut);flex-wrap:wrap}
|
||||
.sw{display:inline-block;width:11px;height:11px;border-radius:3px;vertical-align:-1px;margin-right:4px}
|
||||
.chip{display:inline-flex;gap:5px;align-items:center;background:#eef0f5;border:1px solid #d4d8e0;border-radius:20px;padding:2px 9px;font-size:11px}
|
||||
.chip .x{cursor:pointer;color:#a44}
|
||||
.chip.num{background:#fdeef6;border-color:var(--pink);color:var(--pink);font-weight:600}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="gate"><div class="b"><h1>Record<b style="color:var(--pink)">God</b> · search</h1>
|
||||
<div id="gate"><div class="b"><h1>Record<b>God</b> · Search</h1>
|
||||
<div class="bar"><input id="tok" type="password" placeholder="admin token" style="flex:1"><button class="ghost" onclick="signin()">Enter</button></div>
|
||||
<div id="gerr" class="muted"></div></div></div>
|
||||
|
||||
<div id="app">
|
||||
<!-- LEFT: search + crate contents -->
|
||||
<div>
|
||||
<div class="panel">
|
||||
<div class="bar">
|
||||
<input id="q" placeholder="🔍 search title / artist / SKU — or scan" autocomplete="off" autofocus style="flex:1">
|
||||
<button class="ghost" onclick="run()">Search</button>
|
||||
</div>
|
||||
<div class="muted" id="count" style="margin-top:6px">type to search — click a hit to locate it</div>
|
||||
<div id="res" class="res"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="bar" style="justify-content:space-between">
|
||||
<div><span id="ccName" class="muted">Crate contents</span> <span id="ccMeta" class="muted"></span></div>
|
||||
<div class="bar" id="ccBtns" style="display:none"><button class="ghost" style="padding:3px 8px" onclick="scanToggle()">🔫 Scan</button><button class="ghost" style="padding:3px 8px" onclick="crateEditToggle()">✏️ edit</button></div>
|
||||
</div>
|
||||
<div id="crateEdit" style="display:none;margin-top:8px"></div>
|
||||
<div id="scanPanel" style="display:none;margin-top:8px"></div>
|
||||
<div id="ccList" style="max-height:26vh;overflow:auto;margin-top:8px"><div class="muted">pick a crate on the map →</div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="bar" style="justify-content:space-between"><b style="font-size:13px">💿 Release Info</b>
|
||||
<div class="bar"><input id="relQ" placeholder="release id / sku" style="width:130px"><button class="ghost" onclick="relLookup()">↵</button></div></div>
|
||||
<div id="relBody" class="muted" style="margin-top:8px;max-height:30vh;overflow:auto">click a record in a crate, or look one up →</div>
|
||||
</div>
|
||||
<div class="panel" id="colPanel">
|
||||
<div class="bar" style="justify-content:space-between"><b style="font-size:13px">📚 Collections</b>
|
||||
<span><button class="ghost" style="padding:3px 9px" onclick="edNew()">+ New</button> <span id="colCount" class="muted"></span></span></div>
|
||||
<div id="colList" style="max-height:22vh;overflow:auto;margin-top:8px"><div class="muted">loading…</div></div>
|
||||
</div>
|
||||
<div class="panel" id="editPanel" style="display:none">
|
||||
<div class="bar" style="justify-content:space-between"><b id="edTitle">New collection</b><button class="ghost" style="padding:3px 9px" onclick="edCancel()">✕ close</button></div>
|
||||
<div style="display:grid;gap:8px;margin-top:8px">
|
||||
<div class="bar"><input id="edName" placeholder="Name — e.g. HOUSE $15+" style="flex:1"><input id="edColor" type="color" value="#3498db" style="width:42px;height:38px;padding:2px"></div>
|
||||
<div class="bar"><span class="muted">Priority</span><input id="edPrio" type="number" value="0" style="width:64px">
|
||||
<span class="muted">Sort</span><select id="edSort" style="flex:1">
|
||||
<option value="alpha_artist">Artist A–Z</option><option value="alpha_title">Title A–Z</option>
|
||||
<option value="year_asc">Year ↑</option><option value="year_desc">Year ↓</option>
|
||||
<option value="price_asc">Price ↑</option><option value="price_desc">Price ↓</option><option value="date_added">Date added</option></select></div>
|
||||
<div class="bar"><span class="muted">Price</span><input id="edPmin" type="number" placeholder="min" style="width:62px" oninput="edStats()"><input id="edPmax" type="number" placeholder="max" style="width:62px" oninput="edStats()">
|
||||
<span class="muted">Year</span><input id="edYmin" type="number" placeholder="min" style="width:60px" oninput="edStats()"><input id="edYmax" type="number" placeholder="max" style="width:60px" oninput="edStats()"></div>
|
||||
<div><div class="muted">Genres</div><div id="edGenres" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px"></div></div>
|
||||
<div><div class="muted">Styles</div><div class="res"><input id="edStyleQ" placeholder="search styles to add…" style="width:100%" autocomplete="off"><div id="edStyleRes" class="drop" style="display:none"></div></div>
|
||||
<div id="edStyles" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px"></div></div>
|
||||
<div><div class="muted">Crates <span class="pink">— click crates on the map to add →</span></div><div id="edCrates" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px"></div></div>
|
||||
<div id="edStats" class="muted"></div>
|
||||
<div class="bar"><button onclick="edSave()" style="flex:1;background:var(--pink);color:#fff;border:0;border-radius:8px;padding:9px">Save collection</button>
|
||||
<button class="ghost" onclick="edDelete()" id="edDel" style="display:none;color:#c0392b">Delete</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top">
|
||||
<h1>Search & <b>Inventory</b></h1>
|
||||
<input id="omni" class="omni" placeholder="Scan or type — release ID, SKU, c12 (crate), r3 (rack)" autocomplete="off">
|
||||
<button class="prim" onclick="omni()">🔍 Go</button>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: the Store → Rack navigator -->
|
||||
<div class="panel">
|
||||
<div class="navhdr">
|
||||
<span class="crumb" id="crumb">Store map</span>
|
||||
<select id="spaceSel" style="padding:6px 8px"></select>
|
||||
<button class="ghost" id="backBtn" style="display:none" onclick="toStore()">◀ Store</button>
|
||||
<button class="ghost" id="rackEditBtn" style="display:none" onclick="rackEdit()">✏️ rack</button>
|
||||
<button class="ghost" onclick="reorgOpen()">🔀 Reorganize</button>
|
||||
<span class="lvl" id="lvlCtl" style="display:none">
|
||||
<button class="ghost" id="lvlPrev" onclick="stepLevel(-1)">◀</button>
|
||||
<span id="lvlLabel" class="muted">Level</span>
|
||||
<button class="ghost" id="lvlNext" onclick="stepLevel(1)">▶</button>
|
||||
</span>
|
||||
<div class="tabs" id="tabs">
|
||||
<button class="tab" data-t="scanner" onclick="setTab('scanner')">🔫 Scanner</button>
|
||||
<button class="tab" data-t="returns" onclick="setTab('returns')">↩ Returns</button>
|
||||
<button class="tab" data-t="reorganize" onclick="setTab('reorganize')">📦 Reorganize</button>
|
||||
<button class="tab" data-t="collections" onclick="setTab('collections')">📚 Collections</button>
|
||||
<button class="tab" data-t="finder" onclick="setTab('finder')">🔎 Stock Finder</button>
|
||||
</div>
|
||||
|
||||
<div class="tools">
|
||||
<span class="muted">Space</span>
|
||||
<select id="spaceSel" style="padding:7px 9px"></select>
|
||||
<span class="seg" id="viewSeg">
|
||||
<button data-v="store" onclick="setView('store')">🏬 Store</button>
|
||||
<button data-v="rack" onclick="setView('rack')">🗄️ Rack</button>
|
||||
<button data-v="crate" onclick="setView('crate')">📦 Crate</button>
|
||||
<button data-v="item" onclick="setView('item')">💿 Item</button>
|
||||
</span>
|
||||
<span class="lvl" id="lvlCtl" style="display:none">
|
||||
<button class="ghost" id="lvlPrev" onclick="stepLevel(-1)">◀</button>
|
||||
<span id="lvlLabel" class="muted">Level</span>
|
||||
<button class="ghost" id="lvlNext" onclick="stepLevel(1)">▶</button>
|
||||
</span>
|
||||
<button class="ghost" id="rackEditBtn" style="display:none" onclick="rackEdit()">✏️ rack</button>
|
||||
<span id="selModeHint" class="selmode" style="display:none">● Select Mode — click crates in order</span>
|
||||
</div>
|
||||
|
||||
<div class="work">
|
||||
<!-- MAP -->
|
||||
<div>
|
||||
<div class="panel">
|
||||
<div class="navhdr"><span class="crumb" id="crumb">Store map</span></div>
|
||||
<canvas id="cv" width="600" height="560"></canvas>
|
||||
<div class="legend" id="legend"></div>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="cv" width="560" height="560"></canvas>
|
||||
<div class="legend" id="legend"></div>
|
||||
<!-- CRATE CONTENTS + RELEASE INFO -->
|
||||
<div id="midcol">
|
||||
<div class="panel">
|
||||
<div class="bar" style="justify-content:space-between">
|
||||
<div><span id="ccName" class="muted">Crate contents</span> <span id="ccMeta" class="muted"></span></div>
|
||||
</div>
|
||||
<div id="ccList" style="max-height:42vh;overflow:auto;margin-top:8px"><div class="muted">pick a crate on the map →</div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="bar" style="justify-content:space-between"><b style="font-size:13px">💿 Release Info</b>
|
||||
<div class="bar"><input id="relQ" placeholder="release id / sku" style="width:130px"><button class="ghost" onclick="relLookup()">↵</button></div></div>
|
||||
<div id="relBody" class="muted" style="margin-top:8px;max-height:34vh;overflow:auto">click a record, or look one up →</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ACTIVE TOOL -->
|
||||
<div class="toolcol" id="toolPanel"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -118,57 +128,91 @@ const $=s=>document.querySelector(s);
|
||||
let TOKEN=localStorage.getItem('rg_token')||'';
|
||||
const hdr=()=>({'Authorization':'Bearer '+TOKEN,'Content-Type':'application/json'});
|
||||
const get=u=>fetch(u,{headers:hdr()}).then(r=>r.json());
|
||||
const post=(u,b)=>fetch(u,{method:'POST',headers:hdr(),body:JSON.stringify(b)}).then(r=>r.json());
|
||||
const money=n=>n==null?'—':'$'+Number(n).toFixed(2);
|
||||
const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
// WebP cover via RecordGod's own image cache; fall back to the raw discogs thumb if the cover can't convert
|
||||
const imgTag=it=> it.release_id ? `<img src="/img/r/${it.release_id}" onerror="this.onerror=null;this.src='${(it.thumb||'').replace(/'/g,'')}'">` : (it.thumb?`<img src="${esc(it.thumb)}">`:'<span class=ph></span>');
|
||||
function chip(label,ondel,num){ return `<span class="chip${num?' num':''}">${esc(label)} <span class="x" onclick="${ondel}">✕</span></span>`; }
|
||||
|
||||
// ── facing convention (copied from WowPlatter production, see RECORDGOD_NAVIGATOR_PLAN §3) ──
|
||||
// facing convention (RECORDGOD_NAVIGATOR_PLAN §3)
|
||||
const CELL=0.34;
|
||||
function directionYaw(d){ d=(d||'').toLowerCase();
|
||||
if(d==='back'||d==='south') return Math.PI;
|
||||
if(d==='left'||d==='west') return Math.PI/2;
|
||||
if(d==='right'||d==='east') return -Math.PI/2;
|
||||
return 0; } // forward/front/north
|
||||
return 0; }
|
||||
const ARROW={forward:'↓',front:'↓',north:'↓',back:'↑',south:'↑',left:'←',west:'←',right:'→',east:'→'};
|
||||
|
||||
let ST={view:'store', space:null, racks:[], rack:null, level:1, crates:[], levels:[], selCrate:null, hit:[]};
|
||||
let ST={tab:'scanner', view:'store', space:null, racks:[], rack:null, level:1, crates:[], levels:[],
|
||||
selCrate:null, hit:[], storeTf:null, itemShown:false,
|
||||
pick:[], pickNames:{}, edit:null, crateInfo:null};
|
||||
|
||||
async function signin(){
|
||||
TOKEN=$('#tok').value.trim();
|
||||
if(!(await fetch('/admin/stats',{headers:hdr()})).ok){ $('#gerr').textContent='invalid token'; return; }
|
||||
localStorage.setItem('rg_token',TOKEN);
|
||||
$('#gate').style.display='none'; $('#app').style.display='grid';
|
||||
$('#q').focus(); await loadSpaces(); toStore(); loadCollections();
|
||||
$('#gate').style.display='none'; $('#app').style.display='block';
|
||||
await loadSpaces(); toStore(); setTab('scanner'); $('#omni').focus();
|
||||
}
|
||||
|
||||
async function loadSpaces(){
|
||||
const d=await get('/nav/spaces');
|
||||
$('#spaceSel').innerHTML=(d.spaces||[]).map(s=>`<option value="${s.id}">${esc(s.name||('Space '+s.id))}${s.is_default?' (default)':''}</option>`).join('');
|
||||
$('#spaceSel').onchange=()=>toStore();
|
||||
}
|
||||
|
||||
// ── STORE VIEW ──
|
||||
// ───────────── tabs ─────────────
|
||||
function setTab(t){
|
||||
ST.tab=t;
|
||||
document.querySelectorAll('.tab').forEach(b=>b.classList.toggle('on',b.dataset.t===t));
|
||||
$('#selModeHint').style.display = t==='reorganize' ? 'inline' : 'none';
|
||||
renderTool(t);
|
||||
redraw(); // pick / collection-edit highlights + click behaviour depend on the tab
|
||||
}
|
||||
function renderTool(t){
|
||||
const p=$('#toolPanel');
|
||||
if(t==='scanner'){ p.innerHTML=toolScanner(); renderScanner(); }
|
||||
else if(t==='returns'){ p.innerHTML=toolReturns(); }
|
||||
else if(t==='reorganize'){ p.innerHTML=toolReorg(); renderPick(); }
|
||||
else if(t==='collections'){ p.innerHTML=toolCollections(); loadCollections(); }
|
||||
else if(t==='finder'){ p.innerHTML=toolFinder(); $('#q').focus(); }
|
||||
}
|
||||
|
||||
// ───────────── view modes ─────────────
|
||||
function setViewBtns(){
|
||||
document.querySelectorAll('#viewSeg button').forEach(b=>b.classList.toggle('on',b.dataset.v===ST.view));
|
||||
$('#viewSeg [data-v=rack]').disabled=!ST.rack;
|
||||
$('#viewSeg [data-v=crate]').disabled=!ST.selCrate;
|
||||
$('#viewSeg [data-v=item]').disabled=!ST.itemShown;
|
||||
}
|
||||
function setView(m){
|
||||
if(m==='store') return toStore();
|
||||
if(m==='rack'){ if(ST.rack) openRack(ST.rack.id, ST.selCrate); return; }
|
||||
if(m==='crate'){ if(ST.selCrate){ ST.view='crate'; setViewBtns(); $('#midcol').scrollIntoView({behavior:'smooth',block:'nearest'}); } return; }
|
||||
if(m==='item'){ if(ST.itemShown){ ST.view='item'; setViewBtns(); $('#relBody').scrollIntoView({behavior:'smooth',block:'nearest'}); } return; }
|
||||
}
|
||||
function redraw(){ if(ST.view==='store') drawStore(); else if(ST.rack) drawRack(); }
|
||||
|
||||
// ───────────── STORE VIEW ─────────────
|
||||
async function toStore(){
|
||||
ST.view='store'; ST.rack=null; ST.selCrate=null;
|
||||
$('#backBtn').style.display='none'; $('#lvlCtl').style.display='none'; $('#rackEditBtn').style.display='none';
|
||||
$('#lvlCtl').style.display='none'; $('#rackEditBtn').style.display='none';
|
||||
$('#crumb').textContent='Store map';
|
||||
const sid=$('#spaceSel').value;
|
||||
const d=await get('/nav/store-layout'+(sid?('?space_id='+sid):''));
|
||||
ST.space=d.space; ST.racks=d.racks||[];
|
||||
$('#legend').innerHTML='<span><i class="sw" style="background:#8bc34a"></i>rack — click to open</span>';
|
||||
drawStore();
|
||||
$('#legend').innerHTML='<span><i class="sw" style="background:#8bc34a"></i>rack — click to open'+(ST.tab==='reorganize'?' · <b class="pink">drag to move</b>':'')+'</span>';
|
||||
drawStore(); setViewBtns();
|
||||
}
|
||||
function drawStore(){
|
||||
const cv=$('#cv'), g=cv.getContext('2d'), W=cv.width, H=cv.height;
|
||||
g.clearRect(0,0,W,H); g.fillStyle='#fbfbfd'; g.fillRect(0,0,W,H);
|
||||
ST.hit=[];
|
||||
if(!ST.racks.length){ g.fillStyle='#888'; g.font='14px system-ui'; g.textAlign='center'; g.fillText('No racks in this space',W/2,H/2); return; }
|
||||
// bounds over racks (pos ± half-dims) → fit
|
||||
let minX=1e9,maxX=-1e9,minZ=1e9,maxZ=-1e9;
|
||||
ST.racks.forEach(r=>{ const hw=(r.w||0.5)/2, hd=(r.d||0.5)/2;
|
||||
minX=Math.min(minX,r.x-hw); maxX=Math.max(maxX,r.x+hw); minZ=Math.min(minZ,r.z-hd); maxZ=Math.max(maxZ,r.z+hd); });
|
||||
const pad=34, sc=Math.min((W-2*pad)/((maxX-minX)||1),(H-2*pad)/((maxZ-minZ)||1));
|
||||
ST.storeTf={minX,minZ,sc,pad};
|
||||
const X=x=>pad+(x-minX)*sc, Y=z=>pad+(z-minZ)*sc;
|
||||
ST.racks.forEach(r=>{
|
||||
const yaw=directionYaw(r.direction)+(r.rot||0)*Math.PI/180;
|
||||
@ -184,29 +228,29 @@ function drawStore(){
|
||||
});
|
||||
}
|
||||
|
||||
// ── RACK VIEW ──
|
||||
// ───────────── RACK VIEW ─────────────
|
||||
async function openRack(id, focusCrate){
|
||||
const d=await get('/nav/rack/'+id);
|
||||
ST.view='rack'; ST.rack=d.rack; ST.crates=d.crates||[]; ST.levels=d.levels||[];
|
||||
// default to the level holding the most crates (or the focus crate's level)
|
||||
const byLvl={}; ST.crates.forEach(c=>{ const l=c.level==null?1:c.level; byLvl[l]=(byLvl[l]||0)+1; });
|
||||
ST.level = focusCrate!=null ? (ST.crates.find(c=>c.id===focusCrate)?.level ?? 1)
|
||||
: (+Object.keys(byLvl).sort((a,b)=>byLvl[b]-byLvl[a])[0] || 1);
|
||||
ST.selCrate=focusCrate||null;
|
||||
$('#backBtn').style.display=''; $('#rackEditBtn').style.display='';
|
||||
$('#rackEditBtn').style.display='';
|
||||
$('#crumb').innerHTML='Store / <b>'+esc(ST.rack.name||('Rack '+ST.rack.id))+'</b>';
|
||||
drawRack();
|
||||
drawRack(); setViewBtns();
|
||||
if(ST.tab==='reorganize') renderPick();
|
||||
}
|
||||
function levelMeta(){
|
||||
const lv=ST.rack.levels||1;
|
||||
const cur=ST.level, label=lv>1?`Level ${cur} of ${lv}${cur===1?' (BOTTOM)':(cur===lv?' (TOP)':'')}`:'Single level';
|
||||
return {lv, label};
|
||||
const lv=ST.rack.levels||1, cur=ST.level;
|
||||
return {lv, label: lv>1?`Level ${cur} of ${lv}${cur===1?' (BOTTOM)':(cur===lv?' (TOP)':'')}`:'Single level'};
|
||||
}
|
||||
function crateLabel(id){ const c=(ST.crates||[]).find(x=>x.id===id); return c&&(c.label_text||c.name)||ST.pickNames[id]||('Crate '+id); }
|
||||
function drawRack(){
|
||||
const {lv,label}=levelMeta();
|
||||
$('#lvlCtl').style.display = lv>1?'inline-flex':'none';
|
||||
$('#lvlLabel').textContent=label; $('#lvlPrev').disabled=ST.level<=1; $('#lvlNext').disabled=ST.level>=lv;
|
||||
$('#legend').innerHTML='<span><i class="sw" style="background:#9ecbff"></i>crate</span><span>↑↓←→ = crate facing · BACK/FRONT = rack frame · green=items</span>';
|
||||
$('#legend').innerHTML='<span><i class="sw" style="background:#9ecbff"></i>crate</span><span>↑↓←→ = facing · green=items'+(ST.tab==='reorganize'?' · <b class="pink">click in order to pick</b>':ST.tab==='collections'&&ST.edit?' · <b class="pink">click to add to collection</b>':'')+'</span>';
|
||||
const cv=$('#cv'), g=cv.getContext('2d'), W=cv.width, H=cv.height;
|
||||
g.clearRect(0,0,W,H); g.fillStyle='#fbfbfd'; g.fillRect(0,0,W,H);
|
||||
ST.hit=[];
|
||||
@ -214,52 +258,57 @@ function drawRack(){
|
||||
const cols=Math.max(1,Math.floor(rw/CELL)), rows=Math.max(1,Math.round(rd/CELL));
|
||||
const pad=42, sc=0.92*Math.min((W-2*pad)/rw,(H-2*pad)/rd);
|
||||
const cW=rw*sc, cH=rd*sc, oX=(W-cW)/2, oY=(H-cH)/2, cellW=cW/cols, cellH=cH/rows;
|
||||
// boundary
|
||||
g.fillStyle='#fff'; g.strokeStyle='#222'; g.lineWidth=3; g.beginPath(); g.rect(oX,oY,cW,cH); g.fill(); g.stroke();
|
||||
// edge labels (viewRot 0: BACK top, FRONT bottom, L left, R right)
|
||||
g.fillStyle='#888'; g.font='11px system-ui'; g.textAlign='center';
|
||||
g.fillText('BACK',oX+cW/2,oY-8); g.fillText('FRONT',oX+cW/2,oY+cH+16);
|
||||
g.save(); g.translate(oX-10,oY+cH/2); g.rotate(-Math.PI/2); g.fillText('L',0,0); g.restore();
|
||||
g.save(); g.translate(oX+cW+12,oY+cH/2); g.rotate(Math.PI/2); g.fillText('R',0,0); g.restore();
|
||||
// grid
|
||||
g.strokeStyle='#eee'; g.lineWidth=1;
|
||||
for(let i=1;i<cols;i++){ g.beginPath(); g.moveTo(oX+i*cellW,oY); g.lineTo(oX+i*cellW,oY+cH); g.stroke(); }
|
||||
for(let i=1;i<rows;i++){ g.beginPath(); g.moveTo(oX,oY+i*cellH); g.lineTo(oX+cW,oY+i*cellH); g.stroke(); }
|
||||
// crates on the current level (fall back: crates with null level show on level 1)
|
||||
const here=ST.crates.filter(c=>(c.level==null?1:c.level)===ST.level);
|
||||
let autoSlot=0;
|
||||
here.forEach(c=>{
|
||||
let slot=c.slot; if(!slot||slot<1){ autoSlot++; slot=autoSlot; } // ponytail: place slotless crates sequentially
|
||||
let slot=c.slot; if(!slot||slot<1){ autoSlot++; slot=autoSlot; }
|
||||
const idx=slot-1, col=idx%cols, row=Math.floor(idx/cols);
|
||||
if(row>=rows) return;
|
||||
const x=oX+col*cellW+3, y=oY+row*cellH+3, w=cellW-6, h=cellH-6;
|
||||
const sel=ST.selCrate===c.id;
|
||||
g.fillStyle=sel?'rgba(255,46,147,.30)':'rgba(30,144,255,.16)';
|
||||
g.strokeStyle=sel?'#d10f7a':'#1e90ff'; g.lineWidth=sel?3:1.5;
|
||||
const picked=ST.tab==='reorganize' && ST.pick.indexOf(c.id);
|
||||
const inColl=ST.tab==='collections' && ST.edit && ST.edit.crate_ids.includes(c.id);
|
||||
g.fillStyle = inColl?'rgba(70,180,90,.28)' : sel?'rgba(255,46,147,.30)' : 'rgba(30,144,255,.16)';
|
||||
g.strokeStyle = inColl?'#2e8b57' : sel?'#d10f7a' : '#1e90ff'; g.lineWidth=sel?3:1.5;
|
||||
g.beginPath(); g.rect(x,y,w,h); g.fill(); g.stroke();
|
||||
g.fillStyle='#1b1b22'; g.font='bold 10px system-ui'; g.textAlign='center'; g.textBaseline='top';
|
||||
g.fillText('#'+c.id, x+w/2, y+4);
|
||||
const gl=(c.label_text||c.crate_purpose||'').toString().split(',')[0].slice(0,12);
|
||||
g.fillStyle='#555'; g.font='9px system-ui'; g.fillText(gl||'—', x+w/2, y+16);
|
||||
// facing arrow (big, centre-bottom)
|
||||
g.fillStyle=sel?'#d10f7a':'#1e90ff'; g.font='bold 20px system-ui'; g.textBaseline='middle';
|
||||
g.fillText(ARROW[(c.direction||'').toLowerCase()]||'•', x+w/2, y+h-13);
|
||||
// slot number top-right, item count bottom-left
|
||||
g.fillStyle='#1a8f54'; g.font='9px system-ui'; g.textAlign='right'; g.textBaseline='top';
|
||||
g.fillText(slot, x+w-3, y+3);
|
||||
g.fillStyle='#888'; g.textAlign='left'; g.fillText((c.items||0), x+3, y+h-12);
|
||||
// ordered-pick badge (Reorganize)
|
||||
if(ST.tab==='reorganize'){ const pi=ST.pick.indexOf(c.id);
|
||||
if(pi>=0){ g.fillStyle='#d10f7a'; g.beginPath(); g.arc(x+12,y+h-12,10,0,2*Math.PI); g.fill();
|
||||
g.fillStyle='#fff'; g.font='bold 11px system-ui'; g.textAlign='center'; g.textBaseline='middle'; g.fillText(pi+1, x+12, y+h-12); } }
|
||||
ST.hit.push({type:'crate', id:c.id, x, y, w, h});
|
||||
});
|
||||
}
|
||||
function stepLevel(d){ ST.level=Math.max(1,Math.min(ST.rack.levels||1, ST.level+d)); drawRack(); }
|
||||
|
||||
// ── crate contents ──
|
||||
async function rackEdit(){
|
||||
if(!ST.rack) return;
|
||||
const n=prompt('Rack name:', ST.rack.name||''); if(n==null) return;
|
||||
await post('/nav/rack/'+ST.rack.id,{name:n}); openRack(ST.rack.id, ST.selCrate);
|
||||
}
|
||||
|
||||
// ───────────── crate contents (centre column) ─────────────
|
||||
async function loadCrate(id){
|
||||
ST.selCrate=id; if(ST.view==='rack') drawRack();
|
||||
ST.selCrate=id; redraw();
|
||||
const d=await get('/nav/crate/'+id); const c=d.crate;
|
||||
ST.crateInfo=c; $('#ccBtns').style.display='flex'; $('#scanPanel').style.display='none'; $('#crateEdit').style.display='none';
|
||||
$('#ccName').innerHTML='📦 '+esc(c.label_text||c.name||('Crate '+c.id));
|
||||
$('#ccName').className='pink';
|
||||
ST.crateInfo=c;
|
||||
$('#ccName').innerHTML='📦 '+esc(c.label_text||c.name||('Crate '+c.id)); $('#ccName').className='pink';
|
||||
const ls = c.last_scanned ? ` · scanned ${String(c.last_scanned).slice(0,10)}${c.updated_by?' by '+esc(c.updated_by):''}` : '';
|
||||
$('#ccMeta').innerHTML=`· ${esc(c.rack_name||'')} L${c.level??'?'} · ${c.items_count??d.items.length} items${ls} · <a class="pink" style="cursor:pointer" onclick="compressCrate(${id})">compress</a>`;
|
||||
$('#ccList').innerHTML=d.items.length? '<table><thead><tr><th>Slot</th><th>Title</th><th>Artist</th><th>Genre/Style</th><th>Price</th></tr></thead><tbody>'+
|
||||
@ -268,6 +317,8 @@ async function loadCrate(id){
|
||||
<td class="muted">${esc(i.artist||'')}</td><td class="muted" style="font-size:11px">${esc(i.genre||'')}${i.style?' · '+esc(i.style):''}</td>
|
||||
<td class="pink">${money(i.price)}</td></tr>`).join('')+'</tbody></table>'
|
||||
: '<div class="muted">empty crate</div>';
|
||||
setViewBtns();
|
||||
if(ST.tab==='scanner') renderScanner();
|
||||
}
|
||||
async function compressCrate(id){
|
||||
if(!confirm('Renumber this crate\'s slots 1..N (close gaps)?')) return;
|
||||
@ -276,7 +327,7 @@ async function compressCrate(id){
|
||||
async function relLookup(){
|
||||
const q=$('#relQ').value.trim(); if(!q) return;
|
||||
if(/^\d+$/.test(q)) return showRelease(parseInt(q));
|
||||
const d=await get('/nav/locate?sku='+encodeURIComponent(q)); // sku → release_id
|
||||
const d=await get('/nav/locate?sku='+encodeURIComponent(q));
|
||||
if(d.release_id) showRelease(d.release_id);
|
||||
else $('#relBody').innerHTML='<div class="muted">no release found for that SKU</div>';
|
||||
}
|
||||
@ -288,7 +339,7 @@ function relItems(items){
|
||||
}
|
||||
async function showRelease(rid){
|
||||
if(!rid){ $('#relBody').innerHTML='<div class="muted">that item has no release id</div>'; return; }
|
||||
$('#relQ').value=rid;
|
||||
$('#relQ').value=rid; ST.itemShown=true; setViewBtns();
|
||||
const d=await get('/nav/release/'+rid); const r=d.release;
|
||||
if(!r){ $('#relBody').innerHTML=`<div class="muted">release ${rid} isn't in the local mirror</div>`+relItems(d.items); return; }
|
||||
$('#relBody').innerHTML=`
|
||||
@ -302,42 +353,31 @@ async function showRelease(rid){
|
||||
<div class="muted" style="margin:4px 0">Inventory copies (${d.items.length})</div>${relItems(d.items)}`;
|
||||
}
|
||||
|
||||
// ── editable crate info ──
|
||||
function crateEditToggle(){
|
||||
const p=$('#crateEdit'); if(p.style.display!=='none'){ p.style.display='none'; return; }
|
||||
$('#scanPanel').style.display='none'; const c=ST.crateInfo||{};
|
||||
p.style.display='block';
|
||||
p.innerHTML=`<div class="bar"><input id="ceName" placeholder="name" value="${esc(c.name||'')}" style="flex:1">
|
||||
<input id="ceLabel" placeholder="label / genre" value="${esc(c.label_text||'')}" style="flex:1">
|
||||
<button onclick="crateEditSave()">Save</button></div>`;
|
||||
}
|
||||
async function crateEditSave(){
|
||||
await fetch('/nav/crate/'+ST.selCrate,{method:'POST',headers:hdr(),body:JSON.stringify({name:$('#ceName').value,label_text:$('#ceLabel').value})});
|
||||
$('#crateEdit').style.display='none'; loadCrate(ST.selCrate);
|
||||
}
|
||||
|
||||
// ── scanner (assign scanned items to the active crate's slots) ──
|
||||
function scanToggle(){
|
||||
const p=$('#scanPanel'); if(p.style.display!=='none'){ p.style.display='none'; return; }
|
||||
$('#crateEdit').style.display='none';
|
||||
const last=localStorage.getItem('rg_lastscan')||'', c=ST.crateInfo||{};
|
||||
p.style.display='block';
|
||||
p.innerHTML=`<div class="muted">🔫 Scan into <b class="pink">${esc(c.label_text||('Crate '+ST.selCrate))}</b> — Release IDs / SKUs / barcodes, one per line</div>
|
||||
<textarea id="scanLines" rows="5" style="width:100%;margin-top:4px"></textarea>
|
||||
<div class="bar" style="margin-top:4px;flex-wrap:wrap"><select id="scanMode" onchange="$('#scanAt').style.display=this.value==='insert'?'inline-block':'none'">
|
||||
// ───────────── TOOL: Scanner ─────────────
|
||||
function toolScanner(){ return `<div class="panel">
|
||||
<b style="font-size:13px">🔫 Scanner</b>
|
||||
<div id="scanTarget" class="muted" style="margin-top:6px">pick a crate on the map to scan into →</div>
|
||||
<div id="scanForm" style="margin-top:8px"></div></div>`; }
|
||||
function renderScanner(){
|
||||
const t=$('#scanTarget'), f=$('#scanForm'); if(!t) return;
|
||||
const c=ST.crateInfo;
|
||||
if(!c){ t.innerHTML='pick a crate on the map to scan into →'; f.innerHTML=''; return; }
|
||||
t.innerHTML='Active crate: <b class="pink">'+esc(c.label_text||('Crate '+c.id))+'</b>';
|
||||
const last=localStorage.getItem('rg_lastscan')||'';
|
||||
f.innerHTML=`<div class="muted">Release IDs / SKUs / barcodes — one per line. Assigned to slots sequentially.</div>
|
||||
<textarea id="scanLines" rows="6" style="width:100%;margin-top:4px"></textarea>
|
||||
<div class="bar" style="margin-top:6px;flex-wrap:wrap"><select id="scanMode" onchange="$('#scanAt').style.display=this.value==='insert'?'inline-block':'none'">
|
||||
<option value="replace">Replace all</option><option value="append">Add at end</option><option value="prepend">Add at start</option><option value="insert">Insert at slot…</option></select>
|
||||
<input id="scanAt" type="number" placeholder="slot" style="width:60px;display:none">
|
||||
<button class="ghost" onclick="scanTest()">Test</button><button onclick="scanProcess()">Process</button>
|
||||
<button class="ghost" onclick="$('#scanLines').value=''">Clear</button>${last?'<button class="ghost" onclick="scanRecover()">↺ recover last</button>':''}</div>
|
||||
<div id="scanOut" class="muted" style="margin-top:6px;max-height:24vh;overflow:auto"></div>`;
|
||||
}
|
||||
function scanBody(dry){
|
||||
return { crate_id:ST.selCrate, lines:$('#scanLines').value.split('\n').map(s=>s.trim()).filter(Boolean),
|
||||
mode:$('#scanMode').value, insert_at:($('#scanAt').value?parseInt($('#scanAt').value):null), dry_run:!!dry };
|
||||
<button class="ghost" onclick="scanTest()">Test</button><button class="prim" onclick="scanProcess()">Process</button>
|
||||
<button class="ghost" onclick="$('#scanLines').value=''">Clear</button>${last?'<button class="ghost" onclick="scanRecover()">↺ recover</button>':''}</div>
|
||||
<div id="scanOut" class="muted" style="margin-top:6px;max-height:30vh;overflow:auto"></div>`;
|
||||
}
|
||||
function scanBody(dry){ return { crate_id:ST.selCrate, lines:$('#scanLines').value.split('\n').map(s=>s.trim()).filter(Boolean),
|
||||
mode:$('#scanMode').value, insert_at:($('#scanAt').value?parseInt($('#scanAt').value):null), dry_run:!!dry }; }
|
||||
async function scanTest(){
|
||||
const b=scanBody(true); if(!b.lines.length) return;
|
||||
const d=await fetch('/nav/scan',{method:'POST',headers:hdr(),body:JSON.stringify(b)}).then(x=>x.json());
|
||||
const d=await post('/nav/scan',b);
|
||||
$('#scanOut').innerHTML=`<b>Preview — ${d.count} slots:</b>`+(d.plan||[]).map(p=>`<div>${p.slot}. ${esc(p.title||p.sku)} <span class="muted">${esc(p.artist||'')}</span></div>`).join('')
|
||||
+(d.not_found.length?`<div class="oos">not found: ${d.not_found.map(esc).join(', ')}</div>`:'');
|
||||
}
|
||||
@ -345,49 +385,54 @@ async function scanProcess(){
|
||||
const b=scanBody(false); if(!b.lines.length) return;
|
||||
if(b.mode==='replace' && !confirm('Replace all — items not scanned will be unfiled from this crate. Continue?')) return;
|
||||
localStorage.setItem('rg_lastscan', $('#scanLines').value);
|
||||
const d=await fetch('/nav/scan',{method:'POST',headers:hdr(),body:JSON.stringify(b)}).then(x=>x.json());
|
||||
const d=await post('/nav/scan',b);
|
||||
$('#scanOut').innerHTML=`<span class="ok">✓ ${d.assigned} assigned to slots</span>`+(d.not_found.length?`<div class="oos">not found: ${d.not_found.map(esc).join(', ')}</div>`:'');
|
||||
loadCrate(ST.selCrate);
|
||||
}
|
||||
function scanRecover(){ $('#scanLines').value=localStorage.getItem('rg_lastscan')||''; }
|
||||
|
||||
// ── rack edit ──
|
||||
async function rackEdit(){
|
||||
if(!ST.rack) return;
|
||||
const n=prompt('Rack name:', ST.rack.name||''); if(n==null) return;
|
||||
await fetch('/nav/rack/'+ST.rack.id,{method:'POST',headers:hdr(),body:JSON.stringify({name:n})});
|
||||
openRack(ST.rack.id);
|
||||
}
|
||||
// ───────────── TOOL: Returns (stub) ─────────────
|
||||
function toolReturns(){ return `<div class="panel">
|
||||
<b style="font-size:13px">↩ Returns</b>
|
||||
<div class="muted" style="margin-top:8px">Restock a sold copy: look it up in <b>Release Info</b>, then put it back in its crate via the Scanner.</div>
|
||||
<div class="muted" style="margin-top:6px">A one-scan "return → restock" flow lands here next. <!-- ponytail: no returns backend yet; Scanner+Release Info already cover the manual path --></div></div>`; }
|
||||
|
||||
// ── Reorganize — A–Z re-file planner (uses /nav/reorg/scan + /apply) ──
|
||||
// ───────────── TOOL: Reorganize ─────────────
|
||||
function toolReorg(){ return `<div class="panel">
|
||||
<div class="bar" style="justify-content:space-between"><b style="font-size:13px">📦 Source Crate Management</b><span id="roScanOut" class="muted"></span></div>
|
||||
<div class="muted" style="margin:6px 0">Click crates on the map <b>in order</b>, or type IDs.</div>
|
||||
<div id="pickList" style="display:flex;flex-wrap:wrap;gap:4px"></div>
|
||||
<div class="bar" style="margin-top:6px"><input id="roSrc" placeholder="474, 475, 476" style="flex:1"><button class="ghost" onclick="pickFromInput()">Set</button><button class="ghost" onclick="clearPick()">Clear</button></div>
|
||||
<div class="bar" style="margin-top:8px"><button class="prim" onclick="reorgScan()">Scan sources →</button></div>
|
||||
<div id="roPlanArea" style="display:none;margin-top:12px">
|
||||
<div class="muted">🎯 Filters</div>
|
||||
<div class="bar" style="flex-wrap:wrap;margin-top:4px"><span class="muted">Price ≤</span><input id="roPmax" type="number" style="width:70px">
|
||||
<span class="muted">Year</span><input id="roYmin" type="number" placeholder="min" style="width:60px"><input id="roYmax" type="number" placeholder="max" style="width:60px"></div>
|
||||
<div class="muted" style="margin-top:10px">📦 Distribution Target</div>
|
||||
<div class="bar" style="flex-wrap:wrap;margin-top:4px"><span class="muted">Start</span><input id="roStart" type="number" style="width:72px">
|
||||
<span class="muted">Count</span><input id="roCount" type="number" value="1" style="width:52px">
|
||||
<span class="muted">Slots</span><input id="roSlots" type="number" value="50" style="width:52px">
|
||||
<select id="roSort"><option value="artist">Artist A–Z</option><option value="title">Title A–Z</option><option value="year_asc">Year ↑</option><option value="year_desc">Year ↓</option><option value="price_asc">Price ↑</option><option value="price_desc">Price ↓</option></select>
|
||||
<button class="ghost" onclick="reorgPlan()">Plan</button></div>
|
||||
<div id="roPlanOut" style="margin-top:8px;max-height:40vh;overflow:auto"></div>
|
||||
</div></div>`; }
|
||||
function renderPick(){ const el=$('#pickList'); if(!el) return;
|
||||
el.innerHTML=ST.pick.map((id,i)=>chip(`#${i+1} `+crateLabel(id), `unpick(${id})`, true)).join('')||'<span class="muted">none picked</span>'; }
|
||||
function togglePick(id){ const i=ST.pick.indexOf(id);
|
||||
if(i<0){ ST.pick.push(id); const c=(ST.crates||[]).find(x=>x.id===id); if(c) ST.pickNames[id]=c.label_text||c.name||('Crate '+id); }
|
||||
else ST.pick.splice(i,1);
|
||||
renderPick(); drawRack(); }
|
||||
function unpick(id){ ST.pick=ST.pick.filter(x=>x!==id); renderPick(); redraw(); }
|
||||
function pickFromInput(){ ST.pick=($('#roSrc').value.match(/\d+/g)||[]).map(Number); renderPick(); redraw(); }
|
||||
function clearPick(){ ST.pick=[]; renderPick(); redraw(); }
|
||||
let roItems=[], roMoves=[], roLeftovers=[];
|
||||
function reorgOpen(){
|
||||
let o=$('#reorgModal');
|
||||
if(!o){ o=document.createElement('div'); o.id='reorgModal'; o.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:flex-start;justify-content:center;z-index:300;padding-top:4vh'; document.body.appendChild(o); o.addEventListener('mousedown',e=>{if(e.target===o)o.remove();}); }
|
||||
o.innerHTML=`<div style="background:#fff;border:1px solid var(--line);border-radius:12px;padding:18px;width:580px;max-height:90vh;overflow:auto">
|
||||
<div class="bar" style="justify-content:space-between"><b style="font-size:15px">🔀 Reorganize — A–Z re-file</b><button class="ghost" onclick="document.getElementById('reorgModal').remove()">✕</button></div>
|
||||
<div class="muted" style="margin:8px 0 4px">Source crate IDs (comma-separated)</div>
|
||||
<div class="bar"><input id="roSrc" placeholder="e.g. 474, 475, 476" style="flex:1"><button onclick="reorgScan()">Scan</button></div>
|
||||
<div id="roScanOut" class="muted" style="margin-top:6px"></div>
|
||||
<div id="roPlanArea" style="display:none">
|
||||
<div class="bar" style="margin-top:10px;flex-wrap:wrap"><span class="muted">Price ≤</span><input id="roPmax" type="number" style="width:70px">
|
||||
<span class="muted">Year</span><input id="roYmin" type="number" placeholder="min" style="width:62px"><input id="roYmax" type="number" placeholder="max" style="width:62px"></div>
|
||||
<div class="bar" style="margin-top:8px;flex-wrap:wrap"><span class="muted">Start crate</span><input id="roStart" type="number" style="width:78px">
|
||||
<span class="muted">Count</span><input id="roCount" type="number" value="1" style="width:56px">
|
||||
<span class="muted">Slots/crate</span><input id="roSlots" type="number" value="50" style="width:56px">
|
||||
<select id="roSort"><option value="artist">Artist A–Z</option><option value="title">Title A–Z</option><option value="year_asc">Year ↑</option><option value="year_desc">Year ↓</option><option value="price_asc">Price ↑</option><option value="price_desc">Price ↓</option></select>
|
||||
<button onclick="reorgPlan()">Plan</button></div>
|
||||
<div id="roPlanOut" style="margin-top:8px;max-height:42vh;overflow:auto"></div>
|
||||
</div></div>`;
|
||||
}
|
||||
async function reorgScan(){
|
||||
const ids=$('#roSrc').value.split(',').map(s=>parseInt(s.trim())).filter(Boolean);
|
||||
if(!ids.length) return;
|
||||
const d=await fetch('/nav/reorg/scan',{method:'POST',headers:hdr(),body:JSON.stringify({crate_ids:ids})}).then(x=>x.json());
|
||||
roItems=d.items||[];
|
||||
$('#roScanOut').innerHTML=`<b>${d.count}</b> items in ${ids.length} crate(s)`;
|
||||
$('#roStart').value=ids[0]; $('#roCount').value=ids.length;
|
||||
$('#roPlanArea').style.display='block';
|
||||
const ids = ST.pick.length ? ST.pick : ($('#roSrc').value.match(/\d+/g)||[]).map(Number);
|
||||
if(!ids.length){ $('#roScanOut').textContent='pick crates first'; return; }
|
||||
ST.pick=ids; renderPick();
|
||||
const d=await post('/nav/reorg/scan',{crate_ids:ids}); roItems=d.items||[];
|
||||
$('#roScanOut').innerHTML=`<b>${d.count}</b> items · ${ids.length} crate(s)`;
|
||||
$('#roStart').value=ids[0]; $('#roCount').value=ids.length; $('#roPlanArea').style.display='block';
|
||||
}
|
||||
function roCmp(m){ return (a,b)=>{
|
||||
if(m==='title') return (a.title||'').localeCompare(b.title||'');
|
||||
@ -401,14 +446,14 @@ function reorgPlan(){
|
||||
let items=roItems.filter(i=> (pmax==null||(i.price!=null&&i.price<=pmax)) && (ymin==null||(i.year&&i.year>=ymin)) && (ymax==null||(i.year&&i.year<=ymax)) );
|
||||
items.sort(roCmp($('#roSort').value));
|
||||
const start=parseInt($('#roStart').value), count=parseInt($('#roCount').value)||1, slots=parseInt($('#roSlots').value)||50;
|
||||
const targets=[]; for(let i=0;i<count;i++) targets.push(start+i);
|
||||
const targets=[]; for(let i=0;i<count;i++) targets.push(start+i); // distribute into start..start+count-1
|
||||
roMoves=[]; roLeftovers=[]; let ti=0, slot=1;
|
||||
for(const it of items){
|
||||
if(ti>=targets.length){ roLeftovers.push(it); continue; }
|
||||
roMoves.push({sku:it.sku, new_crate_id:targets[ti], new_slot:slot, title:it.title, artist:it.artist, from:`${it.crate_id||'—'}/${it.slot||'—'}`});
|
||||
slot++; if(slot>slots){ slot=1; ti++; }
|
||||
}
|
||||
$('#roPlanOut').innerHTML=`<div class="bar" style="justify-content:space-between"><b>${roMoves.length} items → crates ${targets.join(', ')}${roLeftovers.length?' · '+roLeftovers.length+' leftover':''}</b><button onclick="reorgApply()">Apply changes</button></div>`
|
||||
$('#roPlanOut').innerHTML=`<div class="bar" style="justify-content:space-between"><b>${roMoves.length} → crates ${targets.join(', ')}${roLeftovers.length?' · '+roLeftovers.length+' leftover':''}</b><button class="prim" onclick="reorgApply()">Apply</button></div>`
|
||||
+'<table style="margin-top:6px"><thead><tr><th>→ Crate/Slot</th><th>Title</th><th>Artist</th><th>From</th></tr></thead><tbody>'
|
||||
+roMoves.slice(0,300).map(m=>`<tr><td class="pink">${m.new_crate_id} / ${m.new_slot}</td><td>${esc(m.title||m.sku)}</td><td class="muted">${esc(m.artist||'')}</td><td class="muted">${esc(m.from)}</td></tr>`).join('')
|
||||
+(roMoves.length>300?`<tr><td colspan=4 class=muted>…+${roMoves.length-300} more</td></tr>`:'')+'</tbody></table>';
|
||||
@ -416,16 +461,37 @@ function reorgPlan(){
|
||||
async function reorgApply(){
|
||||
if(!roMoves.length) return;
|
||||
if(!confirm(`Apply ${roMoves.length} moves? Items get re-filed into their new crate/slot.`)) return;
|
||||
const d=await fetch('/nav/reorg/apply',{method:'POST',headers:hdr(),body:JSON.stringify({
|
||||
matched:roMoves.map(m=>({sku:m.sku,new_crate_id:m.new_crate_id,new_slot:m.new_slot})),
|
||||
leftovers:roLeftovers.map(l=>({sku:l.sku}))})}).then(x=>x.json());
|
||||
const d=await post('/nav/reorg/apply',{matched:roMoves.map(m=>({sku:m.sku,new_crate_id:m.new_crate_id,new_slot:m.new_slot})), leftovers:roLeftovers.map(l=>({sku:l.sku}))});
|
||||
$('#roPlanOut').innerHTML=`<span class="ok">✓ ${d.matched_updated} re-filed${d.leftovers_archived?', '+d.leftovers_archived+' unfiled':''}</span>`;
|
||||
if(ST.view!=='store') toStore();
|
||||
}
|
||||
|
||||
// ── collection editor ──
|
||||
// ───────────── TOOL: Collections ─────────────
|
||||
function toolCollections(){ return `<div class="panel" id="colPanel">
|
||||
<div class="bar" style="justify-content:space-between"><b style="font-size:13px">📚 Collections</b>
|
||||
<span><button class="ghost" onclick="edNew()">+ New</button> <span id="colCount" class="muted"></span></span></div>
|
||||
<div id="colList" style="max-height:30vh;overflow:auto;margin-top:8px"><div class="muted">loading…</div></div></div>
|
||||
<div class="panel" id="editPanel" style="display:none">
|
||||
<div class="bar" style="justify-content:space-between"><b id="edTitle">New collection</b><button class="ghost" onclick="edCancel()">✕ close</button></div>
|
||||
<div style="display:grid;gap:8px;margin-top:8px">
|
||||
<div class="bar"><input id="edName" placeholder="Name — e.g. HOUSE $15+" style="flex:1"><input id="edColor" type="color" value="#3498db" style="width:42px;height:38px;padding:2px"></div>
|
||||
<div class="bar"><span class="muted">Priority</span><input id="edPrio" type="number" value="0" style="width:64px">
|
||||
<span class="muted">Sort</span><select id="edSort" style="flex:1">
|
||||
<option value="alpha_artist">Artist A–Z</option><option value="alpha_title">Title A–Z</option>
|
||||
<option value="year_asc">Year ↑</option><option value="year_desc">Year ↓</option>
|
||||
<option value="price_asc">Price ↑</option><option value="price_desc">Price ↓</option><option value="date_added">Date added</option></select></div>
|
||||
<div class="bar"><span class="muted">Price</span><input id="edPmin" type="number" placeholder="min" style="width:62px" oninput="edStats()"><input id="edPmax" type="number" placeholder="max" style="width:62px" oninput="edStats()">
|
||||
<span class="muted">Year</span><input id="edYmin" type="number" placeholder="min" style="width:60px" oninput="edStats()"><input id="edYmax" type="number" placeholder="max" style="width:60px" oninput="edStats()"></div>
|
||||
<div><div class="muted">Genres</div><div id="edGenres" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px"></div></div>
|
||||
<div><div class="muted">Styles</div><div style="position:relative"><input id="edStyleQ" placeholder="search styles to add…" style="width:100%" autocomplete="off"><div id="edStyleRes" style="display:none;max-height:24vh;overflow:auto"></div></div>
|
||||
<div id="edStyles" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px"></div></div>
|
||||
<div><div class="muted">Crates <span class="pink">— click crates on the map to add →</span></div><div id="edCrates" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px"></div></div>
|
||||
<div id="edStats" class="muted"></div>
|
||||
<div class="bar"><button class="prim" onclick="edSave()" style="flex:1">Save collection</button>
|
||||
<button class="ghost" onclick="edDelete()" id="edDel" style="display:none;color:#c0392b">Delete</button></div>
|
||||
</div></div>`; }
|
||||
function showEditor(){ $('#colPanel').style.display='none'; $('#editPanel').style.display=''; }
|
||||
function edCancel(){ ST.edit=null; $('#editPanel').style.display='none'; $('#colPanel').style.display=''; }
|
||||
function edCancel(){ ST.edit=null; $('#editPanel').style.display='none'; $('#colPanel').style.display=''; redraw(); }
|
||||
let GENRES=[];
|
||||
async function loadGenres(){ if(!GENRES.length) GENRES=(await get('/nav/genres')).genres||[]; renderGenres(); }
|
||||
function renderGenres(){ $('#edGenres').innerHTML=GENRES.map(g=>{ const on=ST.edit.genre_ids.includes(g.id);
|
||||
@ -434,7 +500,7 @@ function edTogGenre(id){ const a=ST.edit.genre_ids, i=a.indexOf(id); if(i<0)a.pu
|
||||
function edNew(){ ST.edit={id:null,crate_ids:[],crateNames:{},genre_ids:[],style_ids:[],styleNames:{}};
|
||||
$('#edTitle').textContent='New collection'; $('#edDel').style.display='none';
|
||||
$('#edName').value=''; $('#edColor').value='#3498db'; $('#edPrio').value=0; $('#edSort').value='alpha_artist';
|
||||
['edPmin','edPmax','edYmin','edYmax'].forEach(k=>$('#'+k).value=''); loadGenres(); edRender(); showEditor(); }
|
||||
['edPmin','edPmax','edYmin','edYmax'].forEach(k=>$('#'+k).value=''); loadGenres(); edRender(); showEditor(); redraw(); }
|
||||
async function edLoad(id){ const d=await get('/nav/collections/'+id); const c=d.collection;
|
||||
ST.edit={id:c.id,crate_ids:(c.crate_ids||[]).slice(),crateNames:{},genre_ids:(c.genre_ids||[]).slice(),style_ids:(c.style_ids||[]).slice(),styleNames:{}};
|
||||
(d.crates||[]).forEach(x=>ST.edit.crateNames[x.id]=x.label_text||x.name||('Crate '+x.id));
|
||||
@ -442,18 +508,17 @@ async function edLoad(id){ const d=await get('/nav/collections/'+id); const c=d.
|
||||
$('#edTitle').textContent='Edit: '+esc(c.name); $('#edDel').style.display='';
|
||||
$('#edName').value=c.name||''; $('#edColor').value=c.color||'#3498db'; $('#edPrio').value=c.priority||0; $('#edSort').value=c.sort_method||'alpha_artist';
|
||||
$('#edPmin').value=c.price_min??''; $('#edPmax').value=c.price_max??''; $('#edYmin').value=c.year_min??''; $('#edYmax').value=c.year_max??'';
|
||||
await loadGenres(); edRender(); showEditor(); }
|
||||
function chip(label,ondel){ return `<span style="display:inline-flex;gap:5px;align-items:center;background:#eef0f5;border:1px solid #d4d8e0;border-radius:20px;padding:2px 8px;font-size:11px">${esc(label)} <span onclick="${ondel}" style="cursor:pointer;color:#a44">✕</span></span>`; }
|
||||
await loadGenres(); edRender(); showEditor(); redraw(); }
|
||||
function edRender(){
|
||||
$('#edStyles').innerHTML=ST.edit.style_ids.map(s=>chip(ST.edit.styleNames[s]||('style '+s),`edDelStyle(${s})`)).join('');
|
||||
$('#edCrates').innerHTML=ST.edit.crate_ids.map(c=>chip(ST.edit.crateNames[c]||('crate '+c),`edDelCrate(${c})`)).join('')||'<span class="muted">none — click crates on the map</span>';
|
||||
edStats();
|
||||
}
|
||||
function edAddCrate(id,label){ if(!ST.edit.crate_ids.includes(id)){ ST.edit.crate_ids.push(id); ST.edit.crateNames[id]=label||('crate '+id); edRender(); } }
|
||||
function edDelCrate(id){ ST.edit.crate_ids=ST.edit.crate_ids.filter(x=>x!==id); edRender(); }
|
||||
function edAddCrate(id,label){ if(!ST.edit.crate_ids.includes(id)){ ST.edit.crate_ids.push(id); ST.edit.crateNames[id]=label||('crate '+id); edRender(); redraw(); } }
|
||||
function edDelCrate(id){ ST.edit.crate_ids=ST.edit.crate_ids.filter(x=>x!==id); edRender(); redraw(); }
|
||||
function edDelStyle(id){ ST.edit.style_ids=ST.edit.style_ids.filter(x=>x!==id); edRender(); }
|
||||
let stq;
|
||||
$('#edStyleQ').addEventListener('input',e=>{ clearTimeout(stq); stq=setTimeout(()=>edStyleSearch(e.target.value),200); });
|
||||
document.addEventListener('input',e=>{ if(e.target&&e.target.id==='edStyleQ'){ clearTimeout(stq); stq=setTimeout(()=>edStyleSearch(e.target.value),200); } });
|
||||
async function edStyleSearch(q){ if(!q.trim()){ $('#edStyleRes').style.display='none'; return; }
|
||||
const d=await get('/nav/styles?q='+encodeURIComponent(q)); $('#edStyleRes').style.display='block';
|
||||
$('#edStyleRes').innerHTML=(d.styles||[]).map(s=>`<div class="ri" onclick='edAddStyle(${s.id},${JSON.stringify(s.name)})'>${esc(s.name)}</div>`).join('')||'<div class="muted" style="padding:6px">no match</div>'; }
|
||||
@ -464,66 +529,14 @@ function edDraft(){ const v=k=>{const x=$('#'+k).value; return x===''?null:+x;};
|
||||
price_min:v('edPmin'), price_max:v('edPmax'), year_min:v('edYmin'), year_max:v('edYmax')}; }
|
||||
let stt;
|
||||
async function edStats(){ if(!ST.edit) return; clearTimeout(stt); stt=setTimeout(async()=>{
|
||||
const r=await fetch('/nav/collections/stats',{method:'POST',headers:hdr(),body:JSON.stringify(edDraft())}).then(x=>x.json());
|
||||
const r=await post('/nav/collections/stats',edDraft());
|
||||
$('#edStats').innerHTML=`<b class="pink">${r.matching}</b> matching · <b class="ok">${r.located}</b> located · <b class="oos">${r.not_located}</b> not located`; },250); }
|
||||
async function edSave(){ const d=edDraft(); if(!d.name){ alert('name required'); return; }
|
||||
const r=await fetch('/nav/collections',{method:'POST',headers:hdr(),body:JSON.stringify(d)}).then(x=>x.json());
|
||||
if(r.ok){ edCancel(); loadCollections(); } }
|
||||
const r=await post('/nav/collections',d); if(r.ok){ edCancel(); loadCollections(); } }
|
||||
async function edDelete(){ if(!ST.edit.id||!confirm('Delete this collection?')) return;
|
||||
await fetch('/nav/collections/'+ST.edit.id,{method:'DELETE',headers:hdr()}); edCancel(); loadCollections(); }
|
||||
|
||||
// ── canvas clicks ──
|
||||
$('#cv').addEventListener('click',e=>{
|
||||
const r=$('#cv').getBoundingClientRect(), sx=$('#cv').width/r.width, sy=$('#cv').height/r.height;
|
||||
const mx=(e.clientX-r.left)*sx, my=(e.clientY-r.top)*sy;
|
||||
if(ST.view==='store'){
|
||||
let best=null,bd=1e9; for(const h of ST.hit){ const dd=Math.hypot(mx-h.cx,my-h.cy); if(dd<h.r&&dd<bd){bd=dd;best=h;} }
|
||||
if(best) openRack(best.id);
|
||||
} else {
|
||||
for(const h of ST.hit){ if(mx>=h.x&&mx<=h.x+h.w&&my>=h.y&&my<=h.y+h.h){
|
||||
if(ST.edit){ const cr=ST.crates.find(c=>c.id===h.id); edAddCrate(h.id, cr&&(cr.label_text||cr.crate_purpose)); }
|
||||
else loadCrate(h.id); return; } }
|
||||
}
|
||||
});
|
||||
|
||||
// ── search → locate ──
|
||||
let st;
|
||||
$('#q').addEventListener('input',e=>{ clearTimeout(st); st=setTimeout(run,200); });
|
||||
$('#q').addEventListener('keydown',e=>{ if(e.key==='Enter'){clearTimeout(st);run();} });
|
||||
async function run(){
|
||||
const q=$('#q').value.trim();
|
||||
if(!q){ $('#res').innerHTML=''; $('#count').textContent='type to search — click a hit to locate it'; return; }
|
||||
const d=await get('/admin/inventory?q='+encodeURIComponent(q));
|
||||
$('#count').textContent=`${d.total} match${d.total===1?'':'es'} — click to locate`;
|
||||
$('#res').innerHTML=(d.items||[]).map(it=>`<div class="ri" onclick='locate(${it.crate_id||'null'}, ${JSON.stringify(it.sku||'')})'>
|
||||
${imgTag(it)}
|
||||
<div class="t"><div class="nm">${esc(it.title||it.sku)}</div>
|
||||
<div class="muted">${esc(it.artist||'')} ${it.condition?'· '+esc(it.condition):''} · <b class="${it.in_stock?'pink':'oos'}">${money(it.price)}</b></div>
|
||||
${it.crate?`<span class="loc">📍 ${esc(it.crate)}</span>`:'<span class="muted">· no bin</span>'}</div></div>`).join('')
|
||||
|| '<div class="muted" style="padding:8px">no match anywhere in the store</div>';
|
||||
}
|
||||
async function locate(crateId, sku){
|
||||
if(crateId){ goCrate(crateId); return; }
|
||||
// no exact bin → fallback: where SHOULD it be (collection zone)
|
||||
const d=await get('/nav/locate?sku='+encodeURIComponent(sku||''));
|
||||
if(d.suggested) showSuggestion(d.suggested);
|
||||
else { $('#ccName').className='muted'; $('#ccName').textContent='No bin & no matching collection zone'; $('#ccMeta').textContent=''; $('#ccList').innerHTML='<div class="muted">this record isn\'t filed and no collection rule matches it yet</div>'; }
|
||||
}
|
||||
async function goCrate(id){
|
||||
const d=await get('/nav/crate/'+id);
|
||||
if(d.crate && d.crate.rack_id) await openRack(d.crate.rack_id, id);
|
||||
loadCrate(id);
|
||||
}
|
||||
function showSuggestion(sug){
|
||||
$('#ccName').className='pink'; $('#ccName').innerHTML='🎯 Should be in: '+esc(sug.name);
|
||||
$('#ccMeta').textContent='· fallback zone ('+sug.crates.length+' crates)';
|
||||
$('#ccList').innerHTML=sug.crates.length? sug.crates.map(c=>`<div class="ri" onclick="goCrate(${c.id})"><div class="t"><div class="nm">${esc(c.label||('Crate '+c.id))}</div></div><span class="loc">go →</span></div>`).join('')
|
||||
: '<div class="muted">collection has no crates assigned</div>';
|
||||
}
|
||||
|
||||
// ── collections ──
|
||||
async function loadCollections(){
|
||||
const d=await get('/nav/collections');
|
||||
const d=await get('/nav/collections'); if(!$('#colList')) return;
|
||||
$('#colCount').textContent=(d.collections||[]).length+' saved';
|
||||
$('#colList').innerHTML=(d.collections||[]).map(c=>{
|
||||
const rule=[c.price_min!=null?('$'+c.price_min+'+'):'', c.year_min?(c.year_min+(c.year_max?'-'+c.year_max:'+')):'', c.n_styles?(c.n_styles+'st'):'', c.n_genres?(c.n_genres+'g'):''].filter(Boolean).join(' · ');
|
||||
@ -540,6 +553,86 @@ async function openCollection(id){
|
||||
: '<div class="muted">no crates in this collection</div>';
|
||||
}
|
||||
|
||||
// ───────────── TOOL: Stock Finder ─────────────
|
||||
function toolFinder(){ return `<div class="panel">
|
||||
<b style="font-size:13px">🔎 Stock Finder</b>
|
||||
<div class="bar" style="margin-top:6px"><input id="q" placeholder="title / artist / SKU" style="flex:1" autocomplete="off"><button class="ghost" onclick="runSearch()">Search</button></div>
|
||||
<div class="muted" id="count" style="margin-top:6px">type to search — click a hit to locate it</div>
|
||||
<div id="finderRes" class="res"></div></div>`; }
|
||||
let st;
|
||||
document.addEventListener('input',e=>{ if(e.target&&e.target.id==='q'){ clearTimeout(st); st=setTimeout(runSearch,200); } });
|
||||
document.addEventListener('keydown',e=>{ if(e.target&&e.target.id==='q'&&e.key==='Enter'){ clearTimeout(st); runSearch(); } });
|
||||
async function runSearch(){
|
||||
const q=$('#q')?$('#q').value.trim():''; if(!$('#finderRes')) return;
|
||||
if(!q){ $('#finderRes').innerHTML=''; $('#count').textContent='type to search — click a hit to locate it'; return; }
|
||||
const d=await get('/admin/inventory?q='+encodeURIComponent(q));
|
||||
$('#count').textContent=`${d.total} match${d.total===1?'':'es'} — click to locate`;
|
||||
$('#finderRes').innerHTML=(d.items||[]).map(it=>`<div class="ri" onclick='locate(${it.crate_id||'null'}, ${JSON.stringify(it.sku||'')})'>
|
||||
${imgTag(it)}
|
||||
<div class="t"><div class="nm">${esc(it.title||it.sku)}</div>
|
||||
<div class="muted">${esc(it.artist||'')} ${it.condition?'· '+esc(it.condition):''} · <b class="${it.in_stock?'pink':'oos'}">${money(it.price)}</b></div>
|
||||
${it.crate?`<span class="loc">📍 ${esc(it.crate)}</span>`:'<span class="muted">· no bin</span>'}</div></div>`).join('')
|
||||
|| '<div class="muted" style="padding:8px">no match anywhere in the store</div>';
|
||||
}
|
||||
async function locate(crateId, sku){
|
||||
if(crateId){ goCrate(crateId); return; }
|
||||
const d=await get('/nav/locate?sku='+encodeURIComponent(sku||''));
|
||||
if(d.suggested) showSuggestion(d.suggested);
|
||||
else { $('#ccName').className='muted'; $('#ccName').textContent='No bin & no matching collection zone'; $('#ccMeta').textContent=''; $('#ccList').innerHTML='<div class="muted">this record isn\'t filed and no collection rule matches it yet</div>'; }
|
||||
}
|
||||
async function goCrate(id){
|
||||
const d=await get('/nav/crate/'+id);
|
||||
if(d.crate && d.crate.rack_id) await openRack(d.crate.rack_id, id);
|
||||
loadCrate(id);
|
||||
}
|
||||
function showSuggestion(sug){
|
||||
$('#ccName').className='pink'; $('#ccName').innerHTML='🎯 Should be in: '+esc(sug.name);
|
||||
$('#ccMeta').textContent='· fallback zone ('+sug.crates.length+' crates)';
|
||||
$('#ccList').innerHTML=sug.crates.length? sug.crates.map(c=>`<div class="ri" onclick="goCrate(${c.id})"><div class="t"><div class="nm">${esc(c.label||('Crate '+c.id))}</div></div><span class="loc">go →</span></div>`).join('')
|
||||
: '<div class="muted">collection has no crates assigned</div>';
|
||||
}
|
||||
|
||||
// ───────────── omni bar ─────────────
|
||||
async function omni(){
|
||||
const v=$('#omni').value.trim(); if(!v) return;
|
||||
let m;
|
||||
if(m=v.match(/^c\s*(\d+)$/i)) return goCrate(+m[1]);
|
||||
if(m=v.match(/^r\s*(\d+)$/i)) return openRack(+m[1]);
|
||||
if(/^\d+$/.test(v)) return showRelease(+v);
|
||||
setTab('finder'); $('#q').value=v; runSearch();
|
||||
}
|
||||
$('#omni').addEventListener('keydown',e=>{ if(e.key==='Enter') omni(); });
|
||||
|
||||
// ───────────── canvas: unified click + rack drag ─────────────
|
||||
function cvXY(e){ const cv=$('#cv'),r=cv.getBoundingClientRect(); return {mx:(e.clientX-r.left)*cv.width/r.width, my:(e.clientY-r.top)*cv.height/r.height}; }
|
||||
function hitRack(mx,my){ let best=null,bd=1e9; for(const h of ST.hit){ if(h.type!=='rack')continue; const dd=Math.hypot(mx-h.cx,my-h.cy); if(dd<h.r&&dd<bd){bd=dd;best=h;} } return best; }
|
||||
function hitCrate(mx,my){ for(const h of ST.hit){ if(h.type==='crate'&&mx>=h.x&&mx<=h.x+h.w&&my>=h.y&&my<=h.y+h.h) return h; } return null; }
|
||||
let down=null;
|
||||
$('#cv').addEventListener('mousedown',e=>{
|
||||
const {mx,my}=cvXY(e); down={mx,my,moved:false,dragRack:null};
|
||||
if(ST.tab==='reorganize' && ST.view==='store'){ const h=hitRack(mx,my); if(h) down.dragRack=h.id; }
|
||||
});
|
||||
$('#cv').addEventListener('mousemove',e=>{
|
||||
if(!down||!down.dragRack) return;
|
||||
const {mx,my}=cvXY(e);
|
||||
if(Math.hypot(mx-down.mx,my-down.my)>5) down.moved=true;
|
||||
if(down.moved){ const tf=ST.storeTf, r=ST.racks.find(x=>x.id===down.dragRack);
|
||||
r.x=tf.minX+(mx-tf.pad)/tf.sc; r.z=tf.minZ+(my-tf.pad)/tf.sc; drawStore(); }
|
||||
});
|
||||
window.addEventListener('mouseup',async e=>{
|
||||
if(!down) return; const d=down; down=null;
|
||||
if(d.dragRack && d.moved){ const r=ST.racks.find(x=>x.id===d.dragRack);
|
||||
await post('/nav/rack/'+d.dragRack,{pos_x:+r.x.toFixed(3),pos_z:+r.z.toFixed(3)}); return; }
|
||||
if(d.moved) return; // a non-rack drag — ignore
|
||||
// a real click → dispatch by view + tab
|
||||
const {mx,my}=cvXY(e);
|
||||
if(ST.view==='store'){ const h=hitRack(mx,my); if(h) openRack(h.id); return; }
|
||||
const h=hitCrate(mx,my); if(!h) return;
|
||||
if(ST.tab==='reorganize'){ togglePick(h.id); loadCrate(h.id); }
|
||||
else if(ST.tab==='collections' && ST.edit){ const cr=ST.crates.find(c=>c.id===h.id); edAddCrate(h.id, cr&&(cr.label_text||cr.name)); loadCrate(h.id); }
|
||||
else loadCrate(h.id);
|
||||
});
|
||||
|
||||
if(TOKEN){ $('#tok').value=TOKEN; signin(); }
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user