From 34b3e88fe2fb566492416d74c7589e35e5520c7b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 06:28:54 +0000 Subject: [PATCH] Fix 1-4: staff dropdown, split pages, print size toggle, receipts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1: Replace free-text staff name with a dropdown populated from staff.json via GET/POST/DELETE /staff endpoints. Staff management panel on cashier page (type name, Add button, chip list with × remove). Dropdown remembers last selection per session via sessionStorage. Fix 2: Split single-page app into /cashier (register + top-up + member list + staff management) and /bar (charge only). Each page is its own HTML file with two plain nav links; / redirects to /cashier. Shared helpers extracted to common.js; page logic in cashier.js and bar.js. Fix 3: Statement view gains an A4/A5 radio toggle that rewrites a dynamic -
+{_print_controls()} +

{club} – Account Statement

@@ -397,6 +451,67 @@ def statement(member_id: int): {rows_html}
Current Balance: {fmt(balance)}
+{_print_size_script()} + +""" + +@app.get("/receipt/{entry_id}", response_class=HTMLResponse) +def receipt(entry_id: int): + with db_conn() as conn: + entry = conn.execute("SELECT * FROM ledger_entries WHERE id=?", (entry_id,)).fetchone() + if not entry: + raise HTTPException(404, "Receipt not found") + member = conn.execute("SELECT * FROM members WHERE id=?", (entry["member_id"],)).fetchone() + balance_after = conn.execute(""" + SELECT COALESCE(SUM(CASE WHEN type='topup' THEN amount ELSE -amount END), 0) + FROM ledger_entries WHERE member_id=? AND id<=? + """, (entry["member_id"], entry_id)).fetchone()[0] + + sym = CONFIG["currency_symbol"] + div = CONFIG["currency_divisor"] + club = CONFIG["club_name"] + + def fmt(p): + return f"{sym}{p/div:.2f}" + + type_label = "Top-up" if entry["type"] == "topup" else "Charge" + amount_colour = "#080" if entry["type"] == "topup" else "#c00" + + return f""" + + + +Receipt – {member['name']} + + + +{_print_controls()} +
+ +
+

{club}

