From fa4884bdb41e2e8d5dbe28ad64ea1e8715bf34cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 05:21:01 +0000 Subject: [PATCH 01/25] Add store-credit web app (FastAPI + SQLite) - main.py: single-file backend with Member, LedgerEntry, Product models; endpoints for register, list/search members, topup (cashier), charge (bar, PIN-verified), transaction history, printable HTML statement, product CRUD. All monetary values stored as integers (pence). bcrypt PIN hashing. Admin-tunable CONFIG dict at top of file. - static/index.html + style.css + app.js: three-view SPA (Members, Cashier, Bar) with member search, product search with member-price support, and XSS-safe rendering throughout. - requirements.txt: fastapi, uvicorn, bcrypt only. https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7 --- main.py | 454 ++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + static/app.js | 285 +++++++++++++++++++++++++++++ static/index.html | 129 +++++++++++++ static/style.css | 148 +++++++++++++++ 5 files changed, 1019 insertions(+) create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 static/app.js create mode 100644 static/index.html create mode 100644 static/style.css diff --git a/main.py b/main.py new file mode 100644 index 0000000..e9a2205 --- /dev/null +++ b/main.py @@ -0,0 +1,454 @@ +""" +ClubLedger - Store Credit Web App +Admin configuration: edit the CONFIG dict below. +""" + +import sqlite3 +import hashlib +import time +import os +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +import bcrypt +from fastapi import FastAPI, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, field_validator +from typing import Optional + +# --------------------------------------------------------------------------- +# Admin configuration +# --------------------------------------------------------------------------- +CONFIG = { + "club_name": "ClubLedger", + "currency_symbol": "£", + "currency_unit": "pence", # smallest unit stored as integer + "currency_divisor": 100, # divide stored int by this for display + "db_path": "clubledger.db", + "allow_negative_balance": False, # set True to allow overdraft at bar + "min_topup_amount": 100, # minimum top-up in pence (£1.00) + "max_topup_amount": 100_000, # maximum top-up in pence (£1000.00) + "max_charge_amount": 50_000, # maximum single charge in pence +} + +DB_PATH = CONFIG["db_path"] + +# --------------------------------------------------------------------------- +# Database +# --------------------------------------------------------------------------- + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + +@contextmanager +def db_conn(): + conn = get_db() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + +def init_db(): + with db_conn() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + member_number TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + pin_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS ledger_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + member_id INTEGER NOT NULL REFERENCES members(id), + amount INTEGER NOT NULL, + type TEXT NOT NULL CHECK(type IN ('topup','charge')), + venue TEXT NOT NULL CHECK(venue IN ('cashier','bar')), + note TEXT, + staff_name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + brand TEXT, + price INTEGER NOT NULL, + member_price INTEGER, + search_tags TEXT, + active INTEGER NOT NULL DEFAULT 1 + ); + + CREATE INDEX IF NOT EXISTS idx_ledger_member + ON ledger_entries(member_id); + """) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def hash_pin(pin: str) -> str: + return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode() + +def verify_pin(pin: str, hashed: str) -> bool: + return bcrypt.checkpw(pin.encode(), hashed.encode()) + +def member_balance(conn, member_id: int) -> int: + row = conn.execute(""" + SELECT COALESCE( + SUM(CASE WHEN type='topup' THEN amount ELSE -amount END), 0 + ) AS balance + FROM ledger_entries WHERE member_id=? + """, (member_id,)).fetchone() + return row["balance"] if row else 0 + +def format_amount(pence: int) -> str: + sym = CONFIG["currency_symbol"] + div = CONFIG["currency_divisor"] + return f"{sym}{pence / div:.2f}" + +# --------------------------------------------------------------------------- +# FastAPI app +# --------------------------------------------------------------------------- + +app = FastAPI(title=CONFIG["club_name"]) + +# Serve static files +static_dir = Path(__file__).parent / "static" +static_dir.mkdir(exist_ok=True) +app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + +@app.on_event("startup") +def on_startup(): + init_db() + +# --------------------------------------------------------------------------- +# Pydantic models +# --------------------------------------------------------------------------- + +class MemberCreate(BaseModel): + member_number: str + name: str + pin: str + + @field_validator("pin") + @classmethod + def pin_length(cls, v): + if len(v) < 4: + raise ValueError("PIN must be at least 4 characters") + return v + + @field_validator("member_number") + @classmethod + def member_number_nonempty(cls, v): + v = v.strip() + if not v: + raise ValueError("member_number cannot be empty") + return v + +class TopupRequest(BaseModel): + member_id: int + amount: int # pence + staff_name: str + note: Optional[str] = None + +class ChargeRequest(BaseModel): + member_id: int + amount: int # pence + pin: str + staff_name: str + note: Optional[str] = None + +class ProductCreate(BaseModel): + name: str + brand: Optional[str] = None + price: int + member_price: Optional[int] = None + search_tags: Optional[str] = None + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@app.get("/", response_class=HTMLResponse) +async def root(): + return (static_dir / "index.html").read_text() + +@app.post("/members") +def create_member(body: MemberCreate): + with db_conn() as conn: + existing = conn.execute( + "SELECT id FROM members WHERE member_number=?", + (body.member_number.strip(),) + ).fetchone() + if existing: + raise HTTPException(400, "Member number already exists") + pin_hash = hash_pin(body.pin) + cur = conn.execute( + "INSERT INTO members (member_number, name, pin_hash) VALUES (?,?,?)", + (body.member_number.strip(), body.name.strip(), pin_hash) + ) + member_id = cur.lastrowid + with db_conn() as conn: + row = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone() + return { + "id": row["id"], + "member_number": row["member_number"], + "name": row["name"], + "created_at": row["created_at"], + } + +@app.get("/members") +def list_members(q: Optional[str] = None): + with db_conn() as conn: + if q: + pattern = f"%{q}%" + rows = conn.execute( + "SELECT * FROM members WHERE name LIKE ? OR member_number LIKE ? ORDER BY name", + (pattern, pattern) + ).fetchall() + else: + rows = conn.execute("SELECT * FROM members ORDER BY name").fetchall() + result = [] + for r in rows: + balance = member_balance(conn, r["id"]) + result.append({ + "id": r["id"], + "member_number": r["member_number"], + "name": r["name"], + "balance": balance, + "balance_display": format_amount(balance), + "created_at": r["created_at"], + }) + return result + +@app.post("/topup") +def topup(body: TopupRequest): + if body.amount < CONFIG["min_topup_amount"]: + raise HTTPException(400, f"Minimum top-up is {format_amount(CONFIG['min_topup_amount'])}") + if body.amount > CONFIG["max_topup_amount"]: + raise HTTPException(400, f"Maximum top-up is {format_amount(CONFIG['max_topup_amount'])}") + with db_conn() as conn: + member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone() + if not member: + raise HTTPException(404, "Member not found") + conn.execute( + "INSERT INTO ledger_entries (member_id, amount, type, venue, note, staff_name) VALUES (?,?,?,?,?,?)", + (body.member_id, body.amount, "topup", "cashier", body.note, body.staff_name) + ) + balance = member_balance(conn, body.member_id) + return { + "ok": True, + "new_balance": balance, + "new_balance_display": format_amount(balance), + } + +@app.post("/charge") +def charge(body: ChargeRequest): + if body.amount <= 0: + raise HTTPException(400, "Amount must be positive") + if body.amount > CONFIG["max_charge_amount"]: + raise HTTPException(400, f"Maximum single charge is {format_amount(CONFIG['max_charge_amount'])}") + with db_conn() as conn: + member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone() + if not member: + raise HTTPException(404, "Member not found") + if not verify_pin(body.pin, member["pin_hash"]): + raise HTTPException(403, "Incorrect PIN") + balance = member_balance(conn, body.member_id) + if not CONFIG["allow_negative_balance"] and balance < body.amount: + raise HTTPException(400, f"Insufficient balance ({format_amount(balance)})") + conn.execute( + "INSERT INTO ledger_entries (member_id, amount, type, venue, note, staff_name) VALUES (?,?,?,?,?,?)", + (body.member_id, body.amount, "charge", "bar", body.note, body.staff_name) + ) + new_balance = member_balance(conn, body.member_id) + return { + "ok": True, + "new_balance": new_balance, + "new_balance_display": format_amount(new_balance), + } + +@app.get("/members/{member_id}/transactions") +def transactions(member_id: int, limit: int = 50, offset: int = 0): + 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") + rows = conn.execute(""" + SELECT * FROM ledger_entries + WHERE member_id=? + ORDER BY created_at DESC + LIMIT ? OFFSET ? + """, (member_id, limit, offset)).fetchall() + balance = member_balance(conn, member_id) + return { + "member": { + "id": member["id"], + "member_number": member["member_number"], + "name": member["name"], + }, + "balance": balance, + "balance_display": format_amount(balance), + "transactions": [ + { + "id": r["id"], + "amount": r["amount"], + "amount_display": format_amount(r["amount"]), + "type": r["type"], + "venue": r["venue"], + "note": r["note"], + "staff_name": r["staff_name"], + "created_at": r["created_at"], + } + for r in rows + ], + } + +@app.get("/members/{member_id}/statement", response_class=HTMLResponse) +def statement(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") + rows = conn.execute(""" + SELECT * FROM ledger_entries WHERE member_id=? + ORDER BY created_at ASC + """, (member_id,)).fetchall() + balance = member_balance(conn, member_id) + + sym = CONFIG["currency_symbol"] + div = CONFIG["currency_divisor"] + club = CONFIG["club_name"] + + def fmt(p): + return f"{sym}{p/div:.2f}" + + rows_html = "" + running = 0 + for r in rows: + if r["type"] == "topup": + running += r["amount"] + dr, cr = "", fmt(r["amount"]) + else: + running -= r["amount"] + dr, cr = fmt(r["amount"]), "" + rows_html += f""" + + {r['created_at'][:16]} + {r['type']} + {r['venue']} + {r['note'] or ''} + {r['staff_name']} + {dr} + {cr} + {fmt(running)} + """ + + return f""" + + + +Statement – {member['name']} + + + +
+ +
+

{club} – Account Statement

+

Member: {member['name']}  |  #{member['member_number']}  |  Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC

