diff --git a/main.py b/main.py index da9e685..835087e 100644 --- a/main.py +++ b/main.py @@ -1,40 +1,48 @@ """ -ClubLedger - Store Credit Web App -Admin configuration: edit the CONFIG dict below. +ClubLedger – Store Credit Web App +Hard defaults live in CONFIG below; everything is overridable via the Admin UI. """ import sqlite3 import json import os +import secrets from contextlib import contextmanager, asynccontextmanager from datetime import datetime, timezone from pathlib import Path - -import bcrypt -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 +import bcrypt +from fastapi import FastAPI, HTTPException, Cookie, Depends, Response +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, field_validator + # --------------------------------------------------------------------------- -# Admin configuration +# Hard defaults (overridden by app_settings table via Admin area) # --------------------------------------------------------------------------- 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 + "club_name": "ClubLedger", + "currency_symbol": "£", + "currency_major": "pounds", # label for major unit (what users enter) + "currency_minor": "pence", # label for stored minor unit + "currency_divisor": 100, # minor units per major unit + "allow_negative_balance": False, + "min_topup": 100, # minor units + "max_topup": 100_000, + "max_charge": 50_000, + "receipt_footer": "", } -DB_PATH = CONFIG["db_path"] -static_dir = Path(__file__).parent / "static" +DB_PATH = "clubledger.db" STAFF_FILE = Path(__file__).parent / "staff.json" +static_dir = Path(__file__).parent / "static" + +# In-memory sessions: token → {user_id, name, role, expires} +_sessions: dict = {} + +# Cached settings merged from CONFIG + DB app_settings +_settings: dict = {} # --------------------------------------------------------------------------- # Database @@ -63,38 +71,69 @@ def init_db(): with db_conn() as conn: conn.executescript(""" CREATE TABLE IF NOT EXISTS members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + 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')) + 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')) + 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, + 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 + search_tags TEXT, + active INTEGER NOT NULL DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS staff_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'staff' + CHECK(role IN ('staff','admin')), + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL ); - CREATE INDEX IF NOT EXISTS idx_ledger_member ON ledger_entries(member_id); """) +def seed_admin(): + with db_conn() as conn: + if conn.execute("SELECT COUNT(*) FROM staff_accounts WHERE role='admin'").fetchone()[0] == 0: + pw = bcrypt.hashpw(b"admin", bcrypt.gensalt()).decode() + conn.execute( + "INSERT INTO staff_accounts (name, username, password_hash, role) VALUES (?,?,?,?)", + ("Administrator", "admin", pw, "admin") + ) + print("=" * 60) + print(" Default admin created → username: admin password: admin") + print(" Change this immediately in the Admin → Staff Accounts area.") + print("=" * 60) + +def refresh_settings(): + global _settings + with db_conn() as conn: + rows = conn.execute("SELECT key, value FROM app_settings").fetchall() + overrides = {r["key"]: json.loads(r["value"]) for r in rows} + _settings = {**CONFIG, **overrides} + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -107,16 +146,14 @@ def verify_pin(pin: str, hashed: str) -> bool: 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 + SELECT COALESCE(SUM(CASE WHEN type='topup' THEN amount ELSE -amount END),0) AS b FROM ledger_entries WHERE member_id=? """, (member_id,)).fetchone() - return row["balance"] if row else 0 + return row["b"] if row else 0 def format_amount(pence: int) -> str: - sym = CONFIG["currency_symbol"] - div = CONFIG["currency_divisor"] + sym = _settings.get("currency_symbol") or CONFIG["currency_symbol"] + div = _settings.get("currency_divisor") or CONFIG["currency_divisor"] return f"{sym}{pence / div:.2f}" def load_staff() -> list: @@ -128,16 +165,37 @@ def save_staff(names: list): STAFF_FILE.write_text(json.dumps({"staff": sorted(set(names))}, indent=2)) # --------------------------------------------------------------------------- -# FastAPI app +# Auth helpers +# --------------------------------------------------------------------------- + +SESSION_TTL = 8 * 3600 # seconds + +def current_user(session: Optional[str] = Cookie(default=None)): + if not session or session not in _sessions: + raise HTTPException(401, "Not authenticated") + s = _sessions[session] + if datetime.now(timezone.utc).timestamp() > s["expires"]: + del _sessions[session] + raise HTTPException(401, "Session expired") + return s + +def admin_user(user: dict = Depends(current_user)): + if user["role"] != "admin": + raise HTTPException(403, "Admin access required") + return user + +# --------------------------------------------------------------------------- +# App # --------------------------------------------------------------------------- @asynccontextmanager async def lifespan(app): init_db() + seed_admin() + refresh_settings() yield -app = FastAPI(title=CONFIG["club_name"], lifespan=lifespan) - +app = FastAPI(title="ClubLedger", lifespan=lifespan) static_dir.mkdir(exist_ok=True) app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") @@ -165,33 +223,60 @@ class MemberCreate(BaseModel): raise ValueError("member_number cannot be empty") return v +class MemberUpdate(BaseModel): + member_number: Optional[str] = None + name: Optional[str] = None + pin: Optional[str] = None + class TopupRequest(BaseModel): member_id: int - amount: int - staff_name: str - note: Optional[str] = None + amount: int # minor units + note: Optional[str] = None class ChargeRequest(BaseModel): member_id: int - amount: int - pin: str - staff_name: str - note: Optional[str] = None + amount: int + pin: str + note: Optional[str] = None class ProductCreate(BaseModel): - name: str - brand: Optional[str] = None - price: int + name: str + brand: Optional[str] = None + price: int member_price: Optional[int] = None search_tags: Optional[str] = None class StaffAdd(BaseModel): name: str -class MemberUpdate(BaseModel): - member_number: Optional[str] = None - name: Optional[str] = None - pin: Optional[str] = None +class LoginRequest(BaseModel): + username: str + password: str + +class StaffAccountCreate(BaseModel): + name: str + username: str + password: str + role: str = "staff" + +class StaffAccountUpdate(BaseModel): + name: Optional[str] = None + username: Optional[str] = None + password: Optional[str] = None + role: Optional[str] = None + active: Optional[bool] = None + +class AppSettingsUpdate(BaseModel): + club_name: Optional[str] = None + currency_symbol: Optional[str] = None + currency_major: Optional[str] = None + currency_minor: Optional[str] = None + currency_divisor: Optional[int] = None + allow_negative_balance: Optional[bool] = None + min_topup: Optional[int] = None + max_topup: Optional[int] = None + max_charge: Optional[int] = None + receipt_footer: Optional[str] = None # --------------------------------------------------------------------------- # Page routes @@ -210,439 +295,468 @@ async def bar_page(): return (static_dir / "bar.html").read_text() # --------------------------------------------------------------------------- -# API endpoints – members +# Auth endpoints +# --------------------------------------------------------------------------- + +@app.post("/auth/login") +def login(body: LoginRequest, response: Response): + with db_conn() as conn: + row = conn.execute( + "SELECT * FROM staff_accounts WHERE username=? AND active=1", + (body.username.strip(),) + ).fetchone() + if not row or not bcrypt.checkpw(body.password.encode(), row["password_hash"].encode()): + raise HTTPException(401, "Invalid username or password") + token = secrets.token_hex(32) + _sessions[token] = { + "user_id": row["id"], + "name": row["name"], + "role": row["role"], + "expires": datetime.now(timezone.utc).timestamp() + SESSION_TTL, + } + response.set_cookie("session", token, httponly=True, max_age=SESSION_TTL, samesite="strict") + return {"name": row["name"], "role": row["role"]} + +@app.post("/auth/logout") +def logout(response: Response, session: Optional[str] = Cookie(default=None)): + if session and session in _sessions: + del _sessions[session] + response.delete_cookie("session") + return {"ok": True} + +@app.get("/auth/me") +def auth_me(user: dict = Depends(current_user)): + return {"name": user["name"], "role": user["role"]} + +# --------------------------------------------------------------------------- +# Member endpoints # --------------------------------------------------------------------------- @app.post("/members") -def create_member(body: MemberCreate): +def create_member(body: MemberCreate, user: dict = Depends(current_user)): with db_conn() as conn: - existing = conn.execute( - "SELECT id FROM members WHERE member_number=?", - (body.member_number.strip(),) - ).fetchone() - if existing: + if conn.execute("SELECT id FROM members WHERE member_number=?", + (body.member_number.strip(),)).fetchone(): 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) + (body.member_number.strip(), body.name.strip(), hash_pin(body.pin)) ) - member_id = cur.lastrowid + mid = 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"], - } + r = conn.execute("SELECT * FROM members WHERE id=?", (mid,)).fetchone() + return {"id": r["id"], "member_number": r["member_number"], + "name": r["name"], "created_at": r["created_at"]} @app.put("/members/{member_id}") -def update_member(member_id: int, body: MemberUpdate): +def update_member(member_id: int, body: MemberUpdate, user: dict = Depends(current_user)): with db_conn() as conn: - member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone() - if not member: + if not conn.execute("SELECT id FROM members WHERE id=?", (member_id,)).fetchone(): 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 + n = body.name.strip() + if not n: raise HTTPException(400, "Name cannot be empty") + updates["name"] = n 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: + if not mn: raise HTTPException(400, "Member number cannot be empty") + if conn.execute("SELECT id FROM members WHERE member_number=? AND id!=?", + (mn, member_id)).fetchone(): 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") + 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=?", + f"UPDATE members SET {', '.join(f'{k}=?' for k in updates)} 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"]} + r = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone() + return {"id": r["id"], "member_number": r["member_number"], "name": r["name"]} @app.delete("/members/{member_id}") -def delete_member(member_id: int): +def delete_member(member_id: int, user: dict = Depends(current_user)): with db_conn() as conn: - member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone() - if not member: + if not conn.execute("SELECT id FROM members WHERE id=?", (member_id,)).fetchone(): 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)}") + bal = member_balance(conn, member_id) + if bal != 0: + raise HTTPException(400, f"Cannot delete: balance is {format_amount(bal)}") 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): +def list_members(q: Optional[str] = None, user: dict = Depends(current_user)): with db_conn() as conn: if q: - pattern = f"%{q}%" + pat = f"%{q}%" rows = conn.execute( "SELECT * FROM members WHERE name LIKE ? OR member_number LIKE ? ORDER BY name", - (pattern, pattern) + (pat, pat) ).fetchall() else: rows = conn.execute("SELECT * FROM members ORDER BY name").fetchall() result = [] for r in rows: - balance = member_balance(conn, r["id"]) + bal = 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"], + "id": r["id"], "member_number": r["member_number"], "name": r["name"], + "balance": bal, "balance_display": format_amount(bal), "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'])}") +def topup(body: TopupRequest, user: dict = Depends(current_user)): + s = _settings + if body.amount < s["min_topup"]: + raise HTTPException(400, f"Minimum top-up is {format_amount(s['min_topup'])}") + if body.amount > s["max_topup"]: + raise HTTPException(400, f"Maximum top-up is {format_amount(s['max_topup'])}") with db_conn() as conn: - member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone() - if not member: + if not conn.execute("SELECT id FROM members WHERE id=?", (body.member_id,)).fetchone(): raise HTTPException(404, "Member not found") 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) + "INSERT INTO ledger_entries (member_id,amount,type,venue,note,staff_name) VALUES (?,?,?,?,?,?)", + (body.member_id, body.amount, "topup", "cashier", body.note, user["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), - } + eid = cur.lastrowid + bal = member_balance(conn, body.member_id) + return {"ok": True, "entry_id": eid, "new_balance": bal, "new_balance_display": format_amount(bal)} @app.post("/charge") -def charge(body: ChargeRequest): +def charge(body: ChargeRequest, user: dict = Depends(current_user)): + s = _settings 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'])}") + if body.amount > s["max_charge"]: + raise HTTPException(400, f"Maximum single charge is {format_amount(s['max_charge'])}") 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)})") + bal = member_balance(conn, body.member_id) + if not s["allow_negative_balance"] and bal < body.amount: + raise HTTPException(400, f"Insufficient balance ({format_amount(bal)})") 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) + "INSERT INTO ledger_entries (member_id,amount,type,venue,note,staff_name) VALUES (?,?,?,?,?,?)", + (body.member_id, body.amount, "charge", "bar", body.note, user["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), - } + eid = cur.lastrowid + new_bal = member_balance(conn, body.member_id) + return {"ok": True, "entry_id": eid, "new_balance": new_bal, "new_balance_display": format_amount(new_bal)} @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 - ], - } - -# --------------------------------------------------------------------------- -# 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): +def transactions(member_id: int, limit: int = 50, offset: int = 0, + user: dict = Depends(current_user)): 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) + ORDER BY created_at DESC LIMIT ? OFFSET ? + """, (member_id, limit, offset)).fetchall() + bal = member_balance(conn, member_id) + return { + "member": {"id": member["id"], "member_number": member["member_number"], "name": member["name"]}, + "balance": bal, "balance_display": format_amount(bal), + "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 + ], + } - sym = CONFIG["currency_symbol"] - div = CONFIG["currency_divisor"] - club = CONFIG["club_name"] +# --------------------------------------------------------------------------- +# Print views (no auth – opened as new-tab popups) +# --------------------------------------------------------------------------- - def fmt(p): - return f"{sym}{p/div:.2f}" +def _print_size_script(): + return """""" - rows_html = "" - running = 0 +def _print_controls(): + return """
+ Paper: + + +
""" + +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:0 0 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;} + .footer{margin-top:16px;font-size:10px;color:#888;text-align:center;white-space:pre-wrap;} + @media print{.no-print{display:none;}} +""" + +@app.get("/members/{member_id}/statement", response_class=HTMLResponse) +def statement(member_id: int): + s = _settings + 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() + bal = member_balance(conn, member_id) + + sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100) + club, footer = s.get("club_name","ClubLedger"), s.get("receipt_footer","") + + 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"]) + 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)} - """ + running -= r["amount"]; dr, cr = fmt(r["amount"]), "" + rows_html += (f"{r['created_at'][:16]}{r['type']}" + f"{r['venue']}{r['note'] or ''}" + f"{r['staff_name']}{dr}" + f"{cr}{fmt(running)}") - return f""" - - - -Statement – {member['name']} - - - + table{{width:100%;border-collapse:collapse;margin-top:16px;}} + th{{background:#222;color:#fff;padding:5px 8px;text-align:left;}} + td{{padding:4px 8px;border-bottom:1px solid #e0e0e0;}} + .num{{text-align:right;font-variant-numeric:tabular-nums;}} + .red{{color:#c00;}} .grn{{color:#080;}} .cap{{text-transform:capitalize;}} + .balance-box{{margin-top:12px;text-align:right;font-size:14px;}} + .balance-box span{{font-weight:bold;font-size:18px;}} + {_print_controls()}

{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)}
-{_print_size_script()} - -""" +

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(bal)}
+{('') if footer else ''} +{_print_size_script()}""" @app.get("/receipt/{entry_id}", response_class=HTMLResponse) def receipt(entry_id: int): + s = _settings 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") + 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) + bal_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"] + sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100) + club, footer = s.get("club_name","ClubLedger"), s.get("receipt_footer","") - def fmt(p): - return f"{sym}{p/div:.2f}" + 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" + colour = "#080" if entry["type"] == "topup" else "#c00" - return f""" - - - -Receipt – {member['name']} - - - + .sub{{font-size:15px;color:#555;margin-bottom:20px;}} + table{{border-collapse:collapse;}} + td{{padding:5px 16px 5px 0;vertical-align:top;}} + td:first-child{{font-weight:600;color:#555;white-space:nowrap;min-width:110px;}} + .amount{{font-size:24px;font-weight:bold;color:{colour};}} + .bal{{font-size:20px;font-weight:bold;}} + hr{{border:none;border-top:1px solid #ccc;margin:16px 0;}} + {_print_controls()}
-

{club}

-
{type_label} Receipt
-
+

{club}

{type_label} Receipt

- +
Member{member['name']}
Member #{member['member_number']}
Type{type_label}
Amount{fmt(entry['amount'])}
Balance after{fmt(balance_after)}
Balance after{fmt(bal_after)}
Staff{entry['staff_name']}
Note{entry['note'] or '—'}
Date / Time{entry['created_at']} UTC
-{_print_size_script()} - -""" +{('') if footer else ''} +{_print_size_script()}""" # --------------------------------------------------------------------------- -# Products endpoints +# Products # --------------------------------------------------------------------------- @app.get("/products") -def list_products(q: Optional[str] = None, active_only: bool = True): +def list_products(q: Optional[str] = None, active_only: bool = True, + user: dict = Depends(current_user)): with db_conn() as conn: - base = "SELECT * FROM products" conds, params = [], [] - if active_only: - conds.append("active=1") + 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 - ] + p = f"%{q}%"; params += [p, p, p] + sql = "SELECT * FROM products" + (" WHERE " + " AND ".join(conds) if conds else "") + " ORDER BY name" + rows = conn.execute(sql, 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): +def create_product(body: ProductCreate, user: dict = Depends(current_user)): with db_conn() as conn: cur = conn.execute( - "INSERT INTO products (name, brand, price, member_price, search_tags) VALUES (?,?,?,?,?)", + "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} # --------------------------------------------------------------------------- -# Staff endpoints +# Legacy staff name list (backward compat with cashier.html / bar.html) # --------------------------------------------------------------------------- @app.get("/staff") -def get_staff(): +def get_staff(user: dict = Depends(current_user)): return {"staff": load_staff()} @app.post("/staff") -def add_staff(body: StaffAdd): +def add_staff(body: StaffAdd, user: dict = Depends(current_user)): name = body.name.strip() - if not name: - raise HTTPException(400, "Name cannot be empty") + if not name: raise HTTPException(400, "Name cannot be empty") staff = load_staff() if name not in staff: - staff.append(name) - save_staff(staff) + staff.append(name); save_staff(staff) return {"staff": sorted(staff)} @app.delete("/staff/{name}") -def remove_staff(name: str): +def remove_staff(name: str, user: dict = Depends(current_user)): staff = [s for s in load_staff() if s != name] save_staff(staff) return {"staff": staff} # --------------------------------------------------------------------------- -# Config endpoint +# Admin – staff accounts +# --------------------------------------------------------------------------- + +@app.get("/admin/staff-accounts") +def list_staff_accounts(user: dict = Depends(admin_user)): + with db_conn() as conn: + rows = conn.execute( + "SELECT id,name,username,role,active,created_at FROM staff_accounts ORDER BY name" + ).fetchall() + return [dict(r) for r in rows] + +@app.post("/admin/staff-accounts") +def create_staff_account(body: StaffAccountCreate, user: dict = Depends(admin_user)): + if body.role not in ("staff", "admin"): + raise HTTPException(400, "Role must be 'staff' or 'admin'") + with db_conn() as conn: + if conn.execute("SELECT id FROM staff_accounts WHERE username=?", + (body.username.strip(),)).fetchone(): + raise HTTPException(400, "Username already taken") + pw = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode() + cur = conn.execute( + "INSERT INTO staff_accounts (name,username,password_hash,role) VALUES (?,?,?,?)", + (body.name.strip(), body.username.strip(), pw, body.role) + ) + return {"id": cur.lastrowid, "ok": True} + +@app.put("/admin/staff-accounts/{account_id}") +def update_staff_account(account_id: int, body: StaffAccountUpdate, + user: dict = Depends(admin_user)): + with db_conn() as conn: + if not conn.execute("SELECT id FROM staff_accounts WHERE id=?", (account_id,)).fetchone(): + raise HTTPException(404, "Account not found") + updates = {} + if body.name is not None: updates["name"] = body.name.strip() + if body.username is not None: + if conn.execute("SELECT id FROM staff_accounts WHERE username=? AND id!=?", + (body.username.strip(), account_id)).fetchone(): + raise HTTPException(400, "Username already taken") + updates["username"] = body.username.strip() + if body.password is not None: + updates["password_hash"] = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode() + if body.role is not None: + if body.role not in ("staff","admin"): raise HTTPException(400, "Invalid role") + updates["role"] = body.role + if body.active is not None: + updates["active"] = 1 if body.active else 0 + if updates: + conn.execute( + f"UPDATE staff_accounts SET {', '.join(f'{k}=?' for k in updates)} WHERE id=?", + list(updates.values()) + [account_id] + ) + return {"ok": True} + +@app.delete("/admin/staff-accounts/{account_id}") +def delete_staff_account(account_id: int, user: dict = Depends(admin_user)): + if account_id == user["user_id"]: + raise HTTPException(400, "Cannot delete your own account") + with db_conn() as conn: + row = conn.execute("SELECT * FROM staff_accounts WHERE id=?", (account_id,)).fetchone() + if not row: raise HTTPException(404, "Account not found") + if row["role"] == "admin": + if conn.execute("SELECT COUNT(*) FROM staff_accounts WHERE role='admin'").fetchone()[0] <= 1: + raise HTTPException(400, "Cannot delete the last admin account") + conn.execute("DELETE FROM staff_accounts WHERE id=?", (account_id,)) + return {"ok": True} + +# --------------------------------------------------------------------------- +# Admin – app settings +# --------------------------------------------------------------------------- + +@app.get("/admin/settings") +def get_admin_settings(user: dict = Depends(admin_user)): + return _settings + +@app.post("/admin/settings") +def update_admin_settings(body: AppSettingsUpdate, user: dict = Depends(admin_user)): + with db_conn() as conn: + for field in body.model_fields_set: + val = getattr(body, field) + if val is not None: + conn.execute( + "INSERT OR REPLACE INTO app_settings (key,value) VALUES (?,?)", + (field, json.dumps(val)) + ) + refresh_settings() + return _settings + +# --------------------------------------------------------------------------- +# Config (public – loaded by frontend before login screen shows) # --------------------------------------------------------------------------- @app.get("/config") def get_config(): - return {k: v for k, v in CONFIG.items() if k != "db_path"} + s = dict(_settings) + # expose currency_major as currency_unit so common.js .currency-unit spans still work + s["currency_unit"] = s.get("currency_major", "pounds") + return s if __name__ == "__main__": import uvicorn diff --git a/static/app.js b/static/app.js index 07120cd..c003fe3 100644 --- a/static/app.js +++ b/static/app.js @@ -1,48 +1,107 @@ /* ClubLedger – main SPA */ +let currentUser = null; let cashierMember = null; let barMember = null; let editMemberId = null; +let editAccountId = null; // --------------------------------------------------------------------------- -// Boot +// Boot – check session, then either show login or start the app // --------------------------------------------------------------------------- -(async function init() { +(async function boot() { + // Load config first so the login page shows the club name await loadConfig(); - await loadStaffInto('cashierStaff'); - await loadStaffInto('barStaff'); + document.getElementById('loginBrand').textContent = cfg.club_name; + let me = null; + try { me = await apiFetch('/auth/me'); } catch (e) { /* not logged in */ } + + if (!me) { showLogin(); return; } + currentUser = me; + await startApp(); +})(); + +function showLogin() { + document.getElementById('loginOverlay').classList.remove('hidden'); + document.getElementById('loginUsername').focus(); + document.getElementById('loginForm').addEventListener('submit', doLogin, { once: true }); +} + +async function doLogin(e) { + e.preventDefault(); + const username = document.getElementById('loginUsername').value.trim(); + const password = document.getElementById('loginPassword').value; try { - const data = await apiFetch('/staff'); - renderStaffChips(data.staff); - } catch (e) { /* ignore */ } + currentUser = await apiFetch('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + document.getElementById('loginOverlay').classList.add('hidden'); + await startApp(); + } catch (err) { + setMsg('loginMsg', err.message, 'err'); + document.getElementById('loginForm').addEventListener('submit', doLogin, { once: true }); + } +} - // Nav +async function doLogout() { + try { await fetch('/auth/logout', { method: 'POST' }); } catch (e) { /* ignore */ } + currentUser = null; + // Reset to members tab + document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); + document.querySelector('[data-view="members"]').classList.add('active'); + document.querySelectorAll('.view').forEach(v => v.classList.add('hidden')); + document.getElementById('view-members').classList.remove('hidden'); + showLogin(); +} + +async function startApp() { + await loadConfig(); + + const brand = document.getElementById('navBrand'); + if (brand) brand.textContent = cfg.club_name; + document.getElementById('navUser').textContent = currentUser.name; + + if (currentUser.role === 'admin') { + document.getElementById('adminTabBtn').classList.remove('hidden'); + } + + // Nav tab switching 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'); + if (btn.dataset.view === 'admin') loadAdminView(); }); }); - document.getElementById('registerForm').addEventListener('submit', async e => { - e.preventDefault(); - await registerMember(); - }); - document.getElementById('editForm').addEventListener('submit', async e => { - e.preventDefault(); - await saveEdit(); - }); + // Form submit handlers + document.getElementById('registerForm').addEventListener('submit', e => { e.preventDefault(); registerMember(); }); + document.getElementById('editForm').addEventListener('submit', e => { e.preventDefault(); saveEdit(); }); + document.getElementById('editAccountForm').addEventListener('submit', e => { e.preventDefault(); saveEditAccount(); }); + document.getElementById('settingsForm').addEventListener('submit', e => { e.preventDefault(); saveSettings(); }); + document.getElementById('addAccountForm').addEventListener('submit', e => { e.preventDefault(); addAccount(); }); + // Enter-key on search inputs 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('staffNameInput').addEventListener('keydown',e => { if (e.key === 'Enter') addStaff(); }); searchMembers(); -})(); +} + +// --------------------------------------------------------------------------- +// Amount helpers (users enter major units, we send minor units) +// --------------------------------------------------------------------------- +function toMinor(inputId) { + const v = parseFloat(document.getElementById(inputId).value); + if (isNaN(v) || v <= 0) return null; + return Math.round(v * cfg.currency_divisor); +} // --------------------------------------------------------------------------- // Members view @@ -54,23 +113,19 @@ async function registerMember() { if (!number || !name || !pin) { setMsg('registerMsg', 'All fields required.', 'err'); return; } try { const m = await apiFetch('/members', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + 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'); - } + } catch (err) { setMsg('registerMsg', err.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); + const members = await apiFetch(q ? `/members?q=${encodeURIComponent(q)}` : '/members'); renderMemberTable(members); } catch (e) { console.error(e); } } @@ -97,9 +152,7 @@ function renderMemberTable(members) { `).join(''); } -// --------------------------------------------------------------------------- -// Edit member -// --------------------------------------------------------------------------- +// Edit member modal function openEditModal(id, name, number) { editMemberId = id; document.getElementById('edit-number').value = number; @@ -117,35 +170,28 @@ function closeEditModal() { 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 }; + const body = { + member_number: document.getElementById('edit-number').value.trim(), + name: document.getElementById('edit-name').value.trim(), + }; + const pin = document.getElementById('edit-pin').value; if (pin) body.pin = pin; try { await apiFetch(`/members/${editMemberId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); closeEditModal(); searchMembers(); - } catch (e) { - setMsg('editMsg', e.message, 'err'); - } + } catch (err) { setMsg('editMsg', err.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; + if (!confirm(`Delete member "${name}"?\n\nThis permanently removes their account and transaction history.`)) return; try { await apiFetch(`/members/${id}`, { method: 'DELETE' }); searchMembers(); - } catch (e) { - alert(e.message); - } + } catch (err) { alert(err.message); } } // --------------------------------------------------------------------------- @@ -153,16 +199,11 @@ async function deleteMember(id, name) { // --------------------------------------------------------------------------- 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 => ` + const members = await apiFetch(q ? `/members?q=${encodeURIComponent(q)}` : '/members'); + document.getElementById('cashierMemberList').innerHTML = members.map(m => `
-
-
${esc(m.name)}
-
#${esc(m.member_number)}
-
+
${esc(m.name)}
#${esc(m.member_number)}
${esc(m.balance_display)}
`).join(''); } catch (e) { console.error(e); } @@ -187,23 +228,18 @@ function clearCashierSelection() { async function doTopup() { if (!cashierMember) return; - const amount = parseInt(document.getElementById('cashierAmount').value, 10); - const staff = document.getElementById('cashierStaff').value; + const amount = toMinor('cashierAmount'); 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; } + if (!amount) { setMsg('cashierMsg', 'Enter a valid amount.', '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 }) + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ member_id: cashierMember.id, amount, note: note || null }) }); window.open(`/receipt/${r.entry_id}`, '_blank'); setMsg('cashierMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok'); clearCashierSelection(); - } catch (e) { - setMsg('cashierMsg', e.message, 'err'); - } + } catch (err) { setMsg('cashierMsg', err.message, 'err'); } } // --------------------------------------------------------------------------- @@ -211,16 +247,11 @@ async function doTopup() { // --------------------------------------------------------------------------- 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 => ` + const members = await apiFetch(q ? `/members?q=${encodeURIComponent(q)}` : '/members'); + document.getElementById('barMemberList').innerHTML = members.map(m => `
-
-
${esc(m.name)}
-
#${esc(m.member_number)}
-
+
${esc(m.name)}
#${esc(m.member_number)}
${esc(m.balance_display)}
`).join(''); } catch (e) { console.error(e); } @@ -246,23 +277,157 @@ function clearBarSelection() { async function doCharge() { if (!barMember) return; - const amount = parseInt(document.getElementById('barAmount').value, 10); + const amount = toMinor('barAmount'); 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; } + if (!amount) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; } + if (!pin) { setMsg('barMsg', 'PIN 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 }) + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ member_id: barMember.id, amount, pin, 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'); - } + } catch (err) { setMsg('barMsg', err.message, 'err'); } +} + +// --------------------------------------------------------------------------- +// Admin view +// --------------------------------------------------------------------------- +async function loadAdminView() { + await Promise.all([loadAdminSettings(), loadStaffAccounts()]); +} + +async function loadAdminSettings() { + try { + const s = await apiFetch('/admin/settings'); + const div = s.currency_divisor || 100; + document.getElementById('s-club-name').value = s.club_name || ''; + document.getElementById('s-currency-symbol').value = s.currency_symbol || ''; + document.getElementById('s-currency-major').value = s.currency_major || ''; + document.getElementById('s-currency-minor').value = s.currency_minor || ''; + document.getElementById('s-currency-divisor').value = div; + document.getElementById('s-min-topup').value = ((s.min_topup || 0) / div).toFixed(2); + 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-receipt-footer').value = s.receipt_footer || ''; + document.getElementById('s-allow-negative').checked = !!s.allow_negative_balance; + const sym = s.currency_symbol || ''; + document.getElementById('s-min-hint').textContent = `in ${s.currency_major || 'major units'}`; + document.getElementById('s-max-hint').textContent = `in ${s.currency_major || 'major units'}`; + document.getElementById('s-charge-hint').textContent= `in ${s.currency_major || 'major units'}`; + } catch (err) { setMsg('settingsMsg', err.message, 'err'); } +} + +async function saveSettings() { + const div = parseInt(document.getElementById('s-currency-divisor').value, 10) || 100; + const body = { + club_name: document.getElementById('s-club-name').value.trim(), + currency_symbol: document.getElementById('s-currency-symbol').value.trim(), + currency_major: document.getElementById('s-currency-major').value.trim(), + currency_minor: document.getElementById('s-currency-minor').value.trim(), + currency_divisor: div, + min_topup: Math.round(parseFloat(document.getElementById('s-min-topup').value) * div), + max_topup: Math.round(parseFloat(document.getElementById('s-max-topup').value) * div), + max_charge: Math.round(parseFloat(document.getElementById('s-max-charge').value) * div), + receipt_footer: document.getElementById('s-receipt-footer').value, + allow_negative_balance: document.getElementById('s-allow-negative').checked, + }; + try { + await apiFetch('/admin/settings', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + setMsg('settingsMsg', 'Settings saved.', 'ok'); + await loadConfig(); // refresh frontend cfg + document.querySelectorAll('.currency-unit').forEach(el => { el.textContent = cfg.currency_major || cfg.currency_unit; }); + if (document.getElementById('navBrand')) + document.getElementById('navBrand').textContent = cfg.club_name; + } catch (err) { setMsg('settingsMsg', err.message, 'err'); } +} + +// Staff accounts table +async function loadStaffAccounts() { + try { + const accounts = await apiFetch('/admin/staff-accounts'); + const tbody = document.querySelector('#staffAccountsTable tbody'); + tbody.innerHTML = accounts.map(a => ` + + ${esc(a.name)} + ${esc(a.username)} + ${esc(a.role)} + ${a.active ? 'Active' : 'Inactive'} + + + + + `).join(''); + } catch (err) { console.error(err); } +} + +async function addAccount() { + const body = { + name: document.getElementById('acc-name').value.trim(), + username: document.getElementById('acc-username').value.trim(), + password: document.getElementById('acc-password').value, + role: document.getElementById('acc-role').value, + }; + if (!body.name || !body.username || !body.password) { + setMsg('accountMsg', 'All fields required.', 'err'); return; + } + try { + await apiFetch('/admin/staff-accounts', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + setMsg('accountMsg', `Account created for ${body.name}.`, 'ok'); + document.getElementById('addAccountForm').reset(); + loadStaffAccounts(); + } catch (err) { setMsg('accountMsg', err.message, 'err'); } +} + +function openEditAccountModal(id, name, username, role, active) { + editAccountId = id; + document.getElementById('eacc-name').value = name; + document.getElementById('eacc-username').value = username; + document.getElementById('eacc-password').value = ''; + document.getElementById('eacc-role').value = role; + document.getElementById('eacc-active').checked = !!active; + setMsg('editAccountMsg', '', ''); + document.getElementById('editAccountModal').classList.remove('hidden'); +} + +function closeEditAccountModal() { + editAccountId = null; + document.getElementById('editAccountModal').classList.add('hidden'); +} + +async function saveEditAccount() { + if (!editAccountId) return; + const body = { + name: document.getElementById('eacc-name').value.trim(), + username: document.getElementById('eacc-username').value.trim(), + role: document.getElementById('eacc-role').value, + active: document.getElementById('eacc-active').checked, + }; + const pw = document.getElementById('eacc-password').value; + if (pw) body.password = pw; + try { + await apiFetch(`/admin/staff-accounts/${editAccountId}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + closeEditAccountModal(); + loadStaffAccounts(); + } catch (err) { setMsg('editAccountMsg', err.message, 'err'); } +} + +async function deleteAccount(id, name) { + if (!confirm(`Delete account for "${name}"?`)) return; + try { + await apiFetch(`/admin/staff-accounts/${id}`, { method: 'DELETE' }); + loadStaffAccounts(); + } catch (err) { alert(err.message); } } diff --git a/static/index.html b/static/index.html index bb083f4..8eee6cf 100644 --- a/static/index.html +++ b/static/index.html @@ -8,11 +8,37 @@ + +
+
+

ClubLedger

+ +
+
+ + +
+
+ + +
+ +
+
+
+
+ + @@ -45,23 +71,11 @@ - - - +
#NameBalanceJoined
#NameBalanceJoined
-
-

Staff

-
- - -
-
-
-
- @@ -78,11 +92,7 @@
- -
-
- - +
@@ -107,19 +117,14 @@ - + + + +