(1) Inventory edit modal gains Cost + New/Used + a live Margin readout (price−cost, %); inv_edit accepts cost_price + condition_type; inventory list returns them. (2) Inertia/Warner SOH list = an xlsx that doubles as the order form (fill ORDER QTY, upload). New /admin/intake/distro-sheet[/preview]: openpyxl parses header-mapped columns (ORDER QTY/UPC/ARTIST/TITLE/ FORMAT/PPD), takes rows with QTY>0 → shared _ingest_distro core (refactored out of the RareWaves path) → staged NEW stock with cost_price=PPD, kind from FORMAT (cd/vinyl), catno kept. Upload+preview UI in New stock. DistroItem gains kind+catno; RareWaves path now passes kind too. Verified on the real 3,368-row Warner sheet: 3 ordered titles → 4 copies, all resolved, kind=cd, cost from PPD. Added openpyxl to requirements. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
987 lines
76 KiB
HTML
987 lines
76 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>RecordGod — admin</title>
|
||
<script defer src="/nav.js?v=5"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
||
<style>
|
||
:root{--pink:#d10f7a;--ink:#f4f5f7;--panel:#ffffff;--line:#e2e2ea;--mut:#5f5f6c;--ok:#1a8f54;--warn:#b06a00}
|
||
*{box-sizing:border-box}
|
||
html,body{margin:0;height:100%;background:var(--ink);color:#1b1b22;font:14px/1.5 system-ui,sans-serif}
|
||
a{color:var(--pink);text-decoration:none}
|
||
/* gate */
|
||
#gate{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--ink);z-index:10}
|
||
#gate .box{width:340px;text-align:center}
|
||
#gate h1{color:var(--pink);font-weight:600}
|
||
input,select{padding:9px 10px;border:1px solid var(--line);border-radius:8px;background:#ffffff;color:#1b1b22;font:14px system-ui}
|
||
button{padding:9px 13px;border:0;border-radius:8px;background:var(--pink);color:#1a0a14;font:500 14px system-ui;cursor:pointer}
|
||
button.ghost{background:#f3f3f6;color:#2a2a30;border:1px solid var(--line)}
|
||
/* layout */
|
||
#app{display:none;grid-template-columns:212px 1fr;height:calc(100vh - 44px)}
|
||
nav{background:#ffffff;border-right:1px solid var(--line);padding:14px 10px;overflow:auto}
|
||
nav .brand{font-weight:600;font-size:18px;padding:6px 10px 14px}
|
||
nav .brand b{color:var(--pink)}
|
||
nav a{display:block;color:#2a2a30;padding:9px 12px;border-radius:8px;margin:2px 0;cursor:pointer}
|
||
nav a:hover{background:#f3f3f6}
|
||
nav a.on{background:#fdeef6;color:var(--pink)}
|
||
nav .sec{color:#55555f;font-size:11px;text-transform:uppercase;letter-spacing:.1em;padding:14px 12px 4px}
|
||
main{overflow:auto;padding:22px 26px}
|
||
h2{font-weight:600;font-size:20px;margin:0 0 16px}
|
||
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin-bottom:24px}
|
||
.kpi{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px}
|
||
.kpi .n{font-size:28px;font-weight:600}
|
||
.kpi .l{color:var(--mut);font-size:13px;margin-top:2px}
|
||
.kpi .n.pink{color:var(--pink)}
|
||
.bar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
|
||
.bar input{flex:1;min-width:200px}
|
||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||
th{text-align:left;color:var(--mut);font-weight:500;padding:8px 10px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--ink)}
|
||
td{padding:8px 10px;border-bottom:1px solid #1b1b20}
|
||
tr:hover td{background:#ffffff}
|
||
.thumb{width:34px;height:34px;border-radius:4px;object-fit:cover;background:#ececf0;vertical-align:middle}
|
||
.pill{padding:2px 8px;border-radius:20px;font-size:11px;background:#f3f3f6;color:#bbb}
|
||
.pill.in{background:#15291f;color:var(--ok)} .pill.out{background:#2a1a1a;color:#e66}
|
||
.price{font-weight:500}
|
||
.muted{color:var(--mut)} .soon{color:#55555f}
|
||
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px;margin-bottom:16px}
|
||
.card h3{margin:0 0 12px;font-weight:500;font-size:15px}
|
||
label{display:block;color:var(--mut);font-size:13px;margin:10px 0 4px}
|
||
.field{display:flex;gap:8px;align-items:center} .field input{flex:1}
|
||
.conn{display:flex;gap:10px;align-items:center;margin:9px 0}
|
||
.pg{display:flex;gap:8px;align-items:center;margin-top:14px}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="gate"><div class="box">
|
||
<h1>Record<b>God</b></h1>
|
||
<p class="muted">admin sign-in</p>
|
||
<div class="field"><input id="tok" type="password" placeholder="admin token" style="flex:1"><button onclick="signin()">Enter</button></div>
|
||
<div id="gerr" class="soon" style="margin-top:10px"></div>
|
||
</div></div>
|
||
|
||
<div id="app">
|
||
<nav>
|
||
<div class="brand">Record<b>God</b></div>
|
||
<a data-v="dashboard" class="on">📊 Dashboard</a>
|
||
<a data-v="inventory">📦 Inventory</a>
|
||
<a data-v="orders">🛒 Orders</a>
|
||
<a data-v="customers">👤 Customers</a>
|
||
<a data-v="wantlist">📝 Wantlist</a>
|
||
<div class="sec">Catalog</div>
|
||
<a data-v="intake">📥 Intake</a>
|
||
<a data-v="newstock">📦 New stock</a>
|
||
<a data-v="heal">🩹 Heal</a>
|
||
<a data-v="storemap">🗺️ Store map</a>
|
||
<a data-v="crates">🗄️ Crates</a>
|
||
<a data-v="reports">📈 Reports</a>
|
||
<div class="sec" data-admin>System</div>
|
||
<a data-v="staff" data-admin>🧑💼 Staff</a>
|
||
<a data-v="connections" data-admin>🔌 Connections</a>
|
||
<a data-v="system" data-admin>🖥️ System</a>
|
||
<a href="/store/">🎧 Virtual store ↗</a>
|
||
<a onclick="changeMyPassword()" style="cursor:pointer">🔑 Change password</a>
|
||
<a onclick="signOut()" style="cursor:pointer">🚪 Sign out</a>
|
||
<a href="/">← Home</a>
|
||
</nav>
|
||
<main id="content"></main>
|
||
</div>
|
||
|
||
<script>
|
||
const $ = s => document.querySelector(s);
|
||
let TOKEN = localStorage.getItem('rg_token') || '';
|
||
const hdr = () => ({ 'Authorization': 'Bearer ' + TOKEN, 'Content-Type': 'application/json' });
|
||
const money = n => n==null ? '—' : '$' + Number(n).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
|
||
|
||
async function api(path){ const r = await fetch('/admin'+path, { headers: hdr() }); if(!r.ok) throw new Error(r.status); return r.json(); }
|
||
|
||
let ROLE = 'staff';
|
||
async function signin(){
|
||
TOKEN = ($('#tok') && $('#tok').value.trim()) || TOKEN; // keep the stored token if the box is empty
|
||
let me; try { me = await api('/me'); } catch(e){ localStorage.removeItem('rg_token'); location.replace('/login'); return; }
|
||
ROLE = me.role || 'staff';
|
||
localStorage.setItem('rg_token', TOKEN);
|
||
window.ME = me;
|
||
// staff don't see admin-only nav (API keys / staff admin); the backend enforces it too
|
||
document.querySelectorAll('[data-admin]').forEach(el => el.style.display = ROLE==='admin' ? '' : 'none');
|
||
$('#gate').style.display='none'; $('#app').style.display='grid';
|
||
go('dashboard');
|
||
}
|
||
|
||
document.querySelectorAll('nav a[data-v]').forEach(a => a.onclick = () => go(a.dataset.v));
|
||
function go(v){
|
||
if(ROLE!=='admin' && (v==='connections'||v==='staff'||v==='system')) v='dashboard'; // staff can't reach admin views
|
||
document.querySelectorAll('nav a[data-v]').forEach(a => a.classList.toggle('on', a.dataset.v===v));
|
||
({dashboard:vDash, inventory:vInv, orders:vOrders, customers:vCustomers, wantlist:vWantlist, intake:vIntake, newstock:vNewstock, heal:vHeal, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn, staff:vStaff, system:vSystem}[v])();
|
||
}
|
||
|
||
// ---------- Dashboard (fast tiles first, charts lazy) ----------
|
||
async function vDash(){
|
||
$('#content').innerHTML = '<h2>Dashboard</h2>'
|
||
+ '<div class="kpis" id="dashKpis"><div class="muted">loading…</div></div>'
|
||
+ '<div style="display:grid;grid-template-columns:2fr 1fr;gap:14px;margin-top:4px">'
|
||
+ '<div class="card"><h3>Revenue — last 30 days</h3><div style="height:150px"><canvas id="dashRev"></canvas></div></div>'
|
||
+ '<div class="card"><h3>Stock by format</h3><div style="height:150px"><canvas id="dashFmt"></canvas></div></div></div>'
|
||
+ '<div class="card" style="margin-top:14px"><h3>Quick actions</h3><div class="bar">'
|
||
+ '<button class="ghost" onclick="go(\'inventory\')">Browse inventory</button>'
|
||
+ '<button class="ghost" onclick="go(\'reports\')">Full reports</button>'
|
||
+ '<button class="ghost" onclick="window.open(\'/store/\')">Open the 3D store</button>'
|
||
+ '</div></div>';
|
||
const s = await api('/stats');
|
||
$('#dashKpis').innerHTML = kpi(s.in_stock.toLocaleString(),'in stock',true) + kpi(money(s.stock_value),'stock value',true)
|
||
+ kpi(s.sales.toLocaleString(),'sales') + kpi(money(s.sales_total),'sales total')
|
||
+ kpi(s.items.toLocaleString(),'catalog') + kpi(s.vinyl.toLocaleString(),'vinyl') + kpi(s.crates.toLocaleString(),'crates')
|
||
+ (s.staged ? kpi(s.staged.toLocaleString(),'staged') : '');
|
||
api('/report/series?days=30&bucket=day').then(d => lineChart('dashRev', d.series.map(x=>x.bucket), d.series.map(x=>x.revenue), 'Revenue', true));
|
||
api('/report/stock?dim=format').then(d => donutChart('dashFmt', d.rows.map(x=>x.name), d.rows.map(x=>x.count)));
|
||
}
|
||
const kpi = (n,l,pink) => `<div class="kpi"><div class="n${pink?' pink':''}">${n}</div><div class="l">${l}</div></div>`;
|
||
|
||
// ---------- Chart.js helpers ----------
|
||
const CHARTS = {};
|
||
const PINK = '#d10f7a';
|
||
const PALETTE = ['#d10f7a','#46d18a','#1e90ff','#ffb020','#9b59b6','#ff6b6b','#19c3c3','#e67e22','#2ecc71','#8e44ad','#34495e','#888'];
|
||
function mkChart(id, cfg){ if(CHARTS[id]) CHARTS[id].destroy(); const c = $('#'+id); if(!c || !window.Chart) return; CHARTS[id] = new Chart(c, cfg); }
|
||
function chopts(isMoney){ return {maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{
|
||
x:{ticks:{font:{size:10},maxRotation:45,autoSkip:true}},
|
||
y:{beginAtZero:true,ticks:{font:{size:10},callback:v=>isMoney?'$'+Number(v).toLocaleString():v}}}}; }
|
||
function lineChart(id,labels,data,label,isMoney){ mkChart(id,{type:'line',data:{labels,datasets:[{label,data,borderColor:PINK,backgroundColor:'rgba(209,15,122,.12)',fill:true,tension:.3,pointRadius:2}]},options:chopts(isMoney)}); }
|
||
function barChart(id,labels,data,label,isMoney){ mkChart(id,{type:'bar',data:{labels,datasets:[{label,data,backgroundColor:PINK,borderRadius:4}]},options:chopts(isMoney)}); }
|
||
function donutChart(id,labels,data){ mkChart(id,{type:'doughnut',data:{labels,datasets:[{data,backgroundColor:PALETTE,borderWidth:0}]},options:{maintainAspectRatio:false,plugins:{legend:{position:'right',labels:{boxWidth:12,font:{size:11}}}}}}); }
|
||
|
||
// ---------- 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>'
|
||
+ '<button class="ghost" onclick="invAdd()">+ Add item</button>'
|
||
+ '<button class="ghost" onclick="invImport()">⇪ Import list</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;
|
||
invSel.clear(); 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><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>
|
||
<td>${r.target?'<span class=muted>'+money(r.target)+'</span>':'—'}</td>
|
||
<td>${r.crate?escapeH(r.crate):(r.crate_id||'—')}</td>
|
||
<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 · 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 oninput=invMargin()')}
|
||
${fld('Cost','eCost',r.cost_price,'type=number step=0.01 oninput=invMargin()')}
|
||
<div class="bar" style="margin:-2px 0 8px"><span class=muted style="width:90px">Margin</span><span id="eMargin" class="muted">—</span></div>
|
||
<div class="bar" style="margin-bottom:6px"><span class=muted style="width:90px">New / used</span><select id="eCondType"><option value="used"${r.condition_type!=='new'?' selected':''}>Used</option><option value="new"${r.condition_type==='new'?' selected':''}>New / sealed</option></select></div>
|
||
${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="invLabel('${sku}')">🏷 Label</button>
|
||
<button class="ghost" onclick="invDelete('${sku}')" style="color:#e66">Delete</button>
|
||
<button class="ghost" onclick="invModalClose()">Cancel</button></div>`);
|
||
invMargin();
|
||
}
|
||
function invMargin(){
|
||
const p=parseFloat($('#ePrice').value), c=parseFloat($('#eCost').value), m=$('#eMargin');
|
||
if(!m) return;
|
||
if(isNaN(p)||isNaN(c)){ m.textContent='—'; return; }
|
||
const d=p-c, pct=p>0?Math.round(d/p*100):0;
|
||
m.innerHTML=`<b style="color:${d>=0?'var(--ok)':'var(--warn)'}">${money(d)}</b> (${pct}%)`;
|
||
}
|
||
async function invSave(sku){
|
||
const v = id => { const x = $('#'+id).value.trim(); return x===''?null:x; };
|
||
const body = { title:v('eTitle'), price:numOrNull('ePrice'), cost_price:numOrNull('eCost'),
|
||
target_price:numOrNull('eTarget'), condition_type:$('#eCondType').value,
|
||
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();
|
||
}
|
||
|
||
// ---------- Label print + RFID (drives the local rfid-daemon :7790, Chafon H102) ----------
|
||
const RFID_DAEMON = 'http://127.0.0.1:7790'; // localhost is a secure context — HTTPS page may call it
|
||
function invLabel(sku){
|
||
const r = invItems[sku] || { sku };
|
||
const qr = 'https://api.qrserver.com/v1/create-qr-code/?size=120x120&margin=0&data=' + encodeURIComponent(r.sku);
|
||
invModalBox(`<h3 style="margin:0 0 10px">🏷 Label — ${escapeH(r.title||sku)}</h3>
|
||
<div style="display:flex;gap:10px;background:#fff;color:#111;border-radius:8px;padding:8px;align-items:center">
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-weight:700;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeH(r.artist||'')}</div>
|
||
<div style="font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeH(r.title||'')}</div>
|
||
<div style="font-size:22px;font-weight:800;margin-top:2px">${money(r.price)}</div>
|
||
<div style="font-size:9px;color:#555">${escapeH(r.sku)}${r.condition?' · '+escapeH(r.condition):''}</div>
|
||
</div>
|
||
<img src="${qr}" style="width:64px;height:64px">
|
||
</div>
|
||
<label class="bar" style="margin:10px 0 4px;cursor:pointer"><input type="checkbox" id="lblRfid"> <span>⚡ Program RFID chip at the same time (UHF H102)</span></label>
|
||
<div id="lblDaemon" class="muted" style="font-size:11px">checking tag reader…</div>
|
||
<div class="bar" style="margin-top:10px"><button onclick='invDoPrint(${JSON.stringify(r).replace(/'/g,"'")})' style="flex:1">🖨 Print label</button>
|
||
<button class="ghost" onclick="invEdit('${sku}')">‹ back</button></div>
|
||
<div id="lblMsg" class="muted" style="margin-top:8px"></div>`);
|
||
daemonPing();
|
||
}
|
||
async function daemonPing(){
|
||
const el = $('#lblDaemon'); if(!el) return;
|
||
try { const r = await fetch(RFID_DAEMON+'/status', {signal:AbortSignal.timeout(2500)}).then(x=>x.json());
|
||
el.innerHTML = '<span style="color:#6ee7b7">● tag reader connected</span>'; }
|
||
catch(e){ el.innerHTML = '<span style="color:#e6a046">● rfid-daemon not running on :7790 — RFID disabled (label still prints)</span>'; }
|
||
}
|
||
function invDoPrint(r){
|
||
const qr = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&margin=0&data=' + encodeURIComponent(r.sku);
|
||
const html = `<html><head><meta charset=utf-8><style>
|
||
@page{size:54mm 25mm;margin:0}
|
||
*{box-sizing:border-box}body{margin:0;font-family:system-ui,sans-serif}
|
||
.lbl{width:54mm;height:25mm;padding:2mm;display:flex;gap:2mm;align-items:center}
|
||
.info{flex:1;min-width:0}
|
||
.a{font-weight:700;font-size:10pt;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.t{font-size:7.5pt;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.p{font-size:17pt;font-weight:800;line-height:1.1;margin-top:0.5mm}
|
||
.s{font-size:5.5pt;color:#444}.qr{width:18mm;height:18mm}
|
||
</style></head><body onload="window.print();setTimeout(()=>window.close(),300)">
|
||
<div class="lbl"><div class="info"><div class="a">${escapeH(r.artist||'')}</div><div class="t">${escapeH(r.title||'')}</div>
|
||
<div class="p">${money(r.price)}</div><div class="s">${escapeH(r.sku)}</div></div>
|
||
<img class="qr" src="${qr}"></div></body></html>`;
|
||
const w = window.open('', '_blank', 'width=420,height=240');
|
||
if(w){ w.document.open(); w.document.write(html); w.document.close(); }
|
||
if($('#lblRfid') && $('#lblRfid').checked) rfidWrite(r.sku, r.release_id);
|
||
}
|
||
async function rfidWrite(sku, releaseId){
|
||
const msg = $('#lblMsg'); if(msg) msg.innerHTML = '⚡ writing tag (present the chip to the reader)…';
|
||
try {
|
||
await fetch(RFID_DAEMON+'/clear-mask', {method:'POST', signal:AbortSignal.timeout(3000)}).catch(()=>{});
|
||
const body = { sku }; if(releaseId) body.releaseId = String(releaseId);
|
||
const d = await fetch(RFID_DAEMON+'/write-tag', {method:'POST', headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify(body), signal:AbortSignal.timeout(120000)}).then(x=>x.json());
|
||
if(msg) msg.innerHTML = d.ok ? `<span style="color:#6ee7b7">✓ RFID tag programmed (${escapeH(sku)})</span>`
|
||
: `<span style="color:#e66">RFID write failed: ${escapeH(d.error||'?')}</span>`;
|
||
} catch(e){ if(msg) msg.innerHTML = '<span style="color:#e6a046">RFID daemon unreachable (:7790)</span>'; }
|
||
}
|
||
|
||
// ---------- Customers (CRM) ----------
|
||
let custState = { q:'' };
|
||
async function vCustomers(){
|
||
const m = $('#content');
|
||
m.innerHTML = '<h2>Customers</h2>'
|
||
+ '<div class="bar"><input id="cq" placeholder="search name / email…" value="'+custState.q+'"><button onclick="custSearch()">Search</button></div>'
|
||
+ '<div id="custrows" class="muted">loading…</div>';
|
||
$('#cq').addEventListener('keydown', e=>{ if(e.key==='Enter') custSearch(); });
|
||
loadCust();
|
||
}
|
||
function custSearch(){ custState.q = $('#cq').value.trim(); loadCust(); }
|
||
async function loadCust(){
|
||
const d = await api('/customers?q='+encodeURIComponent(custState.q));
|
||
const list = (d.customers||[]).filter(c=>c.id!==0);
|
||
const rows = list.map(c=>`<tr onclick="custOpen(${c.id})" style="cursor:pointer">
|
||
<td>${escapeH(c.name||'—')}${c.mailing_optin?' <span class="pill in">✉</span>':''}</td>
|
||
<td class="muted">${escapeH(c.email||'')}</td><td>${escapeH(c.phone||'')}</td>
|
||
<td>${c.orders}</td><td class="price">${money(c.spent)}</td>
|
||
<td class="muted">${c.last_order?String(c.last_order).slice(0,10):'—'}</td></tr>`).join('');
|
||
$('#custrows').innerHTML = `<div class="muted" style="margin-bottom:8px">${list.length} customers · click to open · sorted by spend</div>`
|
||
+ `<table><thead><tr><th>Name</th><th>Email</th><th>Phone</th><th>Orders</th><th>Spent</th><th>Last</th></tr></thead><tbody>${rows||'<tr><td colspan=6 class=muted>none</td></tr>'}</tbody></table>`;
|
||
}
|
||
async function custOpen(id){
|
||
const d = await api('/customers/'+id); const c = d.customer, st = d.stats;
|
||
const card = (label,val) => `<div style="background:rgba(255,255,255,0.04);border:1px solid #26262c;border-radius:10px;padding:10px 14px;min-width:110px"><div class="muted">${label}</div><div style="font-size:20px;font-weight:700">${val}</div></div>`;
|
||
const sales = (d.sales||[]).map(s=>`<tr><td class="muted">${s.sale_date?String(s.sale_date).slice(0,10):'—'}</td>
|
||
<td>${escapeH(s.sale_number||('#'+s.id))}</td><td>${s.items}</td><td class="price">${money(s.total)}</td>
|
||
<td class="muted">${escapeH(s.payment_method||'')} ${escapeH(s.status||'')}</td></tr>`).join('');
|
||
$('#content').innerHTML = `<div class="bar"><button class="ghost" onclick="vCustomers()">‹ Customers</button><h2 style="margin:0">${escapeH(((c.first_name||'')+' '+(c.last_name||'')).trim()||('Customer '+id))}</h2></div>
|
||
<div class="bar" style="gap:12px;margin:12px 0">${card('Orders',st.orders)}${card('Total spent','<span class=price>'+money(st.spent)+'</span>')}${card('Last order',st.last_order?String(st.last_order).slice(0,10):'—')}</div>
|
||
<h3>Info</h3>
|
||
${fld('First name','cFirst',c.first_name)}${fld('Last name','cLast',c.last_name)}
|
||
${fld('Email','cEmail',c.email)}${fld('Phone','cPhone',c.phone)}${fld('Address','cAddr',c.address)}${fld('Notes','cNotes',c.notes)}
|
||
<label class="bar" style="margin:4px 0 8px;cursor:pointer"><input type="checkbox" id="cOptin" ${c.mailing_optin?'checked':''}> <span>Mailing opt-in</span></label>
|
||
<div class="bar"><button onclick="custSave(${id})">Save info</button><span id="cMsg" class="muted"></span></div>
|
||
<h3 style="margin-top:18px">Sales history</h3>
|
||
<table><thead><tr><th>Date</th><th>Sale</th><th>Items</th><th>Total</th><th>Payment</th></tr></thead><tbody>${sales||'<tr><td colspan=5 class=muted>no sales yet</td></tr>'}</tbody></table>`;
|
||
}
|
||
async function custSave(id){
|
||
const v = i => { const x=$('#'+i).value.trim(); return x===''?null:x; };
|
||
const body = { first_name:v('cFirst'), last_name:v('cLast'), email:v('cEmail'), phone:v('cPhone'), address:v('cAddr'), notes:v('cNotes'), mailing_optin:$('#cOptin').checked };
|
||
await fetch('/admin/customers/'+id,{method:'POST',headers:hdr(),body:JSON.stringify(body)});
|
||
const e=$('#cMsg'); if(e){ e.textContent='✓ saved'; setTimeout(()=>{e.textContent='';},1500); }
|
||
}
|
||
// ---------- Wantlist (customer record-requests from the storefront) ----------
|
||
let wantState = { status:'pending' };
|
||
async function vWantlist(){
|
||
$('#content').innerHTML = '<h2>Wantlist</h2><div class="muted">Records customers asked us to find via the storefront request form.</div>'
|
||
+ '<div class="bar" id="wantTabs" style="margin:12px 0"></div><div id="wantrows" class="muted">loading…</div>';
|
||
loadWant();
|
||
}
|
||
async function loadWant(){
|
||
const d = await api('/wantlist?status='+wantState.status);
|
||
const c = d.counts||{};
|
||
const tab = (k,lbl)=>`<button class="${wantState.status===k?'':'ghost'}" onclick="wantState.status='${k}';loadWant()">${lbl}${c[k]?` (${c[k]})`:''}</button>`;
|
||
$('#wantTabs').innerHTML = tab('pending','Pending')+tab('found','Found')+tab('fulfilled','Fulfilled')+tab('cancelled','Cancelled')+tab('all','All');
|
||
const next = { pending:['found','Mark found'], found:['fulfilled','Mark fulfilled'] };
|
||
const rows = (d.requests||[]).map(w=>{
|
||
const what = (escapeH(w.artist||'')+(w.title?' – '+escapeH(w.title):'')) || '<span class=muted>—</span>';
|
||
const meta = [w.format, w.max_price!=null?('≤ '+money(w.max_price)):'', w.delivery_preference, w.postcode].filter(Boolean).map(escapeH).join(' · ');
|
||
const act = next[w.status] ? `<button onclick="wantSet(${w.id},'${next[w.status][0]}')">${next[w.status][1]}</button> ` : '';
|
||
const link = w.release_id ? ` <a href="/release/${w.release_id}" target="_blank" class="muted">↗</a>` : '';
|
||
return `<tr><td class="muted">${w.created_at?String(w.created_at).slice(0,10):''}</td>
|
||
<td><b>${what}</b>${link}${meta?`<div class="muted">${meta}</div>`:''}${w.notes?`<div class="muted">“${escapeH(w.notes)}”</div>`:''}</td>
|
||
<td>${escapeH(w.name||'')}<div class="muted">${escapeH(w.email||'')}${w.phone?' · '+escapeH(w.phone):''}</div></td>
|
||
<td><span class="pill ${w.status==='pending'?'':w.status==='cancelled'?'out':'in'}">${w.status}</span></td>
|
||
<td style="white-space:nowrap">${act}${w.status!=='cancelled'&&w.status!=='fulfilled'?`<button class="ghost" onclick="wantSet(${w.id},'cancelled')">✕</button>`:''}</td></tr>`;
|
||
}).join('');
|
||
$('#wantrows').innerHTML = `<table><thead><tr><th>Date</th><th>Wanted</th><th>Customer</th><th>Status</th><th></th></tr></thead>`
|
||
+ `<tbody>${rows||'<tr><td colspan=5 class=muted>no requests</td></tr>'}</tbody></table>`;
|
||
}
|
||
async function wantSet(id, status){
|
||
await fetch('/admin/wantlist/'+id,{method:'POST',headers:hdr(),body:JSON.stringify({status})});
|
||
loadWant();
|
||
}
|
||
|
||
function invImport(){
|
||
invModalBox(`<h3 style="margin:0 0 6px">Import list (bulk stock-in)</h3>
|
||
<div class="muted" style="margin-bottom:8px">One per line — Release ID / barcode / catalogue no. Resolved against the catalog, each becomes an in-stock item.</div>
|
||
<textarea id="impLines" rows="8" style="width:100%;margin-bottom:6px" placeholder="12345 0123456789012 ABC-001"></textarea>
|
||
${fld('Default price','impPrice','','type=number step=0.01')}
|
||
${fld('Condition','impCond','')}
|
||
${fld('Crate id','impCrate','','type=number')}
|
||
${fld('Kind','impKind','vinyl')}
|
||
<div class="bar" style="margin-top:8px"><button onclick="invDoImport()" style="flex:1">Resolve & import</button><button class="ghost" onclick="invModalClose()">Cancel</button></div>
|
||
<div id="impOut" class="muted" style="margin-top:8px"></div>`);
|
||
}
|
||
async function invDoImport(){
|
||
const lines = $('#impLines').value.split('\n').map(s=>s.trim()).filter(Boolean);
|
||
if(!lines.length) return;
|
||
$('#impOut').textContent = 'resolving '+lines.length+'…';
|
||
const body = { lines, price:numOrNull('impPrice'), condition:($('#impCond').value.trim()||null),
|
||
crate_id:numOrNull('impCrate'), kind:($('#impKind').value.trim()||'vinyl') };
|
||
const d = await fetch('/admin/inventory/import',{method:'POST',headers:hdr(),body:JSON.stringify(body)}).then(x=>x.json());
|
||
$('#impOut').innerHTML = `<span style="color:#6ee7b7">✓ ${d.added} imported</span>`
|
||
+ (d.not_found.length?`<div style="color:#e6a046">not found (${d.not_found.length}): ${d.not_found.map(escapeH).join(', ')}</div>`:'');
|
||
loadInv();
|
||
}
|
||
|
||
// ---------- Orders ----------
|
||
async function vOrders(){
|
||
const m = $('#content'); m.innerHTML = '<h2>Orders <span class="muted" style="font-size:13px">· live from WooCommerce</span></h2><div class="muted">loading…</div>';
|
||
const d = await api('/orders');
|
||
if(!d.ok){ m.innerHTML += `<div class="card warn">${d.reason} — <a onclick="go('connections')">go to Connections</a></div>`; return; }
|
||
const rows = d.orders.map(o => `<tr>
|
||
<td>#${o.number}</td><td>${o.date}</td><td>${escapeH(o.customer||'—')}</td>
|
||
<td>${o.items} item${o.items==1?'':'s'}</td><td class="price">${o.currency||''} ${o.total}</td>
|
||
<td><span class="pill">${o.status}</span></td></tr>`).join('');
|
||
m.innerHTML = '<h2>Orders <span class="muted" style="font-size:13px">· live from WooCommerce</span></h2>'
|
||
+ `<table><thead><tr><th>Order</th><th>Date</th><th>Customer</th><th>Items</th><th>Total</th><th>Status</th></tr></thead><tbody>${rows||'<tr><td colspan=6 class=muted>no orders</td></tr>'}</tbody></table>`;
|
||
}
|
||
|
||
// ---------- Staff (admin only) ----------
|
||
async function vStaff(){
|
||
$('#content').innerHTML = '<h2>Staff</h2>'
|
||
+ '<div class="muted">Staff sign in at <b>recordgod.com/login</b> with their email + password. They can run the store (search, scan, inventory, prices, labels) but can\'t see API keys / connections.</div>'
|
||
+ '<div class="card" style="margin-top:12px"><h3>Add staff</h3>'
|
||
+ '<div class="bar" style="flex-wrap:wrap;gap:8px"><input id="stName" placeholder="name"><input id="stEmail" type="email" placeholder="email (their login)"><input id="stPhone" placeholder="phone"><input id="stPay" type="number" step="0.01" placeholder="$/hr" style="width:90px"><input id="stPw" type="text" placeholder="set password" style="width:130px"><select id="stRole"><option value="staff">Staff</option><option value="admin">Admin</option></select><button onclick="staffAdd()">Add</button></div>'
|
||
+ '<div id="stNew" style="margin-top:10px"></div></div>'
|
||
+ '<div id="staffRows" class="muted" style="margin-top:12px">loading…</div>'
|
||
+ '<div id="timecards" class="muted" style="margin-top:18px">loading hours…</div>';
|
||
loadStaff(); loadTimecards();
|
||
}
|
||
const fmtDT = s => s ? new Date(s).toLocaleString(undefined,{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : '';
|
||
async function loadTimecards(){
|
||
const d = await api('/timecards?days=14');
|
||
const tot = (d.totals||[]).map(t=>`<tr><td>${escapeH(t.name)}</td><td>${t.shifts}</td><td class="price"><b>${t.hours}</b> h</td><td class="price">${t.pay_rate!=null?money(t.pay):'<span class=muted>—</span>'}</td></tr>`).join('');
|
||
const sh = (d.shifts||[]).map(s=>`<tr><td>${escapeH(s.name)}</td><td class="muted">${fmtDT(s.clock_in)}</td><td>${s.clock_out?'<span class="muted">'+fmtDT(s.clock_out)+'</span>':'<span style="color:var(--ok)">● on shift</span>'}</td><td>${s.hours} h</td></tr>`).join('');
|
||
$('#timecards').innerHTML = '<h3>Timecards — last 14 days</h3>'
|
||
+ `<table><thead><tr><th>Staff</th><th>Shifts</th><th>Hours</th><th>Pay</th></tr></thead><tbody>${tot||'<tr><td colspan=4 class=muted>no shifts yet</td></tr>'}</tbody></table>`
|
||
+ '<h3 style="margin-top:14px">Recent shifts</h3>'
|
||
+ `<table><thead><tr><th>Staff</th><th>Clock in</th><th>Clock out</th><th>Hours</th></tr></thead><tbody>${sh||'<tr><td colspan=4 class=muted>none</td></tr>'}</tbody></table>`;
|
||
}
|
||
async function loadStaff(){
|
||
const d = await api('/staff');
|
||
const rows = (d.staff||[]).map(s=>`<tr>
|
||
<td>${escapeH(s.name)}${s.active?'':' <span class="pill out">inactive</span>'}</td>
|
||
<td class="muted">${escapeH(s.email||'—')}${s.email?(s.has_password?' <span style="color:var(--ok)">●</span>':' <span style="color:var(--warn)" title="no password set">○</span>'):''}</td>
|
||
<td>${s.pay_rate!=null?money(s.pay_rate)+'/hr':'<span class=muted>—</span>'}</td>
|
||
<td><select onchange="staffRole(${s.id},this.value)"><option value="staff"${s.role==='staff'?' selected':''}>Staff</option><option value="admin"${s.role==='admin'?' selected':''}>Admin</option></select></td>
|
||
<td class="muted">${s.last_seen?String(s.last_seen).slice(0,10):'never'}</td>
|
||
<td style="white-space:nowrap"><button class="ghost" onclick="staffEditDetails(${s.id},${JSON.stringify({email:s.email||'',phone:s.phone||'',pay_rate:s.pay_rate}).replace(/"/g,'"')})">✏️ details</button>
|
||
<button class="ghost" onclick="staffSetPw(${s.id},'${escapeH(s.name)}')">🔒 set password</button>
|
||
<button class="ghost" onclick="staffReset(${s.id})">🔑 token</button>
|
||
<button class="ghost" onclick="staffActive(${s.id},${s.active?'false':'true'})">${s.active?'Deactivate':'Reactivate'}</button></td></tr>`).join('');
|
||
$('#staffRows').innerHTML = `<table><thead><tr><th>Name</th><th>Email</th><th>Pay</th><th>Role</th><th>Last seen</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan=6 class=muted>no staff yet — add one above</td></tr>'}</tbody></table>`;
|
||
}
|
||
async function staffEditDetails(id,cur){
|
||
const email=prompt('Email (their login):',cur.email||''); if(email===null) return;
|
||
const phone=prompt('Phone:',cur.phone||'')||null;
|
||
const pay=prompt('Pay rate ($/hr):',cur.pay_rate!=null?cur.pay_rate:'');
|
||
const body={email:email.trim()||null,phone:phone,pay_rate:pay===null||pay===''?null:parseFloat(pay)};
|
||
const r=await fetch('/admin/staff/'+id,{method:'POST',headers:hdr(),body:JSON.stringify(body)});
|
||
if(r.ok) loadStaff(); else alert('Failed: '+((await r.json().catch(()=>({}))).detail||r.status));
|
||
}
|
||
async function staffSetPw(id,name){
|
||
const pw = prompt('Set a password for '+name+' (min 6 characters):'); if(pw===null) return;
|
||
if(pw.length<6){ alert('Too short — min 6 characters.'); return; }
|
||
const r = await fetch('/admin/staff/'+id+'/password',{method:'POST',headers:hdr(),body:JSON.stringify({password:pw})});
|
||
if(r.ok){ alert(name+' can now sign in with their email + this password.'); loadStaff(); }
|
||
else alert('Failed: '+((await r.json().catch(()=>({}))).detail||r.status));
|
||
}
|
||
function staffToken(name,token){ return `<div style="background:#fdeef6;border:1px solid var(--pink);border-radius:8px;padding:11px"><b>${escapeH(name)}</b> — sign-in token (shown once, copy it now):<div style="margin:6px 0"><code style="font-size:14px;user-select:all;word-break:break-all">${escapeH(token)}</code></div><div class="muted">They paste this at <b>recordgod.com/search</b> to sign in.</div></div>`; }
|
||
async function staffAdd(){
|
||
const name=$('#stName').value.trim(); if(!name){ return; }
|
||
const body={name, role:$('#stRole').value, email:$('#stEmail').value.trim()||null,
|
||
phone:$('#stPhone').value.trim()||null, pay_rate:parseFloat($('#stPay').value)||null, password:$('#stPw').value||null};
|
||
const d=await fetch('/admin/staff',{method:'POST',headers:hdr(),body:JSON.stringify(body)}).then(r=>r.json());
|
||
['stName','stEmail','stPhone','stPay','stPw'].forEach(i=>$('#'+i).value='');
|
||
$('#stNew').innerHTML = body.email && body.password
|
||
? `<div style="background:#e9f9ee;border:1px solid var(--ok);border-radius:8px;padding:11px"><b>${escapeH(name)}</b> is ready — they sign in at <b>recordgod.com/login</b> with <b>${escapeH(body.email)}</b> + the password you set.</div>`
|
||
: staffToken(name,d.token);
|
||
loadStaff();
|
||
}
|
||
async function staffRole(id,role){ await fetch('/admin/staff/'+id,{method:'POST',headers:hdr(),body:JSON.stringify({role})}); }
|
||
async function staffActive(id,active){ await fetch('/admin/staff/'+id,{method:'POST',headers:hdr(),body:JSON.stringify({active})}); loadStaff(); }
|
||
async function staffReset(id){ if(!confirm('Reset this token? Their current one stops working immediately.')) return;
|
||
const d=await fetch('/admin/staff/'+id+'/token',{method:'POST',headers:hdr()}).then(r=>r.json()); $('#stNew').innerHTML=staffToken('Reset',d.token); }
|
||
|
||
// ---------- System (read-only Postgres health) ----------
|
||
function fmtBytes(b){ b=+b||0; const u=['B','KB','MB','GB','TB']; let i=0; while(b>=1024&&i<u.length-1){b/=1024;i++;} return b.toFixed(b<10&&i>0?1:0)+' '+u[i]; }
|
||
function fmtUptime(s){ s=+s||0; const d=Math.floor(s/86400), h=Math.floor(s%86400/3600), m=Math.floor(s%3600/60); return d?`${d}d ${h}h`:(h?`${h}h ${m}m`:`${m}m`); }
|
||
function ago(t){ if(!t) return '—'; const s=(Date.now()-new Date(t).getTime())/1000; if(s<60)return 'just now'; if(s<3600)return Math.floor(s/60)+'m ago'; if(s<86400)return Math.floor(s/3600)+'h ago'; return Math.floor(s/86400)+'d ago'; }
|
||
async function vSystem(){
|
||
$('#content').innerHTML = '<h2>System</h2><div class="muted">Read-only Postgres health · maintenance is handled by infra (autovacuum + scheduled backups)</div><div id="sys" style="margin-top:10px"><div class="muted">loading…</div></div>';
|
||
const d = await api('/system'), db = d.db, f = d.fresh;
|
||
const tiles = kpi(fmtBytes(db.db_bytes),'database size',true) + kpi('PG '+escapeH(db.pg_version||'?'),'postgres')
|
||
+ kpi(fmtUptime(db.uptime_s),'uptime') + kpi(db.active+' / '+db.conns,'active / conns')
|
||
+ kpi(db.cache_hit==null?'—':db.cache_hit+'%','cache hit',true)
|
||
+ kpi(Number(f.inventory_rows).toLocaleString(),'inventory rows') + kpi(Number(f.releases).toLocaleString(),'releases');
|
||
const rows = (d.tables||[]).map(t=>`<tr><td>${escapeH(t.name)}</td><td class="price">${fmtBytes(t.bytes)}</td><td>${Number(t.rows).toLocaleString()}</td></tr>`).join('');
|
||
$('#sys').innerHTML = '<div class="kpis">'+tiles+'</div>'
|
||
+ '<div class="card"><h3>Data freshness</h3><table><tbody>'
|
||
+ `<tr><td>Last sale</td><td class="price">${ago(f.last_sale)}</td><td class="muted">${f.last_sale?String(f.last_sale).slice(0,16).replace('T',' '):'—'}</td></tr>`
|
||
+ `<tr><td>Inventory last updated</td><td class="price">${ago(f.inventory_updated)}</td><td class="muted">${f.inventory_updated?String(f.inventory_updated).slice(0,16).replace('T',' '):'—'}</td></tr>`
|
||
+ '</tbody></table></div>'
|
||
+ '<div class="card"><h3>Largest tables</h3><table><thead><tr><th>Table</th><th>Size</th><th>Rows (est.)</th></tr></thead><tbody>'+(rows||'<tr><td colspan=3 class=muted>—</td></tr>')+'</tbody></table></div>';
|
||
}
|
||
|
||
// ---------- Connections ----------
|
||
async function vConn(){
|
||
const m = $('#content');
|
||
m.innerHTML = '<h2>Connections</h2><div class="card"><h3>Credentials <span class="soon">— encrypted at rest, never shown back</span></h3><div id="secrets" class="muted">loading…</div></div>'
|
||
+ '<div class="card"><h3>Test connections</h3>'
|
||
+ ['discogs','woo','dealgod','openrouter'].map(s=>`<div class="conn"><button class="ghost" onclick="testConn('${s}')">Test ${s}</button><span id="c-${s}" class="soon"></span></div>`).join('')
|
||
+ '</div>';
|
||
const d = await fetch('/settings/secrets', { headers: hdr() }).then(r=>r.json());
|
||
$('#secrets').innerHTML = d.fields.map(f => {
|
||
const st = f.set ? `<span style="color:var(--ok);font-size:12px">✓ set ${(f.updated_at||'').slice(0,10)}</span>` : '';
|
||
return `<label>${f.label} ${st}</label><div class="field"><input type="password" placeholder="${f.set?'•••••• (replace to update)':'paste value'}" data-name="${f.name}"><button class="ghost" data-save="${f.name}">Save</button></div>`;
|
||
}).join('');
|
||
document.querySelectorAll('[data-save]').forEach(b => b.onclick = () => saveSecret(b.dataset.save, b));
|
||
}
|
||
async function saveSecret(name, btn){
|
||
const i = document.querySelector(`input[data-name="${name}"]`); if(!i.value.trim()) return;
|
||
btn.textContent='…';
|
||
const r = await fetch('/settings/secrets', { method:'POST', headers:hdr(), body: JSON.stringify({name, value:i.value.trim()}) });
|
||
btn.textContent = r.ok ? 'Saved' : 'Error'; if(r.ok){ i.value=''; i.placeholder='•••••• (replace to update)'; } setTimeout(()=>btn.textContent='Save',1500);
|
||
}
|
||
async function testConn(svc){
|
||
const el = document.getElementById('c-'+svc); el.textContent='testing…'; el.style.color='var(--mut)';
|
||
let r,d; try { r = await fetch('/settings/test/'+svc,{method:'POST',headers:hdr()}); d = await r.json(); } catch(e){ el.textContent='✕ network'; return; }
|
||
if(d.connected){ el.textContent='✓ connected'+(d.as?' as '+d.as:''); el.style.color='var(--ok)'; }
|
||
else { el.textContent='✕ '+(d.detail||r.status); el.style.color='var(--warn)'; }
|
||
}
|
||
|
||
// ---------- Intake ----------
|
||
const CONDS = ['M','NM','VG+','VG','G+','G','F','P'];
|
||
const condSel = (sel,cls) => `<select class="${cls||''}">`+CONDS.map(c=>`<option${c===(sel||'VG+')?' selected':''}>${c}</option>`).join('')+'</select>';
|
||
let inkTab = 'find';
|
||
function vIntake(){
|
||
$('#content').innerHTML = '<h2>Intake <span class="muted" style="font-size:13px">· stage new stock</span></h2>'
|
||
+ '<div class="tabrow" style="display:flex;gap:6px;margin-bottom:14px">'
|
||
+ ['find:🔎 Find & stage','collection:💿 Discogs collection','sheet:📄 Google Sheet'].map(t=>{
|
||
const [k,l]=t.split(':'); return `<button class="tab${k===inkTab?' on':''}" onclick="inkGo('${k}')" style="padding:7px 13px;border-radius:8px;border:1px solid var(--line);background:${k===inkTab?'var(--accent,#c2185b)':'var(--panel)'};color:${k===inkTab?'#fff':'inherit'}">${l}</button>`;
|
||
}).join('')
|
||
+ '</div><div id="ink_body"></div>';
|
||
inkGo(inkTab);
|
||
}
|
||
function inkGo(t){ inkTab=t; if(t==='find')inkFind(); else if(t==='collection')inkColl(); else inkSheet();
|
||
document.querySelectorAll('.tabrow .tab').forEach(b=>{const on=b.getAttribute('onclick').includes("'"+t+"'");b.style.background=on?'var(--accent,#c2185b)':'var(--panel)';b.style.color=on?'#fff':'inherit';}); }
|
||
const inkBody = h => { if(inkTab&&$('#ink_body')) $('#ink_body').innerHTML = h; };
|
||
|
||
// --- 1. Find & stage (internal lookup + Discogs fallback) ---
|
||
function inkFind(){
|
||
inkBody('<div class="card"><div style="display:flex;gap:8px;flex-wrap:wrap;align-items:end">'
|
||
+ '<div style="flex:2;min-width:220px"><label>Artist / title</label><input id="ik_q" placeholder="e.g. aphex twin selected ambient" style="width:100%" onkeydown="if(event.key===\'Enter\')inkSearch()"></div>'
|
||
+ '<div><label>Label</label><input id="ik_label" style="width:120px"></div>'
|
||
+ '<div><label>Year</label><input id="ik_year" type="number" style="width:78px"></div>'
|
||
+ '<div><label>Country</label><input id="ik_country" style="width:90px"></div>'
|
||
+ '<div><label>Format</label><input id="ik_fmt" placeholder="Vinyl / CD" style="width:100px"></div>'
|
||
+ '<button onclick="inkSearch()">Search</button></div>'
|
||
+ '<div id="ik_hint" class="muted" style="margin-top:8px;font-size:12px">Searches your local catalog first — instant. New titles? Hit “Search Discogs”.</div></div>'
|
||
+ '<div id="ik_results"></div>');
|
||
}
|
||
async function inkSearch(remote){
|
||
const q=$('#ik_q').value.trim();
|
||
const p=new URLSearchParams({q, label:$('#ik_label').value.trim(), year:$('#ik_year').value.trim(),
|
||
country:$('#ik_country').value.trim(), fmt:$('#ik_fmt').value.trim()});
|
||
for(const[k,v]of[...p])if(!v)p.delete(k);
|
||
$('#ik_results').innerHTML='<div class="muted" style="padding:12px">searching…</div>';
|
||
const url = remote ? '/admin/intake/discogs/search?q='+encodeURIComponent(q) : '/admin/intake/search?'+p;
|
||
let d; try{ d=await(await fetch(url,{headers:hdr()})).json(); }catch(e){ $('#ik_results').innerHTML='<div class="card">search failed</div>'; return; }
|
||
inkRenderCards(d.items||[], d.source, d.error);
|
||
}
|
||
function inkRenderCards(items, source, err){
|
||
let h='';
|
||
if(source!=='discogs') h+='<div style="margin:6px 0 10px"><button onclick="inkSearch(true)" style="background:var(--panel);border:1px solid var(--line)">Search Discogs ↗ (new titles)</button> <span class="muted" style="font-size:12px">'+(items.length?items.length+' local matches':'no local matches')+'</span></div>';
|
||
if(err) h+='<div class="card" style="color:var(--warn)">'+escapeH(err)+'</div>';
|
||
if(!items.length && !err){ inkBody2(h+'<div class="card muted">Nothing found.</div>'); return; }
|
||
h+='<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px">';
|
||
items.forEach((it,i)=>{ window['_ik'+i]=it; h+=inkCard(it,'_ik'+i); });
|
||
h+='</div>';
|
||
inkBody2(h);
|
||
}
|
||
const inkBody2 = h => { $('#ik_results').innerHTML = h; };
|
||
function inkCard(it,ref){
|
||
return `<div class="card" style="display:flex;gap:10px;padding:12px">
|
||
${it.thumb?`<img src="${escapeH(it.thumb)}" style="width:64px;height:64px;object-fit:cover;border-radius:6px;flex-shrink:0">`:'<div style="width:64px;height:64px;background:var(--line);border-radius:6px;flex-shrink:0"></div>'}
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeH(it.title||'')}</div>
|
||
<div class="muted" style="font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeH(it.artist||'')}</div>
|
||
<div class="muted" style="font-size:11px">${[it.year,it.country,it.format].filter(Boolean).map(escapeH).join(' · ')}${it.in_stock?' · <span style="color:var(--ok)">in stock</span>':''}</div>
|
||
<div style="display:flex;gap:6px;margin-top:8px;align-items:center">
|
||
${condSel(it.media,'ik_c')}
|
||
<input class="ik_p" type="number" step="0.01" placeholder="$" style="width:70px">
|
||
<button onclick="inkStage(${ref?`window.${ref}.release_id`:it.release_id},this)" style="padding:5px 12px">Stage</button>
|
||
</div>
|
||
</div></div>`;
|
||
}
|
||
async function inkStage(rid, btn){
|
||
const row=btn.parentElement, cond=row.querySelector('.ik_c').value, price=parseFloat(row.querySelector('.ik_p').value)||null;
|
||
btn.disabled=true; btn.textContent='…';
|
||
const d=await(await fetch('/admin/intake/stage',{method:'POST',headers:hdr(),
|
||
body:JSON.stringify({release_id:rid,condition:cond,price})})).json();
|
||
btn.outerHTML = d.ok ? `<span style="color:var(--ok)">✓ ${escapeH(d.sku)}</span>` : '<span style="color:var(--warn)">failed</span>';
|
||
}
|
||
|
||
// --- 2. Discogs collection folder ---
|
||
async function inkColl(){
|
||
inkBody('<div class="card"><label>Collection folder</label><div id="ik_folders" class="muted">loading folders…</div></div><div id="ik_folitems"></div>');
|
||
let d; try{ d=await(await fetch('/admin/intake/discogs/folders',{headers:hdr()})).json(); }catch(e){ $('#ik_folders').innerHTML='failed'; return; }
|
||
if(d.error){ $('#ik_folders').innerHTML='<span style="color:var(--warn)">'+escapeH(d.error)+'</span>'; return; }
|
||
$('#ik_folders').innerHTML = `<select id="ik_fsel" style="width:100%;max-width:480px" onchange="inkFolder(this.value)">`
|
||
+ '<option value="">— pick a folder —</option>'
|
||
+ d.folders.map(f=>`<option value="${f.id}">${escapeH(f.name)} (${f.count})</option>`).join('')+'</select>'
|
||
+ `<div class="muted" style="font-size:12px;margin-top:6px">Discogs user: ${escapeH(d.user)} · ${d.folders.length} folders with stock</div>`;
|
||
}
|
||
async function inkFolder(id,page){
|
||
if(!id) return; page=page||1;
|
||
$('#ik_folitems').innerHTML='<div class="muted" style="padding:12px">loading releases…</div>';
|
||
const d=await(await fetch(`/admin/intake/discogs/folder/${id}?page=${page}`,{headers:hdr()})).json();
|
||
if(d.error){ $('#ik_folitems').innerHTML='<div class="card" style="color:var(--warn)">'+escapeH(d.error)+'</div>'; return; }
|
||
let h='<div class="card"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">'
|
||
+`<strong>${d.items.length} releases</strong> <button onclick="inkStageAll('${id}')">Stage all on this page</button></div>`
|
||
+'<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px">';
|
||
d.items.forEach((it,i)=>{ window['_ikf'+i]=it; h+=inkCard(it,'_ikf'+i); });
|
||
h+='</div>';
|
||
if(d.pages>1) h+=`<div style="margin-top:12px;text-align:center" class="muted">page ${d.page} of ${d.pages} `
|
||
+(d.page>1?`<button onclick="inkFolder('${id}',${d.page-1})">‹</button> `:'')
|
||
+(d.page<d.pages?`<button onclick="inkFolder('${id}',${d.page+1})">›</button>`:'')+'</div>';
|
||
h+='</div>';
|
||
$('#ik_folitems').innerHTML=h;
|
||
}
|
||
async function inkStageAll(id){
|
||
const sel=$('#ik_fsel'); if(!confirm('Stage all releases on this page with their collection condition/price?'))return;
|
||
const cards=[...document.querySelectorAll('#ik_folitems .card button')].filter(b=>b.textContent==='Stage');
|
||
for(const b of cards){ b.click(); await new Promise(r=>setTimeout(r,250)); }
|
||
}
|
||
|
||
// --- 3. Google Sheet (public CSV) ---
|
||
function inkSheet(){
|
||
inkBody('<div class="card"><div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">'
|
||
+'<div><strong>📋 Connected sheet</strong><div class="muted" style="font-size:12px">Your private intake sheet, read via the Google service account — uses the saved column map.</div></div>'
|
||
+'<button onclick="inkGPreview()">Preview connected sheet</button></div>'
|
||
+'<div id="ik_gsheet" style="margin-top:10px"></div></div>'
|
||
+'<div class="card"><label>…or paste a Google Sheet URL <span class="muted" style="font-weight:400">(Share → anyone with link can view)</span></label>'
|
||
+'<input id="ik_url" placeholder="https://docs.google.com/spreadsheets/d/…" style="width:100%">'
|
||
+'<div style="margin-top:10px"><button onclick="inkPreview()">Preview</button></div>'
|
||
+'<div class="muted" style="font-size:12px;margin-top:8px">Auto-detects columns: release id · media/sleeve condition · price · comment · timestamp (→ SKU).</div></div>'
|
||
+'<div id="ik_sheet"></div>');
|
||
}
|
||
function inkSampleTable(rows){
|
||
return '<table style="width:100%;font-size:12px;border-collapse:collapse"><tr style="text-align:left;color:var(--mut)"><th>release_id</th><th>sku</th><th>media</th><th>sleeve</th><th>price</th><th>notes</th></tr>'
|
||
+rows.map(r=>`<tr style="border-top:1px solid var(--line)"><td>${r.release_id}</td><td>${escapeH(r.sku||'auto')}</td><td>${escapeH(r.media||'')}</td><td>${escapeH(r.sleeve||'')}</td><td>${r.price??''}</td><td>${escapeH(r.notes||'')}</td></tr>`).join('')+'</table>';
|
||
}
|
||
async function inkGPreview(){
|
||
$('#ik_gsheet').innerHTML='<div class="muted">reading connected sheet…</div>';
|
||
const d=await(await fetch('/admin/intake/gsheet/preview',{headers:hdr()})).json();
|
||
if(!d.ok){ $('#ik_gsheet').innerHTML='<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>'; return; }
|
||
$('#ik_gsheet').innerHTML=`<div style="margin-bottom:8px"><strong>${d.new}</strong> new of ${d.total} rows in <em>${escapeH(d.sheet)}</em> <span class="muted">(already-stocked SKUs are skipped)</span></div>`
|
||
+inkSampleTable(d.sample)
|
||
+'<div style="margin-top:12px"><button onclick="inkGImport()">Import '+d.new+' new rows</button> <span id="ik_gimp" class="muted"></span></div>';
|
||
}
|
||
async function inkGImport(){
|
||
const m=$('#ik_gimp'); m.textContent='importing… (enriching new titles, please wait)';
|
||
const d=await(await fetch('/admin/intake/gsheet/import',{method:'POST',headers:hdr()})).json();
|
||
m.innerHTML=d.ok?`<span style="color:var(--ok)">✓ staged ${d.staged} new</span>${d.skipped?` · ${d.skipped} already stocked`:''}`:'<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>';
|
||
}
|
||
async function inkPreview(){
|
||
const url=$('#ik_url').value.trim(); if(!url)return;
|
||
$('#ik_sheet').innerHTML='<div class="muted" style="padding:12px">reading sheet…</div>';
|
||
const d=await(await fetch('/admin/intake/sheet/preview',{method:'POST',headers:hdr(),body:JSON.stringify({url})})).json();
|
||
if(!d.ok){ $('#ik_sheet').innerHTML='<div class="card" style="color:var(--warn)">'+escapeH(d.error||'failed')+'</div>'; return; }
|
||
const cols=Object.keys(d.columns).map(k=>`${k}→col ${d.columns[k]+1}`).join(' · ');
|
||
let h=`<div class="card"><div style="margin-bottom:8px"><strong>${d.total}</strong> stageable rows · <span class="muted">${escapeH(cols)}</span></div>`
|
||
+'<table style="width:100%;font-size:12px;border-collapse:collapse"><tr style="text-align:left;color:var(--mut)"><th>release_id</th><th>sku</th><th>media</th><th>sleeve</th><th>price</th><th>notes</th></tr>'
|
||
+d.sample.map(r=>`<tr style="border-top:1px solid var(--line)"><td>${r.release_id}</td><td>${escapeH(r.sku||'auto')}</td><td>${escapeH(r.media||'')}</td><td>${escapeH(r.sleeve||'')}</td><td>${r.price??''}</td><td>${escapeH(r.notes||'')}</td></tr>`).join('')
|
||
+'</table><div style="margin-top:12px"><button onclick="inkImport()">Import all '+d.total+' rows</button> <span id="ik_imp" class="muted"></span></div></div>';
|
||
$('#ik_sheet').innerHTML=h; $('#ik_sheet').dataset.url=$('#ik_url').value.trim();
|
||
}
|
||
async function inkImport(){
|
||
const url=$('#ik_sheet').dataset.url, m=$('#ik_imp'); m.textContent='importing… (enriching new titles, please wait)';
|
||
const d=await(await fetch('/admin/intake/sheet/import',{method:'POST',headers:hdr(),body:JSON.stringify({url})})).json();
|
||
m.innerHTML = d.ok ? `<span style="color:var(--ok)">✓ staged ${d.staged} new</span>${d.skipped?` · ${d.skipped} already stocked`:''}` : '<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>';
|
||
}
|
||
|
||
// ---------- Heal (backfill missing catalog metadata) ----------
|
||
function vHeal(){
|
||
$('#content').innerHTML = '<h2>Heal <span class="muted" style="font-size:13px">· backfill missing catalog metadata</span></h2>'
|
||
+ '<div class="card" style="max-width:680px">'
|
||
+ '<p class="muted" style="margin-top:0">Scans in-stock items for gaps — missing title, weight, or cover art — and backfills from your local Discogs mirror. Anything the mirror doesn’t have is re-fetched from Discogs (rate-limited, so it heals in batches).</p>'
|
||
+ '<button onclick="healScan()">Scan for gaps</button> <button onclick="healRun()" id="heal_run" style="display:none">🩹 Heal now</button>'
|
||
+ '<div id="heal_out" style="margin-top:14px"></div></div>';
|
||
}
|
||
async function healScan(){
|
||
const o=$('#heal_out'); o.innerHTML='<span class="muted">scanning…</span>';
|
||
const d=await(await fetch('/admin/intake/heal/scan',{headers:hdr()})).json();
|
||
if(!d.ok){ o.innerHTML='<span style="color:var(--warn)">scan failed</span>'; return; }
|
||
const row=(label,n,note)=>`<tr style="border-top:1px solid var(--line)"><td style="padding:5px 10px 5px 0">${label}</td><td style="text-align:right;font-weight:600">${n}</td><td class="muted" style="padding-left:12px;font-size:12px">${note||''}</td></tr>`;
|
||
o.innerHTML='<table style="border-collapse:collapse">'
|
||
+ row('Missing title', d.missing_title, d.local_fixable?`${d.local_fixable} fixable instantly from the mirror`:'')
|
||
+ row('Missing weight', d.missing_weight, 'blocks shipping quotes')
|
||
+ row('Need Discogs re-fetch', d.api_needed, 'not in mirror / no cover art')
|
||
+ row('No release_id', d.no_release_id, 'can’t auto-heal — needs matching')
|
||
+ '</table>';
|
||
const fixable = d.missing_title + d.missing_weight + d.api_needed;
|
||
$('#heal_run').style.display = fixable ? '' : 'none';
|
||
if(!fixable) o.innerHTML += '<div style="color:var(--ok);margin-top:10px">✓ Nothing to heal — catalog is clean.</div>';
|
||
}
|
||
async function healRun(){
|
||
const o=$('#heal_out'), b=$('#heal_run'); b.disabled=true; b.textContent='healing…';
|
||
const d=await(await fetch('/admin/intake/heal/run',{method:'POST',headers:hdr(),body:'{}'})).json();
|
||
b.disabled=false; b.textContent='🩹 Heal now';
|
||
if(!d.ok){ o.innerHTML='<span style="color:var(--warn)">heal failed</span>'; return; }
|
||
o.innerHTML=`<div style="color:var(--ok)">✓ ${d.local_healed} healed locally · ${d.api_enriched} re-fetched from Discogs</div>`
|
||
+ (d.remaining_api ? `<div style="margin-top:8px">${d.remaining_api} still need Discogs (rate-limited) — <button onclick="healRun()">Heal next batch</button></div>`
|
||
: '<div class="muted" style="margin-top:8px">All gaps closed. Re-scan to confirm.</div>');
|
||
}
|
||
|
||
// ---------- New stock (distro purchases → approve → publish) ----------
|
||
async function vNewstock(){
|
||
$('#content').innerHTML = '<h2>New stock <span class="muted" style="font-size:13px">· approve distro purchases, then publish</span></h2>'
|
||
+ '<div class="card"><h3>Import a distro order sheet <span class="muted" style="font-weight:400">· Inertia / Warner SOH (fill ORDER QTY, then upload)</span></h3>'
|
||
+ '<div class="bar"><select id="ns_sheet_src"><option value="inertia">Inertia</option><option value="bertus">Bertus</option><option value="rocket">Rocket</option><option value="other">Other</option></select>'
|
||
+ '<input id="ns_sheet_file" type="file" accept=".xlsx" onchange="nsPickSheet(this)"></div>'
|
||
+ '<div id="ns_sheet_out" class="muted" style="margin-top:8px"></div></div>'
|
||
+ '<div id="ns_bar" class="muted" style="margin:8px 0">loading…</div><div id="ns_body"></div>';
|
||
nsLoad();
|
||
}
|
||
function nsPickSheet(input){
|
||
const f=input.files[0]; if(!f) return;
|
||
const rd=new FileReader();
|
||
rd.onload=()=>{ window._nsSheet={b64:rd.result, filename:f.name}; nsSheetPreview(); };
|
||
rd.readAsDataURL(f);
|
||
}
|
||
async function nsSheetPreview(){
|
||
const s=window._nsSheet; if(!s) return;
|
||
$('#ns_sheet_out').innerHTML='reading sheet…';
|
||
const src=$('#ns_sheet_src').value;
|
||
const d=await fetch('/admin/intake/distro-sheet/preview',{method:'POST',headers:hdr(),body:JSON.stringify({source:src,filename:s.filename,xlsx_b64:s.b64})}).then(r=>r.json());
|
||
if(!d.ok){ $('#ns_sheet_out').innerHTML='<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>'; return; }
|
||
$('#ns_sheet_out').innerHTML=`<div style="margin-bottom:6px"><b>${d.rows}</b> ordered titles · <b>${d.copies}</b> copies in <em>${escapeH(s.filename)}</em></div>`
|
||
+'<table style="font-size:12px"><tr style="color:var(--mut);text-align:left"><th>barcode</th><th>title</th><th>fmt</th><th>qty</th><th>cost</th></tr>'
|
||
+(d.sample||[]).map(r=>`<tr><td>${escapeH(r.barcode||'')}</td><td>${escapeH((r.artist?r.artist+' – ':'')+(r.title||''))}</td><td>${escapeH(r.kind||'')}</td><td>${r.qty}</td><td>${r.unit_cost!=null?money(r.unit_cost):''}</td></tr>`).join('')
|
||
+'</table><div style="margin-top:10px"><button onclick="nsSheetImport()">📥 Import '+d.copies+' copies</button> <span id="ns_sheet_msg" class="muted"></span></div>';
|
||
}
|
||
async function nsSheetImport(){
|
||
const s=window._nsSheet, src=$('#ns_sheet_src').value, m=$('#ns_sheet_msg');
|
||
m.textContent='importing… (resolving barcodes, please wait)';
|
||
const d=await fetch('/admin/intake/distro-sheet',{method:'POST',headers:hdr(),body:JSON.stringify({source:src,filename:s.filename,xlsx_b64:s.b64})}).then(r=>r.json());
|
||
if(d.ok&&d.already_ingested) m.innerHTML='<span class="muted">already imported ('+d.existing+' copies)</span>';
|
||
else if(d.ok){ m.innerHTML=`<span style="color:var(--ok)">✓ staged ${d.staged} · ${d.resolved} matched</span>`; nsLoad(); }
|
||
else m.innerHTML='<span style="color:var(--warn)">'+escapeH(d.error||'failed')+'</span>';
|
||
}
|
||
async function nsLoad(){
|
||
const d = await api('/newstock');
|
||
if(!d.items.length){ $('#ns_bar').innerHTML='<div class="card" style="color:var(--ok)">✓ Nothing pending — scrape a distro order with the PRICEGOD extension and it lands here.</div>'; $('#ns_body').innerHTML=''; return; }
|
||
const resolved = d.items.filter(i=>i.release_id).length, priced = d.items.filter(i=>i.price!=null).length;
|
||
$('#ns_bar').innerHTML = `<b>${d.count}</b> copies pending · ${resolved} matched · ${priced} priced `
|
||
+ `<button onclick="nsPublishAll()" style="margin-left:8px">🚀 Publish all matched & priced</button>`;
|
||
const groups = {}; d.items.forEach(i=>{ (groups[i.cost_source]=groups[i.cost_source]||[]).push(i); });
|
||
$('#ns_body').innerHTML = Object.entries(groups).map(([src,items])=>
|
||
`<div class="card"><h3>${escapeH(src)} <span class="muted" style="font-weight:400">· ${items.length} copies</span></h3>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(310px,1fr));gap:10px">${items.map(nsCard).join('')}</div></div>`).join('');
|
||
}
|
||
function nsCard(it){
|
||
const cover = it.thumb ? `<img src="${escapeH(it.thumb)}" style="width:54px;height:54px;object-fit:cover;border-radius:5px;flex-shrink:0">`
|
||
: '<div style="width:54px;height:54px;background:var(--line);border-radius:5px;flex-shrink:0"></div>';
|
||
return `<div id="nsc_${it.sku}" style="display:flex;gap:9px;border:1px solid var(--line);border-radius:8px;padding:9px">${cover}
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeH(it.title||it.barcode||'?')}</div>
|
||
<div class="muted" style="font-size:11px">${escapeH(it.artist||'')}${it.year?' · '+it.year:''} · ${escapeH(it.barcode||'')}</div>
|
||
<div style="font-size:11px;margin:2px 0">${it.release_id?`<span style="color:var(--ok)">✓ rel ${it.release_id}</span>`:'<span style="color:var(--warn)">⚠ needs release</span>'} · cost ${money(it.cost_price)}</div>
|
||
<div style="display:flex;gap:5px;align-items:center;margin-top:4px">
|
||
<select class="ns_ct">${['new','used'].map(c=>`<option${c===it.condition_type?' selected':''}>${c}</option>`).join('')}</select>
|
||
<input class="ns_pr" type="number" step="0.01" placeholder="retail $" value="${it.price!=null?it.price:''}" style="width:78px">
|
||
<button onclick="nsPublish('${it.sku}')"${it.release_id?'':' disabled title="pick a release first"'}>✓ publish</button>
|
||
<button class="ghost" onclick="nsRepick('${it.sku}')">🔎</button>
|
||
</div>
|
||
<div class="ns_msg muted" style="font-size:11px;margin-top:3px"></div>
|
||
</div></div>`;
|
||
}
|
||
async function nsPublish(sku){
|
||
const c=$('#nsc_'+sku), price=parseFloat(c.querySelector('.ns_pr').value)||null, ct=c.querySelector('.ns_ct').value, msg=c.querySelector('.ns_msg');
|
||
if(price==null){ msg.innerHTML='<span style="color:var(--warn)">set a retail price first</span>'; return; }
|
||
msg.textContent='publishing…';
|
||
const r=await fetch('/admin/newstock/'+encodeURIComponent(sku),{method:'POST',headers:hdr(),body:JSON.stringify({price,condition_type:ct,publish:true})});
|
||
if(r.ok){ c.style.opacity=.4; msg.innerHTML='<span style="color:var(--ok)">✓ published & live</span>'; }
|
||
else msg.innerHTML='<span style="color:var(--warn)">failed</span>';
|
||
}
|
||
async function nsPublishAll(){
|
||
if(!confirm('Publish every matched copy that has a retail price?')) return;
|
||
const d=await fetch('/admin/newstock/publish-all',{method:'POST',headers:hdr()}).then(r=>r.json());
|
||
alert('Published '+d.published+' copies.'); nsLoad();
|
||
}
|
||
function nsRepick(sku){
|
||
const c=$('#nsc_'+sku);
|
||
c.innerHTML=`<div style="flex:1"><div style="display:flex;gap:5px"><input class="ns_q" placeholder="search artist / title" style="flex:1" onkeydown="if(event.key==='Enter')nsSearch('${sku}')"><button onclick="nsSearch('${sku}')">Search</button><button class="ghost" onclick="nsLoad()">✕</button></div><div class="ns_res" style="margin-top:6px"></div></div>`;
|
||
c.querySelector('.ns_q').focus();
|
||
}
|
||
async function nsSearch(sku){
|
||
const c=$('#nsc_'+sku), q=c.querySelector('.ns_q').value.trim(); if(!q) return;
|
||
const res=c.querySelector('.ns_res'); res.innerHTML='<span class="muted">searching…</span>';
|
||
const d=await api('/intake/search?q='+encodeURIComponent(q)); window['_nsres_'+sku]=d.items||[];
|
||
res.innerHTML=(d.items||[]).slice(0,8).map((r,idx)=>`<div onclick="nsSetRelease('${sku}',${idx})" style="cursor:pointer;padding:4px;border-bottom:1px solid var(--line);font-size:12px">${escapeH(r.artist||'')} – ${escapeH(r.title||'')} <span class="muted">${escapeH(r.year||'')} ${escapeH(r.country||'')}</span></div>`).join('')||'<span class="muted">no matches — try the barcode or fewer words</span>';
|
||
}
|
||
async function nsSetRelease(sku,idx){
|
||
const r=(window['_nsres_'+sku]||[])[idx]; if(!r) return;
|
||
await fetch('/admin/newstock/'+encodeURIComponent(sku),{method:'POST',headers:hdr(),body:JSON.stringify({release_id:r.release_id,title:(r.artist?r.artist+' – ':'')+(r.title||'')})});
|
||
nsLoad();
|
||
}
|
||
|
||
// ---------- Crates ----------
|
||
async function vCrates(){
|
||
const m = $('#content'); m.innerHTML = '<h2>Crates</h2><div class="muted">loading…</div>';
|
||
const d = await api('/crates');
|
||
const cards = d.crates.map(c => `<div class="kpi" style="cursor:pointer" onclick="crateOpen(${c.id})">
|
||
<div class="n pink" style="font-size:17px">${escapeH(c.label_text||c.name||('#'+c.id))}</div>
|
||
<div class="l">${c.items||0} items · ${money(c.value)}</div>
|
||
<div class="soon">${escapeH(c.purpose||'stock')}</div></div>`).join('');
|
||
m.innerHTML = `<h2>Crates <span class="muted" style="font-size:13px">· ${d.crates.length} bins · click to see contents</span></h2><div class="kpis">${cards}</div>`;
|
||
}
|
||
function crateOpen(id){ invState = { q:'', kind:'', crate_id:id, page:1 }; go('inventory'); }
|
||
|
||
// ---------- Reports ----------
|
||
// ---------- Reports workbench (date range + dimension + metric, all lazy) ----------
|
||
let RP = {days:30, from:null, to:null, metric:'revenue', report:'trend'};
|
||
const RP_TABS = [{r:'trend',l:'Revenue trend'},{r:'genre',l:'By genre'},{r:'style',l:'By style'},{r:'format',l:'By format'},
|
||
{r:'condition',l:'By condition'},{r:'payment',l:'By payment'},{r:'weekday',l:'By weekday'},{r:'hour',l:'By hour'},
|
||
{r:'top:release',l:'Top releases'},{r:'top:artist',l:'Top artists'},{r:'top:label',l:'Top labels'},{r:'top:customer',l:'Top customers'}];
|
||
function vReports(){
|
||
$('#content').innerHTML = '<h2>Reports</h2>'
|
||
+ '<div class="card"><div class="bar" style="flex-wrap:wrap;gap:8px;align-items:center">'
|
||
+ '<span class="muted">Period</span>'
|
||
+ [['7','7d'],['30','30d'],['90','90d'],['365','12mo'],['0','All']].map(x=>`<button class="ghost rpd" data-d="${x[0]}" onclick="rpDays(${x[0]})">${x[1]}</button>`).join('')
|
||
+ '<span class="muted">·</span><input type="date" id="rpFrom" onchange="rpCustom()" style="width:140px"><input type="date" id="rpTo" onchange="rpCustom()" style="width:140px">'
|
||
+ '<span style="flex:1"></span><span class="muted">Metric</span>'
|
||
+ '<button class="ghost" id="rpRev" onclick="rpMetric(\'revenue\')">Revenue</button><button class="ghost" id="rpCnt" onclick="rpMetric(\'count\')">Count</button>'
|
||
+ '</div></div>'
|
||
+ '<div class="kpis" id="rpKpis"><div class="muted">loading…</div></div>'
|
||
+ '<div class="card"><div class="bar" id="rpTabs" style="flex-wrap:wrap;gap:6px"></div></div>'
|
||
+ '<div class="card"><div style="height:300px"><canvas id="rpChart"></canvas></div></div>'
|
||
+ '<div class="card"><div id="rpTable"></div></div>';
|
||
$('#rpTabs').innerHTML = RP_TABS.map(t=>`<button class="ghost rpt" data-r="${t.r}" onclick="rpReport('${t.r}')">${t.l}</button>`).join('');
|
||
rpSync(); loadRpKpis(); rpReport(RP.report);
|
||
}
|
||
function pq(){ return (RP.from && RP.to) ? ('from='+RP.from+'&to='+RP.to) : ('days='+RP.days); }
|
||
function rpDays(d){ RP.days=d; RP.from=RP.to=null; $('#rpFrom').value=''; $('#rpTo').value=''; rpSync(); loadRpKpis(); rpReport(RP.report); }
|
||
function rpCustom(){ const f=$('#rpFrom').value, t=$('#rpTo').value; if(f&&t){ RP.from=f; RP.to=t; rpSync(); loadRpKpis(); rpReport(RP.report); } }
|
||
function rpMetric(mt){ RP.metric=mt; rpSync(); rpReport(RP.report); }
|
||
function rpReport(r){ RP.report=r; rpSync(); if(r==='trend') rpTrend(); else if(r.startsWith('top:')) rpTop(r.slice(4)); else rpBreakdown(r); }
|
||
function rpSync(){
|
||
document.querySelectorAll('.rpd').forEach(b=>b.classList.toggle('on', !RP.from && +b.dataset.d===+RP.days));
|
||
document.querySelectorAll('.rpt').forEach(b=>b.classList.toggle('on', b.dataset.r===RP.report));
|
||
if($('#rpRev')){ $('#rpRev').classList.toggle('on',RP.metric==='revenue'); $('#rpCnt').classList.toggle('on',RP.metric==='count'); }
|
||
}
|
||
async function loadRpKpis(){
|
||
const k = await api('/report/kpis?'+pq());
|
||
$('#rpKpis').innerHTML = kpi(k.sales.toLocaleString(),'sales',true) + kpi(money(k.revenue),'revenue',true)
|
||
+ kpi(money(k.avg_sale),'avg sale') + kpi(Number(k.units||0).toLocaleString(),'units')
|
||
+ kpi(k.customers.toLocaleString(),'customers') + kpi(money(k.discounts),'discounts given')
|
||
+ kpi(k.in_stock.toLocaleString(),'in stock now') + (k.open_laybys?kpi(k.open_laybys,'open laybys'):'');
|
||
}
|
||
function rpTable(head, rows){
|
||
$('#rpTable').innerHTML = '<table><thead><tr>'+head.map(h=>`<th>${escapeH(h)}</th>`).join('')+'</tr></thead><tbody>'
|
||
+ (rows.map(r=>'<tr>'+r.map((c,i)=>`<td${i>0?' class="price"':''}>${escapeH(String(c))}</td>`).join('')+'</tr>').join('') || '<tr><td class=muted colspan=9>no data in this period</td></tr>')
|
||
+ '</tbody></table>';
|
||
}
|
||
async function rpTrend(){
|
||
const bucket = (RP.from||RP.days===0||RP.days>120)?'month':(RP.days>31?'week':'day');
|
||
const d = await api('/report/series?'+pq()+'&bucket='+bucket);
|
||
const isRev = RP.metric==='revenue';
|
||
lineChart('rpChart', d.series.map(x=>x.bucket), d.series.map(x=>isRev?x.revenue:x.sales), isRev?'Revenue':'Sales', isRev);
|
||
rpTable(['Period','Sales','Revenue'], d.series.map(x=>[x.bucket, x.sales, money(x.revenue)]));
|
||
}
|
||
async function rpBreakdown(dim){
|
||
const d = await api('/report/breakdown?dim='+dim+'&metric='+RP.metric+'&'+pq()+'&limit=15');
|
||
const isRev = RP.metric==='revenue', vals = d.rows.map(x=>isRev?x.revenue:x.count);
|
||
if(dim!=='weekday' && dim!=='hour' && d.rows.length<=8) donutChart('rpChart', d.rows.map(x=>x.name), vals);
|
||
else barChart('rpChart', d.rows.map(x=>x.name), vals, isRev?'Revenue':'Count', isRev);
|
||
rpTable(['Name','Units','Revenue'], d.rows.map(x=>[x.name, x.count, money(x.revenue)]));
|
||
}
|
||
async function rpTop(dim){
|
||
const d = await api('/report/top?dim='+dim+'&'+pq()+'&limit=15');
|
||
barChart('rpChart', d.rows.map(x=>x.name), d.rows.map(x=>x.revenue), 'Revenue', true);
|
||
rpTable([dim.charAt(0).toUpperCase()+dim.slice(1),'Units','Revenue'], d.rows.map(x=>[x.name, x.count, money(x.revenue)]));
|
||
}
|
||
|
||
// ---------- Store map ----------
|
||
let mapData = null;
|
||
async function vStoreMap(){
|
||
const m = $('#content');
|
||
m.innerHTML = '<h2>Store map <span class="muted" style="font-size:13px">· click a bin for contents · brighter = fuller</span></h2>'
|
||
+ '<div class="bar"><input id="mq" placeholder="find a record → locate its bin…"><button onclick="mapFind()">Locate</button></div>'
|
||
+ '<div style="display:flex;gap:18px;align-items:flex-start">'
|
||
+ '<div id="mapwrap" style="flex:1;min-width:0;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:10px;overflow:auto">loading…</div>'
|
||
+ '<div id="cratepane" style="width:300px;flex-shrink:0"><div class="card soon">click a bin on the map</div></div></div>';
|
||
$('#mq').addEventListener('keydown', e => { if(e.key==='Enter') mapFind(); });
|
||
mapData = await api('/storemap');
|
||
renderMap();
|
||
}
|
||
function renderMap(highlight){
|
||
const { crates, racks, crate_types:ct } = mapData;
|
||
const pts = crates.map(c=>[c.x,c.z]).concat(racks.map(r=>[r.x,r.z]));
|
||
if(!pts.length){ $('#mapwrap').innerHTML = '<div class="muted">no crate positions in this space</div>'; return; }
|
||
const minX=Math.min(...pts.map(p=>p[0]))-1, maxX=Math.max(...pts.map(p=>p[0]))+1;
|
||
const minZ=Math.min(...pts.map(p=>p[1]))-1, maxZ=Math.max(...pts.map(p=>p[1]))+1;
|
||
const W=maxX-minX, D=maxZ-minZ, scale=Math.min(820/W, 560/D);
|
||
const sx=x=>((x-minX)*scale).toFixed(1), sy=z=>((z-minZ)*scale).toFixed(1);
|
||
const maxItems=Math.max(1, ...crates.map(c=>c.items||0));
|
||
let svg = `<svg viewBox="0 0 ${(W*scale).toFixed(0)} ${(D*scale).toFixed(0)}" style="width:100%;height:auto;background:#f4f5f7;border-radius:8px">`;
|
||
racks.forEach(r => { const w=(r.w||0.6)*scale, d=(r.d||0.4)*scale;
|
||
svg += `<g transform="translate(${sx(r.x)},${sy(r.z)}) rotate(${r.rot||0})"><rect x="${(-w/2).toFixed(1)}" y="${(-d/2).toFixed(1)}" width="${w.toFixed(1)}" height="${d.toFixed(1)}" rx="2" fill="#16161b" stroke="#2a2a30" stroke-width="0.5"/></g>`; });
|
||
crates.forEach(c => { const t=ct[c.crate_type_id]||{w:0.35,d:0.5}; const w=Math.max(7,t.w*scale), d=Math.max(7,t.d*scale);
|
||
const fill = c.items ? `rgba(255,93,177,${(0.22+0.65*(c.items/maxItems)).toFixed(2)})` : '#22222a';
|
||
const hl = highlight===c.id ? 'stroke="#fff" stroke-width="2.5"' : 'stroke="#ffffff" stroke-width="0.5"';
|
||
svg += `<g transform="translate(${sx(c.x)},${sy(c.z)}) rotate(${c.rot||0})" style="cursor:pointer" onclick="openCrate(${c.id})"><title>${escapeH(c.label_text||c.name||('#'+c.id))} · ${c.items||0} items</title><rect x="${(-w/2).toFixed(1)}" y="${(-d/2).toFixed(1)}" width="${w.toFixed(1)}" height="${d.toFixed(1)}" rx="1.5" fill="${fill}" ${hl}/></g>`; });
|
||
$('#mapwrap').innerHTML = svg + '</svg>';
|
||
}
|
||
async function openCrate(id){
|
||
renderMap(id);
|
||
const pane = $('#cratepane'); pane.innerHTML = '<div class="card muted">loading…</div>';
|
||
const d = await api(`/inventory?crate_id=${id}&page=1`);
|
||
const c = mapData.crates.find(x => x.id===id) || {};
|
||
const rows = d.items.map(r => `<div style="display:flex;gap:8px;padding:6px 0;border-bottom:1px solid #1b1b20">
|
||
${r.thumb?`<img class=thumb src="${r.thumb}">`:'<span class=thumb style="display:inline-block"></span>'}
|
||
<div style="min-width:0"><div style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${escapeH(r.title||r.sku)}</div>
|
||
<div class="muted" style="font-size:12px">${escapeH(r.artist||'')} · ${money(r.price)}</div></div></div>`).join('');
|
||
pane.innerHTML = `<div class="card"><h3>${escapeH(c.label_text||c.name||('Crate #'+id))} <span class="muted" style="font-weight:400">· ${d.total} items</span></h3>${rows||'<div class=muted>empty</div>'}</div>`;
|
||
}
|
||
async function mapFind(){
|
||
const q = $('#mq').value.trim(); if(!q) return;
|
||
const d = await api(`/inventory?q=${encodeURIComponent(q)}&page=1`);
|
||
const hit = d.items.find(i => i.crate_id);
|
||
if(!hit){ $('#cratepane').innerHTML = `<div class="card" style="color:var(--warn)">no in-store match for “${escapeH(q)}”</div>`; return; }
|
||
openCrate(hit.crate_id);
|
||
}
|
||
|
||
// ---------- helpers ----------
|
||
function vSoon(title, body){ return () => { $('#content').innerHTML = `<h2>${title}</h2><div class="card soon">${body}<br><br>Coming next.</div>`; }; }
|
||
function escapeH(s){ return (s==null?'':String(s)).replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||
|
||
function signOut(){ localStorage.removeItem('rg_token'); localStorage.removeItem('rg_name'); localStorage.removeItem('rg_role'); location.replace('/login'); }
|
||
async function changeMyPassword(){
|
||
const np = prompt('New password (min 6 characters):'); if(np===null) return;
|
||
if(np.length<6){ alert('Too short — min 6 characters.'); return; }
|
||
const cur = prompt('Your current password (leave blank if none set yet):') || '';
|
||
const r = await fetch('/auth/change-password',{method:'POST',headers:hdr(),body:JSON.stringify({current_password:cur,new_password:np})});
|
||
alert(r.ok ? 'Password updated.' : 'Failed: ' + ((await r.json().catch(()=>({}))).detail || r.status));
|
||
}
|
||
if(TOKEN){ signin(); } else { location.replace('/login'); }
|
||
</script>
|
||
</body>
|
||
</html>
|