+ + + + + + + + {rows_html} +
Date/TimeTypeVenueNoteStaffChargeTop-upBalance
+
Current Balance: {fmt(balance)}
+ +""" + +# --------------------------------------------------------------------------- +# Products endpoints +# --------------------------------------------------------------------------- + +@app.get("/products") +def list_products(q: Optional[str] = None, active_only: bool = True): + with db_conn() as conn: + base = "SELECT * FROM products" + conds, params = [], [] + if active_only: + conds.append("active=1") + if q: + conds.append("(name LIKE ? OR brand LIKE ? OR search_tags LIKE ?)") + p = f"%{q}%" + params += [p, p, p] + if conds: + base += " WHERE " + " AND ".join(conds) + base += " ORDER BY name" + rows = conn.execute(base, params).fetchall() + return [ + { + "id": r["id"], + "name": r["name"], + "brand": r["brand"], + "price": r["price"], + "price_display": format_amount(r["price"]), + "member_price": r["member_price"], + "member_price_display": format_amount(r["member_price"]) if r["member_price"] else None, + "search_tags": r["search_tags"], + "active": bool(r["active"]), + } + for r in rows + ] + +@app.post("/products") +def create_product(body: ProductCreate): + with db_conn() as conn: + cur = conn.execute( + "INSERT INTO products (name, brand, price, member_price, search_tags) VALUES (?,?,?,?,?)", + (body.name, body.brand, body.price, body.member_price, body.search_tags) + ) + return {"id": cur.lastrowid, "ok": True} + +@app.get("/config") +def get_config(): + safe = {k: v for k, v in CONFIG.items() if k != "db_path"} + return safe + +if __name__ == "__main__": + import uvicorn + uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d52f40e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi +uvicorn[standard] +bcrypt diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..7e83511 --- /dev/null +++ b/static/app.js @@ -0,0 +1,285 @@ +/* ClubLedger – frontend */ + +let cfg = { currency_unit: 'pence', currency_symbol: '£', currency_divisor: 100, club_name: 'ClubLedger' }; +let cashierMember = null; +let barMember = null; + +// --------------------------------------------------------------------------- +// Boot +// --------------------------------------------------------------------------- +(async function init() { + 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 */ } + + // Nav + document.querySelectorAll('.nav-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + document.querySelectorAll('.view').forEach(v => v.classList.add('hidden')); + document.getElementById('view-' + btn.dataset.view).classList.remove('hidden'); + }); + }); + + // Register form + document.getElementById('registerForm').addEventListener('submit', async e => { + e.preventDefault(); + await registerMember(); + }); + + // Enter key on search fields + 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(); }); + + 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; + 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'); + } +} + +async function searchMembers() { + const q = document.getElementById('memberSearch').value.trim(); + const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members'; + try { + const members = await apiFetch(url); + renderMemberTable(members); + } catch (e) { + console.error(e); + } +} + +function renderMemberTable(members) { + 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(''); +} + +// --------------------------------------------------------------------------- +// Cashier view +// --------------------------------------------------------------------------- +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('cashierStaff').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(); + if (!amount || isNaN(amount) || amount <= 0) { setMsg('cashierMsg', 'Enter a valid amount.', 'err'); return; } + if (!staff) { setMsg('cashierMsg', 'Staff name required.', '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 }) + }); + setMsg('cashierMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok'); + clearCashierSelection(); + searchMembers(); + } catch (e) { + setMsg('cashierMsg', e.message, 'err'); + } +} + +// --------------------------------------------------------------------------- +// Bar view +// --------------------------------------------------------------------------- +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('barStaff').value = ''; + document.getElementById('barNote').value = ''; + document.getElementById('barProductSearch').value = ''; + document.getElementById('barProductResults').innerHTML = ''; + setMsg('barMsg', '', ''); +} + +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 = ''; +} + +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(); + 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; } + 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 }) + }); + 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 new file mode 100644 index 0000000..1d2f117 --- /dev/null +++ b/static/index.html @@ -0,0 +1,129 @@ + + + + + +ClubLedger + + + + + + + +
+
+

Register New Member

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

Member Search

+
+ + +
+ + + +
#NameBalanceJoined
+
+
+ + + + + + + + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..eec8276 --- /dev/null +++ b/static/style.css @@ -0,0 +1,148 @@ +/* ClubLedger – main styles */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --primary: #1a56db; + --primary-dark: #1140a6; + --danger: #d63b3b; + --danger-dark: #a82e2e; + --success: #1a7f3c; + --bg: #f4f6fb; + --panel-bg: #ffffff; + --border: #d1d5db; + --text: #111827; + --muted: #6b7280; + --nav-bg: #1a1a2e; + --nav-text: #e0e0e0; +} + +body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; } + +/* ---- Nav ---- */ +nav { + background: var(--nav-bg); + color: var(--nav-text); + display: flex; + align-items: center; + padding: 0 20px; + height: 52px; + gap: 8px; + position: sticky; + top: 0; + z-index: 100; +} +.brand { font-size: 1.2rem; font-weight: 700; letter-spacing: .5px; margin-right: 16px; color: #fff; } +.nav-btn { + background: transparent; + border: 2px solid transparent; + color: var(--nav-text); + padding: 6px 16px; + border-radius: 6px; + cursor: pointer; + font-size: .95rem; + transition: background .15s, border-color .15s; +} +.nav-btn:hover { background: rgba(255,255,255,.1); } +.nav-btn.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; } + +/* ---- Panel ---- */ +.panel { background: var(--panel-bg); border: 1px solid var(--border); border-radius: 10px; padding: 24px; } +.panel h2 { font-size: 1.15rem; margin-bottom: 18px; color: var(--text); } + +/* ---- Forms ---- */ +.form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; } +.form-row label { font-size: .85rem; font-weight: 600; color: var(--muted); } +.form-row input { + padding: 9px 12px; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 1rem; + outline: none; + transition: border-color .15s; +} +.form-row input:focus { border-color: var(--primary); } + +.search-row { display: flex; gap: 8px; margin-bottom: 14px; } +.search-row input { flex: 1; padding: 9px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 1rem; outline: none; } +.search-row input:focus { border-color: var(--primary); } + +/* ---- Buttons ---- */ +.btn { + padding: 9px 20px; + border: 1px solid var(--border); + border-radius: 6px; + background: #fff; + cursor: pointer; + font-size: .95rem; + transition: background .15s; + margin-right: 6px; +} +.btn:hover { background: #f0f0f0; } +.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); } +.btn-primary:hover { background: var(--primary-dark); } +.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); } +.btn-danger:hover { background: var(--danger-dark); } + +/* ---- Data table ---- */ +.data-table { width: 100%; border-collapse: collapse; font-size: .93rem; margin-top: 8px; } +.data-table th { background: #f0f2f7; padding: 8px 10px; text-align: left; font-weight: 600; border-bottom: 2px solid var(--border); } +.data-table td { padding: 8px 10px; border-bottom: 1px solid #eee; } +.data-table tr:last-child td { border-bottom: none; } +.data-table tr:hover td { background: #f9f9ff; } +.num { text-align: right; font-variant-numeric: tabular-nums; } +.balance-pos { color: var(--success); font-weight: 600; } +.balance-neg { color: var(--danger); font-weight: 600; } + +/* ---- Member pick list ---- */ +.member-pick-list { margin-bottom: 14px; } +.member-pick-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 6px; + cursor: pointer; + transition: background .12s, border-color .12s; +} +.member-pick-item:hover { background: #eef2ff; border-color: var(--primary); } +.member-pick-name { font-weight: 600; } +.member-pick-sub { font-size: .83rem; color: var(--muted); } + +/* ---- Selected member box ---- */ +.selected-member-box { + background: #eef2ff; + border: 1px solid #c7d2f7; + border-radius: 8px; + padding: 12px 16px; + margin-bottom: 16px; + font-size: .95rem; +} +.selected-member-box strong { font-size: 1.05rem; } + +/* ---- Product results ---- */ +.product-results { margin-bottom: 14px; } +.product-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + margin-bottom: 5px; + cursor: pointer; + transition: background .12s; +} +.product-item:hover { background: #f0fff4; border-color: #34d399; } +.product-price { font-weight: 700; color: var(--primary); } + +/* ---- Messages ---- */ +.msg { margin-top: 12px; padding: 10px 14px; border-radius: 6px; font-size: .93rem; } +.msg:empty { display: none; } +.msg.ok { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; } +.msg.err { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; } From 7af0dd0496adbe8d010538c2fe17b76ed6f56d0d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 06:00:34 +0000 Subject: [PATCH 02/25] Fix on_event deprecation, use lifespan handler FastAPI 0.93+ deprecates @app.on_event; replaced with @asynccontextmanager lifespan pattern. Also cleaned up unused stdlib imports. https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7 --- main.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/main.py b/main.py index e9a2205..6cbe255 100644 --- a/main.py +++ b/main.py @@ -4,10 +4,8 @@ Admin configuration: edit the CONFIG dict below. """ import sqlite3 -import hashlib -import time import os -from contextlib import contextmanager +from contextlib import contextmanager, asynccontextmanager from datetime import datetime, timezone from pathlib import Path @@ -122,17 +120,18 @@ def format_amount(pence: int) -> str: # FastAPI app # --------------------------------------------------------------------------- -app = FastAPI(title=CONFIG["club_name"]) +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + yield + +app = FastAPI(title=CONFIG["club_name"], lifespan=lifespan) # Serve static files static_dir = Path(__file__).parent / "static" static_dir.mkdir(exist_ok=True) app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") -@app.on_event("startup") -def on_startup(): - init_db() - # --------------------------------------------------------------------------- # Pydantic models # --------------------------------------------------------------------------- From 34b3e88fe2fb566492416d74c7589e35e5520c7b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 06:28:54 +0000 Subject: [PATCH 03/25] 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; } From 6c155d00bb522ae8721908abeab7d3b0b185d0ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 08:33:44 +0000 Subject: [PATCH 04/25] Restore three-view SPA; add member edit and delete - / now serves index.html (three-view SPA: Members, Cashier, Bar) - /cashier and /bar remain as standalone pages (unchanged) - Members view: Edit button on every row opens a modal to update name, member number, and optionally PIN. Delete button only appears when balance is exactly 0; confirmation dialog before deletion removes the member and their ledger entries. - PUT /members/{id}: updates any combination of name/member_number/pin; guards against duplicate member numbers. - DELETE /members/{id}: rejects with 400 if balance != 0, otherwise deletes ledger entries then member row. - Modal styles added to style.css; app.js rebuilt as combined SPA script (loads common.js for shared helpers). https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7 --- main.py | 57 +++++++++++++- static/app.js | 184 ++++++++++++++++++++++++++-------------------- static/index.html | 54 ++++++++++++-- static/style.css | 25 +++++++ 4 files changed, 230 insertions(+), 90 deletions(-) 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; } From 79d51973cd59a6264310d1752f67b53e17784af7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 08:52:18 +0000 Subject: [PATCH 05/25] Remove product search from bar tab Removed product search field, results list, barProductLookup(), and selectProduct() from the bar view in index.html, bar.html, app.js, and bar.js. Backend /products endpoints are unchanged. https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7 --- static/app.js | 39 --------------------------------------- static/bar.html | 6 ------ static/bar.js | 42 ------------------------------------------ static/index.html | 6 ------ 4 files changed, 93 deletions(-) diff --git a/static/app.js b/static/app.js index 6063942..07120cd 100644 --- a/static/app.js +++ b/static/app.js @@ -232,8 +232,6 @@ function selectBarMember(id, name, number, balance, balanceDisplay) { 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', '', ''); } @@ -243,46 +241,9 @@ function clearBarSelection() { document.getElementById('barAmount').value = ''; document.getElementById('barPin').value = ''; document.getElementById('barNote').value = ''; - document.getElementById('barProductSearch').value = ''; - document.getElementById('barProductResults').innerHTML = ''; setMsg('barMsg', '', ''); } -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 = ''; -} - async function doCharge() { if (!barMember) return; const amount = parseInt(document.getElementById('barAmount').value, 10); diff --git a/static/bar.html b/static/bar.html index 509c88d..0306a34 100644 --- a/static/bar.html +++ b/static/bar.html @@ -27,12 +27,6 @@
- + + +
+
+ + +
+
+
@@ -166,6 +186,8 @@

App Settings

+ +

General

@@ -182,8 +204,6 @@
-
-
- + +
+

Business Address

+
+
+
+
+
+
+
+
+ +
+

Branding

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

Transactions

+
+
+
+
+ +
+

Receipt Labels (for localisation)

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

Receipt Footers

+
+
+
+
+
+
+ +
From 4125972b67459627e391d63fb3a1ec1f21a0d309 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 15:44:56 +0000 Subject: [PATCH 19/25] Ignore runtime data files (clubledger.db, staff.json) https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7 --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 83972fa..2c6195e 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,9 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +# ClubLedger runtime data +clubledger.db +clubledger.db-wal +clubledger.db-shm +staff.json From 79ae833fa94cdf02071d67465a521e077c3cd8c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 16:42:46 +0000 Subject: [PATCH 20/25] Replace literal em-dashes with — HTML entities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes garbled display ("â€"") on Windows where the UTF-8 bytes for U+2014 were being misread as Windows-1252. All em-dash occurrences in index.html, app.js, and common.js are now expressed as HTML entities. https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7 --- static/app.js | 2 +- static/common.js | 4 ++-- static/index.html | 16 ++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/static/app.js b/static/app.js index 3f62ef4..4791b48 100644 --- a/static/app.js +++ b/static/app.js @@ -67,7 +67,7 @@ function populateTransferTypes() { const sel = document.getElementById(id); if (!sel) return; const prev = sel.value; - sel.innerHTML = '' + + sel.innerHTML = '' + types.map(t => ``).join(''); if (prev && types.includes(prev)) sel.value = prev; }); diff --git a/static/common.js b/static/common.js index e0358d4..bdedb58 100644 --- a/static/common.js +++ b/static/common.js @@ -54,7 +54,7 @@ async function loadStaffInto(selectId) { try { const data = await apiFetch('/staff'); const saved = sessionStorage.getItem('lastStaff') || ''; - sel.innerHTML = '' + + sel.innerHTML = '' + data.staff.map(n => ``).join(''); sel.addEventListener('change', () => { if (sel.value) sessionStorage.setItem('lastStaff', sel.value); @@ -67,7 +67,7 @@ async function refreshAllStaffDropdowns() { const data = await apiFetch('/staff'); const saved = sessionStorage.getItem('lastStaff') || ''; document.querySelectorAll('select[id$="Staff"]').forEach(sel => { - sel.innerHTML = '' + + sel.innerHTML = '' + data.staff.map(n => ``).join(''); }); renderStaffChips(data.staff); diff --git a/static/index.html b/static/index.html index 0999ecb..5247332 100644 --- a/static/index.html +++ b/static/index.html @@ -100,7 +100,7 @@
@@ -128,7 +128,7 @@
@@ -209,9 +209,9 @@
@@ -268,11 +268,11 @@