+
{type_label} Receipt
+
+ + + + + + + + + +
Member{member['name']}
Member #{member['member_number']}
Type{type_label}
Amount{fmt(entry['amount'])}
Balance after{fmt(balance_after)}
Staff{entry['staff_name']}
Note{entry['note'] or '—'}
Date / Time{entry['created_at']} UTC
+{_print_size_script()} """ @@ -443,10 +558,38 @@ def create_product(body: ProductCreate): ) return {"id": cur.lastrowid, "ok": True} +# --------------------------------------------------------------------------- +# Staff endpoints +# --------------------------------------------------------------------------- + +@app.get("/staff") +def get_staff(): + return {"staff": load_staff()} + +@app.post("/staff") +def add_staff(body: StaffAdd): + name = body.name.strip() + if not name: + raise HTTPException(400, "Name cannot be empty") + staff = load_staff() + if name not in staff: + staff.append(name) + save_staff(staff) + return {"staff": sorted(staff)} + +@app.delete("/staff/{name}") +def remove_staff(name: str): + staff = [s for s in load_staff() if s != name] + save_staff(staff) + return {"staff": staff} + +# --------------------------------------------------------------------------- +# Config endpoint +# --------------------------------------------------------------------------- + @app.get("/config") def get_config(): - safe = {k: v for k, v in CONFIG.items() if k != "db_path"} - return safe + return {k: v for k, v in CONFIG.items() if k != "db_path"} if __name__ == "__main__": import uvicorn diff --git a/static/bar.html b/static/bar.html new file mode 100644 index 0000000..509c88d --- /dev/null +++ b/static/bar.html @@ -0,0 +1,63 @@ + + + + + +Bar – ClubLedger + + + + +
+ +
+ +
+

Charge Account

+
+ + +
+
+ + +
+
+ +
+ + + + + diff --git a/static/bar.js b/static/bar.js new file mode 100644 index 0000000..969b5e3 --- /dev/null +++ b/static/bar.js @@ -0,0 +1,116 @@ +/* ClubLedger – bar page */ + +let barMember = null; + +(async function init() { + await loadConfig(); + await loadStaffInto('barStaff'); + + document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); }); +})(); + +// --------------------------------------------------------------------------- +// Member selection +// --------------------------------------------------------------------------- +async function barSearchMembers() { + const q = document.getElementById('barSearch').value.trim(); + const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members'; + try { + const members = await apiFetch(url); + const list = document.getElementById('barMemberList'); + list.innerHTML = members.map(m => ` +
+
+
${esc(m.name)}
+
#${esc(m.member_number)}
+
+
${esc(m.balance_display)}
+
`).join(''); + } catch (e) { console.error(e); } +} + +function selectBarMember(id, name, number, balance, balanceDisplay) { + barMember = { id, name, number }; + document.getElementById('barMemberList').innerHTML = ''; + document.getElementById('barSelected').innerHTML = + `${esc(name)}   #${esc(number)}   Balance: ${esc(balanceDisplay)}`; + document.getElementById('barForm').classList.remove('hidden'); + document.getElementById('barProductSearch').value = ''; + document.getElementById('barProductResults').innerHTML = ''; + setMsg('barMsg', '', ''); +} + +function clearBarSelection() { + barMember = null; + document.getElementById('barForm').classList.add('hidden'); + document.getElementById('barAmount').value = ''; + document.getElementById('barPin').value = ''; + document.getElementById('barNote').value = ''; + document.getElementById('barProductSearch').value = ''; + document.getElementById('barProductResults').innerHTML = ''; + setMsg('barMsg', '', ''); +} + +// --------------------------------------------------------------------------- +// Product search +// --------------------------------------------------------------------------- +let productTimer = null; +async function barProductLookup() { + clearTimeout(productTimer); + productTimer = setTimeout(async () => { + const q = document.getElementById('barProductSearch').value.trim(); + if (!q) { document.getElementById('barProductResults').innerHTML = ''; return; } + try { + const products = await apiFetch(`/products?q=${encodeURIComponent(q)}`); + const div = document.getElementById('barProductResults'); + 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)}
` : ''} +
+
+ ${esc(p.price_display)} + ${p.member_price_display ? `mbr: ${esc(p.member_price_display)}` : ''} +
+
`).join(''); + } catch (e) { console.error(e); } + }, 250); +} + +function selectProduct(price, memberPrice, label) { + document.getElementById('barAmount').value = memberPrice; + document.getElementById('barNote').value = label; + document.getElementById('barProductResults').innerHTML = ''; + document.getElementById('barProductSearch').value = ''; +} + +// --------------------------------------------------------------------------- +// Charge +// --------------------------------------------------------------------------- +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; + 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', '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(); + } catch (e) { + setMsg('barMsg', e.message, 'err'); + } +} diff --git a/static/cashier.html b/static/cashier.html new file mode 100644 index 0000000..e32add2 --- /dev/null +++ b/static/cashier.html @@ -0,0 +1,98 @@ + + + + + +Cashier – ClubLedger + + + + + + +
+ + +
+

Register New Member

+
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+

Top Up Account

+
+ + +
+
+ + +
+
+ + +
+

Members

+
+ + +
+ + + +
#NameBalanceJoined
+
+ + +
+

Staff

+
+ + +
+
+
+
+ +
+ + + + + diff --git a/static/cashier.js b/static/cashier.js new file mode 100644 index 0000000..f016316 --- /dev/null +++ b/static/cashier.js @@ -0,0 +1,132 @@ +/* ClubLedger – cashier page */ + +let cashierMember = null; + +(async function init() { + await loadConfig(); + await loadStaffInto('cashierStaff'); + + // load initial staff chips + try { + const data = await apiFetch('/staff'); + renderStaffChips(data.staff); + } catch (e) { /* ignore */ } + + document.getElementById('registerForm').addEventListener('submit', async e => { + e.preventDefault(); + await registerMember(); + }); + + document.getElementById('memberSearch').addEventListener('keydown', e => { if (e.key === 'Enter') searchMembers(); }); + document.getElementById('cashierSearch').addEventListener('keydown', e => { if (e.key === 'Enter') cashierSearchMembers(); }); + document.getElementById('staffNameInput').addEventListener('keydown', e => { if (e.key === 'Enter') addStaff(); }); + + searchMembers(); +})(); + +// --------------------------------------------------------------------------- +// Register +// --------------------------------------------------------------------------- +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; + if (!number || !name || !pin) { setMsg('registerMsg', 'All fields required.', 'err'); return; } + try { + const m = await apiFetch('/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ member_number: number, name, pin }) + }); + setMsg('registerMsg', `Registered: ${m.name} (#${m.member_number})`, 'ok'); + document.getElementById('registerForm').reset(); + searchMembers(); + } catch (e) { + setMsg('registerMsg', e.message, 'err'); + } +} + +// --------------------------------------------------------------------------- +// Member list +// --------------------------------------------------------------------------- +async function searchMembers() { + const q = document.getElementById('memberSearch').value.trim(); + const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members'; + try { + const members = await apiFetch(url); + const tbody = document.querySelector('#memberTable tbody'); + 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 + + `).join(''); + } catch (e) { console.error(e); } +} + +// --------------------------------------------------------------------------- +// Top-up +// --------------------------------------------------------------------------- +async function cashierSearchMembers() { + const q = document.getElementById('cashierSearch').value.trim(); + const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members'; + try { + const members = await apiFetch(url); + const list = document.getElementById('cashierMemberList'); + list.innerHTML = members.map(m => ` +
+
+
${esc(m.name)}
+
#${esc(m.member_number)}
+
+
${esc(m.balance_display)}
+
`).join(''); + } catch (e) { console.error(e); } +} + +function selectCashierMember(id, name, number, balance, balanceDisplay) { + cashierMember = { id, name, number }; + document.getElementById('cashierMemberList').innerHTML = ''; + document.getElementById('cashierSelected').innerHTML = + `${esc(name)}   #${esc(number)}   Balance: ${esc(balanceDisplay)}`; + document.getElementById('cashierForm').classList.remove('hidden'); + setMsg('cashierMsg', '', ''); +} + +function clearCashierSelection() { + cashierMember = null; + document.getElementById('cashierForm').classList.add('hidden'); + document.getElementById('cashierAmount').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; + 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', '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'); + } +} diff --git a/static/common.js b/static/common.js new file mode 100644 index 0000000..e0358d4 --- /dev/null +++ b/static/common.js @@ -0,0 +1,112 @@ +/* ClubLedger – shared helpers */ + +let cfg = { currency_unit: 'pence', currency_symbol: '£', currency_divisor: 100, club_name: 'ClubLedger' }; + +async function loadConfig() { + try { + const r = await fetch('/config'); + cfg = await r.json(); + const brand = document.getElementById('navBrand'); + if (brand) brand.textContent = cfg.club_name; + document.title = document.title.replace('ClubLedger', cfg.club_name); + document.querySelectorAll('.currency-unit').forEach(el => { el.textContent = cfg.currency_unit; }); + } catch (e) { /* use defaults */ } +} + +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; +} + +function esc(str) { + if (str == null) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// --------------------------------------------------------------------------- +// Staff dropdown +// --------------------------------------------------------------------------- + +async function loadStaffInto(selectId) { + const sel = document.getElementById(selectId); + if (!sel) return; + try { + const data = await apiFetch('/staff'); + const saved = sessionStorage.getItem('lastStaff') || ''; + sel.innerHTML = '' + + data.staff.map(n => ``).join(''); + sel.addEventListener('change', () => { + if (sel.value) sessionStorage.setItem('lastStaff', sel.value); + }); + } catch (e) { console.error('Could not load staff', e); } +} + +async function refreshAllStaffDropdowns() { + try { + const data = await apiFetch('/staff'); + const saved = sessionStorage.getItem('lastStaff') || ''; + document.querySelectorAll('select[id$="Staff"]').forEach(sel => { + sel.innerHTML = '' + + data.staff.map(n => ``).join(''); + }); + renderStaffChips(data.staff); + } catch (e) { console.error(e); } +} + +function renderStaffChips(staffList) { + const div = document.getElementById('staffChips'); + if (!div) return; + div.innerHTML = staffList.map(n => ` + + ${esc(n)} + + `).join(''); +} + +async function addStaff() { + const input = document.getElementById('staffNameInput'); + const name = input.value.trim(); + if (!name) return; + try { + const data = await apiFetch('/staff', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }); + input.value = ''; + setMsg('staffMsg', `Added: ${name}`, 'ok'); + renderStaffChips(data.staff); + await refreshAllStaffDropdowns(); + } catch (e) { + setMsg('staffMsg', e.message, 'err'); + } +} + +async function removeStaff(name) { + try { + const data = await apiFetch(`/staff/${encodeURIComponent(name)}`, { method: 'DELETE' }); + renderStaffChips(data.staff); + await refreshAllStaffDropdowns(); + } catch (e) { console.error(e); } +} diff --git a/static/style.css b/static/style.css index eec8276..e9d7aa7 100644 --- a/static/style.css +++ b/static/style.css @@ -45,6 +45,18 @@ nav { .nav-btn:hover { background: rgba(255,255,255,.1); } .nav-btn.active { border-color: #4a9eff; color: #fff; } +.nav-link { + color: var(--nav-text); + text-decoration: none; + padding: 6px 16px; + border-radius: 6px; + border: 2px solid transparent; + font-size: .95rem; + transition: background .15s, border-color .15s; +} +.nav-link:hover { background: rgba(255,255,255,.1); } +.nav-link.active { border-color: #4a9eff; color: #fff; } + /* ---- Views ---- */ .view { max-width: 900px; margin: 28px auto; padding: 0 16px; display: flex; flex-direction: column; gap: 24px; } .hidden { display: none !important; } @@ -141,6 +153,42 @@ nav { .product-item:hover { background: #f0fff4; border-color: #34d399; } .product-price { font-weight: 700; color: var(--primary); } +/* ---- Staff chips ---- */ +.staff-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } +.staff-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: #eef2ff; + border: 1px solid #c7d2f7; + border-radius: 20px; + padding: 4px 10px 4px 12px; + font-size: .88rem; +} +.chip-del { + background: none; + border: none; + cursor: pointer; + color: #888; + font-size: 1rem; + line-height: 1; + padding: 0; +} +.chip-del:hover { color: var(--danger); } + +/* ---- Select / dropdown ---- */ +.form-row select { + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 1rem; + outline: none; + background: #fff; + cursor: pointer; + transition: border-color .15s; +} +.form-row select:focus { border-color: var(--primary); } + /* ---- Messages ---- */ .msg { margin-top: 12px; padding: 10px 14px; border-radius: 6px; font-size: .93rem; } .msg:empty { display: none; }