feat(inventory): management UI — row edit modal, add-item, delete, bulk-select actions, webp thumbs

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-22 22:45:37 +10:00
parent 4ece321b76
commit c5ce84de1c

View File

@ -126,24 +126,30 @@ const kpi = (n,l,pink) => `<div class="kpi"><div class="n${pink?' pink':''}">${n
// ---------- Inventory ----------
let invState = { q:'', kind:'', crate_id:null, page:1 };
let invItems = {}; // sku -> row (for the edit modal)
let invSel = new Set(); // selected skus (bulk)
async function vInv(){
const m = $('#content');
m.innerHTML = '<h2>Inventory</h2>'
+ '<div class="bar"><input id="iq" placeholder="search title / artist / sku…" value="'+invState.q+'">'
+ '<select id="ikind"><option value="">all kinds</option><option>vinyl</option><option>book</option><option>magazine</option><option>cd</option></select>'
+ '<button onclick="invSearch()">Search</button></div>'
+ '<button onclick="invSearch()">Search</button>'
+ '<button class="ghost" onclick="invAdd()"> Add item</button></div>'
+ '<div id="invbulk"></div>'
+ '<div id="invrows" class="muted">loading…</div>';
$('#iq').addEventListener('keydown', e => { if(e.key==='Enter') invSearch(); });
$('#ikind').value = invState.kind;
loadInv();
invSel.clear(); loadInv();
}
function invSearch(){ invState.q = $('#iq').value.trim(); invState.kind = $('#ikind').value; invState.crate_id = null; invState.page = 1; loadInv(); }
function invSearch(){ invState.q = $('#iq').value.trim(); invState.kind = $('#ikind').value; invState.crate_id = null; invState.page = 1; invSel.clear(); loadInv(); }
async function loadInv(){
const cr = invState.crate_id ? `&crate_id=${invState.crate_id}` : '';
const d = await api(`/inventory?q=${encodeURIComponent(invState.q)}&kind=${invState.kind}&page=${invState.page}${cr}`);
invItems = {}; d.items.forEach(r => invItems[r.sku] = r);
const rows = d.items.map(r => `<tr>
<td>${r.thumb?`<img class=thumb src="${r.thumb}">`:'<span class=thumb style="display:inline-block"></span>'}</td>
<td>${r.title?escapeH(r.title):'<span class=muted>'+r.sku+'</span>'}<div class="muted">${r.artist?escapeH(r.artist):''}</div></td>
<td><input type=checkbox ${invSel.has(r.sku)?'checked':''} onclick="event.stopPropagation();invSelTog('${r.sku}')"></td>
<td onclick="invEdit('${r.sku}')" style="cursor:pointer">${r.release_id?`<img class=thumb src="/img/r/${r.release_id}" onerror="this.style.visibility='hidden'">`:'<span class=thumb style="display:inline-block"></span>'}</td>
<td onclick="invEdit('${r.sku}')" style="cursor:pointer">${r.title?escapeH(r.title):'<span class=muted>'+r.sku+'</span>'}<div class="muted">${r.artist?escapeH(r.artist):''}</div></td>
<td><span class="pill">${r.kind}</span></td>
<td>${r.condition||'—'}</td>
<td class="price">${money(r.price)}</td>
@ -152,11 +158,87 @@ async function loadInv(){
<td>${r.in_stock?'<span class="pill in">in</span>':'<span class="pill out">sold</span>'}</td>
</tr>`).join('');
const pages = Math.max(1, Math.ceil(d.total/d.per));
$('#invrows').innerHTML = `<div class="muted" style="margin-bottom:8px">${d.total.toLocaleString()} items</div>`
+ `<table><thead><tr><th></th><th>Title / artist</th><th>Kind</th><th>Cond</th><th>Price</th><th>Target</th><th>Crate</th><th>Stock</th></tr></thead><tbody>${rows||'<tr><td colspan=8 class=muted>nothing found</td></tr>'}</tbody></table>`
$('#invrows').innerHTML = `<div class="muted" style="margin-bottom:8px">${d.total.toLocaleString()} items · click a row to edit</div>`
+ `<table><thead><tr><th><input type=checkbox onclick="invSelAll(this.checked)"></th><th></th><th>Title / artist</th><th>Kind</th><th>Cond</th><th>Price</th><th>Target</th><th>Crate</th><th>Stock</th></tr></thead><tbody>${rows||'<tr><td colspan=9 class=muted>nothing found</td></tr>'}</tbody></table>`
+ `<div class="pg"><button class="ghost" onclick="invPage(-1)" ${d.page<=1?'disabled':''}> prev</button><span class="muted">page ${d.page} / ${pages}</span><button class="ghost" onclick="invPage(1)" ${d.page>=pages?'disabled':''}>next </button></div>`;
invBulkBar();
}
function invPage(n){ invState.page += n; loadInv(); }
function invSelTog(sku){ invSel.has(sku)?invSel.delete(sku):invSel.add(sku); invBulkBar(); }
function invSelAll(on){ if(on) Object.keys(invItems).forEach(s=>invSel.add(s)); else invSel.clear(); loadInv(); }
function invBulkBar(){
const b = $('#invbulk'); if(!b) return; const n = invSel.size;
b.innerHTML = n ? `<div class="bar" style="background:rgba(255,93,177,.08);border:1px solid #3a2230;border-radius:8px;padding:8px;margin:8px 0">
<b>${n} selected</b>
<select id="bAct"><option value="set_price">set price $</option><option value="adjust_pct">adjust %</option><option value="set_crate">set crate id</option><option value="mark_sold">mark sold</option><option value="restock">restock</option><option value="delete">delete</option></select>
<input id="bVal" placeholder="value" style="width:90px">
<button onclick="invBulk()">Apply</button><button class="ghost" onclick="invSel.clear();loadInv()">clear</button></div>` : '';
}
async function invBulk(){
const action = $('#bAct').value; let value = $('#bVal').value;
if(action==='delete' && !confirm(`Delete ${invSel.size} items? This can't be undone.`)) return;
if(['set_price','adjust_pct','set_crate'].includes(action)) value = parseFloat(value);
await fetch('/admin/inventory/bulk',{method:'POST',headers:hdr(),body:JSON.stringify({skus:[...invSel],action,value})});
invSel.clear(); loadInv();
}
function invModalBox(html){
let o = $('#invModal');
if(!o){ o = document.createElement('div'); o.id='invModal'; o.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:flex-start;justify-content:center;z-index:200;padding-top:6vh'; document.body.appendChild(o); o.addEventListener('mousedown',e=>{ if(e.target===o) o.remove(); }); }
o.innerHTML = '<div style="background:#141418;border:1px solid #26262c;border-radius:12px;padding:18px;width:400px;max-height:86vh;overflow:auto">'+html+'</div>';
}
function invModalClose(){ const o = $('#invModal'); if(o) o.remove(); }
function numOrNull(id){ const x = $('#'+id).value; return x===''?null:parseFloat(x); }
function fld(label,id,val,attr){ return `<div class="bar" style="margin-bottom:6px"><span class=muted style="width:90px">${label}</span><input id="${id}" ${attr||''} value="${val==null?'':String(val).replace(/"/g,'&quot;')}" style="flex:1"></div>`; }
function invEdit(sku){
const r = invItems[sku] || {};
invModalBox(`<h3 style="margin:0 0 12px">${escapeH(r.title||sku)}</h3>
${fld('Title','eTitle',r.title)}
${fld('Price','ePrice',r.price,'type=number step=0.01')}
${fld('Target','eTarget',r.target,'type=number step=0.01')}
${fld('Condition','eCond',r.condition)}
${fld('Sleeve','eSleeve',r.sleeve_cond)}
${fld('Crate id','eCrate',r.crate_id,'type=number')}
${fld('Slot','eSlot',r.slot_number,'type=number')}
${fld('Kind','eKind',r.kind)}
<div class="bar" style="margin-bottom:6px"><span class=muted style="width:90px">In stock</span><input id="eStock" type=checkbox ${r.in_stock?'checked':''}></div>
${fld('Notes','eNotes',r.notes)}
<div class="bar" style="margin-top:8px"><button onclick="invSave('${sku}')" style="flex:1">Save</button>
<button class="ghost" onclick="invDelete('${sku}')" style="color:#e66">Delete</button>
<button class="ghost" onclick="invModalClose()">Cancel</button></div>`);
}
async function invSave(sku){
const v = id => { const x = $('#'+id).value.trim(); return x===''?null:x; };
const body = { title:v('eTitle'), price:numOrNull('ePrice'), target_price:numOrNull('eTarget'),
condition:v('eCond'), sleeve_cond:v('eSleeve'), crate_id:numOrNull('eCrate'), slot_number:numOrNull('eSlot'),
kind:v('eKind'), notes:v('eNotes'), in_stock:$('#eStock').checked };
await fetch('/admin/inventory/'+encodeURIComponent(sku)+'/edit',{method:'POST',headers:hdr(),body:JSON.stringify(body)});
invModalClose(); loadInv();
}
async function invDelete(sku){
if(!confirm('Delete this item?')) return;
await fetch('/admin/inventory/'+encodeURIComponent(sku)+'/delete',{method:'POST',headers:hdr()});
invModalClose(); loadInv();
}
function invAdd(){
invModalBox(`<h3 style="margin:0 0 12px">Add item</h3>
${fld('Title','aTitle','')}
${fld('Release ID','aRid','','type=number')}
${fld('Barcode/UPC','aIdent','')}
${fld('Price','aPrice','','type=number step=0.01')}
${fld('Condition','aCond','')}
${fld('Kind','aKind','vinyl')}
${fld('Crate id','aCrate','','type=number')}
${fld('SKU (blank=auto)','aSku','')}
<div class="muted" style="margin:4px 0 8px">Tip: enter a Release ID and the title fills from the catalog.</div>
<div class="bar"><button onclick="invDoAdd()" style="flex:1">Add item</button><button class="ghost" onclick="invModalClose()">Cancel</button></div>`);
}
async function invDoAdd(){
const v = id => { const x = $('#'+id).value.trim(); return x===''?null:x; };
const body = { sku:v('aSku'), title:v('aTitle'), release_id:numOrNull('aRid'), identifier:v('aIdent'),
price:numOrNull('aPrice'), condition:v('aCond'), kind:v('aKind')||'vinyl', crate_id:numOrNull('aCrate') };
await fetch('/admin/inventory/add',{method:'POST',headers:hdr(),body:JSON.stringify(body)});
invModalClose(); loadInv();
}
// ---------- Orders ----------
async function vOrders(){