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:
parent
4ece321b76
commit
c5ce84de1c
@ -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,'"')}" 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(){
|
||||
|
||||
Loading…
Reference in New Issue
Block a user