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:
type-two 2026-06-22 23:40:06 +10:00
parent d6e75667bf
commit 19c6e03c70
2 changed files with 75 additions and 7 deletions

View File

@ -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]

View File

@ -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=''; }