Receipt Footers

-
+
-
+
-
+
From 09df5efb07bf10f2f31f9efc92d9b60934beb8bf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 16:55:23 +0000 Subject: [PATCH 21/25] Redesign receipts/statement to match spec; add logo upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Receipts: - Font size raised to 11pt base (labels 9pt, amounts 13pt bold) - Each field now shows LABEL (small, uppercase, gray) above VALUE — two cells per row in a 1fr/1fr CSS grid, matching the provided samples - Business header: left column = address lines, right column = Tel/Email/Web - Charge receipt: STAFF+TRANSACTION / CHARGE+TIME / AMOUNT+BALANCE - Top-up/Withdrawal receipt: STAFF+TXN / TRANSFER_TYPE+TIME / AMOUNT+BALANCE / TRANSFER_TYPE+TRANSFER_REF - Print button moved into the paper-size controls bar Statement: - Reduced from 9 to 7 columns: Date, Reference, Type, Venue, Staff, Amount (+/-), Balance — removes the separate Charge/Credit split - Amount shown as "+ £X.XX" (green) or "- £X.XX" (red) - Sub-row shows "Transfer type: X — Ref" for top-ups/withdrawals, or the note text for charges Logo: - New POST /admin/logo endpoint: accepts image upload, saves to static/logo.{ext}, auto-updates logo_url setting - New logo_max_width / logo_max_height config fields (default 200×80px) - Admin branding section: file upload input + max-width/height fields - python-multipart added to requirements.txt (needed for file upload) https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7 --- main.py | 337 ++++++++++++++++++++++++++++------------------ requirements.txt | 1 + static/app.js | 35 ++++- static/index.html | 16 ++- 4 files changed, 251 insertions(+), 138 deletions(-) diff --git a/main.py b/main.py index bf7e710..6fce780 100644 --- a/main.py +++ b/main.py @@ -13,7 +13,7 @@ from pathlib import Path from typing import Optional import bcrypt -from fastapi import FastAPI, HTTPException, Cookie, Depends, Response +from fastapi import FastAPI, HTTPException, Cookie, Depends, Response, UploadFile, File from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, field_validator @@ -43,6 +43,8 @@ CONFIG = { # Branding "logo_url": "", "logo_align": "left", + "logo_max_width": 200, + "logo_max_height": 80, "bar_name": "Bar", "cashier_name": "Cashier", # Transactions @@ -423,6 +425,8 @@ class AppSettingsUpdate(BaseModel): # Branding logo_url: Optional[str] = None logo_align: Optional[str] = None + logo_max_width: Optional[int] = None + logo_max_height: Optional[int] = None bar_name: Optional[str] = None cashier_name: Optional[str] = None # Transactions @@ -690,7 +694,7 @@ def _print_size_script(): function setSize(s){ var el=document.getElementById('psStyle'); if(!el){el=document.createElement('style');el.id='psStyle';document.head.appendChild(el);} - el.textContent='@media print{@page{size:'+s+';margin:'+(s==='A5'?'8mm':'14mm')+';}}';} + el.textContent='@media print{@page{size:'+s+';margin:'+(s==='A5'?'10mm':'16mm')+';}}';} setSize('A4'); """ @@ -699,6 +703,7 @@ def _print_controls(): Paper: +
""" def _txn_ref(entry_id: int, s: dict) -> str: @@ -709,57 +714,86 @@ def _logo_html(s: dict) -> str: url = (s.get("logo_url") or "").strip() if not url: return "" - align = s.get("logo_align", "left") - css_cls = f"biz-logo align-{align}" if align in ("left", "center", "right") else "biz-logo align-left" - return f'logo' + align = s.get("logo_align", "left") + max_w = int(s.get("logo_max_width", 200) or 200) + max_h = int(s.get("logo_max_height", 80) or 80) + style = f"max-width:{max_w}px;max-height:{max_h}px;" + css_cl = f"biz-logo align-{align}" if align in ("left","center","right") else "biz-logo" + return f'logo' def _biz_header_html(s: dict) -> str: - parts = [_logo_html(s)] - parts.append(f'
{s.get("club_name") or "ClubLedger"}
') - addr = [s.get(f"biz_address{i}", "") for i in range(1, 5)] + [s.get("biz_country", "")] - addr = [l.strip() for l in addr if l and l.strip()] - if addr: - parts.append('
' + "
".join(addr) + "
") - contact = [x for x in [s.get("biz_phone",""), s.get("biz_email",""), s.get("biz_website","")] if x and x.strip()] - if contact: - parts.append('
' + "  |  ".join(contact) + "
") - return '
' + "\n".join(p for p in parts if p) + "
" + logo = _logo_html(s) + name = s.get("club_name") or "ClubLedger" + + addr = [( s.get(f"biz_address{i}") or "").strip() for i in range(1,5)] + addr += [(s.get("biz_country") or "").strip()] + addr = [l for l in addr if l] + + contacts = [] + if (s.get("biz_phone") or "").strip(): contacts.append(f'Tel.   {s["biz_phone"]}') + if (s.get("biz_email") or "").strip(): contacts.append(f'Email: {s["biz_email"]}') + if (s.get("biz_website") or "").strip(): contacts.append(f'Web:   {s["biz_website"]}') + + parts = [] + if logo: parts.append(logo) + parts.append(f'
{name}
') + + if addr and contacts: + parts.append( + f'
' + f'
{"
".join(addr)}
' + f'
{"
".join(contacts)}
' + f'
' + ) + elif addr: + parts.append(f'
{"
".join(addr)}
') + elif contacts: + parts.append(f'
{"
".join(contacts)}
') + + return '
' + "\n".join(parts) + "
" + +def _rx_cell(label: str, value: str, extra_cls: str = "") -> str: + val_cls = ("rx-val " + extra_cls).strip() + return f'
{label}
{value}
' RECEIPT_CSS = """ - body{font-family:Arial,sans-serif;font-size:11px;color:#111;margin:24px;} - h2{font-size:14px;font-weight:bold;margin:10px 0 4px;} - hr{border:none;border-top:1px solid #ccc;margin:10px 0;} - .controls{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;} - .size-label{font-size:12px;color:#555;} .controls label{font-size:12px;cursor:pointer;} - .print-btn{padding:7px 18px;font-size:13px;cursor:pointer;margin-left:auto;} + body{font-family:Arial,sans-serif;font-size:11pt;color:#111;margin:28px;} + hr{border:none;border-top:1px solid #ccc;margin:12px 0;} + .controls{display:flex;align-items:center;gap:12px;margin-bottom:16px;flex-wrap:wrap;} + .size-label{font-size:10pt;color:#555;} + .controls label{font-size:10pt;cursor:pointer;} + .print-btn{padding:6px 16px;font-size:10pt;cursor:pointer;margin-left:auto;} @media print{.no-print{display:none;}} - .biz-header{margin-bottom:4px;} - .biz-logo{max-height:60px;max-width:200px;display:block;margin-bottom:4px;} + /* Business header */ + .biz-logo{display:block;margin-bottom:8px;} .biz-logo.align-center{margin-left:auto;margin-right:auto;} .biz-logo.align-right{margin-left:auto;} - .biz-name{font-size:15px;font-weight:bold;margin:2px 0;} - .biz-address{color:#555;line-height:1.5;} - .biz-contact{color:#555;margin-top:3px;} - .receipt-title{font-size:13px;font-weight:bold;text-transform:uppercase;letter-spacing:.04em;margin:10px 0 6px;} - .member-section{display:flex;justify-content:space-between;align-items:baseline;margin:5px 0;} - .member-name{font-size:13px;font-weight:bold;} - .member-num{color:#555;} - .receipt-grid{display:grid;grid-template-columns:auto 1fr;gap:3px 14px;margin:6px 0;} - .rlbl{font-weight:600;color:#555;white-space:nowrap;font-size:10px;text-transform:uppercase;letter-spacing:.03em;} - .rval{text-align:right;} - .section-head{grid-column:span 2;font-weight:bold;font-size:11px;text-transform:uppercase;letter-spacing:.04em;margin:6px 0 2px;} - .charge-val{font-size:20px;font-weight:bold;color:#c00;} - .credit-val{font-size:20px;font-weight:bold;color:#080;} - .balance-val{font-size:14px;font-weight:bold;} - table{width:100%;border-collapse:collapse;margin-top:10px;} - th{background:#222;color:#fff;padding:5px 8px;text-align:left;font-size:10px;} - td{padding:4px 8px;border-bottom:1px solid #e0e0e0;} - .num{text-align:right;font-variant-numeric:tabular-nums;} - .red{color:#c00;} .grn{color:#080;} - .balance-box{margin-top:12px;text-align:right;font-size:14px;} - .balance-box span{font-weight:bold;font-size:18px;} - .sub-row td{font-size:10px;color:#777;padding-top:0;border-bottom:none;padding-left:24px;} - .footer{margin-top:14px;font-size:10px;color:#888;text-align:center;white-space:pre-wrap;} + .biz-name{font-size:14pt;font-weight:bold;margin:4px 0 6px;} + .biz-info-row{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;font-size:10pt;line-height:1.7;} + .biz-addr{line-height:1.7;} + .biz-contacts{text-align:right;white-space:nowrap;line-height:1.7;} + /* Receipt */ + .rx-title{font-size:13pt;font-weight:bold;text-transform:uppercase;letter-spacing:.06em;margin:14px 0 12px;} + .rx-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 40px;margin:10px 0;} + .rx-cell{} + .rx-lbl{font-size:9pt;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.05em;margin-bottom:3px;} + .rx-val{font-size:11pt;} + .rx-val.bold{font-weight:bold;} + .rx-val.large{font-size:13pt;font-weight:bold;} + .rx-val.charge{color:#c00;} + .rx-val.credit{color:#080;} + .footer{margin-top:20px;font-size:10pt;color:#444;line-height:1.7;white-space:pre-wrap;} + /* Statement */ + h2{font-size:13pt;font-weight:bold;margin:14px 0 4px;} + .stmt-info{font-size:10pt;color:#555;margin-bottom:12px;line-height:1.6;} + table{width:100%;border-collapse:collapse;margin-top:4px;font-size:10pt;} + th{border-bottom:2px solid #222;padding:6px 8px 6px 0;text-align:left;font-size:9pt;font-weight:700;white-space:nowrap;} + td{padding:5px 8px 5px 0;border-bottom:1px solid #e0e0e0;vertical-align:top;} + th.rnum,td.rnum{text-align:right;padding-right:0;} + .credit{color:#080;} + .debit{color:#c00;} + .sub-row td{font-size:10pt;color:#555;padding-top:0;border-bottom:none;padding-left:88px;} + .balance-box{margin-top:14px;text-align:right;font-size:11pt;font-weight:bold;} """ @app.get("/members/{member_id}/statement", response_class=HTMLResponse) @@ -775,55 +809,67 @@ def statement(member_id: int): bal = member_balance(conn, member_id) sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100) - footer = s.get("receipt_footer","") - bar_name = s.get("bar_name","Bar") - cashier_name = s.get("cashier_name","Cashier") + footer = s.get("receipt_footer","") + bar_name = s.get("bar_name","Bar") + cashier_name= s.get("cashier_name","Cashier") def fmt(p): return f"{sym}{p/div:.2f}" - def venue_label(v): return bar_name if v == "bar" else cashier_name rows_html, running = "", 0 for r in rows: txn_ref = _txn_ref(r["id"], s) + venue = bar_name if r["venue"] == "bar" else cashier_name if r["type"] == "topup": - running += r["amount"]; dr, cr = "", fmt(r["amount"]); type_lbl = "Top-up" + running += r["amount"] + amt_html = f'+ {fmt(r["amount"])}' + type_lbl = "Top-up" elif r["type"] == "withdrawal": - running -= r["amount"]; dr, cr = fmt(r["amount"]), ""; type_lbl = "Withdrawal" + running -= r["amount"] + amt_html = f'- {fmt(r["amount"])}' + type_lbl = "Withdrawal" else: - running -= r["amount"]; dr, cr = fmt(r["amount"]), ""; type_lbl = "Charge" - rows_html += (f"{r['created_at'][:16]}{type_lbl}" - f"{venue_label(r['venue'])}{txn_ref}" - f"{r['note'] or ''}{r['staff_name']}" - f"{dr}{cr}" - f"{fmt(running)}") - if r["type"] in ("topup", "withdrawal"): + running -= r["amount"] + amt_html = f'- {fmt(r["amount"])}' + type_lbl = "Charge" + + rows_html += ( + f"{r['created_at'][:16]}{txn_ref}" + f"{type_lbl}{venue}{r['staff_name']}" + f"{amt_html}{fmt(running)}" + ) + + # Detail sub-row + sub = "" + if r["type"] in ("topup","withdrawal"): tf_type = r["transfer_type"] or "" tf_ref = r["transfer_ref"] or "" - if tf_type or tf_ref: - sub = "  ·  ".join(filter(None, [ - f"Type: {tf_type}" if tf_type else "", - f"Ref: {tf_ref}" if tf_ref else "", - ])) - rows_html += f'{sub}' + if tf_type and tf_ref: + sub = f"Transfer type: {tf_type} — {tf_ref}" + elif tf_type: + sub = f"Transfer type: {tf_type}" + elif tf_ref: + sub = f"Ref: {tf_ref}" + elif r["note"]: + sub = r["note"] + + if sub: + rows_html += f'{sub}' return f""" -Statement – {member['name']} +Statement — {member['name']} {_print_controls()} -
- -
{_biz_header_html(s)}

Account Statement

-
- Member: {member['name']}  |  #{member['member_number']}  |  +
+ Member: {member['name']} — #{member['member_number']} — Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC
- - + + {rows_html}
Date/TimeTypeVenueReferenceNoteStaffChargeCreditBalanceDate and TimeReferenceTypeVenueStaffAmountBalance
-
Current Balance: {fmt(bal)}
+
Current Balance: {fmt(bal)}
{('') if footer else ''} {_print_size_script()}""" @@ -839,80 +885,86 @@ def receipt(entry_id: int): FROM ledger_entries WHERE member_id=? AND id<=? """, (entry["member_id"], entry_id)).fetchone()[0] - sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100) + sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100) def fmt(p): return f"{sym}{p/div:.2f}" - txn_ref = _txn_ref(entry_id, s) - etype = entry["type"] - venue_name = s.get("bar_name","Bar") if entry["venue"] == "bar" else s.get("cashier_name","Cashier") + txn_ref = _txn_ref(entry_id, s) + etype = entry["type"] + venue_name = s.get("bar_name","Bar") if entry["venue"]=="bar" else s.get("cashier_name","Cashier") + tf_type = entry["transfer_type"] or "" + tf_ref = entry["transfer_ref"] or "" + timestamp = entry["created_at"][:16] + " UTC" - lbl_staff = s.get("lbl_staff", "STAFF") - lbl_txn = s.get("lbl_transaction", "TRANSACTION") - lbl_txn_time = s.get("lbl_txn_time", "TRANSACTION TIME") + lbl_staff = s.get("lbl_staff", "STAFF") + lbl_txn = s.get("lbl_transaction", "TRANSACTION") + lbl_txn_time = s.get("lbl_txn_time", "TRANSACTION TIME") lbl_remaining = s.get("lbl_remaining_balance", "REMAINING BALANCE") if etype == "topup": - title = s.get("lbl_topup_receipt", "TOP-UP RECEIPT") - footer = s.get("receipt_footer_cashier") or s.get("receipt_footer", "") + title = s.get("lbl_topup_receipt", "TOP-UP RECEIPT") + footer = s.get("receipt_footer_cashier") or s.get("receipt_footer","") + lbl_tf_sec = s.get("lbl_balance_transfer", "BALANCE TRANSFER") + lbl_amount = s.get("lbl_amount_topup", "AMOUNT TOPPED-UP") + tf_label = "Top-up" + amount_cls = "large credit" elif etype == "withdrawal": - title = s.get("lbl_withdrawal_receipt", "WITHDRAWAL RECEIPT") - footer = s.get("receipt_footer_cashier") or s.get("receipt_footer", "") + title = s.get("lbl_withdrawal_receipt","WITHDRAWAL RECEIPT") + footer = s.get("receipt_footer_cashier") or s.get("receipt_footer","") + lbl_tf_sec = s.get("lbl_balance_transfer", "BALANCE TRANSFER") + lbl_amount = s.get("lbl_amount_withdrawal","AMOUNT WITHDRAWN") + tf_label = "Withdrawal" + amount_cls = "large charge" else: - title = s.get("lbl_receipt", "RECEIPT") - footer = s.get("receipt_footer_charge") or s.get("receipt_footer", "") + title = s.get("lbl_receipt", "RECEIPT") + footer = s.get("receipt_footer_charge") or s.get("receipt_footer","") + lbl_charge = s.get("lbl_charge_venue", "CHARGE") + lbl_amount = s.get("lbl_amount_charged", "AMOUNT CHARGED") if etype == "charge": - lbl_charge = s.get("lbl_charge_venue", "CHARGE") - lbl_amount = s.get("lbl_amount_charged", "AMOUNT CHARGED") - grid_details = ( - f'
{lbl_staff}
{entry["staff_name"]}
' - f'
{lbl_txn}
{txn_ref}
' - f'
{lbl_charge}
{venue_name}
' - f'
{lbl_txn_time}
{entry["created_at"][:16]} UTC
' - ) - grid_amounts = ( - f'
{lbl_amount}
{fmt(entry["amount"])}
' - f'
{lbl_remaining}
{fmt(bal_after)}
' - ) + body_html = f"""
+ {_rx_cell(lbl_staff, entry['staff_name'])} + {_rx_cell(lbl_txn, txn_ref)} +
+
+
+ {_rx_cell(lbl_charge, venue_name)} + {_rx_cell(lbl_txn_time, timestamp)} +
+
+
+ {_rx_cell(lbl_amount, fmt(entry['amount']), 'large charge')} + {_rx_cell(lbl_remaining, fmt(bal_after), 'large')} +
""" else: - lbl_tf_section = s.get("lbl_balance_transfer", "BALANCE TRANSFER") - lbl_tf_type = s.get("lbl_transfer_type", "TRANSFER TYPE") - lbl_tf_ref = s.get("lbl_transfer_ref", "TRANSFER REFERENCE") - lbl_amount = s.get("lbl_amount_topup", "AMOUNT TOPPED-UP") if etype == "topup" else s.get("lbl_amount_withdrawal", "AMOUNT WITHDRAWN") - tf_type = entry["transfer_type"] or "" - tf_ref = entry["transfer_ref"] or "" - grid_details = ( - f'
{lbl_staff}
{entry["staff_name"]}
' - f'
{lbl_txn}
{txn_ref}
' - f'
{lbl_txn_time}
{entry["created_at"][:16]} UTC
' - ) - tf_rows = "" - if tf_type: tf_rows += f'
{lbl_tf_type}
{tf_type}
' - if tf_ref: tf_rows += f'
{lbl_tf_ref}
{tf_ref}
' - grid_amounts = ( - f'
{lbl_tf_section}
' - f'{tf_rows}' - f'
{lbl_amount}
{fmt(entry["amount"])}
' - f'
{lbl_remaining}
{fmt(bal_after)}
' - ) + lbl_tf_type = s.get("lbl_transfer_type", "TRANSFER TYPE") + lbl_tf_ref = s.get("lbl_transfer_ref", "TRANSFER REFERENCE") + body_html = f"""
+ {_rx_cell(lbl_staff, entry['staff_name'])} + {_rx_cell(lbl_txn, txn_ref)} +
+
+
+ {_rx_cell(lbl_tf_sec, tf_label)} + {_rx_cell(lbl_txn_time, timestamp)} +
+
+
+ {_rx_cell(lbl_amount, fmt(entry['amount']), amount_cls)} + {_rx_cell(lbl_remaining, fmt(bal_after), 'large')} +
+
+
+ {_rx_cell(lbl_tf_type, tf_type or '—')} + {_rx_cell(lbl_tf_ref, tf_ref or '—')} +
""" return f""" -Receipt – {member['name']} +Receipt — {member['name']} {_print_controls()} -
- -
{_biz_header_html(s)}
-
{title}
-
-
{member['name']}
-
#{member['member_number']}
-
-
-
{grid_details}
-
-
{grid_amounts}
+
{title}
+{body_html}
{('') if footer else ''} {_print_size_script()}""" @@ -1062,6 +1114,27 @@ def update_admin_settings(body: AppSettingsUpdate, user: dict = Depends(admin_us refresh_settings() return _settings +# --------------------------------------------------------------------------- +# Admin – logo upload +# --------------------------------------------------------------------------- + +@app.post("/admin/logo") +async def upload_logo(file: UploadFile = File(...), user: dict = Depends(admin_user)): + content_type = file.content_type or "" + if not content_type.startswith("image/"): + raise HTTPException(400, "Only image files are allowed") + suffix = Path(file.filename or "logo.png").suffix.lower() + if suffix not in (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"): + suffix = ".png" + dest = static_dir / f"logo{suffix}" + dest.write_bytes(await file.read()) + url = f"/static/logo{suffix}" + with db_conn() as conn: + conn.execute("INSERT OR REPLACE INTO app_settings (key,value) VALUES (?,?)", + ("logo_url", json.dumps(url))) + refresh_settings() + return {"url": url} + # --------------------------------------------------------------------------- # Config (public – loaded by frontend before login screen shows) # --------------------------------------------------------------------------- diff --git a/requirements.txt b/requirements.txt index d52f40e..d36de0e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ fastapi uvicorn[standard] bcrypt +python-multipart diff --git a/static/app.js b/static/app.js index 4791b48..5f081a3 100644 --- a/static/app.js +++ b/static/app.js @@ -398,6 +398,29 @@ async function doCharge() { // --------------------------------------------------------------------------- async function loadAdminView() { await Promise.all([loadAdminSettings(), loadStaffAccounts()]); + setupLogoUpload(); +} + +let _logoUploadWired = false; +function setupLogoUpload() { + if (_logoUploadWired) return; + const input = document.getElementById('s-logo-upload'); + if (!input) return; + _logoUploadWired = true; + input.addEventListener('change', async function() { + const file = this.files[0]; + if (!file) return; + const fd = new FormData(); + fd.append('file', file); + try { + const r = await fetch('/admin/logo', { method: 'POST', body: fd }); + const json = await r.json(); + if (!r.ok) throw new Error(json.detail || 'Upload failed'); + document.getElementById('s-logo-url').value = json.url; + setMsg('logoUploadMsg', 'Logo uploaded.', 'ok'); + } catch (e) { setMsg('logoUploadMsg', e.message, 'err'); } + this.value = ''; + }); } async function loadAdminSettings() { @@ -428,8 +451,10 @@ async function loadAdminSettings() { document.getElementById('s-biz-email').value = s.biz_email || ''; document.getElementById('s-biz-website').value = s.biz_website || ''; // Branding - document.getElementById('s-logo-url').value = s.logo_url || ''; - document.getElementById('s-logo-align').value = s.logo_align || 'left'; + document.getElementById('s-logo-url').value = s.logo_url || ''; + document.getElementById('s-logo-align').value = s.logo_align || 'left'; + document.getElementById('s-logo-max-width').value = s.logo_max_width || ''; + document.getElementById('s-logo-max-height').value = s.logo_max_height || ''; document.getElementById('s-bar-name').value = s.bar_name || ''; document.getElementById('s-cashier-name').value = s.cashier_name || ''; // Transactions @@ -485,8 +510,10 @@ async function saveSettings() { biz_email: _svt('s-biz-email'), biz_website: _svt('s-biz-website'), // Branding - logo_url: _svt('s-logo-url'), - logo_align: _sv('s-logo-align'), + logo_url: _svt('s-logo-url'), + logo_align: _sv('s-logo-align'), + logo_max_width: parseInt(_sv('s-logo-max-width'), 10) || null, + logo_max_height: parseInt(_sv('s-logo-max-height'), 10) || null, bar_name: _svt('s-bar-name'), cashier_name: _svt('s-cashier-name'), // Transactions diff --git a/static/index.html b/static/index.html index 5247332..e81659c 100644 --- a/static/index.html +++ b/static/index.html @@ -228,15 +228,27 @@

Branding

-
+
+ + +
+
+
-
+
+
+
+
+
+
+
+
From b1fcc3dbe906bbd9701a7cb0de4af5f3dec793e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 16:59:54 +0000 Subject: [PATCH 22/25] Add configurable display timezone; default to server's local timezone - _server_timezone(): detects IANA timezone from /etc/timezone or /etc/localtime symlink at startup; used as the CONFIG default - CONFIG: new "timezone" key set to server's detected timezone - AppSettingsUpdate: new optional timezone field - _display_tz(), _fmt_dt(), _now_display() helpers: convert stored UTC datetimes to the configured timezone for display; falls back to server local if the setting is empty or the zone name is invalid - receipt(): transaction timestamp uses _fmt_dt() instead of raw UTC slice - statement(): row timestamps and "Generated" line use _fmt_dt()/_now_display() - Admin settings: Timezone text input (IANA name) in General section - app.js: loadAdminSettings/saveSettings handle timezone field https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7 --- main.py | 54 ++++++++++++++++++++++++++++++++++++++++++++--- static/app.js | 2 ++ static/index.html | 4 ++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 6fce780..4529eaa 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import Optional import bcrypt +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from fastapi import FastAPI, HTTPException, Cookie, Depends, Response, UploadFile, File from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles @@ -21,6 +22,22 @@ from pydantic import BaseModel, field_validator # --------------------------------------------------------------------------- # Hard defaults (overridden by app_settings table via Admin area) # --------------------------------------------------------------------------- + +def _server_timezone() -> str: + """Detect the server's IANA timezone name for use as the default.""" + try: + p = Path('/etc/timezone') + if p.exists(): + return p.read_text().strip() + p = Path('/etc/localtime') + if p.is_symlink(): + target = str(p.resolve()) + if 'zoneinfo/' in target: + return target.split('zoneinfo/', 1)[-1] + except Exception: + pass + return 'UTC' + CONFIG = { "club_name": "ClubLedger", "currency_symbol": "£", @@ -69,6 +86,8 @@ CONFIG = { "receipt_footer": "", "receipt_footer_charge": "", "receipt_footer_cashier": "", + # Timezone for display (IANA name); defaults to server local timezone + "timezone": _server_timezone(), } DB_PATH = "clubledger.db" @@ -272,6 +291,33 @@ def format_amount(pence: int) -> str: div = _settings.get("currency_divisor") or CONFIG["currency_divisor"] return f"{sym}{pence / div:.2f}" +def _display_tz(s: dict): + """Return a ZoneInfo (or local tzinfo) for the configured display timezone.""" + tz_name = (s.get("timezone") or "").strip() + if tz_name: + try: + return ZoneInfo(tz_name) + except (ZoneInfoNotFoundError, KeyError): + pass + return datetime.now().astimezone().tzinfo # server local + +def _fmt_dt(dt_str: str, s: dict) -> str: + """Convert a stored UTC datetime string to the configured display timezone.""" + try: + dt_utc = datetime.fromisoformat(dt_str.replace(' ', 'T')).replace(tzinfo=timezone.utc) + local = dt_utc.astimezone(_display_tz(s)) + return local.strftime('%Y-%m-%d %H:%M %Z') + except Exception: + return dt_str[:16] + ' UTC' + +def _now_display(s: dict) -> str: + """Current time formatted in the configured display timezone.""" + try: + local = datetime.now(timezone.utc).astimezone(_display_tz(s)) + return local.strftime('%Y-%m-%d %H:%M %Z') + except Exception: + return datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC') + def load_staff() -> list: if STAFF_FILE.exists(): return json.loads(STAFF_FILE.read_text()).get("staff", []) @@ -451,6 +497,8 @@ class AppSettingsUpdate(BaseModel): receipt_footer: Optional[str] = None receipt_footer_charge: Optional[str] = None receipt_footer_cashier: Optional[str] = None + # Timezone + timezone: Optional[str] = None # --------------------------------------------------------------------------- # Page routes @@ -833,7 +881,7 @@ def statement(member_id: int): type_lbl = "Charge" rows_html += ( - f"{r['created_at'][:16]}{txn_ref}" + f"{_fmt_dt(r['created_at'], s)}{txn_ref}" f"{type_lbl}{venue}{r['staff_name']}" f"{amt_html}{fmt(running)}" ) @@ -863,7 +911,7 @@ def statement(member_id: int):

