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>
796 lines
58 KiB
HTML
796 lines
58 KiB
HTML
<!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 & <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=>({'&':'&','<':'<','>':'>','"':'"'}[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 A–Z, 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 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=[];
|
||
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 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=''; 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>
|