diff --git a/app/admin_routes.py b/app/admin_routes.py
index e056d79..5bf89a6 100644
--- a/app/admin_routes.py
+++ b/app/admin_routes.py
@@ -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("""
diff --git a/app/main.py b/app/main.py
index e62053c..dd5c08b 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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[]",
diff --git a/site/admin.html b/site/admin.html
index 03edd57..e43ab7d 100644
--- a/site/admin.html
+++ b/site/admin.html
@@ -66,6 +66,7 @@
📊 Dashboard
📦 Inventory
🛒 Orders
+ 👤 Customers
Catalog
📥 Intake
🗺️ Store map
@@ -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){
: `RFID write failed: ${escapeH(d.error||'?')}`;
} catch(e){ if(msg) msg.innerHTML = 'RFID daemon unreachable (:7790)'; }
}
+
+// ---------- Customers (CRM) ----------
+let custState = { q:'' };
+async function vCustomers(){
+ const m = $('#content');
+ m.innerHTML = 'Customers
'
+ + ''
+ + 'loading…
';
+ $('#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=>`
+ | ${escapeH(c.name||'—')}${c.mailing_optin?' ✉':''} |
+ ${escapeH(c.email||'')} | ${escapeH(c.phone||'')} |
+ ${c.orders} | ${money(c.spent)} |
+ ${c.last_order?String(c.last_order).slice(0,10):'—'} |
`).join('');
+ $('#custrows').innerHTML = `${list.length} customers · click to open · sorted by spend
`
+ + `| Name | Email | Phone | Orders | Spent | Last |
${rows||'| none |
'}
`;
+}
+async function custOpen(id){
+ const d = await api('/customers/'+id); const c = d.customer, st = d.stats;
+ const card = (label,val) => ``;
+ const sales = (d.sales||[]).map(s=>`| ${s.sale_date?String(s.sale_date).slice(0,10):'—'} |
+ ${escapeH(s.sale_number||('#'+s.id))} | ${s.items} | ${money(s.total)} |
+ ${escapeH(s.payment_method||'')} ${escapeH(s.status||'')} |
`).join('');
+ $('#content').innerHTML = `${escapeH(((c.first_name||'')+' '+(c.last_name||'')).trim()||('Customer '+id))}
+ ${card('Orders',st.orders)}${card('Total spent',''+money(st.spent)+'')}${card('Last order',st.last_order?String(st.last_order).slice(0,10):'—')}
+ Info
+ ${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)}
+
+
+ Sales history
+ | Date | Sale | Items | Total | Payment |
${sales||'| no sales yet |
'}
`;
+}
+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(`Import list (bulk stock-in)
One per line — Release ID / barcode / catalogue no. Resolved against the catalog, each becomes an in-stock item.