Account Statement

Member: {member['name']} — #{member['member_number']} — - Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC + Generated: {_now_display(s)}
@@ -893,7 +941,7 @@ def receipt(entry_id: int): venue_name = s.get("bar_name","Bar") if entry["venue"]=="bar" else s.get("cashier_name","Cashier") tf_type = entry["transfer_type"] or "" tf_ref = entry["transfer_ref"] or "" - timestamp = entry["created_at"][:16] + " UTC" + timestamp = _fmt_dt(entry["created_at"], s) lbl_staff = s.get("lbl_staff", "STAFF") lbl_txn = s.get("lbl_transaction", "TRANSACTION") diff --git a/static/app.js b/static/app.js index 5f081a3..5f6fc66 100644 --- a/static/app.js +++ b/static/app.js @@ -438,6 +438,7 @@ async function loadAdminSettings() { document.getElementById('s-max-topup').value = ((s.max_topup || 0) / div).toFixed(2); document.getElementById('s-max-charge').value = ((s.max_charge || 0) / div).toFixed(2); document.getElementById('s-overdraft-policy').value = s.overdraft_policy || 'never'; + document.getElementById('s-timezone').value = s.timezone || ''; document.getElementById('s-min-hint').textContent = `in ${majorUnit}`; document.getElementById('s-max-hint').textContent = `in ${majorUnit}`; document.getElementById('s-charge-hint').textContent= `in ${majorUnit}`; @@ -500,6 +501,7 @@ async function saveSettings() { max_topup: Math.round(parseFloat(_sv('s-max-topup')) * div), max_charge: Math.round(parseFloat(_sv('s-max-charge')) * div), overdraft_policy: _sv('s-overdraft-policy'), + timezone: _svt('s-timezone'), // Business address biz_address1: _svt('s-biz-address1'), biz_address2: _svt('s-biz-address2'), diff --git a/static/index.html b/static/index.html index e81659c..b0dfa45 100644 --- a/static/index.html +++ b/static/index.html @@ -214,6 +214,10 @@ +
+ + +

Business Address

From ea03355743c9ed3470e4da6cf8ab083ed765a5bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 17:02:40 +0000 Subject: [PATCH 23/25] Add manage.py CLI for password reset and database wipe Two commands, run from the server terminal: python manage.py reset-admin Interactively select an admin account and set a new password. The app does not need to be stopped first (SQLite WAL handles concurrent access safely). Existing sessions remain valid until they expire (8 h); restart the app to invalidate them immediately. python manage.py reset-db Deletes clubledger.db plus any -wal/-shm sidecar files. Requires the app to be stopped first. After restart the app recreates a fresh database with the default admin/admin account. Asks the user to type RESET to confirm before deleting anything. https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7 --- manage.py | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 manage.py diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..73017ff --- /dev/null +++ b/manage.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +ClubLedger management CLI +Run from the server terminal (NOT through the web UI). +The app does not need to be stopped first for reset-admin; +it MUST be stopped before reset-db. + +Usage: + python manage.py reset-admin – reset an admin account password + python manage.py reset-db – wipe all data and start fresh +""" + +import sys +import getpass +import sqlite3 +from pathlib import Path + +DB_PATH = Path("clubledger.db") + + +def _connect(): + if not DB_PATH.exists(): + print(f"Database not found: {DB_PATH}") + print("Start the app at least once to create it.") + sys.exit(1) + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def cmd_reset_admin(): + """Interactively reset the password for an admin account.""" + import bcrypt + + conn = _connect() + admins = conn.execute( + "SELECT id, name, username FROM staff_accounts WHERE role='admin' ORDER BY name" + ).fetchall() + + if not admins: + print("No admin accounts exist.") + print("Start the app — it will create the default admin/admin account automatically.") + conn.close() + sys.exit(0) + + if len(admins) == 1: + target = admins[0] + else: + print("Admin accounts:") + for i, a in enumerate(admins, 1): + print(f" {i}. {a['name']} ({a['username']})") + while True: + raw = input("Select account number: ").strip() + try: + target = admins[int(raw) - 1] + break + except (ValueError, IndexError): + print(" Invalid — enter the number shown above.") + + print(f"\nResetting password for: {target['name']} ({target['username']})") + while True: + pw = getpass.getpass("New password: ") + if len(pw) < 4: + print(" Password must be at least 4 characters.") + continue + pw2 = getpass.getpass("Confirm password: ") + if pw != pw2: + print(" Passwords do not match — try again.") + continue + break + + hashed = bcrypt.hashpw(pw.encode(), bcrypt.gensalt()).decode() + conn.execute( + "UPDATE staff_accounts SET password_hash=? WHERE id=?", + (hashed, target["id"]), + ) + conn.commit() + conn.close() + print(f"\nDone. Password updated for '{target['username']}'.") + print("Any existing sessions for this account will still be valid until they expire (8 h).") + print("Restart the app now to invalidate all active sessions immediately.") + + +def cmd_reset_db(): + """Delete all data files and prepare for a clean start.""" + print("=" * 60) + print(" DATABASE RESET") + print("=" * 60) + print() + print("This will permanently delete:") + print(" • All members and their balances") + print(" • All transactions and receipts") + print(" • All staff accounts") + print(" • All app settings") + print() + print("STOP THE APP before continuing.") + print() + confirm = input('Type RESET to confirm (anything else cancels): ').strip() + if confirm != "RESET": + print("Cancelled — nothing was changed.") + sys.exit(0) + + deleted = [] + for suffix in ("", "-wal", "-shm"): + p = DB_PATH.parent / (DB_PATH.name + suffix) + if p.exists(): + p.unlink() + deleted.append(p.name) + + if deleted: + print(f"\nDeleted: {', '.join(deleted)}") + else: + print("\nNo database files found — nothing to delete.") + + print() + print("Reset complete. Start the app to create a fresh database.") + print("Default admin credentials after restart: username=admin password=admin") + print("Change the password immediately after logging in.") + + +# --------------------------------------------------------------------------- + +COMMANDS = { + "reset-admin": (cmd_reset_admin, "Reset an admin account password"), + "reset-db": (cmd_reset_db, "Wipe all data and start fresh (irreversible)"), +} + + +def usage(): + print(__doc__) + print("Commands:") + for name, (_, desc) in COMMANDS.items(): + print(f" {name:<16} {desc}") + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS: + usage() + COMMANDS[sys.argv[1]][0]() From 6aa4c456168c6617354db8a27759a0f86e17efed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 17:12:09 +0000 Subject: [PATCH 24/25] docs: update all four guides to reflect new features Covers timezone settings, business address/branding/logo upload, transfer types, transaction reference prefix, receipt label localisation, separate charge/cashier footers, three-role system (POS Staff / Cashier / Admin), five-option overdraft policy, per-member overdraft override, withdrawal transaction type, and manage.py CLI (reset-admin, reset-db). https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7 --- docs/admin-guide.md | 158 ++++++++++++++++++++++++++++------ docs/deployment.md | 27 +++++- docs/developer-guide.md | 105 +++++++++++++++++++---- docs/user-guide.md | 184 +++++++++++++++++++++++++++++++--------- 4 files changed, 391 insertions(+), 83 deletions(-) diff --git a/docs/admin-guide.md b/docs/admin-guide.md index c59f711..5243644 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -16,6 +16,18 @@ On first startup the system creates a default admin account: --- +## Roles + +ClubLedger has three roles: + +| Role | Tabs visible | +|---|---| +| **POS Staff** | Members, Bar | +| **Cashier** | Members, Cashier | +| **Admin** | Members, Cashier, Bar, Admin | + +--- + ## Admin Tab The Admin tab contains two sections: **App Settings** and **Staff Accounts**. @@ -26,11 +38,12 @@ The Admin tab contains two sections: **App Settings** and **Staff Accounts**. These settings control how ClubLedger looks and behaves. Changes take effect immediately without restarting the server. -### Club Identity +### General | Setting | Description | |---|---| | **Club Name** | Appears in the navigation bar, on receipts, and on statements | +| **Timezone** | IANA timezone name (e.g. `Europe/London`, `Asia/Bangkok`). All receipt and statement timestamps are shown in this timezone. Leave blank to use the server's local time. | ### Currency @@ -55,18 +68,80 @@ All limits are entered in the **major unit** (e.g. pounds). | **Maximum top-up** | Cashier cannot top up more than this in a single transaction | | **Maximum single charge** | Bar cannot charge more than this in a single transaction | -### Receipt Footer +### Business Address -Optional text printed at the bottom of every receipt and statement. Useful for: -- A thank-you message -- A refund or returns policy -- Contact details +These fields populate the business header printed at the top of every receipt and statement. All fields are optional — leave blank to omit. -Accepts plain text. Line breaks are preserved. +| Field | Description | +|---|---| +| **Address Line 1–4** | Street address, city, postcode, etc. | +| **Country** | Country name or code | +| **Phone** | Contact phone number | +| **Email** | Contact email address | +| **Website** | Contact website URL | -### Allow Negative Balance (Overdraft) +### Branding -When ticked, the bar can charge a member even if their balance would go below zero. When unticked (the default), charges are blocked if the member has insufficient funds. +| Setting | Description | +|---|---| +| **Logo** | Upload an image file (PNG, JPG, GIF, WebP, or SVG). The file is stored in the `static/` folder of the application. | +| **Logo URL** | The path used to display the logo — set automatically when you upload. Can also be set manually (e.g. `/static/yourlogo.png`) if you are copying a file directly to the server. | +| **Logo Alignment** | `Left`, `Centre`, or `Right` — controls where the logo appears in the receipt/statement header | +| **Logo Max Width** | Maximum display width in pixels (default 200) | +| **Logo Max Height** | Maximum display height in pixels (default 80) | +| **Bar Name** | Label used for the bar/POS venue on receipts (default `Bar`) | +| **Cashier Name** | Label used for the cashier venue on receipts (default `Cashier`) | + +### Transactions + +| Setting | Description | +|---|---| +| **Transaction Reference Prefix** | Prepended to the auto-generated transaction number. For example, `TXN` produces references like `TXN0000001` (default `TXN`). | +| **Transfer Types** | Comma-separated list of payment methods shown to cashiers in the Transfer Type dropdown on the Cashier tab (e.g. `Bank Transfer,Cash,QR`). | + +### Overdraft Policy + +Controls whether members are allowed to have a negative balance. The setting is a dropdown with five options: + +| Policy | Meaning | +|---|---| +| **Never allowed** | No member can ever go into overdraft | +| **Always allowed** | All members can always go into overdraft | +| **Staff override** | Staff can tick a per-member checkbox to allow overdraft for that specific member | +| **Admin override** | Only admins can tick the per-member overdraft checkbox | +| **Staff block** | Staff can tick a per-member checkbox to block overdraft for a specific member (all others are allowed) | + +When the policy is **Staff override**, **Admin override**, or **Staff block**, an **Overdraft override** checkbox appears in the Edit Member modal. + +### Receipt Labels + +All fields in this section are optional and are provided for localisation. Each field overrides the default label printed on receipts and statements. + +| Field | Default | +|---|---| +| Receipt title | `RECEIPT` | +| Top-up receipt title | `TOP-UP RECEIPT` | +| Withdrawal receipt title | `WITHDRAWAL RECEIPT` | +| Staff label | `STAFF` | +| Transaction label | `TRANSACTION` | +| Charge venue label | `CHARGE` | +| Transaction time label | `TRANSACTION TIME` | +| Amount charged label | `AMOUNT CHARGED` | +| Remaining balance label | `REMAINING BALANCE` | +| Balance transfer label | `BALANCE TRANSFER` | +| Amount topped-up label | `AMOUNT TOPPED-UP` | +| Amount withdrawn label | `AMOUNT WITHDRAWN` | +| Transfer type label | `TRANSFER TYPE` | +| Transfer reference label | `TRANSFER REFERENCE` | + +### Receipt Footers + +Optional text printed at the bottom of receipts. Useful for thank-you messages, refund policies, or contact details. Plain text; line breaks are preserved. + +| Field | Appears on | +|---|---| +| **Footer — charge receipts** | Bar charge receipts | +| **Footer — cashier receipts** | Top-up and withdrawal receipts | --- @@ -78,17 +153,10 @@ Fill in the **Add Account** form at the bottom of the Staff Accounts panel: | Field | Notes | |---|---| -| Name | The person's real name — appears on receipts and transaction logs | -| Username | Used to sign in. Lowercase letters and numbers recommended. | -| Password | Minimum length enforced by the browser. Choose something strong. | -| Role | **Staff** or **Admin** — see below | - -### Roles - -| Role | Capabilities | -|---|---| -| **Staff** | Members, Cashier, Bar tabs | -| **Admin** | Everything above, plus the Admin tab (settings and account management) | +| **Name** | The person's real name — appears on receipts and transaction logs | +| **Username** | Used to sign in. Lowercase letters and numbers recommended. | +| **Password** | Choose something strong. | +| **Role** | **POS Staff**, **Cashier**, or **Admin** — see the Roles section above | ### Editing an Account @@ -115,9 +183,10 @@ Open the edit modal for their account, enter a new password, and save. The next There is no transaction editing or deletion by design (audit trail). To correct a mistake: -- **Overcharged:** Apply a top-up for the difference, with a note explaining the correction. -- **Under-charged:** Apply a charge for the difference, with a note. +- **Overcharged at bar:** Apply a top-up for the difference, with a note explaining the correction. +- **Under-charged at bar:** Apply a charge for the difference, with a note. - **Wrong member charged:** Top up the affected member and charge the correct one, with matching notes on both. +- **Incorrect top-up or withdrawal:** Apply an equal and opposite transaction (top-up to reverse a withdrawal, or withdrawal to reverse a top-up) with a note. ### Resetting a Member's PIN @@ -135,16 +204,53 @@ Members tab → click **Statement** on any row. Statements open in a new browser ## Backing Up Data -All data is stored in a single SQLite file: `clubledger.db` in the application folder. To back up, simply copy this file to another location. +All application data is stored in a SQLite database in the application folder. To take a full backup, copy the following files to another location: + +**Database files:** +- `clubledger.db` — the main database +- `clubledger.db-wal` and `clubledger.db-shm` — write-ahead log files that may be present while the app is running + +If the app is stopped, only `clubledger.db` needs to be copied (the WAL files will have been checkpointed). If the app is running, copy all three files. + +**Logo file (if applicable):** +- `static/logo.png` (or `.jpg`, `.gif`, etc.) — the uploaded logo image. Copy this if you have uploaded a logo via the Branding settings. ``` -# Linux / Mac – copy to home directory +# Linux / Mac – back up the database to the home directory cp /path/to/ClubLedger/clubledger.db ~/clubledger-backup-$(date +%Y%m%d).db ``` -The `staff.json` file stores the legacy staff name list (used only by the standalone `/cashier` and `/bar` pages, not the main app). Back this up too if you use those pages. +To restore, stop the server, replace `clubledger.db` with the backup copy (and the logo file if needed), and restart. -To restore, stop the server, replace `clubledger.db` with the backup copy, and restart. +--- + +## Command-Line Tools (`manage.py`) + +Two administrative commands are available from the server terminal. They do not require using the web interface. + +### Reset an Admin Password + +``` +python manage.py reset-admin +``` + +- Interactively resets the password for an admin account. +- If there are multiple admin accounts, lists them and prompts you to select one. +- Prompts for a new password and a confirmation (minimum 4 characters). The password is not echoed to the screen. +- The app does **not** need to be stopped first — WAL mode allows concurrent access. +- Existing sessions for that account remain valid until they expire naturally (8 hours). To invalidate them immediately, restart the app after running this command. + +### Reset the Database + +``` +python manage.py reset-db +``` + +- **Permanently deletes all data:** members, balances, transactions, staff accounts, and settings. This cannot be undone. +- You must type `RESET` to confirm. Anything else cancels the operation. +- The app **must be stopped** before running this command. +- After running: restart the app. It will create a fresh database with the default `admin` / `admin` credentials. +- Change the admin password immediately after the fresh start. --- diff --git a/docs/deployment.md b/docs/deployment.md index fa29835..7f27240 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -384,7 +384,30 @@ To find your local network range: if your server's IP is `192.168.1.42`, your ra --- -## Part 8 – Security Notes +## Part 8 – Backing Up Data + +All club data is stored in a single SQLite file: `clubledger.db`. To back up: + +1. **If the app is stopped:** copy `clubledger.db` to a safe location. +2. **If the app is running:** SQLite may be in WAL (Write-Ahead Log) mode. Copy all three files if they exist: + - `clubledger.db` + - `clubledger.db-wal` + - `clubledger.db-shm` + + Copy all three together in one operation so the backup is consistent. + +3. **Logo file:** if an admin has uploaded a club logo, also copy `static/logo.*` (e.g. `static/logo.png`). The exact extension depends on the file that was uploaded. + +**To restore from a backup:** + +1. Stop the server. +2. Replace `clubledger.db` (and `clubledger.db-wal` / `clubledger.db-shm` if present) with the backed-up copies. +3. Copy any backed-up logo file back to the `static/` folder. +4. Restart the server. + +--- + +## Part 9 – Security Notes | Risk | Mitigation | |---|---| @@ -411,3 +434,5 @@ To find your local network range: if your server's IP is `192.168.1.42`, your ra | View systemd logs (Linux) | `journalctl -u clubledger -f` | | Backup data | Copy `clubledger.db` to a safe location | | Verify not internet-accessible | From mobile data: `http://:8000` should time out | +| Reset admin password | `python manage.py reset-admin` (app can be running) | +| Wipe database (start fresh) | Stop app first, then `python manage.py reset-db` | diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 57d7517..750afe9 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -7,7 +7,9 @@ | Backend | Python 3.11+ · FastAPI · SQLite (via stdlib `sqlite3`) | | Auth | bcrypt password hashing · in-memory session tokens · httpOnly cookies | | Frontend | Vanilla HTML/CSS/JS — no build step, no framework | -| Dependencies | `fastapi`, `uvicorn[standard]`, `bcrypt` | +| Dependencies | `fastapi`, `uvicorn[standard]`, `bcrypt`, `python-multipart` | + +`python-multipart` is required for file upload support via FastAPI's `UploadFile`. --- @@ -16,6 +18,7 @@ ``` ClubLedger/ ├── main.py # Entire backend — one file +├── manage.py # CLI for database/admin management (reset-admin, reset-db) ├── requirements.txt # pip dependencies ├── run.sh # Start script (creates venv, installs deps, runs server) ├── clubledger.db # SQLite database (created on first run, git-ignored) @@ -33,10 +36,11 @@ ClubLedger/ ├── cashier.html # Standalone cashier page (/cashier) ├── cashier.js ├── bar.html # Standalone bar page (/bar) - └── bar.js + ├── bar.js + └── logo.* # Uploaded logo file (created when admin uploads a logo; git-ignored) ``` -`main.py` is intentionally a single file. It stays under ~450 lines because the domain is simple. Split it only if it grows substantially. +`main.py` is kept as a single file for simplicity. Split it only if it grows substantially. --- @@ -59,6 +63,20 @@ The default admin account (`admin` / `admin`) is printed to the console on first --- +## manage.py + +The `manage.py` script provides CLI commands for server-side administration. Run it from the project root with the virtual environment active. + +### `python manage.py reset-admin` + +Interactively resets an admin account password from the terminal. Safe to run while the app is running (SQLite WAL mode). Uses `getpass` so the password is not echoed. + +### `python manage.py reset-db` + +Wipes `clubledger.db`, `clubledger.db-wal`, and `clubledger.db-shm`. Requires typing `RESET` to confirm. The app must be stopped before running this command. + +--- + ## Database Schema ### `members` @@ -68,6 +86,7 @@ The default admin account (`admin` / `admin`) is printed to the console on first | member_number | TEXT UNIQUE | Human-readable ID | | name | TEXT | | | pin_hash | TEXT | bcrypt hash | +| overdraft_override | INTEGER | NULL = use global policy; 1 = override allowed; 0 = override blocked | | created_at | TEXT | `datetime('now')` UTC | ### `ledger_entries` @@ -75,14 +94,16 @@ The default admin account (`admin` / `admin`) is printed to the console on first |---|---|---| | id | INTEGER PK | | | member_id | INTEGER FK | → members.id | -| amount | INTEGER | Minor currency units (e.g. pence) — always positive | -| type | TEXT | `topup` or `charge` | +| amount | INTEGER | Minor currency units — always positive | +| type | TEXT | `topup`, `charge`, or `withdrawal` | | venue | TEXT | `cashier` or `bar` | | note | TEXT | Optional free text | | staff_name | TEXT | Name of logged-in staff at time of transaction | +| transfer_type | TEXT | Payment method for top-ups/withdrawals (e.g. "Cash") | +| transfer_ref | TEXT | Optional payment reference for top-ups/withdrawals | | created_at | TEXT | UTC datetime | -Balance is computed on-the-fly: `SUM(topups) - SUM(charges)`. There is no stored balance column — this avoids drift and makes the audit trail self-consistent. +Balance is computed on-the-fly: `SUM(topups) - SUM(charges) - SUM(withdrawals)`. There is no stored balance column — this avoids drift and makes the audit trail self-consistent. ### `staff_accounts` | Column | Type | Notes | @@ -91,10 +112,12 @@ Balance is computed on-the-fly: `SUM(topups) - SUM(charges)`. There is no stored | name | TEXT | Display name, used as `staff_name` on transactions | | username | TEXT UNIQUE | Login credential | | password_hash | TEXT | bcrypt hash | -| role | TEXT | `staff` or `admin` | +| role | TEXT | `pos-staff`, `cashier`, or `admin` | | active | INTEGER | 0 or 1 | | created_at | TEXT | | +On startup, any existing rows with `role = 'staff'` are automatically migrated to `role = 'pos-staff'`. + ### `products` | Column | Type | Notes | |---|---|---| @@ -131,6 +154,51 @@ format_amount() ← reads _settings at call time /config endpoint ← returns _settings to the frontend on every page load ``` +### CONFIG keys + +| Key | Default / Notes | +|---|---| +| `club_name` | Club display name | +| `currency_symbol` | e.g. `£` | +| `currency_major` | e.g. `GBP` | +| `currency_minor` | e.g. `pence` | +| `currency_divisor` | e.g. `100` | +| `overdraft_policy` | `"never"` / `"always"` / `"staff-override"` / `"admin-override"` / `"staff-block"` | +| `min_topup` | Minimum top-up amount (minor units) | +| `max_topup` | Maximum top-up amount (minor units) | +| `max_charge` | Maximum single charge amount (minor units) | +| `biz_address1` – `biz_address4` | Business address lines | +| `biz_country` | | +| `biz_phone` | | +| `biz_email` | | +| `biz_website` | | +| `logo_url` | URL path to uploaded logo (set automatically on upload) | +| `logo_align` | | +| `logo_max_width` | Default `200` | +| `logo_max_height` | Default `80` | +| `bar_name` | Default `"Bar"` | +| `cashier_name` | Default `"Cashier"` | +| `txn_ref_prefix` | Default `"TXN"` | +| `transfer_types` | Comma-separated string (e.g. `"Bank Transfer,Cash,QR"`); returned as an array by `/config` | +| `lbl_receipt` | Receipt label keys (14 total — see source for full list) | +| `lbl_topup_receipt` | | +| `lbl_withdrawal_receipt` | | +| `lbl_staff` | | +| `lbl_transaction` | | +| `lbl_charge_venue` | | +| `lbl_txn_time` | | +| `lbl_amount_charged` | | +| `lbl_remaining_balance` | | +| `lbl_balance_transfer` | | +| `lbl_amount_topup` | | +| `lbl_amount_withdrawal` | | +| `lbl_transfer_type` | | +| `lbl_transfer_ref` | | +| `receipt_footer` | Footer text for all receipts | +| `receipt_footer_charge` | Override footer for charge receipts | +| `receipt_footer_cashier` | Override footer for cashier receipts | +| `timezone` | IANA timezone name; defaults to server local timezone via `_server_timezone()` | + To add a new configurable value: 1. Add a default to `CONFIG` 2. Add the field to the `AppSettingsUpdate` Pydantic model @@ -142,11 +210,13 @@ To add a new configurable value: ## Auth System - `POST /auth/login` validates credentials against `staff_accounts`, creates a `secrets.token_hex(32)` token, stores it in the module-level `_sessions` dict, and sets it as an `httpOnly` cookie. -- All protected endpoints use `Depends(current_user)` which reads the cookie and looks up the session. -- Admin-only endpoints use `Depends(admin_user)` which calls `current_user` then checks `role == "admin"`. +- All protected endpoints use a `Depends()` guard appropriate to the required role: + - `Depends(pos_user)` — allows `pos-staff` and `admin`. Used by bar endpoints. + - `Depends(cashier_user)` — allows `cashier` and `admin`. Used by cashier endpoints. + - `Depends(admin_user)` — `admin` only. - Sessions expire after 8 hours (configurable via `SESSION_TTL` in `main.py`). - Sessions are lost on server restart (in-memory). This is intentional for simplicity; upgrade to a DB-backed session store if persistence is needed. -- Print views (`/receipt/`, `/members/*/statement`) deliberately have **no auth** — they are opened as pop-up tabs from an authenticated page. +- Print views (`/receipt/`, `/members/*/statement`) have no auth — they are opened as pop-up tabs from an authenticated session. --- @@ -168,19 +238,20 @@ All endpoints except `/config`, `/auth/login`, and the print views require a val |---|---|---| | GET | `/members?q=` | List/search. Returns balance per member. | | POST | `/members` | `{member_number, name, pin}` | -| PUT | `/members/{id}` | `{member_number?, name?, pin?}` — all optional | +| PUT | `/members/{id}` | `{member_number?, name?, pin?, overdraft_override?}` — all optional | | DELETE | `/members/{id}` | Blocked if balance ≠ 0 | | GET | `/members/{id}/transactions` | `?limit=50&offset=0` | -| GET | `/members/{id}/statement` | Returns printable HTML | +| GET | `/members/{id}/statement` | Returns printable HTML. No auth. | ### Transactions | Method | Path | Body | |---|---|---| -| POST | `/topup` | `{member_id, amount, note?}` — amount in minor units | +| POST | `/topup` | `{member_id, amount, transfer_type?, transfer_ref?, note?}` — amount in minor units | | POST | `/charge` | `{member_id, amount, pin, note?}` | +| POST | `/withdrawal` | `{member_id, amount, pin, transfer_type?, transfer_ref?, note?}` | -Both return `{ok, entry_id, new_balance, new_balance_display}`. +All return `{ok, entry_id, new_balance, new_balance_display}`. ### Receipts @@ -201,6 +272,7 @@ Both return `{ok, entry_id, new_balance, new_balance_display}`. |---|---|---| | GET | `/admin/settings` | Admin only | | POST | `/admin/settings` | Admin only. Partial update — only sent fields are changed. | +| POST | `/admin/logo` | Admin only. Multipart file upload. Saves image to `static/logo.`, updates `logo_url` setting, returns `{url}`. Accepts PNG, JPG, GIF, WebP, SVG. | | GET | `/admin/staff-accounts` | Admin only | | POST | `/admin/staff-accounts` | `{name, username, password, role}` | | PUT | `/admin/staff-accounts/{id}` | All fields optional | @@ -210,7 +282,7 @@ Both return `{ok, entry_id, new_balance, new_balance_display}`. | Method | Path | Notes | |---|---|---| -| GET | `/config` | Returns live `_settings`. Called by the frontend on every page load. | +| GET | `/config` | Returns live `_settings`. Called by the frontend on every page load. `transfer_types` is returned as an array. | --- @@ -239,7 +311,7 @@ See the Settings System section above. ### Adding a new transaction venue 1. Add the new venue value to the `CHECK` constraint in the `ledger_entries` schema — requires a migration or database recreation. -2. Add a new endpoint (or extend `/topup`/`/charge` with a `venue` parameter). +2. Add a new endpoint (or extend existing transaction endpoints with a `venue` parameter). 3. Add a new tab or form in `index.html` / `app.js`. ### Adding product management UI @@ -266,5 +338,6 @@ Adjust `current_user()` to query the table instead of the dict. - The database file `clubledger.db` is created automatically in the working directory on first run. Add it to `.gitignore`. - `staff.json` is also created in the working directory. Add to `.gitignore`. +- `static/logo.*` is created when an admin uploads a logo via the Admin panel. Add to `.gitignore`. - No environment variables are required. All configuration is in `CONFIG` (code) or `app_settings` (database). - The app binds to `0.0.0.0:8000` by default — accessible from any device on the network. Pass `--host 127.0.0.1` to restrict to localhost only. diff --git a/docs/user-guide.md b/docs/user-guide.md index 3abfd3a..b3b2d94 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -2,114 +2,218 @@ ## What is ClubLedger? -ClubLedger is a store-credit system for clubs and venues. Members load credit onto their account at the cashier desk, then spend it at the bar or other service points. All transactions are tracked and receipts are printed automatically. +ClubLedger is a store-credit system for clubs and venues. Members load credit onto their account at the cashier desk, then spend it at the bar or other service points. All transactions are tracked and receipts are generated automatically. --- ## Signing In -Open the ClubLedger address in any web browser. You will see a sign-in screen. Enter the username and password given to you by your administrator, then click **Sign In**. +Open the ClubLedger address in any web browser. Enter the username and password provided by your administrator, then click **Sign In**. -Your name appears in the top-right corner of every screen while you are signed in. Click **Sign out** when you are done. +Your name appears in the top corner of every screen while you are signed in. Click **Sign out** when you are finished. -> Sessions expire after 8 hours. The sign-in screen will reappear automatically when your session ends. +> **Sessions expire after 8 hours.** The sign-in screen reappears automatically when your session ends. If the server is restarted, everyone is logged out regardless of how long they have been signed in. --- -## The Three Tabs +## Tabs and Roles -The navigation bar at the top has three tabs: **Members**, **Cashier**, and **Bar**. Click a tab to switch between them. Administrators also see an **Admin** tab. +The navigation bar shows tabs depending on your role. You will only see the tabs listed for your role below. + +| Role | Tabs visible | +|---|---| +| **POS Staff** | Members, Bar | +| **Cashier** | Members, Cashier | +| **Admin** | Members, Cashier, Bar, Admin | + +Click any tab to switch to it. --- ## Members Tab -Use this tab to register new members, look up existing members, and print account statements. +All roles can see this tab. Use it to register new members, search for existing members, and manage member records. ### Registering a New Member -Fill in the **Register New Member** form: +Fill in the **Register New Member** form at the top of the tab: | Field | Notes | |---|---| -| Member Number | A unique ID for the member — a number, code, or anything you choose | -| Full Name | The member's name as it should appear on receipts | -| PIN | A secret 4-digit (or longer) code the member uses at the bar. Tell the member their PIN privately. | +| Member Number | A unique identifier — a number, a code, or any text your venue uses | +| Full Name | The member's name as it will appear on receipts and statements | +| PIN | A secret code (minimum 4 characters) the member uses to authorise charges. Tell the member their PIN privately. | -Click **Register**. The member appears in the table below. +Click **Register**. The new member appears in the table below. ### Searching for a Member -Type part of a name or member number into the search box and click **Search** (or press Enter). Leave the box empty and search to list everyone. +Type part of a name or member number into the search box and click **Search** (or press **Enter**). Searching with an empty box lists all members. ### The Member Table -Each row shows the member's number, name, current balance, and join date. +Search results appear in a table with these columns: + +| Column | Meaning | +|---|---| +| # | Member number | +| Name | Full name | +| Balance | Current account balance | +| Joined | Registration date | +| Actions | Buttons to act on this member | + +### Actions + +Each row has up to three action buttons: | Button | What it does | |---|---| | **Statement** | Opens a printable full transaction history in a new tab | -| **Edit** | Change the member's name, number, or PIN | -| **Delete** | Only appears when balance is exactly zero. Permanently removes the member. | +| **Edit** | Opens a modal to change the member's details | +| **Delete** | Permanently removes the member. Only appears when the balance is exactly zero. | ### Editing a Member -Click **Edit** on any row. A panel appears with the current name and member number pre-filled. Change what you need. Leave the **New PIN** field blank to keep their current PIN. Click **Save**. +Click **Edit** on a row. A modal appears with the current details pre-filled. + +| Field | Notes | +|---|---| +| Member Number | Change the member's unique ID if needed | +| Full Name | Update the name | +| New PIN | Enter a new PIN to change it. Leave blank to keep the existing PIN. | +| Overdraft override | May appear depending on the global overdraft policy and your role — see the Overdraft section below. | + +Click **Save** to apply changes or close the modal to cancel. ### Printing a Statement -Click **Statement** to open the statement in a new tab. Use the **A4 / A5** toggle to choose the paper size, then click **Print Statement**. +Click **Statement** on any member's row. The full transaction history opens in a new tab. Use the **A4 / A5** toggle to select paper size, then print from your browser. --- ## Cashier Tab -Use this tab to add credit to a member's account (top-up). +Cashiers and Admins can see this tab. Use it to add credit to a member's account (top-up) or withdraw credit from it. -### How to Top Up +### Selecting a Member -1. Search for the member by name or number and click their row. -2. The selected member's name and current balance appear at the top of the form. -3. Enter the **Amount** — type it in the major currency unit (e.g. `10.00` for ten pounds). -4. Add an optional **Note** (e.g. "cash payment", "card payment"). -5. Click **Top Up**. +Search for the member and click their row to select them. Their name and current balance appear at the top of the panels. Click **Cancel** at any time to deselect the member and clear all fields. -A receipt opens automatically in a new tab. Print it or close it. +### Top Up Panel -If you need to start over, click **Cancel** to deselect the member. +Use this panel to add credit to the member's account. -### Receipts +| Field | Notes | +|---|---| +| Amount | The amount to add, in the major currency unit (e.g. `10.00`) | +| Transfer Type | How the payment was made — options are configured by your administrator (e.g. Bank Transfer, Cash, QR) | +| Transfer Reference | Optional. A reference for your records, such as a payment reference number | +| Note | Optional. Any additional note about this transaction | -Receipts show: member name and number, transaction type, amount, balance after, staff name, timestamp, and any footer text set by the administrator. +Click **Top Up**. A receipt opens automatically in a new tab. -Use the **A4 / A5** toggle at the top of the receipt page before printing. +### Withdrawal Panel + +Use this panel to remove credit from the member's account. + +| Field | Notes | +|---|---| +| Amount | The amount to withdraw | +| Member PIN | Required. The member must provide their PIN to authorise every withdrawal. | +| Transfer Type | How the funds are being returned — options configured by your administrator | +| Transfer Reference | Optional. A reference for your records | +| Note | Optional. Any additional note | + +Click **Withdraw**. A receipt opens automatically in a new tab. --- ## Bar Tab -Use this tab to charge a member's account (debit). The member must enter their PIN. +POS Staff and Admins can see this tab. Use it to charge a member's account for purchases. ### How to Charge -1. Search for the member and click their row. -2. Enter the **Amount** to charge (e.g. `3.50`). -3. The member enters their **PIN** into the field. -4. Add an optional **Note** (e.g. the item name). +1. Search for the member by name or number and click their row. +2. Enter the **Amount** to charge. +3. Enter the member's **PIN** — this is always required. +4. Optionally add a **Note** (for example, what was purchased). 5. Click **Charge**. -If the PIN is wrong, an error appears and nothing is charged. If the balance is insufficient, the charge is also blocked (unless the administrator has enabled overdraft). +If the PIN is incorrect, an error appears and nothing is charged. If the balance is insufficient, the charge is blocked unless the member has overdraft permission (see the Overdraft section). A receipt opens automatically in a new tab on a successful charge. +Click **Cancel** to deselect the member and clear all fields. + +--- + +## Receipts and Statements + +### Receipts + +A receipt opens in a new tab automatically after every successful transaction. Receipts include: + +- Business header (logo, name, address, contact details) +- Receipt title and transaction reference (e.g. `TXN0000001`) +- Staff name who processed the transaction +- Venue and timestamp (in the configured timezone) +- Amount and remaining balance +- For top-ups and withdrawals: the transfer type and transfer reference + +Use the **A4 / A5** toggle at the top of the receipt before printing. + +> **Tip:** If the receipt tab does not open, your browser may be blocking pop-ups. Allow pop-ups for this site in your browser settings. + +### Statements + +Statements are accessed via the **Statement** button in the Members tab. They show the member's complete transaction history as a table: + +| Column | Content | +|---|---| +| Date/Time | When the transaction occurred (configured timezone) | +| Reference | Transaction reference (e.g. `TXN0000001`) | +| Type | Top-up, Withdrawal, or Charge | +| Venue | Where the transaction was processed | +| Staff | Who processed the transaction | +| Amount | Positive for top-ups, negative for charges and withdrawals | +| Balance | Account balance after that transaction | + +Each transaction also has a second row showing transfer details (for top-ups and withdrawals) or the note (for charges). + +Use the **A4 / A5** toggle before printing. + +--- + +## Overdraft + +By default, charges that would take a member's balance below zero are blocked. Your administrator can change this behaviour using a global overdraft policy. The policy affects what you see in the **Edit Member** modal: + +| Policy | What you see in Edit Member | What it means | +|---|---|---| +| Never allowed | No checkbox | No member can go into overdraft, ever | +| Always allowed | No checkbox | All members can always go into overdraft | +| Staff override | Checkbox (staff can tick it) | Ticking the checkbox for a member allows them to go into overdraft | +| Admin override | Checkbox (only admins can tick it) | Same as above, but only admins can set it | +| Staff block | Checkbox (staff can tick it) | Ticking the checkbox for a member blocks them from overdraft | + +If you are unsure whether a member should be allowed to go into overdraft, check with your administrator before changing any checkbox. + --- ## Common Questions -**The member forgot their PIN.** An administrator can reset it: Members tab → Edit → enter a new PIN. +**The member forgot their PIN.** +An admin or cashier with edit access can reset it: Members tab → Edit → enter a new PIN in the **New PIN** field → Save. Leave the field blank if you do not want to change it. -**I topped up the wrong amount.** Contact an administrator. There is no undo button — a correcting charge or top-up must be applied manually and noted. +**I entered the wrong amount.** +There is no undo button. A correcting transaction must be applied manually. For a top-up error, process a withdrawal for the difference (or the full amount and re-top-up correctly). For a bar charge error, contact an administrator. -**The receipt tab didn't open.** Your browser may be blocking pop-ups. Allow pop-ups for this site in your browser settings, or navigate directly to the statement page via Members → Statement. +**The receipt tab did not open.** +Your browser is likely blocking pop-ups. Find the pop-up blocked notification in your browser's address bar and allow pop-ups for this site, then try the transaction again. -**The balance shows in the wrong currency.** Contact your administrator to update the currency settings in the Admin area. +**A member's balance is wrong.** +Use the **Statement** button on the member's row to view their full transaction history and identify any discrepancies. Contact an administrator if a correction is needed. + +**I cannot see a tab I expect.** +Tab visibility depends on your role. If you believe your role is incorrect, contact your administrator. From 7b4e33254c9e4fd7041f1108036212c81bafaf7a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 17:29:59 +0000 Subject: [PATCH 25/25] feat: admin-configurable default paper size for receipts/statements Adds a Paper Size setting (A4/A5) to the General section of Admin settings. Receipts and statements pre-select the configured size and apply the correct @page margins; staff can still override per-print. https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7 --- main.py | 36 ++++++++++++++++++++++-------------- static/app.js | 2 ++ static/index.html | 7 +++++++ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/main.py b/main.py index 4529eaa..e14fdd1 100644 --- a/main.py +++ b/main.py @@ -88,6 +88,8 @@ CONFIG = { "receipt_footer_cashier": "", # Timezone for display (IANA name); defaults to server local timezone "timezone": _server_timezone(), + # Default paper size for receipts and statements + "paper_size": "A4", } DB_PATH = "clubledger.db" @@ -499,6 +501,8 @@ class AppSettingsUpdate(BaseModel): receipt_footer_cashier: Optional[str] = None # Timezone timezone: Optional[str] = None + # Default paper size + paper_size: Optional[str] = None # --------------------------------------------------------------------------- # Page routes @@ -737,20 +741,24 @@ def transactions(member_id: int, limit: int = 50, offset: int = 0, # Print views (no auth – opened as new-tab popups) # --------------------------------------------------------------------------- -def _print_size_script(): - return """""" -def _print_controls(): - return """
+def _print_controls(s: dict): + size = "A5" if (s.get("paper_size") or "A4").upper() == "A5" else "A4" + a4_chk = ' checked' if size == "A4" else '' + a5_chk = ' checked' if size == "A5" else '' + return f"""
Paper: - - + +
""" @@ -905,7 +913,7 @@ def statement(member_id: int): return f""" Statement — {member['name']} -{_print_controls()} +{_print_controls(s)} {_biz_header_html(s)}

Account Statement

@@ -919,7 +927,7 @@ def statement(member_id: int):
{rows_html}
Date and TimeReferenceTypeVenue
Current Balance: {fmt(bal)}
{('') if footer else ''} -{_print_size_script()}""" +{_print_size_script(s)}""" @app.get("/receipt/{entry_id}", response_class=HTMLResponse) def receipt(entry_id: int): @@ -1008,14 +1016,14 @@ def receipt(entry_id: int): return f""" Receipt — {member['name']} -{_print_controls()} +{_print_controls(s)} {_biz_header_html(s)}
{title}
{body_html}
{('') if footer else ''} -{_print_size_script()}""" +{_print_size_script(s)}""" # --------------------------------------------------------------------------- # Products diff --git a/static/app.js b/static/app.js index 5f6fc66..b8f1e62 100644 --- a/static/app.js +++ b/static/app.js @@ -439,6 +439,7 @@ async function loadAdminSettings() { document.getElementById('s-max-charge').value = ((s.max_charge || 0) / div).toFixed(2); document.getElementById('s-overdraft-policy').value = s.overdraft_policy || 'never'; document.getElementById('s-timezone').value = s.timezone || ''; + document.getElementById('s-paper-size').value = s.paper_size || 'A4'; document.getElementById('s-min-hint').textContent = `in ${majorUnit}`; document.getElementById('s-max-hint').textContent = `in ${majorUnit}`; document.getElementById('s-charge-hint').textContent= `in ${majorUnit}`; @@ -502,6 +503,7 @@ async function saveSettings() { max_charge: Math.round(parseFloat(_sv('s-max-charge')) * div), overdraft_policy: _sv('s-overdraft-policy'), timezone: _svt('s-timezone'), + paper_size: _sv('s-paper-size'), // Business address biz_address1: _svt('s-biz-address1'), biz_address2: _svt('s-biz-address2'), diff --git a/static/index.html b/static/index.html index b0dfa45..4463b3c 100644 --- a/static/index.html +++ b/static/index.html @@ -218,6 +218,13 @@
+
+ + +

Business Address