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
` + + `${rows||''}
NameEmailPhoneOrdersSpentLast
none
`; +} +async function custOpen(id){ + const d = await api('/customers/'+id); const c = d.customer, st = d.stats; + const card = (label,val) => `
${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

+ ${sales||''}
DateSaleItemsTotalPayment
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.