From fa4884bdb41e2e8d5dbe28ad64ea1e8715bf34cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 05:21:01 +0000 Subject: [PATCH] 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; }