feat(customers): CRM — list with spend/orders/last + record page (overview stats, editable info, mailing opt-in, sales history)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
38c6714184
commit
d6e75667bf
@ -218,6 +218,63 @@ async def inv_bulk(body: BulkIn, ident=Depends(require_token), db=Depends(get_db
|
||||
return {"ok": True, "affected": r.rowcount}
|
||||
|
||||
|
||||
class CustEditIn(BaseModel):
|
||||
first_name: str | None = None
|
||||
last_name: str | None = None
|
||||
email: str | None = None
|
||||
phone: str | None = None
|
||||
address: str | None = None
|
||||
notes: str | None = None
|
||||
mailing_optin: bool | None = None
|
||||
|
||||
|
||||
@router.get("/customers")
|
||||
async def customers_list(q: str = Query(""), ident=Depends(require_token), db=Depends(get_db)):
|
||||
where, params = "", {}
|
||||
if q.strip():
|
||||
where = ("WHERE c.first_name ILIKE :q OR c.last_name ILIKE :q OR c.email ILIKE :q "
|
||||
"OR (c.first_name || ' ' || coalesce(c.last_name,'')) ILIKE :q")
|
||||
params = {"q": f"%{q.strip()}%"}
|
||||
rows = await db.execute(text(f"""
|
||||
SELECT c.id, trim(c.first_name || ' ' || coalesce(c.last_name,'')) AS name, c.email, c.phone,
|
||||
c.is_guest, c.mailing_optin, count(s.id) AS orders,
|
||||
coalesce(sum(s.total),0)::float AS spent, max(s.sale_date) AS last_order
|
||||
FROM customer c LEFT JOIN sales s ON s.customer_id = c.id
|
||||
{where} GROUP BY c.id ORDER BY spent DESC NULLS LAST, name LIMIT 300
|
||||
"""), params)
|
||||
return {"customers": [dict(r) for r in rows.mappings()]}
|
||||
|
||||
|
||||
@router.get("/customers/{cid}")
|
||||
async def customer_detail(cid: int, ident=Depends(require_token), db=Depends(get_db)):
|
||||
c = (await db.execute(text("SELECT * FROM customer WHERE id=:i"), {"i": cid})).mappings().first()
|
||||
if not c:
|
||||
raise HTTPException(404, "customer not found")
|
||||
sales = [dict(r) for r in (await db.execute(text("""
|
||||
SELECT s.id, s.sale_number, s.total::float AS total, s.sale_date, s.payment_method, s.status,
|
||||
(SELECT count(*) FROM sale_items si WHERE si.sale_id = s.id) AS items
|
||||
FROM sales s WHERE s.customer_id = :i ORDER BY s.sale_date DESC NULLS LAST LIMIT 100
|
||||
"""), {"i": cid})).mappings()]
|
||||
stats = dict((await db.execute(text("""
|
||||
SELECT count(*) AS orders, coalesce(sum(total),0)::float AS spent, max(sale_date) AS last_order
|
||||
FROM sales WHERE customer_id = :i
|
||||
"""), {"i": cid})).mappings().first())
|
||||
return {"customer": dict(c), "sales": sales, "stats": stats}
|
||||
|
||||
|
||||
@router.post("/customers/{cid}")
|
||||
async def customer_edit(cid: int, body: CustEditIn, ident=Depends(require_token), db=Depends(get_db)):
|
||||
fields = {k: v for k, v in body.model_dump().items() if v is not None}
|
||||
if not fields:
|
||||
return {"ok": True, "unchanged": True}
|
||||
sets = ", ".join(f"{k} = :{k}" for k in fields)
|
||||
r = await db.execute(text(f"UPDATE customer SET {sets} WHERE id = :i"), {**fields, "i": cid})
|
||||
await db.commit()
|
||||
if not r.rowcount:
|
||||
raise HTTPException(404, "not found")
|
||||
return {"ok": True, "updated": r.rowcount}
|
||||
|
||||
|
||||
@router.get("/crates")
|
||||
async def crates(ident=Depends(require_token), db=Depends(get_db)):
|
||||
rows = await db.execute(text("""
|
||||
|
||||
@ -52,6 +52,8 @@ _STARTUP_DDL = [
|
||||
"CREATE TABLE IF NOT EXISTS sales_setting (setting_key text PRIMARY KEY, setting_value text, is_active boolean NOT NULL DEFAULT true, updated_at timestamptz DEFAULT now())",
|
||||
# a Guest customer always available at the counter
|
||||
"INSERT INTO customer (id, first_name, last_name, email, is_guest) VALUES (0, 'Guest', 'Sale', NULL, true) ON CONFLICT (id) DO NOTHING",
|
||||
"ALTER TABLE customer ADD COLUMN IF NOT EXISTS mailing_optin boolean DEFAULT false",
|
||||
"ALTER TABLE customer ADD COLUMN IF NOT EXISTS notes text",
|
||||
# disc_cache enrichment for the collection fallback locator (backfilled from discogs metadata separately)
|
||||
"ALTER TABLE disc_cache ADD COLUMN IF NOT EXISTS year int",
|
||||
"ALTER TABLE disc_cache ADD COLUMN IF NOT EXISTS genre_ids int[]",
|
||||
|
||||
@ -66,6 +66,7 @@
|
||||
<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>
|
||||
<div class="sec">Catalog</div>
|
||||
<a data-v="intake">📥 Intake</a>
|
||||
<a data-v="storemap">🗺️ Store map</a>
|
||||
@ -98,7 +99,7 @@ async function signin(){
|
||||
document.querySelectorAll('nav a[data-v]').forEach(a => a.onclick = () => go(a.dataset.v));
|
||||
function go(v){
|
||||
document.querySelectorAll('nav a[data-v]').forEach(a => a.classList.toggle('on', a.dataset.v===v));
|
||||
({dashboard:vDash, inventory:vInv, orders:vOrders, intake:vIntake, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn}[v])();
|
||||
({dashboard:vDash, inventory:vInv, orders:vOrders, customers:vCustomers, intake:vIntake, storemap:vStoreMap, crates:vCrates, reports:vReports, connections:vConn}[v])();
|
||||
}
|
||||
|
||||
// ---------- Dashboard ----------
|
||||
@ -300,6 +301,51 @@ async function rfidWrite(sku, releaseId){
|
||||
: `<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); }
|
||||
}
|
||||
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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user