feat(search): clickable crate contents → Release Info panel (cover/label/format/country/year/genre/style + all copies + locate); crate genre/style + last-scanned + compress
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d6e75667bf
commit
19c6e03c70
@ -87,15 +87,19 @@ async def crate_contents(crate_id: int, ident=Depends(require_token), db=Depends
|
||||
crate = (await db.execute(text("""
|
||||
SELECT c.id, c.name, c.label_text, c.rack_id, c.rack_level_index::int AS level,
|
||||
c.slot_number::int AS slot, c.direction, c.crate_purpose,
|
||||
r.name AS rack_name
|
||||
c.updated_at AS last_scanned, c.updated_by, r.name AS rack_name, rt.type AS rack_type,
|
||||
(SELECT count(*) FROM inventory i WHERE i.crate_id = c.id AND i.in_stock) AS items_count
|
||||
FROM virtual_crate c LEFT JOIN virtual_rack r ON r.id = c.rack_id
|
||||
LEFT JOIN virtual_rack_type rt ON rt.id = r.rack_type_id
|
||||
WHERE c.id = :id
|
||||
"""), {"id": crate_id})).mappings().first()
|
||||
if not crate:
|
||||
raise HTTPException(404, "crate not found")
|
||||
items = [dict(r) for r in (await db.execute(text("""
|
||||
SELECT i.sku, i.slot_number::int AS slot, coalesce(i.title, dc.title) AS title,
|
||||
dc.artist, i.price::float AS price, i.condition, i.in_stock
|
||||
SELECT i.sku, i.release_id, i.slot_number::int AS slot, coalesce(i.title, dc.title) AS title,
|
||||
dc.artist, i.price::float AS price, i.condition, i.in_stock,
|
||||
(SELECT string_agg(DISTINCT g.genre_name, ', ') FROM disc_release_genre g WHERE g.release_id = i.release_id) AS genre,
|
||||
(SELECT string_agg(DISTINCT s.style_name, ', ') FROM disc_release_style s WHERE s.release_id = i.release_id) AS style
|
||||
FROM inventory i LEFT JOIN disc_cache dc ON dc.release_id = i.release_id
|
||||
WHERE i.crate_id = :id
|
||||
ORDER BY i.slot_number NULLS LAST, i.sku
|
||||
@ -103,6 +107,29 @@ async def crate_contents(crate_id: int, ident=Depends(require_token), db=Depends
|
||||
return {"ok": True, "crate": dict(crate), "items": items}
|
||||
|
||||
|
||||
@router.get("/release/{release_id}")
|
||||
async def release_info(release_id: int, ident=Depends(require_token), db=Depends(get_db)):
|
||||
"""Full release metadata (from the disc_release mirror) + every inventory copy + where each lives."""
|
||||
dr = (await db.execute(text("""
|
||||
SELECT dr.id, dr.title, dr.artists_sort AS artist, dr.country, dr.year, dr.thumb, dr.notes, dr.master_id,
|
||||
(SELECT string_agg(label_name || coalesce(' ('||catno||')',''), ', ') FROM disc_release_label WHERE release_id=dr.id) AS label,
|
||||
(SELECT string_agg(name || coalesce(' '||descriptions,''), ', ') FROM disc_release_format WHERE release_id=dr.id) AS format,
|
||||
(SELECT string_agg(DISTINCT genre_name, ', ') FROM disc_release_genre WHERE release_id=dr.id) AS genre,
|
||||
(SELECT string_agg(DISTINCT style_name, ', ') FROM disc_release_style WHERE release_id=dr.id) AS style,
|
||||
(SELECT value FROM disc_release_identifier WHERE release_id=dr.id AND type ILIKE 'barcode' LIMIT 1) AS barcode
|
||||
FROM disc_release dr WHERE dr.id = :r
|
||||
"""), {"r": release_id})).mappings().first()
|
||||
items = [dict(r) for r in (await db.execute(text("""
|
||||
SELECT i.sku, i.price::float AS price, i.condition, i.sleeve_cond, i.in_stock,
|
||||
i.crate_id, i.slot_number::int AS slot, c.label_text AS crate, r.name AS rack,
|
||||
c.rack_level_index::int AS level
|
||||
FROM inventory i LEFT JOIN virtual_crate c ON c.id = i.crate_id
|
||||
LEFT JOIN virtual_rack r ON r.id = c.rack_id
|
||||
WHERE i.release_id = :r ORDER BY i.in_stock DESC, i.sku
|
||||
"""), {"r": release_id})).mappings()]
|
||||
return {"ok": True, "release": dict(dr) if dr else None, "items": items}
|
||||
|
||||
|
||||
# ── Reorganize — A-Z re-file planner (server scans + applies; client plans the moves) ──────────
|
||||
class ScanIn(BaseModel):
|
||||
crate_ids: list[int]
|
||||
|
||||
@ -57,7 +57,12 @@
|
||||
<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:28vh;overflow:auto;margin-top:8px"><div class="muted">pick a crate on the map →</div></div>
|
||||
<div id="ccList" style="max-height:26vh;overflow:auto;margin-top:8px"><div class="muted">pick a crate on the map →</div></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:30vh;overflow:auto">click a record in a crate, or look one up →</div>
|
||||
</div>
|
||||
<div class="panel" id="colPanel">
|
||||
<div class="bar" style="justify-content:space-between"><b style="font-size:13px">📚 Collections</b>
|
||||
@ -249,11 +254,47 @@ async function loadCrate(id){
|
||||
const d=await get('/nav/crate/'+id); const c=d.crate;
|
||||
$('#ccName').innerHTML='📦 '+esc(c.label_text||c.name||('Crate '+c.id));
|
||||
$('#ccName').className='pink';
|
||||
$('#ccMeta').textContent=`· ${c.rack_name||''} L${c.level??'?'} slot ${c.slot??'?'} · ${d.items.length} items`;
|
||||
$('#ccList').innerHTML=d.items.length? '<table><thead><tr><th>Slot</th><th>Title</th><th>Artist</th><th>Price</th></tr></thead><tbody>'+
|
||||
d.items.map(i=>`<tr><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="pink">${money(i.price)}</td></tr>`).join('')+'</tbody></table>'
|
||||
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||'?'}">
|
||||
<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>';
|
||||
}
|
||||
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)); // sku → release_id
|
||||
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;
|
||||
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)}`;
|
||||
}
|
||||
|
||||
// ── collection editor ──
|
||||
function showEditor(){ $('#colPanel').style.display='none'; $('#editPanel').style.display=''; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user