feat(search): editable Rack Info (name) + Reorganize planner UI (scan source crates → filter → A-Z distribute into target crates → review → apply)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-22 23:58:10 +10:00
parent 83466389af
commit 0269decb87
2 changed files with 93 additions and 2 deletions

View File

@ -81,6 +81,23 @@ async def rack_detail(rack_id: int, ident=Depends(require_token), db=Depends(get
return {"ok": True, "rack": dict(rack), "crates": crates, "levels": levels}
class RackEditIn(BaseModel):
name: str | None = None
@router.post("/rack/{rack_id}")
async def rack_edit(rack_id: int, body: RackEditIn, ident=Depends(require_token), db=Depends(get_db)):
fields = {k: v for k, v in body.model_dump().items() if v is not None}
if not fields:
return {"ok": True, "unchanged": True}
sets = ", ".join(f"{k} = :{k}" for k in fields) + ", updated_at = now()"
r = await db.execute(text(f"UPDATE virtual_rack SET {sets} WHERE id = :i"), {**fields, "i": rack_id})
await db.commit()
if not r.rowcount:
raise HTTPException(404, "rack not found")
return {"ok": True}
@router.get("/crate/{crate_id}")
async def crate_contents(crate_id: int, ident=Depends(require_token), db=Depends(get_db)):
"""A crate's records, in slot order — the Crate Contents list."""

View File

@ -100,6 +100,8 @@
<span class="crumb" id="crumb">Store map</span>
<select id="spaceSel" style="padding:6px 8px"></select>
<button class="ghost" id="backBtn" style="display:none" onclick="toStore()">◀ Store</button>
<button class="ghost" id="rackEditBtn" style="display:none" onclick="rackEdit()">✏️ rack</button>
<button class="ghost" onclick="reorgOpen()">🔀 Reorganize</button>
<span class="lvl" id="lvlCtl" style="display:none">
<button class="ghost" id="lvlPrev" onclick="stepLevel(-1)"></button>
<span id="lvlLabel" class="muted">Level</span>
@ -149,7 +151,7 @@ async function loadSpaces(){
// ── STORE VIEW ──
async function toStore(){
ST.view='store'; ST.rack=null; ST.selCrate=null;
$('#backBtn').style.display='none'; $('#lvlCtl').style.display='none';
$('#backBtn').style.display='none'; $('#lvlCtl').style.display='none'; $('#rackEditBtn').style.display='none';
$('#crumb').textContent='Store map';
const sid=$('#spaceSel').value;
const d=await get('/nav/store-layout'+(sid?('?space_id='+sid):''));
@ -191,7 +193,7 @@ async function openRack(id, focusCrate){
ST.level = focusCrate!=null ? (ST.crates.find(c=>c.id===focusCrate)?.level ?? 1)
: (+Object.keys(byLvl).sort((a,b)=>byLvl[b]-byLvl[a])[0] || 1);
ST.selCrate=focusCrate||null;
$('#backBtn').style.display='';
$('#backBtn').style.display=''; $('#rackEditBtn').style.display='';
$('#crumb').innerHTML='Store / <b>'+esc(ST.rack.name||('Rack '+ST.rack.id))+'</b>';
drawRack();
}
@ -349,6 +351,78 @@ async function scanProcess(){
}
function scanRecover(){ $('#scanLines').value=localStorage.getItem('rg_lastscan')||''; }
// ── rack edit ──
async function rackEdit(){
if(!ST.rack) return;
const n=prompt('Rack name:', ST.rack.name||''); if(n==null) return;
await fetch('/nav/rack/'+ST.rack.id,{method:'POST',headers:hdr(),body:JSON.stringify({name:n})});
openRack(ST.rack.id);
}
// ── Reorganize — AZ re-file planner (uses /nav/reorg/scan + /apply) ──
let roItems=[], roMoves=[], roLeftovers=[];
function reorgOpen(){
let o=$('#reorgModal');
if(!o){ o=document.createElement('div'); o.id='reorgModal'; o.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:flex-start;justify-content:center;z-index:300;padding-top:4vh'; document.body.appendChild(o); o.addEventListener('mousedown',e=>{if(e.target===o)o.remove();}); }
o.innerHTML=`<div style="background:#fff;border:1px solid var(--line);border-radius:12px;padding:18px;width:580px;max-height:90vh;overflow:auto">
<div class="bar" style="justify-content:space-between"><b style="font-size:15px">🔀 Reorganize — AZ re-file</b><button class="ghost" onclick="document.getElementById('reorgModal').remove()"></button></div>
<div class="muted" style="margin:8px 0 4px">Source crate IDs (comma-separated)</div>
<div class="bar"><input id="roSrc" placeholder="e.g. 474, 475, 476" style="flex:1"><button onclick="reorgScan()">Scan</button></div>
<div id="roScanOut" class="muted" style="margin-top:6px"></div>
<div id="roPlanArea" style="display:none">
<div class="bar" style="margin-top:10px;flex-wrap:wrap"><span class="muted">Price ≤</span><input id="roPmax" type="number" style="width:70px">
<span class="muted">Year</span><input id="roYmin" type="number" placeholder="min" style="width:62px"><input id="roYmax" type="number" placeholder="max" style="width:62px"></div>
<div class="bar" style="margin-top:8px;flex-wrap:wrap"><span class="muted">Start crate</span><input id="roStart" type="number" style="width:78px">
<span class="muted">Count</span><input id="roCount" type="number" value="1" style="width:56px">
<span class="muted">Slots/crate</span><input id="roSlots" type="number" value="50" style="width:56px">
<select id="roSort"><option value="artist">Artist 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 onclick="reorgPlan()">Plan</button></div>
<div id="roPlanOut" style="margin-top:8px;max-height:42vh;overflow:auto"></div>
</div></div>`;
}
async function reorgScan(){
const ids=$('#roSrc').value.split(',').map(s=>parseInt(s.trim())).filter(Boolean);
if(!ids.length) return;
const d=await fetch('/nav/reorg/scan',{method:'POST',headers:hdr(),body:JSON.stringify({crate_ids:ids})}).then(x=>x.json());
roItems=d.items||[];
$('#roScanOut').innerHTML=`<b>${d.count}</b> items in ${ids.length} crate(s)`;
$('#roStart').value=ids[0]; $('#roCount').value=ids.length;
$('#roPlanArea').style.display='block';
}
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 start=parseInt($('#roStart').value), count=parseInt($('#roCount').value)||1, slots=parseInt($('#roSlots').value)||50;
const targets=[]; for(let i=0;i<count;i++) targets.push(start+i);
roMoves=[]; roLeftovers=[]; let ti=0, slot=1;
for(const it of items){
if(ti>=targets.length){ roLeftovers.push(it); continue; }
roMoves.push({sku:it.sku, new_crate_id:targets[ti], new_slot:slot, title:it.title, artist:it.artist, from:`${it.crate_id||'—'}/${it.slot||'—'}`});
slot++; if(slot>slots){ slot=1; ti++; }
}
$('#roPlanOut').innerHTML=`<div class="bar" style="justify-content:space-between"><b>${roMoves.length} items → crates ${targets.join(', ')}${roLeftovers.length?' · '+roLeftovers.length+' leftover':''}</b><button onclick="reorgApply()">Apply changes</button></div>`
+'<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 fetch('/nav/reorg/apply',{method:'POST',headers:hdr(),body:JSON.stringify({
matched:roMoves.map(m=>({sku:m.sku,new_crate_id:m.new_crate_id,new_slot:m.new_slot})),
leftovers:roLeftovers.map(l=>({sku:l.sku}))})}).then(x=>x.json());
$('#roPlanOut').innerHTML=`<span class="ok">✓ ${d.matched_updated} re-filed${d.leftovers_archived?', '+d.leftovers_archived+' unfiled':''}</span>`;
if(ST.view!=='store') toStore();
}
// ── collection editor ──
function showEditor(){ $('#colPanel').style.display='none'; $('#editPanel').style.display=''; }
function edCancel(){ ST.edit=null; $('#editPanel').style.display='none'; $('#colPanel').style.display=''; }