diff --git a/main.py b/main.py index 6cbe255..26f8607 100644 --- a/main.py +++ b/main.py @@ -4,14 +4,15 @@ Admin configuration: edit the CONFIG dict below. """ import sqlite3 +import json import os from contextlib import contextmanager, asynccontextmanager 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 import FastAPI, HTTPException +from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, field_validator from typing import Optional @@ -32,6 +33,8 @@ CONFIG = { } DB_PATH = CONFIG["db_path"] +static_dir = Path(__file__).parent / "static" +STAFF_FILE = Path(__file__).parent / "staff.json" # --------------------------------------------------------------------------- # Database @@ -116,19 +119,25 @@ def format_amount(pence: int) -> str: div = CONFIG["currency_divisor"] return f"{sym}{pence / div:.2f}" +def load_staff() -> list: + if STAFF_FILE.exists(): + return json.loads(STAFF_FILE.read_text()).get("staff", []) + return [] + +def save_staff(names: list): + STAFF_FILE.write_text(json.dumps({"staff": sorted(set(names))}, indent=2)) + # --------------------------------------------------------------------------- # FastAPI app # --------------------------------------------------------------------------- @asynccontextmanager -async def lifespan(app: FastAPI): +async def lifespan(app): 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") @@ -158,13 +167,13 @@ class MemberCreate(BaseModel): class TopupRequest(BaseModel): member_id: int - amount: int # pence + amount: int staff_name: str note: Optional[str] = None class ChargeRequest(BaseModel): member_id: int - amount: int # pence + amount: int pin: str staff_name: str note: Optional[str] = None @@ -176,13 +185,28 @@ class ProductCreate(BaseModel): member_price: Optional[int] = None search_tags: Optional[str] = None +class StaffAdd(BaseModel): + name: str + # --------------------------------------------------------------------------- -# Endpoints +# Page routes # --------------------------------------------------------------------------- -@app.get("/", response_class=HTMLResponse) +@app.get("/", response_class=RedirectResponse) async def root(): - return (static_dir / "index.html").read_text() + return RedirectResponse(url="/cashier", status_code=302) + +@app.get("/cashier", response_class=HTMLResponse) +async def cashier_page(): + return (static_dir / "cashier.html").read_text() + +@app.get("/bar", response_class=HTMLResponse) +async def bar_page(): + return (static_dir / "bar.html").read_text() + +# --------------------------------------------------------------------------- +# API endpoints – members +# --------------------------------------------------------------------------- @app.post("/members") def create_member(body: MemberCreate): @@ -242,13 +266,15 @@ def topup(body: TopupRequest): member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone() if not member: raise HTTPException(404, "Member not found") - conn.execute( + cur = 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) ) + entry_id = cur.lastrowid balance = member_balance(conn, body.member_id) return { "ok": True, + "entry_id": entry_id, "new_balance": balance, "new_balance_display": format_amount(balance), } @@ -268,13 +294,15 @@ def charge(body: ChargeRequest): 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( + cur = 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) ) + entry_id = cur.lastrowid new_balance = member_balance(conn, body.member_id) return { "ok": True, + "entry_id": entry_id, "new_balance": new_balance, "new_balance_display": format_amount(new_balance), } @@ -315,6 +343,39 @@ def transactions(member_id: int, limit: int = 50, offset: int = 0): ], } +# --------------------------------------------------------------------------- +# Print views +# --------------------------------------------------------------------------- + +def _print_size_script() -> str: + return """ +""" + +def _print_controls(extra_class: str = "") -> str: + return f"""
+ Paper size: + + +
""" + +PRINT_CSS = """ + body { font-family: Arial, sans-serif; font-size: 11px; color: #111; margin: 24px; } + h1 { font-size: 18px; margin-bottom: 2px; } + h2 { font-size: 13px; font-weight: normal; color: #555; margin-top: 0; margin-bottom: 16px; } + .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; } + @media print { .no-print { display: none; } } +""" + @app.get("/members/{member_id}/statement", response_class=HTMLResponse) def statement(member_id: int): with db_conn() as conn: @@ -361,14 +422,7 @@ def statement(member_id: int): Statement – {member['name']} -
+{_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; }