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:
parent
83466389af
commit
0269decb87
@ -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."""
|
||||
|
||||
@ -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 — A–Z 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 — A–Z re-file</b><button class="ghost" onclick="document.getElementById('reorgModal').remove()">✕</button></div>
|
||||
<div class="muted" style="margin:8px 0 4px">Source crate IDs (comma-separated)</div>
|
||||
<div class="bar"><input id="roSrc" placeholder="e.g. 474, 475, 476" style="flex:1"><button onclick="reorgScan()">Scan</button></div>
|
||||
<div id="roScanOut" class="muted" style="margin-top:6px"></div>
|
||||
<div id="roPlanArea" style="display:none">
|
||||
<div class="bar" style="margin-top:10px;flex-wrap:wrap"><span class="muted">Price ≤</span><input id="roPmax" type="number" style="width:70px">
|
||||
<span class="muted">Year</span><input id="roYmin" type="number" placeholder="min" style="width:62px"><input id="roYmax" type="number" placeholder="max" style="width:62px"></div>
|
||||
<div class="bar" style="margin-top:8px;flex-wrap:wrap"><span class="muted">Start crate</span><input id="roStart" type="number" style="width:78px">
|
||||
<span class="muted">Count</span><input id="roCount" type="number" value="1" style="width:56px">
|
||||
<span class="muted">Slots/crate</span><input id="roSlots" type="number" value="50" style="width:56px">
|
||||
<select id="roSort"><option value="artist">Artist A–Z</option><option value="title">Title A–Z</option><option value="year_asc">Year ↑</option><option value="year_desc">Year ↓</option><option value="price_asc">Price ↑</option><option value="price_desc">Price ↓</option></select>
|
||||
<button onclick="reorgPlan()">Plan</button></div>
|
||||
<div id="roPlanOut" style="margin-top:8px;max-height:42vh;overflow:auto"></div>
|
||||
</div></div>`;
|
||||
}
|
||||
async function reorgScan(){
|
||||
const ids=$('#roSrc').value.split(',').map(s=>parseInt(s.trim())).filter(Boolean);
|
||||
if(!ids.length) return;
|
||||
const d=await fetch('/nav/reorg/scan',{method:'POST',headers:hdr(),body:JSON.stringify({crate_ids:ids})}).then(x=>x.json());
|
||||
roItems=d.items||[];
|
||||
$('#roScanOut').innerHTML=`<b>${d.count}</b> items in ${ids.length} crate(s)`;
|
||||
$('#roStart').value=ids[0]; $('#roCount').value=ids.length;
|
||||
$('#roPlanArea').style.display='block';
|
||||
}
|
||||
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=''; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user