feat(nav): Quick Insert + click-to-pick insert slot (insert-with-shift)

/nav/insert: drop one record (release id/sku/barcode) into a crate at a chosen slot,
shifting items at>=slot down by one. Scanner: click a contents row to set the insert
anchor (before/after toggle) — drives both the bulk 'Insert at picked slot' mode and a
new Quick Insert one-record panel. Picked row highlighted; anchor is per-crate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-23 19:45:42 +10:00
parent a57b74700c
commit e20be06bf0
2 changed files with 68 additions and 9 deletions

View File

@ -199,6 +199,32 @@ async def _resolve_sku(db, ln):
return r[0] if r else None
class InsertOneIn(BaseModel):
item: str # release id / sku / barcode → resolved to one inventory sku
crate_id: int
slot: int # insert AT this slot; existing items at >= slot shift down by one
@router.post("/insert")
async def insert_one(body: InsertOneIn, ident=Depends(require_token), db=Depends(get_db)):
"""Drop a single record into a crate at a chosen slot, shifting the rest down (the Quick Insert)."""
sku = await _resolve_sku(db, body.item.strip())
if not sku:
raise HTTPException(404, "no record found for that ID / SKU / barcode")
slot = max(1, body.slot)
# detach the record first so it isn't caught by the shift (handles re-filing within the same crate)
await db.execute(text("UPDATE inventory SET crate_id=NULL, slot_number=NULL WHERE sku=:sku"), {"sku": sku})
shifted = (await db.execute(text(
"UPDATE inventory SET slot_number = slot_number + 1, updated_at = now() "
"WHERE crate_id = :c AND slot_number >= :s"), {"c": body.crate_id, "s": slot})).rowcount or 0
await db.execute(text(
"UPDATE inventory SET crate_id = :c, slot_number = :s, updated_at = now() WHERE sku = :sku"),
{"c": body.crate_id, "s": slot, "sku": sku})
await db.execute(text("UPDATE virtual_crate SET updated_at = now() WHERE id = :c"), {"c": body.crate_id})
await db.commit()
return {"ok": True, "sku": sku, "slot": slot, "shifted": shifted}
@router.post("/scan")
async def scan_assign(body: ScanAssignIn, ident=Depends(require_token), db=Depends(get_db)):
resolved, not_found = [], []

View File

@ -50,6 +50,7 @@
.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}
@ -162,7 +163,7 @@ const ARROW={forward:'↓',front:'↓',north:'↓',back:'↑',south:'↑',left:'
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};
pick:[], pickNames:{}, edit:null, crateInfo:null, pickSlot:null, pickPos:'before'};
async function signin(){
TOKEN=$('#tok').value.trim();
@ -346,6 +347,7 @@ async function rackEdit(){
// ───────────── 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;
@ -353,7 +355,7 @@ async function loadCrate(id){
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 style="cursor:pointer" onclick="showRelease(${i.release_id||'null'})" title="release ${i.release_id||'?'}">
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>'
@ -361,6 +363,12 @@ async function loadCrate(id){
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; }
@ -431,17 +439,41 @@ function renderScanner(){
if(!c){ t.innerHTML='pick a crate on the map to scan into →'; f.innerHTML=''; return; }
t.innerHTML='Active crate: <b class="pink">'+esc(c.label_text||('Crate '+c.id))+'</b>';
const last=localStorage.getItem('rg_lastscan')||'';
f.innerHTML=`<div class="muted">Release IDs / SKUs / barcodes — one per line. Assigned to slots sequentially.</div>
<textarea id="scanLines" rows="6" style="width:100%;margin-top:4px"></textarea>
<div class="bar" style="margin-top:6px;flex-wrap:wrap"><select id="scanMode" onchange="$('#scanAt').style.display=this.value==='insert'?'inline-block':'none'">
<option value="replace">Replace all</option><option value="append">Add at end</option><option value="prepend">Add at start</option><option value="insert">Insert at slot…</option></select>
<input id="scanAt" type="number" placeholder="slot" style="width:60px;display:none">
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="scanOut" class="muted" style="margin-top:6px;max-height:30vh;overflow:auto"></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:($('#scanAt').value?parseInt($('#scanAt').value):null), dry_run:!!dry }; }
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);
@ -450,6 +482,7 @@ async function scanTest(){
}
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);