diff --git a/main.py b/main.py index 26f8607..da9e685 100644 --- a/main.py +++ b/main.py @@ -188,13 +188,18 @@ class ProductCreate(BaseModel): class StaffAdd(BaseModel): name: str +class MemberUpdate(BaseModel): + member_number: Optional[str] = None + name: Optional[str] = None + pin: Optional[str] = None + # --------------------------------------------------------------------------- # Page routes # --------------------------------------------------------------------------- -@app.get("/", response_class=RedirectResponse) +@app.get("/", response_class=HTMLResponse) async def root(): - return RedirectResponse(url="/cashier", status_code=302) + return (static_dir / "index.html").read_text() @app.get("/cashier", response_class=HTMLResponse) async def cashier_page(): @@ -232,6 +237,54 @@ def create_member(body: MemberCreate): "created_at": row["created_at"], } +@app.put("/members/{member_id}") +def update_member(member_id: int, body: MemberUpdate): + with db_conn() as conn: + member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone() + if not member: + raise HTTPException(404, "Member not found") + updates = {} + if body.name is not None: + name = body.name.strip() + if not name: + raise HTTPException(400, "Name cannot be empty") + updates["name"] = name + if body.member_number is not None: + mn = body.member_number.strip() + if not mn: + raise HTTPException(400, "Member number cannot be empty") + clash = conn.execute( + "SELECT id FROM members WHERE member_number=? AND id!=?", (mn, member_id) + ).fetchone() + if clash: + raise HTTPException(400, "Member number already in use") + updates["member_number"] = mn + if body.pin is not None: + if len(body.pin) < 4: + raise HTTPException(400, "PIN must be at least 4 characters") + updates["pin_hash"] = hash_pin(body.pin) + if updates: + set_clause = ", ".join(f"{k}=?" for k in updates) + conn.execute( + f"UPDATE members SET {set_clause} WHERE id=?", + list(updates.values()) + [member_id] + ) + row = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone() + return {"id": row["id"], "member_number": row["member_number"], "name": row["name"]} + +@app.delete("/members/{member_id}") +def delete_member(member_id: int): + with db_conn() as conn: + member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone() + if not member: + raise HTTPException(404, "Member not found") + balance = member_balance(conn, member_id) + if balance != 0: + raise HTTPException(400, f"Cannot delete: balance is {format_amount(balance)}") + conn.execute("DELETE FROM ledger_entries WHERE member_id=?", (member_id,)) + conn.execute("DELETE FROM members WHERE id=?", (member_id,)) + return {"ok": True} + @app.get("/members") def list_members(q: Optional[str] = None): with db_conn() as conn: diff --git a/static/app.js b/static/app.js index 7e83511..6063942 100644 --- a/static/app.js +++ b/static/app.js @@ -1,22 +1,21 @@ -/* ClubLedger – frontend */ +/* ClubLedger – main SPA */ -let cfg = { currency_unit: 'pence', currency_symbol: '£', currency_divisor: 100, club_name: 'ClubLedger' }; let cashierMember = null; -let barMember = null; +let barMember = null; +let editMemberId = null; // --------------------------------------------------------------------------- // Boot // --------------------------------------------------------------------------- (async function init() { + await loadConfig(); + await loadStaffInto('cashierStaff'); + await loadStaffInto('barStaff'); + try { - const r = await fetch('/config'); - cfg = await r.json(); - document.getElementById('navBrand').textContent = cfg.club_name; - document.title = cfg.club_name; - document.querySelectorAll('.currency-unit').forEach(el => { - el.textContent = cfg.currency_unit; - }); - } catch (e) { /* use defaults */ } + const data = await apiFetch('/staff'); + renderStaffChips(data.staff); + } catch (e) { /* ignore */ } // Nav document.querySelectorAll('.nav-btn').forEach(btn => { @@ -28,51 +27,30 @@ let barMember = null; }); }); - // Register form document.getElementById('registerForm').addEventListener('submit', async e => { e.preventDefault(); await registerMember(); }); + document.getElementById('editForm').addEventListener('submit', async e => { + e.preventDefault(); + await saveEdit(); + }); - // Enter key on search fields - document.getElementById('memberSearch').addEventListener('keydown', e => { if (e.key === 'Enter') searchMembers(); }); + document.getElementById('memberSearch').addEventListener('keydown', e => { if (e.key === 'Enter') searchMembers(); }); document.getElementById('cashierSearch').addEventListener('keydown', e => { if (e.key === 'Enter') cashierSearchMembers(); }); - document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); }); + document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); }); + document.getElementById('staffNameInput').addEventListener('keydown',e => { if (e.key === 'Enter') addStaff(); }); searchMembers(); })(); -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- -function fmtAmount(pence) { - return cfg.currency_symbol + (pence / cfg.currency_divisor).toFixed(2); -} - -function balanceClass(v) { - return v < 0 ? 'balance-neg' : 'balance-pos'; -} - -function setMsg(id, text, type) { - const el = document.getElementById(id); - el.textContent = text; - el.className = 'msg ' + (type || ''); -} - -async function apiFetch(url, opts) { - const r = await fetch(url, opts); - const json = await r.json(); - if (!r.ok) throw new Error(json.detail || 'Server error'); - return json; -} - // --------------------------------------------------------------------------- // Members view // --------------------------------------------------------------------------- async function registerMember() { const number = document.getElementById('reg-number').value.trim(); - const name = document.getElementById('reg-name').value.trim(); - const pin = document.getElementById('reg-pin').value; + const name = document.getElementById('reg-name').value.trim(); + const pin = document.getElementById('reg-pin').value; if (!number || !name || !pin) { setMsg('registerMsg', 'All fields required.', 'err'); return; } try { const m = await apiFetch('/members', { @@ -94,26 +72,82 @@ async function searchMembers() { try { const members = await apiFetch(url); renderMemberTable(members); - } catch (e) { - console.error(e); - } + } catch (e) { console.error(e); } } function renderMemberTable(members) { const tbody = document.querySelector('#memberTable tbody'); - if (!members.length) { tbody.innerHTML = 'No members found'; return; } + if (!members.length) { + tbody.innerHTML = 'No members found'; + return; + } tbody.innerHTML = members.map(m => ` ${esc(m.member_number)} ${esc(m.name)} ${esc(m.balance_display)} - ${m.created_at ? m.created_at.slice(0,10) : ''} - - Statement + ${m.created_at ? m.created_at.slice(0, 10) : ''} + + Statement + + ${m.balance === 0 + ? `` + : ''} `).join(''); } +// --------------------------------------------------------------------------- +// Edit member +// --------------------------------------------------------------------------- +function openEditModal(id, name, number) { + editMemberId = id; + document.getElementById('edit-number').value = number; + document.getElementById('edit-name').value = name; + document.getElementById('edit-pin').value = ''; + setMsg('editMsg', '', ''); + document.getElementById('editModal').classList.remove('hidden'); + document.getElementById('edit-name').focus(); +} + +function closeEditModal() { + editMemberId = null; + document.getElementById('editModal').classList.add('hidden'); +} + +async function saveEdit() { + if (!editMemberId) return; + const number = document.getElementById('edit-number').value.trim(); + const name = document.getElementById('edit-name').value.trim(); + const pin = document.getElementById('edit-pin').value; + const body = { member_number: number, name }; + if (pin) body.pin = pin; + try { + await apiFetch(`/members/${editMemberId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + closeEditModal(); + searchMembers(); + } catch (e) { + setMsg('editMsg', e.message, 'err'); + } +} + +// --------------------------------------------------------------------------- +// Delete member +// --------------------------------------------------------------------------- +async function deleteMember(id, name) { + if (!confirm(`Delete member "${name}"?\n\nThis will permanently remove their account and transaction history.`)) return; + try { + await apiFetch(`/members/${id}`, { method: 'DELETE' }); + searchMembers(); + } catch (e) { + alert(e.message); + } +} + // --------------------------------------------------------------------------- // Cashier view // --------------------------------------------------------------------------- @@ -124,7 +158,7 @@ async function cashierSearchMembers() { const members = await apiFetch(url); const list = document.getElementById('cashierMemberList'); list.innerHTML = members.map(m => ` -
+
${esc(m.name)}
#${esc(m.member_number)}
@@ -147,27 +181,26 @@ function clearCashierSelection() { cashierMember = null; document.getElementById('cashierForm').classList.add('hidden'); document.getElementById('cashierAmount').value = ''; - document.getElementById('cashierStaff').value = ''; - document.getElementById('cashierNote').value = ''; + document.getElementById('cashierNote').value = ''; setMsg('cashierMsg', '', ''); } async function doTopup() { if (!cashierMember) return; const amount = parseInt(document.getElementById('cashierAmount').value, 10); - const staff = document.getElementById('cashierStaff').value.trim(); - const note = document.getElementById('cashierNote').value.trim(); + const staff = document.getElementById('cashierStaff').value; + const note = document.getElementById('cashierNote').value.trim(); if (!amount || isNaN(amount) || amount <= 0) { setMsg('cashierMsg', 'Enter a valid amount.', 'err'); return; } - if (!staff) { setMsg('cashierMsg', 'Staff name required.', 'err'); return; } + if (!staff) { setMsg('cashierMsg', 'Select a staff member.', 'err'); return; } try { const r = await apiFetch('/topup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ member_id: cashierMember.id, amount, staff_name: staff, note: note || null }) }); + window.open(`/receipt/${r.entry_id}`, '_blank'); setMsg('cashierMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok'); clearCashierSelection(); - searchMembers(); } catch (e) { setMsg('cashierMsg', e.message, 'err'); } @@ -183,7 +216,7 @@ async function barSearchMembers() { const members = await apiFetch(url); const list = document.getElementById('barMemberList'); list.innerHTML = members.map(m => ` -
+
${esc(m.name)}
#${esc(m.member_number)}
@@ -208,9 +241,8 @@ function clearBarSelection() { barMember = null; document.getElementById('barForm').classList.add('hidden'); document.getElementById('barAmount').value = ''; - document.getElementById('barPin').value = ''; - document.getElementById('barStaff').value = ''; - document.getElementById('barNote').value = ''; + document.getElementById('barPin').value = ''; + document.getElementById('barNote').value = ''; document.getElementById('barProductSearch').value = ''; document.getElementById('barProductResults').innerHTML = ''; setMsg('barMsg', '', ''); @@ -225,9 +257,12 @@ async function barProductLookup() { try { const products = await apiFetch(`/products?q=${encodeURIComponent(q)}`); const div = document.getElementById('barProductResults'); - if (!products.length) { div.innerHTML = '
No products found
'; return; } + if (!products.length) { + div.innerHTML = '
No products found
'; + return; + } div.innerHTML = products.map(p => ` -
+
${esc(p.name)}${p.brand ? ` – ${esc(p.brand)}` : ''} ${p.search_tags ? `
${esc(p.search_tags)}
` : ''} @@ -243,7 +278,7 @@ async function barProductLookup() { function selectProduct(price, memberPrice, label) { document.getElementById('barAmount').value = memberPrice; - document.getElementById('barNote').value = label; + document.getElementById('barNote').value = label; document.getElementById('barProductResults').innerHTML = ''; document.getElementById('barProductSearch').value = ''; } @@ -251,35 +286,22 @@ function selectProduct(price, memberPrice, label) { async function doCharge() { if (!barMember) return; const amount = parseInt(document.getElementById('barAmount').value, 10); - const pin = document.getElementById('barPin').value; - const staff = document.getElementById('barStaff').value.trim(); - const note = document.getElementById('barNote').value.trim(); + const pin = document.getElementById('barPin').value; + const staff = document.getElementById('barStaff').value; + const note = document.getElementById('barNote').value.trim(); if (!amount || isNaN(amount) || amount <= 0) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; } - if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; } - if (!staff) { setMsg('barMsg', 'Staff name required.', 'err'); return; } + if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; } + if (!staff) { setMsg('barMsg', 'Select a staff member.', 'err'); return; } try { const r = await apiFetch('/charge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ member_id: barMember.id, amount, pin, staff_name: staff, note: note || null }) }); + window.open(`/receipt/${r.entry_id}`, '_blank'); setMsg('barMsg', `Charge complete. New balance: ${r.new_balance_display}`, 'ok'); clearBarSelection(); - searchMembers(); } catch (e) { setMsg('barMsg', e.message, 'err'); } } - -// --------------------------------------------------------------------------- -// XSS-safe escape -// --------------------------------------------------------------------------- -function esc(str) { - if (str == null) return ''; - return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} diff --git a/static/index.html b/static/index.html index 1d2f117..00489b9 100644 --- a/static/index.html +++ b/static/index.html @@ -17,6 +17,7 @@
+

Register New Member

@@ -38,16 +39,29 @@
-

Member Search

+

Members

- + + +
#NameBalanceJoined
#NameBalanceJoined
+ +
+

Staff

+
+ + +
+
+
+
+
@@ -67,8 +81,8 @@
- - + +
@@ -94,7 +108,6 @@ + + + + diff --git a/static/style.css b/static/style.css index e9d7aa7..118b6d9 100644 --- a/static/style.css +++ b/static/style.css @@ -189,6 +189,31 @@ nav { } .form-row select:focus { border-color: var(--primary); } +/* ---- Row action buttons ---- */ +.row-actions { white-space: nowrap; } +.row-btn { padding: 4px 10px !important; font-size: .82rem !important; margin-right: 4px !important; } + +/* ---- Edit modal ---- */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; +} +.modal { + background: #fff; + border-radius: 10px; + padding: 28px 32px; + width: 420px; + max-width: calc(100vw - 32px); + box-shadow: 0 20px 60px rgba(0,0,0,.3); +} +.modal h3 { font-size: 1.1rem; margin-bottom: 18px; } +.modal-actions { display: flex; gap: 8px; margin-top: 18px; } + /* ---- Messages ---- */ .msg { margin-top: 12px; padding: 10px 14px; border-radius: 6px; font-size: .93rem; } .msg:empty { display: none; }