RECORDGOD/site/search.html
type-two db1788bfe5 feat(app): payments/packs/discogs-mp/dealgod modules + admin, shop, sales route work
Batch-committing the in-flight app work (all modules compile clean):
- app/payments.py, app/packs.py, app/discogs_mp.py, app/dealgod.py (new)
- admin_routes, main, navigator/sales/shop routes, site/search.html
- customer_woo_sync + disc_sync tweaks; test_dealgod_supply, test_startup_ddl

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:07:28 +10:00

796 lines
58 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.5 system-ui,sans-serif}
button{cursor:pointer;border:0;border-radius:8px;font:500 13px system-ui}
.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)}
.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;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}
#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}
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}
tr.pickrow td{background:#fdeef6;box-shadow:inset 3px 0 0 var(--pink)}
.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>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">
<div class="top">
<h1>Search &amp; <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>
<span id="clockw" style="display:flex;gap:6px;align-items:center"></span>
</div>
<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>
<select id="rackRoomSel" style="display:none;padding:7px 9px" title="move this rack (and its crates) to another room" onchange="moveRackRoom()"></select>
<button class="ghost" id="rotBtn" onclick="rotateView()" title="rotate the diagram 90°">🔄 rotate</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>
<!-- 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" id="crateInfoPanel" style="display:none">
<b style="font-size:13px">📦 Crate Info</b>
<div class="bar" style="margin-top:8px"><input id="ciName" placeholder="crate name / label" style="flex:1"><button class="ghost" onclick="crateRename()">Save name</button></div>
<div class="bar" style="margin-top:8px;align-items:center"><span class="muted">Facing</span>
<span class="seg" id="ciFacing">
<button data-d="back" onclick="crateRotate('back')">↑ back</button>
<button data-d="forward" onclick="crateRotate('forward')">↓ front</button>
<button data-d="left" onclick="crateRotate('left')">← left</button>
<button data-d="right" onclick="crateRotate('right')">→ right</button>
</span>
<span id="ciSaved" class="ok" style="font-size:12px"></span></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>
<script>
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
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>`; }
// crate-type colour (material_color from virtual_crate_type): blue crate #0000FF, wooden #BA906A, white tub #FFFFFF…
function hexRgb(h){ h=String(h||'').replace('#',''); if(h.length===3)h=h.split('').map(c=>c+c).join(''); const n=parseInt(h,16); return isNaN(n)?[30,144,255]:[(n>>16)&255,(n>>8)&255,n&255]; }
function rgba(h,a){ const [r,g,b]=hexRgb(h); return `rgba(${r},${g},${b},${a})`; }
function darken(h,f){ const [r,g,b]=hexRgb(h); return `rgb(${Math.round(r*f)},${Math.round(g*f)},${Math.round(b*f)})`; }
// 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; }
const ARROW={forward:'↓',front:'↓',north:'↓',back:'↑',south:'↑',left:'←',west:'←',right:'→',east:'→'};
// Match the 3D store EXACTLY: stored pos_x/pos_z is a rotated CORNER-anchor, not the centre (webstore l.319-326).
function rackGeom(r){ // -> scene-coord rack CENTRE (cx,cz) + effective yaw (rad), so the 2D map == the 3D store
const W=+(ST.space&&ST.space.room_width)||12, D=+(ST.space&&ST.space.room_depth)||12;
const hx=(r.w||0.5)/2, hz=(r.d||0.5)/2, rpx=+r.x||0, rpz=+r.z||0, a=(r.attach_wall||'').toLowerCase();
const rot=(+r.rot||0)*Math.PI/180, base=directionYaw(r.direction)+rot; let yaw=base, cx, cz;
if(a==='north'){ yaw=rot; cz=-D/2+hz+Math.max(0,rpz); cx=-W/2+hx+rpx; }
else if(a==='south'){ yaw=Math.PI+rot; cz=D/2-hz-Math.max(0,rpz); cx=-W/2+hx+rpx; }
else if(a==='east'){ yaw=-Math.PI/2+rot; cx=W/2-hz-Math.max(0,rpz); cz=-D/2+hx+rpx; }
else if(a==='west'){ yaw=Math.PI/2+rot; cx=-W/2+hz+Math.max(0,rpz); cz=-D/2+hx+rpx; }
else { cx=-W/2+rpx+hx*Math.cos(base)-hz*Math.sin(base); cz=-D/2+rpz+hx*Math.sin(base)+hz*Math.cos(base); }
return {cx, cz, yaw};
}
function geomToPos(r, cx, cz, yaw){ // FREE inverse: desired scene CENTRE -> corner-anchored pos_x/pos_z
const W=+(ST.space&&ST.space.room_width)||12, D=+(ST.space&&ST.space.room_depth)||12, hx=(r.w||0.5)/2, hz=(r.d||0.5)/2;
return { pos_x: cx+W/2-(hx*Math.cos(yaw)-hz*Math.sin(yaw)), pos_z: cz+D/2-(hx*Math.sin(yaw)+hz*Math.cos(yaw)) };
}
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, pickSlot:null, pickPos:'before', viewRot:0};
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='block';
await loadSpaces(); toStore(); setTab('scanner'); $('#omni').focus(); loadClock();
}
// ───────────── time clock (clock on for your shift) ─────────────
let CLOCK={staff:false,open:null,today:0};
const fmtDur=s=>{ s=Math.max(0,s|0); const h=Math.floor(s/3600),m=Math.floor((s%3600)/60); return h?`${h}h ${m}m`:`${m}m`; };
async function loadClock(){ const d=await get('/admin/clock'); CLOCK={staff:d.staff,open:d.open,today:d.today_seconds||0}; drawClock(); }
function drawClock(){
const el=$('#clockw'); if(!el) return;
if(!CLOCK.staff){ el.innerHTML='<button class="ghost" onclick="logout()">Log out</button>'; return; } // owner
if(CLOCK.open){
const sec=(Date.now()-new Date(CLOCK.open.clock_in).getTime())/1000;
el.innerHTML=`<span class="ok" style="font-weight:600">🟢 On ${fmtDur(sec)}</span><button class="ghost" onclick="clockOut()">Clock off</button><button class="ghost" onclick="logout()">Log out</button>`;
} else {
el.innerHTML=`<button class="prim" onclick="clockIn()">🟢 Clock on</button><button class="ghost" onclick="logout()">Log out</button>`;
}
}
setInterval(()=>{ if(CLOCK.open) drawClock(); }, 30000); // tick the elapsed time
async function clockIn(){ await post('/admin/clock/in',{}); loadClock(); }
async function clockOut(){ if(!confirm('Clock off — end your shift?')) return; await post('/admin/clock/out',{}); loadClock(); }
function logout(){
if(CLOCK.open && !confirm('You are still clocked ON. Log out without clocking off?')) return;
localStorage.removeItem('rg_token'); location.reload();
}
async function loadSpaces(){
const d=await get('/nav/spaces'); ST.spaces=d.spaces||[];
$('#spaceSel').innerHTML=ST.spaces.map(s=>`<option value="${s.id}">${esc(s.name||('Space '+s.id))}${s.is_default?' (default)':''}</option>`).join('');
$('#spaceSel').onchange=()=>toStore();
}
// ───────────── 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(); }
function rotateView(){ ST.viewRot=((ST.viewRot||0)+90)%360; redraw(); }
// ───────────── STORE VIEW ─────────────
async function toStore(){
ST.view='store'; ST.rack=null; ST.selCrate=null; ST.crateInfo=null; renderCrateInfo();
$('#lvlCtl').style.display='none'; $('#rackEditBtn').style.display='none'; $('#rackRoomSel').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'+(ST.tab==='reorganize'?' · <b class="pink">drag = move · shift-drag = rotate</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; }
// each rack's TRUE scene centre + yaw (== the 3D store); _edit* overrides win during a drag/rotate gesture
ST.racks.forEach(r=>{ const gm=rackGeom(r); r._cx=r._editCx??gm.cx; r._cz=r._editCz??gm.cz; r._yaw=r._editYaw??gm.yaw; });
let minX=1e9,maxX=-1e9,minZ=1e9,maxZ=-1e9;
ST.racks.forEach(r=>{ const hd=Math.hypot((r.w||0.5)/2,(r.d||0.5)/2);
minX=Math.min(minX,r._cx-hd); maxX=Math.max(maxX,r._cx+hd); minZ=Math.min(minZ,r._cz-hd); maxZ=Math.max(maxZ,r._cz+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;
const q=(((ST.viewRot||0)/90)%4+4)%4;
ST.racks.forEach(r=>{
const yaw=r._yaw + q*Math.PI/2;
const hw=(r.w||0.5)/2*sc, hd=(r.d||0.5)/2*sc;
let cx=X(r._cx), cy=Y(r._cz);
if(q){ const a=q*Math.PI/2, co=Math.cos(a), si=Math.sin(a), dx=cx-W/2, dy=cy-H/2; cx=W/2+dx*co-dy*si; cy=H/2+dx*si+dy*co; }
const cos=Math.cos(yaw), sin=Math.sin(yaw);
const corners=[[-hw,-hd],[hw,-hd],[hw,hd],[-hw,hd]].map(([px,pz])=>[cx+px*cos-pz*sin, cy+px*sin+pz*cos]);
g.beginPath(); g.moveTo(corners[0][0],corners[0][1]); corners.slice(1).forEach(c=>g.lineTo(c[0],c[1])); g.closePath();
g.fillStyle=r.items? '#8bc34a':'#cfe3b0'; g.fill(); g.strokeStyle='#558b2f'; g.lineWidth=1.5; g.stroke();
g.beginPath(); g.moveTo(cx,cy); g.lineTo(cx+sin*hd, cy-cos*hd); g.strokeStyle='#33691e'; g.lineWidth=2; g.stroke(); // facing tick
g.fillStyle='#1b1b22'; g.font='bold 10px system-ui'; g.textAlign='center'; g.textBaseline='middle';
const lbl=(r.name||('Rack '+r.id)); g.fillText(lbl.length>15?lbl.slice(0,14)+'…':lbl, cx, cy-(r.items?5:0));
if(r.items){ g.fillStyle='#3a3a3a'; g.font='9px system-ui'; g.fillText(r.items+' items', cx, cy+7); }
ST.hit.push({type:'rack', id:r.id, cx, cy, r:Math.max(hw,hd)+4});
});
}
// ───────────── 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||[];
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;
$('#rackEditBtn').style.display='';
const rrs=$('#rackRoomSel'); rrs.style.display='';
rrs.innerHTML=(ST.spaces||[]).map(s=>`<option value="${s.id}" ${s.id===ST.rack.space_id?'selected':''}>📍 ${esc(s.name||('Space '+s.id))}</option>`).join('');
$('#crumb').innerHTML='Store / <b>'+esc(ST.rack.name||('Rack '+ST.rack.id))+'</b>';
drawRack(); setViewBtns();
if(ST.tab==='reorganize') renderPick();
}
function levelMeta(){
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;
const types=[...new Map((ST.crates||[]).map(c=>[c.crate_type||'crate', c.color||'#1e90ff'])).entries()];
$('#legend').innerHTML=types.map(([n,c])=>`<span><i class="sw" style="background:${esc(c)};border:1px solid #aaa"></i>${esc(n)}</span>`).join('')
+'<span>↑↓←→ = facing</span>'+(ST.tab==='reorganize'?'<span><b class="pink">click in order to pick</b></span>':ST.tab==='collections'&&ST.edit?'<span><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=[];
const rw=ST.rack.w||1.7, rd=ST.rack.d||0.7;
const cols=Math.max(1,Math.floor(rw/CELL)), rows=Math.max(1,Math.round(rd/CELL));
const q=(((ST.viewRot||0)/90)%4+4)%4; // view rotation (quarter turns CW; text stays upright)
const DC=q%2?rows:cols, DR=q%2?cols:rows; // displayed grid dims
const drw=q%2?rd:rw, drh=q%2?rw:rd; // displayed physical dims
const pad=42, sc=0.92*Math.min((W-2*pad)/drw,(H-2*pad)/drh);
const cW=drw*sc, cH=drh*sc, oX=(W-cW)/2, oY=(H-cH)/2, cellW=cW/DC, cellH=cH/DR;
g.fillStyle='#fff'; g.strokeStyle='#222'; g.lineWidth=3; g.beginPath(); g.rect(oX,oY,cW,cH); g.fill(); g.stroke();
const SIDES=[['BACK','R','FRONT','L'],['L','BACK','R','FRONT'],['FRONT','L','BACK','R'],['R','FRONT','L','BACK']][q]; // top,right,bottom,left
g.fillStyle='#888'; g.font='11px system-ui'; g.textAlign='center';
g.fillText(SIDES[0],oX+cW/2,oY-8); g.fillText(SIDES[2],oX+cW/2,oY+cH+16);
g.save(); g.translate(oX-10,oY+cH/2); g.rotate(-Math.PI/2); g.fillText(SIDES[3],0,0); g.restore();
g.save(); g.translate(oX+cW+12,oY+cH/2); g.rotate(Math.PI/2); g.fillText(SIDES[1],0,0); g.restore();
g.strokeStyle='#eee'; g.lineWidth=1;
for(let i=1;i<DC;i++){ g.beginPath(); g.moveTo(oX+i*cellW,oY); g.lineTo(oX+i*cellW,oY+cH); g.stroke(); }
for(let i=1;i<DR;i++){ g.beginPath(); g.moveTo(oX,oY+i*cellH); g.lineTo(oX+cW,oY+i*cellH); g.stroke(); }
const rcell=(col,row)=> q===0?[col,row] : q===1?[rows-1-row,col] : q===2?[cols-1-col,rows-1-row] : [row,cols-1-col];
const rotGlyph=g0=>{ let gx=g0; for(let i=0;i<q;i++) gx=({'↑':'→','→':'↓','↓':'←','←':'↑'})[gx]||gx; return gx; };
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; }
const idx=slot-1, col=idx%cols, row=Math.floor(idx/cols);
if(row>=rows) return;
const [dx,dy]=rcell(col,row);
const x=oX+dx*cellW+3, y=oY+dy*cellH+3, w=cellW-6, h=cellH-6;
const sel=ST.selCrate===c.id;
const inColl=ST.tab==='collections' && ST.edit && ST.edit.crate_ids.includes(c.id);
const tcol=c.color||'#1e90ff'; // crate-type material colour
g.fillStyle = inColl?'rgba(70,180,90,.28)' : sel?'rgba(255,46,147,.30)' : rgba(tcol,0.34);
g.strokeStyle = inColl?'#2e8b57' : sel?'#d10f7a' : darken(tcol,0.55); 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);
g.fillStyle=sel?'#d10f7a':darken(tcol,0.55); g.font='bold 20px system-ui'; g.textBaseline='middle';
g.fillText(rotGlyph(ARROW[(c.direction||'').toLowerCase()]||'•'), x+w/2, y+h-13);
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(); }
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);
}
async function moveRackRoom(){
const to=+$('#rackRoomSel').value; if(!ST.rack || to===ST.rack.space_id) return;
const nm=(ST.spaces.find(s=>s.id===to)||{}).name||('Space '+to);
if(!confirm(`Move "${ST.rack.name||('Rack '+ST.rack.id)}" and its crates to ${nm}?`)){ $('#rackRoomSel').value=ST.rack.space_id; return; }
await post('/nav/rack/'+ST.rack.id,{space_id:to});
$('#spaceSel').value=to; toStore(); // jump to the destination room, moved rack in place
}
// ───────────── crate contents (centre column) ─────────────
async function loadCrate(id){
if(id!==ST.selCrate) ST.pickSlot=null; // slot-pick is per-crate
ST.selCrate=id; redraw();
const d=await get('/nav/crate/'+id); const c=d.crate;
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>'+
d.items.map(i=>`<tr class="${ST.tab==='scanner'&&ST.pickSlot===i.slot?'pickrow':''}" data-slot="${i.slot??''}" style="cursor:pointer" onclick="ccRowClick(${i.slot??'null'},${i.release_id||'null'})" title="release ${i.release_id||'?'}">
<td>${i.slot??'—'}</td><td>${esc(i.title||i.sku)}${i.in_stock?'':' <span class="oos">sold</span>'}</td>
<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(); renderCrateInfo();
if(ST.tab==='scanner') renderScanner();
}
function ccRowClick(slot, rid){ if(ST.tab==='scanner' && slot!=null) setPickSlot(slot); if(rid) showRelease(rid); }
function setPickSlot(slot){
ST.pickSlot=slot;
document.querySelectorAll('#ccList tr').forEach(tr=>tr.classList.toggle('pickrow', +tr.dataset.slot===slot));
updatePickUI();
}
function renderCrateInfo(){
const c=ST.crateInfo, p=$('#crateInfoPanel'); if(!p) return;
if(!c){ p.style.display='none'; return; }
p.style.display='';
$('#ciName').value=c.label_text||c.name||'';
const d=(c.direction||'').toLowerCase();
document.querySelectorAll('#ciFacing button').forEach(b=>b.classList.toggle('on', b.dataset.d===d));
$('#ciSaved').textContent='';
}
async function crateRotate(dir){
if(!ST.selCrate) return;
await post('/nav/crate/'+ST.selCrate,{direction:dir});
ST.crateInfo.direction=dir;
const c=(ST.crates||[]).find(x=>x.id===ST.selCrate); if(c) c.direction=dir;
renderCrateInfo(); redraw(); // facing arrow flips live on the map
$('#ciSaved').textContent='✓ rotated';
}
async function crateRename(){
if(!ST.selCrate) return;
const v=$('#ciName').value.trim();
await post('/nav/crate/'+ST.selCrate,{label_text:v});
ST.crateInfo.label_text=v;
const c=(ST.crates||[]).find(x=>x.id===ST.selCrate); if(c) c.label_text=v;
$('#ccName').innerHTML='📦 '+esc(v||('Crate '+ST.selCrate));
$('#ciSaved').textContent='✓ saved'; redraw(); // crate label updates on the map
}
async function compressCrate(id){
if(!confirm('Renumber this crate\'s slots 1..N (close gaps)?')) return;
await fetch('/nav/reorg/compress/'+id,{method:'POST',headers:hdr()}); loadCrate(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));
if(d.release_id) showRelease(d.release_id);
else $('#relBody').innerHTML='<div class="muted">no release found for that SKU</div>';
}
function relItems(items){
return items.length ? items.map(i=>`<div class="ri" ${i.crate_id?`onclick="goCrate(${i.crate_id})"`:''} style="${i.crate_id?'cursor:pointer':''}">
<div class="t"><div class="nm">${esc(i.sku)} · <b class="${i.in_stock?'pink':'oos'}">${money(i.price)}</b>${i.in_stock?'':' <span class="oos">sold</span>'}</div>
<div class="muted">${esc(i.condition||'')}${i.sleeve_cond?' / '+esc(i.sleeve_cond):''} ${i.crate?'· 📍 '+esc(i.crate)+' L'+(i.level??'?')+' slot '+(i.slot??'?'):'· unfiled'}</div></div>
${i.crate_id?'<span class="loc">go →</span>':''}</div>`).join('') : '<div class="muted">no inventory copies</div>';
}
async function showRelease(rid){
if(!rid){ $('#relBody').innerHTML='<div class="muted">that item has no release id</div>'; return; }
$('#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=`
<div style="display:flex;gap:10px;margin-bottom:8px">
<img src="/img/r/${r.id}" style="width:66px;height:66px;border-radius:6px;object-fit:cover;background:#ececf0" onerror="this.style.visibility='hidden'">
<div style="flex:1;min-width:0"><div style="font-weight:700">${esc(r.title||'')}</div><div>${esc(r.artist||'')}</div>
<div class="muted" style="font-size:11px">${esc(r.label||'')}${r.format?' · '+esc(r.format):''}</div>
<div class="muted" style="font-size:11px">${esc(r.country||'')} ${r.year||''}${r.genre?' · '+esc(r.genre):''}${r.style?' / '+esc(r.style):''}</div>
${r.barcode?`<div class="muted" style="font-size:11px">📷 ${esc(r.barcode)}</div>`:''}</div>
</div>
<div class="muted" style="margin:4px 0">Inventory copies (${d.items.length})</div>${relItems(d.items)}`;
}
// ───────────── 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 style="border:1px dashed var(--line);border-radius:9px;padding:9px;margin-bottom:10px">
<div class="bar" style="justify-content:space-between"><b style="font-size:12px">⚡ Quick insert one</b><span id="qiAnchor" class="muted"></span></div>
<div class="bar" style="margin-top:6px;flex-wrap:wrap"><input id="qiRec" placeholder="release id / sku / barcode" style="flex:1;min-width:130px">
<span class="seg"><button data-pos="before" onclick="setPos('before')">before</button><button data-pos="after" onclick="setPos('after')">after</button></span>
<button class="prim" onclick="quickInsert()">Insert</button></div>
<div id="qiMsg" class="muted" style="margin-top:4px"></div>
</div>
<div class="muted">Bulk — 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:6px;flex-wrap:wrap"><select id="scanMode" onchange="updatePickUI()">
<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 picked slot</option></select>
<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="scanHint" class="muted" style="margin-top:4px"></div>
<div id="scanOut" class="muted" style="margin-top:6px;max-height:26vh;overflow:auto"></div>`;
updatePickUI();
}
function setPos(p){ ST.pickPos=p; updatePickUI(); }
function targetSlot(){ if(ST.pickSlot==null) return null; return ST.pickPos==='after' ? ST.pickSlot+1 : ST.pickSlot; }
function updatePickUI(){
document.querySelectorAll('#scanForm [data-pos]').forEach(b=>b.classList.toggle('on', b.dataset.pos===ST.pickPos));
const lbl = ST.pickSlot==null ? 'click a record below to pick the slot' : `<b class="pink">${ST.pickPos} slot ${ST.pickSlot}</b>`;
const a=$('#qiAnchor'); if(a) a.innerHTML=lbl;
const h=$('#scanHint'); if(h) h.innerHTML = ($('#scanMode')&&$('#scanMode').value==='insert') ? ('Insert mode — will insert '+lbl) : '';
}
async function quickInsert(){
const rec=$('#qiRec').value.trim(); if(!rec){ $('#qiMsg').innerHTML='<span class="oos">enter a record</span>'; return; }
const slot=targetSlot(); if(slot==null){ $('#qiMsg').innerHTML='<span class="oos">click a record in the crate to pick the slot</span>'; return; }
const d=await post('/nav/insert',{item:rec,crate_id:ST.selCrate,slot});
if(d.ok){ $('#qiMsg').innerHTML=`<span class="ok">✓ inserted at slot ${d.slot}${d.shifted?' · '+d.shifted+' shifted down':''}</span>`; $('#qiRec').value=''; ST.pickSlot=null; loadCrate(ST.selCrate); setTimeout(()=>{const r=$('#qiRec'); if(r)r.focus();},60); }
else $('#qiMsg').innerHTML='<span class="oos">'+esc(d.detail||'not found')+'</span>';
}
function scanBody(dry){ return { crate_id:ST.selCrate, lines:$('#scanLines').value.split('\n').map(s=>s.trim()).filter(Boolean),
mode:$('#scanMode').value, insert_at:($('#scanMode').value==='insert'?targetSlot():null), dry_run:!!dry }; }
async function scanTest(){
const b=scanBody(true); if(!b.lines.length) return;
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>`:'');
}
async function scanProcess(){
const b=scanBody(false); if(!b.lines.length) return;
if(b.mode==='insert' && b.insert_at==null){ $('#scanOut').innerHTML='<span class="oos">pick a slot first — click a record in the crate</span>'; 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 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')||''; }
// ───────────── 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>`; }
// ───────────── 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 <span class="muted">— sorted AZ, poured into these crates in order</span></div>
<div class="bar" style="flex-wrap:wrap;margin-top:4px"><span class="muted">Target crates</span><input id="roTargets" placeholder="auto from picks — e.g. 1, 2, 3, 4" style="flex:1;min-width:130px">
<span class="muted">Slots/crate</span><input id="roSlots" type="number" value="50" style="width:56px">
<select id="roSort"><option value="artist">Artist AZ</option><option value="title">Title AZ</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=[];
async function reorgScan(){
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)`;
// targets default to the picked crates sorted ascending (4,3,2,1 → fill 1,2,3,4) — editable
$('#roTargets').value=[...new Set(ids)].sort((a,b)=>a-b).join(', '); $('#roPlanArea').style.display='block';
}
function roCmp(m){ return (a,b)=>{
if(m==='title') return (a.title||'').localeCompare(b.title||'');
if(m==='year_asc') return (a.year||0)-(b.year||0);
if(m==='year_desc') return (b.year||0)-(a.year||0);
if(m==='price_asc') return (a.price||0)-(b.price||0);
if(m==='price_desc') return (b.price||0)-(a.price||0);
return (a.artist||'').localeCompare(b.artist||''); }; }
function reorgPlan(){
const pmax=parseFloat($('#roPmax').value)||null, ymin=parseInt($('#roYmin').value)||null, ymax=parseInt($('#roYmax').value)||null;
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 slots=parseInt($('#roSlots').value)||50;
const targets=($('#roTargets').value.match(/\d+/g)||[]).map(Number); // real crate IDs (default = sorted picks)
if(!targets.length){ $('#roPlanOut').innerHTML='<div class="oos">set at least one target crate</div>'; return; }
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++; }
}
const perT={}; roMoves.forEach(m=>perT[m.new_crate_id]=(perT[m.new_crate_id]||0)+1);
const summary=targets.map(t=>`<b class="pink">${t}</b>: ${perT[t]||0}`).join(' · ');
$('#roPlanOut').innerHTML=`<div class="bar" style="justify-content:space-between"><b>${roMoves.length} items${roLeftovers.length?' · '+roLeftovers.length+' leftover (need more crates)':''}</b><button class="prim" onclick="reorgApply()">Apply</button></div>`
+`<div class="muted" style="margin:4px 0">into → ${summary}</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>';
}
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 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();
}
// ───────────── 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 AZ</option><option value="alpha_title">Title AZ</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=''; 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);
return `<span onclick="edTogGenre(${g.id})" style="cursor:pointer;padding:2px 8px;border-radius:20px;font-size:11px;border:1px solid var(--line);${on?'background:#fdeef6;color:var(--pink);border-color:var(--pink)':'background:#fff'}">${esc(g.name)}</span>`;}).join(''); }
function edTogGenre(id){ const a=ST.edit.genre_ids, i=a.indexOf(id); if(i<0)a.push(id);else a.splice(i,1); renderGenres(); edStats(); }
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(); 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));
(c.styles||[]).forEach(s=>ST.edit.styleNames[s.id]=s.name);
$('#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(); 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(); 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;
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>'; }
function edAddStyle(id,name){ if(!ST.edit.style_ids.includes(id)){ ST.edit.style_ids.push(id); ST.edit.styleNames[id]=name; } $('#edStyleQ').value=''; $('#edStyleRes').style.display='none'; edRender(); }
function edDraft(){ const v=k=>{const x=$('#'+k).value; return x===''?null:+x;};
return {id:ST.edit.id, name:$('#edName').value.trim(), color:$('#edColor').value, priority:+$('#edPrio').value||0,
sort_method:$('#edSort').value, crate_ids:ST.edit.crate_ids, genre_ids:ST.edit.genre_ids, style_ids:ST.edit.style_ids,
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 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 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(); }
async function loadCollections(){
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(' · ');
return `<div class="ri" onclick="openCollection(${c.id})"><span class="sw" style="background:${esc(c.color||'#3498db')}"></span>
<div class="t"><div class="nm">${esc(c.name)}</div><div class="muted">${c.crate_count} crates${rule?' · '+esc(rule):''}</div></div>
<span onclick="event.stopPropagation();edLoad(${c.id})" style="cursor:pointer;padding:0 4px" title="edit">✏️</span></div>`;
}).join('')||'<div class="muted">no collections</div>';
}
async function openCollection(id){
const d=await get('/nav/collections/'+id); const c=d.collection;
$('#ccName').className='pink'; $('#ccName').innerHTML='📚 '+esc(c.name);
$('#ccMeta').textContent='· '+(d.crates||[]).length+' crates';
$('#ccList').innerHTML=(d.crates||[]).length? (d.crates||[]).map(x=>`<div class="ri" onclick="goCrate(${x.id})"><div class="t"><div class="nm">${esc(x.label_text||x.name||('Crate '+x.id))}</div><div class="muted">${x.items||0} items</div></div><span class="loc">go →</span></div>`).join('')
: '<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 unrotPt(mx,my){ const q=(((ST.viewRot||0)/90)%4+4)%4; if(!q) return [mx,my]; const cv=$('#cv'),W=cv.width,H=cv.height,a=-q*Math.PI/2,c=Math.cos(a),s=Math.sin(a),dx=mx-W/2,dy=my-H/2; return [W/2+dx*c-dy*s, H/2+dx*s+dy*c]; }
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,rot:false};
if(ST.tab==='reorganize' && ST.view==='store'){ const h=hitRack(mx,my);
if(h){ const r=ST.racks.find(x=>x.id===h.id); down.dragRack=h.id; down.rot=e.shiftKey; // drag = move · shift-drag = rotate
down.cx=h.cx; down.cy=h.cy; down.startYaw=r._yaw||0; down.startAng=Math.atan2(my-h.cy,mx-h.cx); } }
});
$('#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) return;
const r=ST.racks.find(x=>x.id===down.dragRack), tf=ST.storeTf;
if(down.rot){ r._editYaw=down.startYaw+(Math.atan2(my-down.cy,mx-down.cx)-down.startAng); } // rotate about its centre
else { const [wmx,wmy]=unrotPt(mx,my); r._editCx=tf.minX+(wmx-tf.pad)/tf.sc; r._editCz=tf.minZ+(wmy-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);
const cx=r._editCx??r._cx, cz=r._editCz??r._cz, yaw=r._editYaw??r._yaw; // final centre + facing
const p=geomToPos(r,cx,cz,yaw), deg=+(((yaw*180/Math.PI)%360+360)%360).toFixed(1);
r.x=p.pos_x; r.z=p.pos_z; r.rot=deg; r.direction='forward'; r.attach_wall=''; // moved rack becomes FREE, facing → rotation_y
delete r._editCx; delete r._editCz; delete r._editYaw; drawStore();
await post('/nav/rack/'+d.dragRack,{pos_x:+p.pos_x.toFixed(3),pos_z:+p.pos_z.toFixed(3),rotation_y:deg,direction:'forward',attach_wall:''});
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>
</html>