Add staff auth, admin area, currency decimal input

Auth system
- staff_accounts table: name, username, bcrypt password, role (staff|admin)
- Session tokens in memory (8-hour TTL), httpOnly cookie
- POST /auth/login, /auth/logout, GET /auth/me
- All API endpoints now require a valid session
- Default admin account seeded on first run (admin/admin), printed to console
- Staff name for transactions comes from the session, no more dropdown

Currency input fix
- Amount inputs are now decimal (step=0.01); users enter 1.00 not 100
- Frontend multiplies by cfg.currency_divisor before POSTing
- TopupRequest/ChargeRequest no longer include staff_name (from session)

Admin area (4th tab, admin role only)
- App Settings: club name, currency symbol, major/minor unit names,
  divisor, min/max topup, max charge, receipt footer, allow overdraft
- Settings persisted in app_settings DB table; merged with CONFIG defaults
  at startup and refreshed after each save
- Staff Accounts: list with edit modal (name, username, password, role,
  active flag) and delete; Add Account inline form
- /admin/settings GET/POST, /admin/staff-accounts CRUD
- /config endpoint exposes live settings to frontend on every page load

receipt_footer field rendered on both receipt and statement print views

https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
This commit is contained in:
Claude 2026-05-30 09:19:07 +00:00
parent 79d51973cd
commit a5c9af1ca6
No known key found for this signature in database
4 changed files with 876 additions and 462 deletions

714
main.py
View file

@ -1,40 +1,48 @@
""" """
ClubLedger - Store Credit Web App ClubLedger Store Credit Web App
Admin configuration: edit the CONFIG dict below. Hard defaults live in CONFIG below; everything is overridable via the Admin UI.
""" """
import sqlite3 import sqlite3
import json import json
import os import os
import secrets
from contextlib import contextmanager, asynccontextmanager from contextlib import contextmanager, asynccontextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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 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 = { CONFIG = {
"club_name": "ClubLedger", "club_name": "ClubLedger",
"currency_symbol": "£", "currency_symbol": "£",
"currency_unit": "pence", # smallest unit stored as integer "currency_major": "pounds", # label for major unit (what users enter)
"currency_divisor": 100, # divide stored int by this for display "currency_minor": "pence", # label for stored minor unit
"db_path": "clubledger.db", "currency_divisor": 100, # minor units per major unit
"allow_negative_balance": False, # set True to allow overdraft at bar "allow_negative_balance": False,
"min_topup_amount": 100, # minimum top-up in pence (£1.00) "min_topup": 100, # minor units
"max_topup_amount": 100_000, # maximum top-up in pence (£1000.00) "max_topup": 100_000,
"max_charge_amount": 50_000, # maximum single charge in pence "max_charge": 50_000,
"receipt_footer": "",
} }
DB_PATH = CONFIG["db_path"] DB_PATH = "clubledger.db"
static_dir = Path(__file__).parent / "static"
STAFF_FILE = Path(__file__).parent / "staff.json" 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 # Database
@ -69,7 +77,6 @@ def init_db():
pin_hash TEXT NOT NULL, pin_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS ledger_entries ( CREATE TABLE IF NOT EXISTS ledger_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
member_id INTEGER NOT NULL REFERENCES members(id), member_id INTEGER NOT NULL REFERENCES members(id),
@ -80,7 +87,6 @@ def init_db():
staff_name TEXT NOT NULL, staff_name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS products ( CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, name TEXT NOT NULL,
@ -90,11 +96,44 @@ def init_db():
search_tags TEXT, search_tags TEXT,
active INTEGER NOT NULL DEFAULT 1 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 CREATE INDEX IF NOT EXISTS idx_ledger_member
ON ledger_entries(member_id); 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 # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -107,16 +146,14 @@ def verify_pin(pin: str, hashed: str) -> bool:
def member_balance(conn, member_id: int) -> int: def member_balance(conn, member_id: int) -> int:
row = conn.execute(""" row = conn.execute("""
SELECT COALESCE( SELECT COALESCE(SUM(CASE WHEN type='topup' THEN amount ELSE -amount END),0) AS b
SUM(CASE WHEN type='topup' THEN amount ELSE -amount END), 0
) AS balance
FROM ledger_entries WHERE member_id=? FROM ledger_entries WHERE member_id=?
""", (member_id,)).fetchone() """, (member_id,)).fetchone()
return row["balance"] if row else 0 return row["b"] if row else 0
def format_amount(pence: int) -> str: def format_amount(pence: int) -> str:
sym = CONFIG["currency_symbol"] sym = _settings.get("currency_symbol") or CONFIG["currency_symbol"]
div = CONFIG["currency_divisor"] div = _settings.get("currency_divisor") or CONFIG["currency_divisor"]
return f"{sym}{pence / div:.2f}" return f"{sym}{pence / div:.2f}"
def load_staff() -> list: 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)) 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 @asynccontextmanager
async def lifespan(app): async def lifespan(app):
init_db() init_db()
seed_admin()
refresh_settings()
yield yield
app = FastAPI(title=CONFIG["club_name"], lifespan=lifespan) app = FastAPI(title="ClubLedger", lifespan=lifespan)
static_dir.mkdir(exist_ok=True) static_dir.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
@ -165,17 +223,20 @@ class MemberCreate(BaseModel):
raise ValueError("member_number cannot be empty") raise ValueError("member_number cannot be empty")
return v return v
class MemberUpdate(BaseModel):
member_number: Optional[str] = None
name: Optional[str] = None
pin: Optional[str] = None
class TopupRequest(BaseModel): class TopupRequest(BaseModel):
member_id: int member_id: int
amount: int amount: int # minor units
staff_name: str
note: Optional[str] = None note: Optional[str] = None
class ChargeRequest(BaseModel): class ChargeRequest(BaseModel):
member_id: int member_id: int
amount: int amount: int
pin: str pin: str
staff_name: str
note: Optional[str] = None note: Optional[str] = None
class ProductCreate(BaseModel): class ProductCreate(BaseModel):
@ -188,10 +249,34 @@ class ProductCreate(BaseModel):
class StaffAdd(BaseModel): class StaffAdd(BaseModel):
name: str name: str
class MemberUpdate(BaseModel): class LoginRequest(BaseModel):
member_number: Optional[str] = None username: str
password: str
class StaffAccountCreate(BaseModel):
name: str
username: str
password: str
role: str = "staff"
class StaffAccountUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
pin: 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 # Page routes
@ -210,400 +295,340 @@ async def bar_page():
return (static_dir / "bar.html").read_text() 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") @app.post("/members")
def create_member(body: MemberCreate): def create_member(body: MemberCreate, user: dict = Depends(current_user)):
with db_conn() as conn: with db_conn() as conn:
existing = conn.execute( if conn.execute("SELECT id FROM members WHERE member_number=?",
"SELECT id FROM members WHERE member_number=?", (body.member_number.strip(),)).fetchone():
(body.member_number.strip(),)
).fetchone()
if existing:
raise HTTPException(400, "Member number already exists") raise HTTPException(400, "Member number already exists")
pin_hash = hash_pin(body.pin)
cur = conn.execute( cur = conn.execute(
"INSERT INTO members (member_number, name, pin_hash) VALUES (?,?,?)", "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: with db_conn() as conn:
row = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone() r = conn.execute("SELECT * FROM members WHERE id=?", (mid,)).fetchone()
return { return {"id": r["id"], "member_number": r["member_number"],
"id": row["id"], "name": r["name"], "created_at": r["created_at"]}
"member_number": row["member_number"],
"name": row["name"],
"created_at": row["created_at"],
}
@app.put("/members/{member_id}") @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: with db_conn() as conn:
member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone() if not conn.execute("SELECT id FROM members WHERE id=?", (member_id,)).fetchone():
if not member:
raise HTTPException(404, "Member not found") raise HTTPException(404, "Member not found")
updates = {} updates = {}
if body.name is not None: if body.name is not None:
name = body.name.strip() n = body.name.strip()
if not name: if not n: raise HTTPException(400, "Name cannot be empty")
raise HTTPException(400, "Name cannot be empty") updates["name"] = n
updates["name"] = name
if body.member_number is not None: if body.member_number is not None:
mn = body.member_number.strip() mn = body.member_number.strip()
if not mn: if not mn: raise HTTPException(400, "Member number cannot be empty")
raise HTTPException(400, "Member number cannot be empty") if conn.execute("SELECT id FROM members WHERE member_number=? AND id!=?",
clash = conn.execute( (mn, member_id)).fetchone():
"SELECT id FROM members WHERE member_number=? AND id!=?", (mn, member_id)
).fetchone()
if clash:
raise HTTPException(400, "Member number already in use") raise HTTPException(400, "Member number already in use")
updates["member_number"] = mn updates["member_number"] = mn
if body.pin is not None: if body.pin is not None:
if len(body.pin) < 4: if len(body.pin) < 4: raise HTTPException(400, "PIN must be at least 4 characters")
raise HTTPException(400, "PIN must be at least 4 characters")
updates["pin_hash"] = hash_pin(body.pin) updates["pin_hash"] = hash_pin(body.pin)
if updates: if updates:
set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute( 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] list(updates.values()) + [member_id]
) )
row = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone() r = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
return {"id": row["id"], "member_number": row["member_number"], "name": row["name"]} return {"id": r["id"], "member_number": r["member_number"], "name": r["name"]}
@app.delete("/members/{member_id}") @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: with db_conn() as conn:
member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone() if not conn.execute("SELECT id FROM members WHERE id=?", (member_id,)).fetchone():
if not member:
raise HTTPException(404, "Member not found") raise HTTPException(404, "Member not found")
balance = member_balance(conn, member_id) bal = member_balance(conn, member_id)
if balance != 0: if bal != 0:
raise HTTPException(400, f"Cannot delete: balance is {format_amount(balance)}") 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 ledger_entries WHERE member_id=?", (member_id,))
conn.execute("DELETE FROM members WHERE id=?", (member_id,)) conn.execute("DELETE FROM members WHERE id=?", (member_id,))
return {"ok": True} return {"ok": True}
@app.get("/members") @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: with db_conn() as conn:
if q: if q:
pattern = f"%{q}%" pat = f"%{q}%"
rows = conn.execute( rows = conn.execute(
"SELECT * FROM members WHERE name LIKE ? OR member_number LIKE ? ORDER BY name", "SELECT * FROM members WHERE name LIKE ? OR member_number LIKE ? ORDER BY name",
(pattern, pattern) (pat, pat)
).fetchall() ).fetchall()
else: else:
rows = conn.execute("SELECT * FROM members ORDER BY name").fetchall() rows = conn.execute("SELECT * FROM members ORDER BY name").fetchall()
result = [] result = []
for r in rows: for r in rows:
balance = member_balance(conn, r["id"]) bal = member_balance(conn, r["id"])
result.append({ result.append({
"id": r["id"], "id": r["id"], "member_number": r["member_number"], "name": r["name"],
"member_number": r["member_number"], "balance": bal, "balance_display": format_amount(bal), "created_at": r["created_at"],
"name": r["name"],
"balance": balance,
"balance_display": format_amount(balance),
"created_at": r["created_at"],
}) })
return result return result
@app.post("/topup") @app.post("/topup")
def topup(body: TopupRequest): def topup(body: TopupRequest, user: dict = Depends(current_user)):
if body.amount < CONFIG["min_topup_amount"]: s = _settings
raise HTTPException(400, f"Minimum top-up is {format_amount(CONFIG['min_topup_amount'])}") if body.amount < s["min_topup"]:
if body.amount > CONFIG["max_topup_amount"]: raise HTTPException(400, f"Minimum top-up is {format_amount(s['min_topup'])}")
raise HTTPException(400, f"Maximum top-up is {format_amount(CONFIG['max_topup_amount'])}") if body.amount > s["max_topup"]:
raise HTTPException(400, f"Maximum top-up is {format_amount(s['max_topup'])}")
with db_conn() as conn: with db_conn() as conn:
member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone() if not conn.execute("SELECT id FROM members WHERE id=?", (body.member_id,)).fetchone():
if not member:
raise HTTPException(404, "Member not found") raise HTTPException(404, "Member not found")
cur = conn.execute( cur = conn.execute(
"INSERT INTO ledger_entries (member_id,amount,type,venue,note,staff_name) VALUES (?,?,?,?,?,?)", "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) (body.member_id, body.amount, "topup", "cashier", body.note, user["name"])
) )
entry_id = cur.lastrowid eid = cur.lastrowid
balance = member_balance(conn, body.member_id) bal = member_balance(conn, body.member_id)
return { return {"ok": True, "entry_id": eid, "new_balance": bal, "new_balance_display": format_amount(bal)}
"ok": True,
"entry_id": entry_id,
"new_balance": balance,
"new_balance_display": format_amount(balance),
}
@app.post("/charge") @app.post("/charge")
def charge(body: ChargeRequest): def charge(body: ChargeRequest, user: dict = Depends(current_user)):
s = _settings
if body.amount <= 0: if body.amount <= 0:
raise HTTPException(400, "Amount must be positive") raise HTTPException(400, "Amount must be positive")
if body.amount > CONFIG["max_charge_amount"]: if body.amount > s["max_charge"]:
raise HTTPException(400, f"Maximum single charge is {format_amount(CONFIG['max_charge_amount'])}") raise HTTPException(400, f"Maximum single charge is {format_amount(s['max_charge'])}")
with db_conn() as conn: with db_conn() as conn:
member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone() member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone()
if not member: if not member:
raise HTTPException(404, "Member not found") raise HTTPException(404, "Member not found")
if not verify_pin(body.pin, member["pin_hash"]): if not verify_pin(body.pin, member["pin_hash"]):
raise HTTPException(403, "Incorrect PIN") raise HTTPException(403, "Incorrect PIN")
balance = member_balance(conn, body.member_id) bal = member_balance(conn, body.member_id)
if not CONFIG["allow_negative_balance"] and balance < body.amount: if not s["allow_negative_balance"] and bal < body.amount:
raise HTTPException(400, f"Insufficient balance ({format_amount(balance)})") raise HTTPException(400, f"Insufficient balance ({format_amount(bal)})")
cur = conn.execute( cur = conn.execute(
"INSERT INTO ledger_entries (member_id,amount,type,venue,note,staff_name) VALUES (?,?,?,?,?,?)", "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) (body.member_id, body.amount, "charge", "bar", body.note, user["name"])
) )
entry_id = cur.lastrowid eid = cur.lastrowid
new_balance = member_balance(conn, body.member_id) new_bal = member_balance(conn, body.member_id)
return { return {"ok": True, "entry_id": eid, "new_balance": new_bal, "new_balance_display": format_amount(new_bal)}
"ok": True,
"entry_id": entry_id,
"new_balance": new_balance,
"new_balance_display": format_amount(new_balance),
}
@app.get("/members/{member_id}/transactions") @app.get("/members/{member_id}/transactions")
def transactions(member_id: int, limit: int = 50, offset: int = 0): def transactions(member_id: int, limit: int = 50, offset: int = 0,
with db_conn() as conn: user: dict = Depends(current_user)):
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 """
<script>
function setSize(s) {
var el = document.getElementById('psStyle');
if (!el) { el = document.createElement('style'); el.id = 'psStyle'; document.head.appendChild(el); }
el.textContent = '@media print { @page { size: ' + s + '; margin: ' + (s === 'A5' ? '8mm' : '14mm') + '; } }';
}
setSize('A4');
</script>"""
def _print_controls(extra_class: str = "") -> str:
return f"""<div class="no-print controls {extra_class}">
<span class="size-label">Paper size:</span>
<label><input type="radio" name="ps" value="A4" checked onchange="setSize('A4')"> A4</label>
<label><input type="radio" name="ps" value="A5" onchange="setSize('A5')"> A5</label>
</div>"""
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: with db_conn() as conn:
member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone() member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
if not member: if not member:
raise HTTPException(404, "Member not found") raise HTTPException(404, "Member not found")
rows = conn.execute(""" rows = conn.execute("""
SELECT * FROM ledger_entries WHERE member_id=? SELECT * FROM ledger_entries WHERE member_id=?
ORDER BY created_at ASC ORDER BY created_at DESC LIMIT ? OFFSET ?
""", (member_id,)).fetchall() """, (member_id, limit, offset)).fetchall()
balance = member_balance(conn, member_id) 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"] # Print views (no auth opened as new-tab popups)
club = CONFIG["club_name"] # ---------------------------------------------------------------------------
def fmt(p): def _print_size_script():
return f"{sym}{p/div:.2f}" return """<script>
function setSize(s){
var el=document.getElementById('psStyle');
if(!el){el=document.createElement('style');el.id='psStyle';document.head.appendChild(el);}
el.textContent='@media print{@page{size:'+s+';margin:'+(s==='A5'?'8mm':'14mm')+';}}';}
setSize('A4');
</script>"""
rows_html = "" def _print_controls():
running = 0 return """<div class="no-print controls">
<span class="size-label">Paper:</span>
<label><input type="radio" name="ps" value="A4" checked onchange="setSize('A4')"> A4</label>
<label><input type="radio" name="ps" value="A5" onchange="setSize('A5')"> A5</label>
</div>"""
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: for r in rows:
if r["type"] == "topup": if r["type"] == "topup":
running += r["amount"] running += r["amount"]; dr, cr = "", fmt(r["amount"])
dr, cr = "", fmt(r["amount"])
else: else:
running -= r["amount"] running -= r["amount"]; dr, cr = fmt(r["amount"]), ""
dr, cr = fmt(r["amount"]), "" rows_html += (f"<tr><td>{r['created_at'][:16]}</td><td class='cap'>{r['type']}</td>"
rows_html += f""" f"<td class='cap'>{r['venue']}</td><td>{r['note'] or ''}</td>"
<tr> f"<td>{r['staff_name']}</td><td class='num red'>{dr}</td>"
<td>{r['created_at'][:16]}</td> f"<td class='num grn'>{cr}</td><td class='num'>{fmt(running)}</td></tr>")
<td class="cap">{r['type']}</td>
<td class="cap">{r['venue']}</td>
<td>{r['note'] or ''}</td>
<td>{r['staff_name']}</td>
<td class="num red">{dr}</td>
<td class="num grn">{cr}</td>
<td class="num">{fmt(running)}</td>
</tr>"""
return f"""<!DOCTYPE html> return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<html lang="en"> <title>Statement {member['name']}</title><style>
<head>
<meta charset="UTF-8">
<title>Statement {member['name']}</title>
<style>
{PRINT_CSS} {PRINT_CSS}
table{{width:100%;border-collapse:collapse;margin-top:16px;}} table{{width:100%;border-collapse:collapse;margin-top:16px;}}
th{{background:#222;color:#fff;padding:5px 8px;text-align:left;}} th{{background:#222;color:#fff;padding:5px 8px;text-align:left;}}
td{{padding:4px 8px;border-bottom:1px solid #e0e0e0;}} td{{padding:4px 8px;border-bottom:1px solid #e0e0e0;}}
.num{{text-align:right;font-variant-numeric:tabular-nums;}} .num{{text-align:right;font-variant-numeric:tabular-nums;}}
.red {{ color: #c00; }} .red{{color:#c00;}} .grn{{color:#080;}} .cap{{text-transform:capitalize;}}
.grn {{ color: #080; }}
.cap {{ text-transform: capitalize; }}
.balance-box{{margin-top:12px;text-align:right;font-size:14px;}} .balance-box{{margin-top:12px;text-align:right;font-size:14px;}}
.balance-box span{{font-weight:bold;font-size:18px;}} .balance-box span{{font-weight:bold;font-size:18px;}}
</style> </style></head><body>
</head>
<body>
{_print_controls()} {_print_controls()}
<div class="no-print controls" style="margin-top:0"> <div class="no-print controls" style="margin-top:0">
<button class="print-btn" onclick="window.print()">Print Statement</button> <button class="print-btn" onclick="window.print()">Print Statement</button>
</div> </div>
<h1>{club} Account Statement</h1> <h1>{club} Account Statement</h1>
<h2>Member: {member['name']} &nbsp;|&nbsp; #{member['member_number']} &nbsp;|&nbsp; Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC</h2> <h2>Member: {member['name']} &nbsp;|&nbsp; #{member['member_number']} &nbsp;|&nbsp;
<table> Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC</h2>
<thead> <table><thead><tr>
<tr>
<th>Date/Time</th><th>Type</th><th>Venue</th><th>Note</th> <th>Date/Time</th><th>Type</th><th>Venue</th><th>Note</th>
<th>Staff</th><th class="num">Charge</th><th class="num">Top-up</th><th class="num">Balance</th> <th>Staff</th><th class="num">Charge</th><th class="num">Top-up</th><th class="num">Balance</th>
</tr> </tr></thead><tbody>{rows_html}</tbody></table>
</thead> <div class="balance-box">Current Balance: <span>{fmt(bal)}</span></div>
<tbody>{rows_html}</tbody> {('<div class="footer">' + footer + '</div>') if footer else ''}
</table> {_print_size_script()}</body></html>"""
<div class="balance-box">Current Balance: <span>{fmt(balance)}</span></div>
{_print_size_script()}
</body>
</html>"""
@app.get("/receipt/{entry_id}", response_class=HTMLResponse) @app.get("/receipt/{entry_id}", response_class=HTMLResponse)
def receipt(entry_id: int): def receipt(entry_id: int):
s = _settings
with db_conn() as conn: with db_conn() as conn:
entry = conn.execute("SELECT * FROM ledger_entries WHERE id=?", (entry_id,)).fetchone() entry = conn.execute("SELECT * FROM ledger_entries WHERE id=?", (entry_id,)).fetchone()
if not entry: if not entry: raise HTTPException(404, "Receipt not found")
raise HTTPException(404, "Receipt not found")
member = conn.execute("SELECT * FROM members WHERE id=?", (entry["member_id"],)).fetchone() member = conn.execute("SELECT * FROM members WHERE id=?", (entry["member_id"],)).fetchone()
balance_after = conn.execute(""" bal_after = conn.execute("""
SELECT COALESCE(SUM(CASE WHEN type='topup' THEN amount ELSE -amount END),0) SELECT COALESCE(SUM(CASE WHEN type='topup' THEN amount ELSE -amount END),0)
FROM ledger_entries WHERE member_id=? AND id<=? FROM ledger_entries WHERE member_id=? AND id<=?
""", (entry["member_id"], entry_id)).fetchone()[0] """, (entry["member_id"], entry_id)).fetchone()[0]
sym = CONFIG["currency_symbol"] sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100)
div = CONFIG["currency_divisor"] club, footer = s.get("club_name","ClubLedger"), s.get("receipt_footer","")
club = CONFIG["club_name"]
def fmt(p): def fmt(p): return f"{sym}{p/div:.2f}"
return f"{sym}{p/div:.2f}"
type_label = "Top-up" if entry["type"] == "topup" else "Charge" 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"""<!DOCTYPE html> return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<html lang="en"> <title>Receipt {member['name']}</title><style>
<head>
<meta charset="UTF-8">
<title>Receipt {member['name']}</title>
<style>
{PRINT_CSS} {PRINT_CSS}
.receipt-title {{ font-size: 15px; color: #555; margin-bottom: 20px; }} .sub{{font-size:15px;color:#555;margin-bottom:20px;}}
table{{border-collapse:collapse;}} table{{border-collapse:collapse;}}
td{{padding:5px 16px 5px 0;vertical-align:top;}} td{{padding:5px 16px 5px 0;vertical-align:top;}}
td:first-child{{font-weight:600;color:#555;white-space:nowrap;min-width:110px;}} td:first-child{{font-weight:600;color:#555;white-space:nowrap;min-width:110px;}}
.amount {{ font-size: 24px; font-weight: bold; color: {amount_colour}; }} .amount{{font-size:24px;font-weight:bold;color:{colour};}}
.balance {{ font-size: 20px; font-weight: bold; }} .bal{{font-size:20px;font-weight:bold;}}
hr{{border:none;border-top:1px solid #ccc;margin:16px 0;}} hr{{border:none;border-top:1px solid #ccc;margin:16px 0;}}
</style> </style></head><body>
</head>
<body>
{_print_controls()} {_print_controls()}
<div class="no-print controls" style="margin-top:0"> <div class="no-print controls" style="margin-top:0">
<button class="print-btn" onclick="window.print()">Print Receipt</button> <button class="print-btn" onclick="window.print()">Print Receipt</button>
</div> </div>
<h1>{club}</h1> <h1>{club}</h1><div class="sub">{type_label} Receipt</div><hr>
<div class="receipt-title">{type_label} Receipt</div>
<hr>
<table> <table>
<tr><td>Member</td><td><strong>{member['name']}</strong></td></tr> <tr><td>Member</td><td><strong>{member['name']}</strong></td></tr>
<tr><td>Member #</td><td>{member['member_number']}</td></tr> <tr><td>Member #</td><td>{member['member_number']}</td></tr>
<tr><td>Type</td><td>{type_label}</td></tr> <tr><td>Type</td><td>{type_label}</td></tr>
<tr><td>Amount</td><td class="amount">{fmt(entry['amount'])}</td></tr> <tr><td>Amount</td><td class="amount">{fmt(entry['amount'])}</td></tr>
<tr><td>Balance after</td><td class="balance">{fmt(balance_after)}</td></tr> <tr><td>Balance after</td><td class="bal">{fmt(bal_after)}</td></tr>
<tr><td>Staff</td><td>{entry['staff_name']}</td></tr> <tr><td>Staff</td><td>{entry['staff_name']}</td></tr>
<tr><td>Note</td><td>{entry['note'] or ''}</td></tr> <tr><td>Note</td><td>{entry['note'] or ''}</td></tr>
<tr><td>Date / Time</td><td>{entry['created_at']} UTC</td></tr> <tr><td>Date / Time</td><td>{entry['created_at']} UTC</td></tr>
</table> </table>
{_print_size_script()} {('<div class="footer">' + footer + '</div>') if footer else ''}
</body> {_print_size_script()}</body></html>"""
</html>"""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Products endpoints # Products
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@app.get("/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: with db_conn() as conn:
base = "SELECT * FROM products"
conds, params = [], [] conds, params = [], []
if active_only: if active_only: conds.append("active=1")
conds.append("active=1")
if q: if q:
conds.append("(name LIKE ? OR brand LIKE ? OR search_tags LIKE ?)") conds.append("(name LIKE ? OR brand LIKE ? OR search_tags LIKE ?)")
p = f"%{q}%" p = f"%{q}%"; params += [p, p, p]
params += [p, p, p] sql = "SELECT * FROM products" + (" WHERE " + " AND ".join(conds) if conds else "") + " ORDER BY name"
if conds: rows = conn.execute(sql, params).fetchall()
base += " WHERE " + " AND ".join(conds) return [{"id": r["id"], "name": r["name"], "brand": r["brand"],
base += " ORDER BY name" "price": r["price"], "price_display": format_amount(r["price"]),
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": r["member_price"],
"member_price_display": format_amount(r["member_price"]) if r["member_price"] else None, "member_price_display": format_amount(r["member_price"]) if r["member_price"] else None,
"search_tags": r["search_tags"], "search_tags": r["search_tags"], "active": bool(r["active"])} for r in rows]
"active": bool(r["active"]),
}
for r in rows
]
@app.post("/products") @app.post("/products")
def create_product(body: ProductCreate): def create_product(body: ProductCreate, user: dict = Depends(current_user)):
with db_conn() as conn: with db_conn() as conn:
cur = conn.execute( 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 (?,?,?,?,?)",
@ -612,37 +637,126 @@ def create_product(body: ProductCreate):
return {"id": cur.lastrowid, "ok": True} return {"id": cur.lastrowid, "ok": True}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Staff endpoints # Legacy staff name list (backward compat with cashier.html / bar.html)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@app.get("/staff") @app.get("/staff")
def get_staff(): def get_staff(user: dict = Depends(current_user)):
return {"staff": load_staff()} return {"staff": load_staff()}
@app.post("/staff") @app.post("/staff")
def add_staff(body: StaffAdd): def add_staff(body: StaffAdd, user: dict = Depends(current_user)):
name = body.name.strip() name = body.name.strip()
if not name: if not name: raise HTTPException(400, "Name cannot be empty")
raise HTTPException(400, "Name cannot be empty")
staff = load_staff() staff = load_staff()
if name not in staff: if name not in staff:
staff.append(name) staff.append(name); save_staff(staff)
save_staff(staff)
return {"staff": sorted(staff)} return {"staff": sorted(staff)}
@app.delete("/staff/{name}") @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] staff = [s for s in load_staff() if s != name]
save_staff(staff) save_staff(staff)
return {"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") @app.get("/config")
def 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__": if __name__ == "__main__":
import uvicorn import uvicorn

View file

@ -1,48 +1,107 @@
/* ClubLedger main SPA */ /* ClubLedger main SPA */
let currentUser = null;
let cashierMember = null; let cashierMember = null;
let barMember = null; let barMember = null;
let editMemberId = 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 loadConfig();
await loadStaffInto('cashierStaff'); document.getElementById('loginBrand').textContent = cfg.club_name;
await loadStaffInto('barStaff');
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 { try {
const data = await apiFetch('/staff'); currentUser = await apiFetch('/auth/login', {
renderStaffChips(data.staff); method: 'POST',
} catch (e) { /* ignore */ } 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 => { document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden')); document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
document.getElementById('view-' + btn.dataset.view).classList.remove('hidden'); document.getElementById('view-' + btn.dataset.view).classList.remove('hidden');
if (btn.dataset.view === 'admin') loadAdminView();
}); });
}); });
document.getElementById('registerForm').addEventListener('submit', async e => { // Form submit handlers
e.preventDefault(); document.getElementById('registerForm').addEventListener('submit', e => { e.preventDefault(); registerMember(); });
await registerMember(); document.getElementById('editForm').addEventListener('submit', e => { e.preventDefault(); saveEdit(); });
}); document.getElementById('editAccountForm').addEventListener('submit', e => { e.preventDefault(); saveEditAccount(); });
document.getElementById('editForm').addEventListener('submit', async e => { document.getElementById('settingsForm').addEventListener('submit', e => { e.preventDefault(); saveSettings(); });
e.preventDefault(); document.getElementById('addAccountForm').addEventListener('submit', e => { e.preventDefault(); addAccount(); });
await saveEdit();
});
// Enter-key on search inputs
document.getElementById('memberSearch').addEventListener('keydown', e => { if (e.key === 'Enter') searchMembers(); }); document.getElementById('memberSearch').addEventListener('keydown', e => { if (e.key === 'Enter') searchMembers(); });
document.getElementById('cashierSearch').addEventListener('keydown', e => { if (e.key === 'Enter') cashierSearchMembers(); }); document.getElementById('cashierSearch').addEventListener('keydown', e => { if (e.key === 'Enter') cashierSearchMembers(); });
document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); }); document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); });
document.getElementById('staffNameInput').addEventListener('keydown',e => { if (e.key === 'Enter') addStaff(); });
searchMembers(); 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 // Members view
@ -54,23 +113,19 @@ async function registerMember() {
if (!number || !name || !pin) { setMsg('registerMsg', 'All fields required.', 'err'); return; } if (!number || !name || !pin) { setMsg('registerMsg', 'All fields required.', 'err'); return; }
try { try {
const m = await apiFetch('/members', { const m = await apiFetch('/members', {
method: 'POST', method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_number: number, name, pin }) body: JSON.stringify({ member_number: number, name, pin })
}); });
setMsg('registerMsg', `Registered: ${m.name} (#${m.member_number})`, 'ok'); setMsg('registerMsg', `Registered: ${m.name} (#${m.member_number})`, 'ok');
document.getElementById('registerForm').reset(); document.getElementById('registerForm').reset();
searchMembers(); searchMembers();
} catch (e) { } catch (err) { setMsg('registerMsg', err.message, 'err'); }
setMsg('registerMsg', e.message, 'err');
}
} }
async function searchMembers() { async function searchMembers() {
const q = document.getElementById('memberSearch').value.trim(); const q = document.getElementById('memberSearch').value.trim();
const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members';
try { try {
const members = await apiFetch(url); const members = await apiFetch(q ? `/members?q=${encodeURIComponent(q)}` : '/members');
renderMemberTable(members); renderMemberTable(members);
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
@ -97,9 +152,7 @@ function renderMemberTable(members) {
</tr>`).join(''); </tr>`).join('');
} }
// --------------------------------------------------------------------------- // Edit member modal
// Edit member
// ---------------------------------------------------------------------------
function openEditModal(id, name, number) { function openEditModal(id, name, number) {
editMemberId = id; editMemberId = id;
document.getElementById('edit-number').value = number; document.getElementById('edit-number').value = number;
@ -117,35 +170,28 @@ function closeEditModal() {
async function saveEdit() { async function saveEdit() {
if (!editMemberId) return; if (!editMemberId) return;
const number = document.getElementById('edit-number').value.trim(); const body = {
const name = document.getElementById('edit-name').value.trim(); member_number: document.getElementById('edit-number').value.trim(),
name: document.getElementById('edit-name').value.trim(),
};
const pin = document.getElementById('edit-pin').value; const pin = document.getElementById('edit-pin').value;
const body = { member_number: number, name };
if (pin) body.pin = pin; if (pin) body.pin = pin;
try { try {
await apiFetch(`/members/${editMemberId}`, { await apiFetch(`/members/${editMemberId}`, {
method: 'PUT', method: 'PUT', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
closeEditModal(); closeEditModal();
searchMembers(); searchMembers();
} catch (e) { } catch (err) { setMsg('editMsg', err.message, 'err'); }
setMsg('editMsg', e.message, 'err');
}
} }
// ---------------------------------------------------------------------------
// Delete member
// ---------------------------------------------------------------------------
async function deleteMember(id, name) { 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 { try {
await apiFetch(`/members/${id}`, { method: 'DELETE' }); await apiFetch(`/members/${id}`, { method: 'DELETE' });
searchMembers(); searchMembers();
} catch (e) { } catch (err) { alert(err.message); }
alert(e.message);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -153,16 +199,11 @@ async function deleteMember(id, name) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function cashierSearchMembers() { async function cashierSearchMembers() {
const q = document.getElementById('cashierSearch').value.trim(); const q = document.getElementById('cashierSearch').value.trim();
const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members';
try { try {
const members = await apiFetch(url); const members = await apiFetch(q ? `/members?q=${encodeURIComponent(q)}` : '/members');
const list = document.getElementById('cashierMemberList'); document.getElementById('cashierMemberList').innerHTML = members.map(m => `
list.innerHTML = members.map(m => `
<div class="member-pick-item" onclick="selectCashierMember(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${m.balance},'${esc(m.balance_display)}')"> <div class="member-pick-item" onclick="selectCashierMember(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${m.balance},'${esc(m.balance_display)}')">
<div> <div><div class="member-pick-name">${esc(m.name)}</div><div class="member-pick-sub">#${esc(m.member_number)}</div></div>
<div class="member-pick-name">${esc(m.name)}</div>
<div class="member-pick-sub">#${esc(m.member_number)}</div>
</div>
<div class="${balanceClass(m.balance)}">${esc(m.balance_display)}</div> <div class="${balanceClass(m.balance)}">${esc(m.balance_display)}</div>
</div>`).join(''); </div>`).join('');
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
@ -187,23 +228,18 @@ function clearCashierSelection() {
async function doTopup() { async function doTopup() {
if (!cashierMember) return; if (!cashierMember) return;
const amount = parseInt(document.getElementById('cashierAmount').value, 10); const amount = toMinor('cashierAmount');
const staff = document.getElementById('cashierStaff').value;
const note = document.getElementById('cashierNote').value.trim(); const note = document.getElementById('cashierNote').value.trim();
if (!amount || isNaN(amount) || amount <= 0) { setMsg('cashierMsg', 'Enter a valid amount.', 'err'); return; } if (!amount) { setMsg('cashierMsg', 'Enter a valid amount.', 'err'); return; }
if (!staff) { setMsg('cashierMsg', 'Select a staff member.', 'err'); return; }
try { try {
const r = await apiFetch('/topup', { const r = await apiFetch('/topup', {
method: 'POST', method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ member_id: cashierMember.id, amount, note: note || null })
body: JSON.stringify({ member_id: cashierMember.id, amount, staff_name: staff, note: note || null })
}); });
window.open(`/receipt/${r.entry_id}`, '_blank'); window.open(`/receipt/${r.entry_id}`, '_blank');
setMsg('cashierMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok'); setMsg('cashierMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok');
clearCashierSelection(); clearCashierSelection();
} catch (e) { } catch (err) { setMsg('cashierMsg', err.message, 'err'); }
setMsg('cashierMsg', e.message, 'err');
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -211,16 +247,11 @@ async function doTopup() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function barSearchMembers() { async function barSearchMembers() {
const q = document.getElementById('barSearch').value.trim(); const q = document.getElementById('barSearch').value.trim();
const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members';
try { try {
const members = await apiFetch(url); const members = await apiFetch(q ? `/members?q=${encodeURIComponent(q)}` : '/members');
const list = document.getElementById('barMemberList'); document.getElementById('barMemberList').innerHTML = members.map(m => `
list.innerHTML = members.map(m => `
<div class="member-pick-item" onclick="selectBarMember(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${m.balance},'${esc(m.balance_display)}')"> <div class="member-pick-item" onclick="selectBarMember(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${m.balance},'${esc(m.balance_display)}')">
<div> <div><div class="member-pick-name">${esc(m.name)}</div><div class="member-pick-sub">#${esc(m.member_number)}</div></div>
<div class="member-pick-name">${esc(m.name)}</div>
<div class="member-pick-sub">#${esc(m.member_number)}</div>
</div>
<div class="${balanceClass(m.balance)}">${esc(m.balance_display)}</div> <div class="${balanceClass(m.balance)}">${esc(m.balance_display)}</div>
</div>`).join(''); </div>`).join('');
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
@ -246,23 +277,157 @@ function clearBarSelection() {
async function doCharge() { async function doCharge() {
if (!barMember) return; if (!barMember) return;
const amount = parseInt(document.getElementById('barAmount').value, 10); const amount = toMinor('barAmount');
const pin = document.getElementById('barPin').value; const pin = document.getElementById('barPin').value;
const staff = document.getElementById('barStaff').value;
const note = document.getElementById('barNote').value.trim(); const note = document.getElementById('barNote').value.trim();
if (!amount || isNaN(amount) || amount <= 0) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; } if (!amount) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; }
if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; } if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; }
if (!staff) { setMsg('barMsg', 'Select a staff member.', 'err'); return; }
try { try {
const r = await apiFetch('/charge', { const r = await apiFetch('/charge', {
method: 'POST', method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ member_id: barMember.id, amount, pin, note: note || null })
body: JSON.stringify({ member_id: barMember.id, amount, pin, staff_name: staff, note: note || null })
}); });
window.open(`/receipt/${r.entry_id}`, '_blank'); window.open(`/receipt/${r.entry_id}`, '_blank');
setMsg('barMsg', `Charge complete. New balance: ${r.new_balance_display}`, 'ok'); setMsg('barMsg', `Charge complete. New balance: ${r.new_balance_display}`, 'ok');
clearBarSelection(); clearBarSelection();
} catch (e) { } catch (err) { setMsg('barMsg', err.message, 'err'); }
setMsg('barMsg', e.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 => `
<tr>
<td>${esc(a.name)}</td>
<td>${esc(a.username)}</td>
<td style="text-transform:capitalize">${esc(a.role)}</td>
<td>${a.active ? '<span style="color:#080">Active</span>' : '<span style="color:#999">Inactive</span>'}</td>
<td class="row-actions">
<button class="btn row-btn" onclick="openEditAccountModal(${a.id},'${esc(a.name)}','${esc(a.username)}','${esc(a.role)}',${a.active})">Edit</button>
<button class="btn btn-danger row-btn" onclick="deleteAccount(${a.id},'${esc(a.name)}')">Delete</button>
</td>
</tr>`).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); }
} }

View file

@ -8,11 +8,37 @@
</head> </head>
<body> <body>
<!-- ===================== LOGIN OVERLAY ===================== -->
<div id="loginOverlay" class="login-overlay">
<div class="login-card">
<h1 id="loginBrand">ClubLedger</h1>
<p class="login-sub">Staff sign in</p>
<form id="loginForm">
<div class="form-row">
<label>Username</label>
<input type="text" id="loginUsername" autocomplete="username" required>
</div>
<div class="form-row">
<label>Password</label>
<input type="password" id="loginPassword" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary" style="width:100%;margin-top:4px">Sign In</button>
</form>
<div id="loginMsg" class="msg"></div>
</div>
</div>
<!-- ===================== NAV ===================== -->
<nav> <nav>
<span class="brand" id="navBrand">ClubLedger</span> <span class="brand" id="navBrand">ClubLedger</span>
<button class="nav-btn active" data-view="members">Members</button> <button class="nav-btn active" data-view="members">Members</button>
<button class="nav-btn" data-view="cashier">Cashier</button> <button class="nav-btn" data-view="cashier">Cashier</button>
<button class="nav-btn" data-view="bar">Bar</button> <button class="nav-btn" data-view="bar">Bar</button>
<button class="nav-btn hidden" data-view="admin" id="adminTabBtn">Admin</button>
<div class="nav-right">
<span class="nav-user" id="navUser"></span>
<button class="nav-logout" onclick="doLogout()">Sign out</button>
</div>
</nav> </nav>
<!-- ===================== MEMBERS VIEW ===================== --> <!-- ===================== MEMBERS VIEW ===================== -->
@ -45,23 +71,11 @@
<button class="btn" onclick="searchMembers()">Search</button> <button class="btn" onclick="searchMembers()">Search</button>
</div> </div>
<table id="memberTable" class="data-table"> <table id="memberTable" class="data-table">
<thead> <thead><tr><th>#</th><th>Name</th><th>Balance</th><th>Joined</th><th class="actions-col"></th></tr></thead>
<tr><th>#</th><th>Name</th><th>Balance</th><th>Joined</th><th class="actions-col"></th></tr>
</thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
<div class="panel">
<h2>Staff</h2>
<div class="search-row">
<input type="text" id="staffNameInput" placeholder="Staff name">
<button class="btn btn-primary" onclick="addStaff()">Add</button>
</div>
<div id="staffChips" class="staff-chips"></div>
<div id="staffMsg" class="msg"></div>
</div>
</div> </div>
<!-- ===================== CASHIER VIEW ===================== --> <!-- ===================== CASHIER VIEW ===================== -->
@ -78,11 +92,7 @@
<div class="selected-member-box" id="cashierSelected"></div> <div class="selected-member-box" id="cashierSelected"></div>
<div class="form-row"> <div class="form-row">
<label>Amount (<span class="currency-unit"></span>)</label> <label>Amount (<span class="currency-unit"></span>)</label>
<input type="number" id="cashierAmount" placeholder="e.g. 1000" min="1" step="1"> <input type="number" id="cashierAmount" placeholder="e.g. 10.00" min="0.01" step="0.01">
</div>
<div class="form-row">
<label>Staff</label>
<select id="cashierStaff"></select>
</div> </div>
<div class="form-row"> <div class="form-row">
<label>Note (optional)</label> <label>Note (optional)</label>
@ -107,19 +117,14 @@
<div id="barForm" class="hidden"> <div id="barForm" class="hidden">
<div class="selected-member-box" id="barSelected"></div> <div class="selected-member-box" id="barSelected"></div>
<div class="form-row"> <div class="form-row">
<label>Amount (<span class="currency-unit"></span>)</label> <label>Amount (<span class="currency-unit"></span>)</label>
<input type="number" id="barAmount" placeholder="e.g. 350" min="1" step="1"> <input type="number" id="barAmount" placeholder="e.g. 3.50" min="0.01" step="0.01">
</div> </div>
<div class="form-row"> <div class="form-row">
<label>PIN</label> <label>Member PIN</label>
<input type="password" id="barPin" placeholder="Member PIN" maxlength="20"> <input type="password" id="barPin" placeholder="Member PIN" maxlength="20">
</div> </div>
<div class="form-row">
<label>Staff</label>
<select id="barStaff"></select>
</div>
<div class="form-row"> <div class="form-row">
<label>Note (optional)</label> <label>Note (optional)</label>
<input type="text" id="barNote" placeholder=""> <input type="text" id="barNote" placeholder="">
@ -131,21 +136,70 @@
</div> </div>
</div> </div>
<!-- ===================== EDIT MODAL ===================== --> <!-- ===================== ADMIN VIEW ===================== -->
<div id="view-admin" class="view hidden">
<div class="panel">
<h2>App Settings</h2>
<form id="settingsForm">
<div class="form-row"><label>Club Name</label>
<input type="text" id="s-club-name"></div>
<div class="form-row"><label>Currency Symbol</label>
<input type="text" id="s-currency-symbol" style="max-width:80px"></div>
<div class="form-row"><label>Currency Name <span class="label-hint">(major unit, e.g. pounds)</span></label>
<input type="text" id="s-currency-major" placeholder="pounds"></div>
<div class="form-row"><label>Subunit Name <span class="label-hint">(minor unit, e.g. pence)</span></label>
<input type="text" id="s-currency-minor" placeholder="pence"></div>
<div class="form-row"><label>Subunits per unit <span class="label-hint">(e.g. 100)</span></label>
<input type="number" id="s-currency-divisor" min="1" step="1" style="max-width:100px"></div>
<div class="form-row"><label>Minimum top-up <span class="label-hint" id="s-min-hint"></span></label>
<input type="number" id="s-min-topup" step="0.01" min="0.01"></div>
<div class="form-row"><label>Maximum top-up <span class="label-hint" id="s-max-hint"></span></label>
<input type="number" id="s-max-topup" step="0.01"></div>
<div class="form-row"><label>Maximum single charge <span class="label-hint" id="s-charge-hint"></span></label>
<input type="number" id="s-max-charge" step="0.01"></div>
<div class="form-row"><label>Receipt footer text <span class="label-hint">(optional)</span></label>
<textarea id="s-receipt-footer" rows="2" placeholder="Printed at the bottom of every receipt and statement"></textarea></div>
<div class="form-row form-row-check">
<input type="checkbox" id="s-allow-negative">
<label for="s-allow-negative">Allow negative balance (overdraft at bar)</label>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
<div id="settingsMsg" class="msg"></div>
</div>
<div class="panel">
<h2>Staff Accounts</h2>
<table id="staffAccountsTable" class="data-table">
<thead><tr><th>Name</th><th>Username</th><th>Role</th><th>Status</th><th></th></tr></thead>
<tbody></tbody>
</table>
<div class="panel-divider"></div>
<h3 class="sub-heading">Add Account</h3>
<form id="addAccountForm">
<div class="form-row"><label>Name</label><input type="text" id="acc-name" required></div>
<div class="form-row"><label>Username</label><input type="text" id="acc-username" required autocomplete="off"></div>
<div class="form-row"><label>Password</label><input type="password" id="acc-password" required autocomplete="new-password"></div>
<div class="form-row"><label>Role</label>
<select id="acc-role"><option value="staff">Staff</option><option value="admin">Admin</option></select>
</div>
<button type="submit" class="btn btn-primary">Add Account</button>
</form>
<div id="accountMsg" class="msg"></div>
</div>
</div>
<!-- ===================== EDIT MEMBER MODAL ===================== -->
<div id="editModal" class="modal-overlay hidden" onclick="if(event.target===this)closeEditModal()"> <div id="editModal" class="modal-overlay hidden" onclick="if(event.target===this)closeEditModal()">
<div class="modal"> <div class="modal">
<h3>Edit Member</h3> <h3>Edit Member</h3>
<form id="editForm"> <form id="editForm">
<div class="form-row"><label>Member Number</label><input type="text" id="edit-number" required></div>
<div class="form-row"><label>Full Name</label><input type="text" id="edit-name" required></div>
<div class="form-row"> <div class="form-row">
<label>Member Number</label> <label>New PIN <span class="label-hint">(leave blank to keep current)</span></label>
<input type="text" id="edit-number" required>
</div>
<div class="form-row">
<label>Full Name</label>
<input type="text" id="edit-name" required>
</div>
<div class="form-row">
<label>New PIN <span style="font-weight:400;color:#aaa">(leave blank to keep current)</span></label>
<input type="password" id="edit-pin" placeholder="Leave blank to keep"> <input type="password" id="edit-pin" placeholder="Leave blank to keep">
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -157,6 +211,33 @@
</div> </div>
</div> </div>
<!-- ===================== EDIT ACCOUNT MODAL ===================== -->
<div id="editAccountModal" class="modal-overlay hidden" onclick="if(event.target===this)closeEditAccountModal()">
<div class="modal">
<h3>Edit Account</h3>
<form id="editAccountForm">
<div class="form-row"><label>Name</label><input type="text" id="eacc-name"></div>
<div class="form-row"><label>Username</label><input type="text" id="eacc-username" autocomplete="off"></div>
<div class="form-row">
<label>New Password <span class="label-hint">(leave blank to keep)</span></label>
<input type="password" id="eacc-password" placeholder="Leave blank to keep" autocomplete="new-password">
</div>
<div class="form-row"><label>Role</label>
<select id="eacc-role"><option value="staff">Staff</option><option value="admin">Admin</option></select>
</div>
<div class="form-row form-row-check">
<input type="checkbox" id="eacc-active">
<label for="eacc-active">Active (can log in)</label>
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn" onclick="closeEditAccountModal()">Cancel</button>
</div>
</form>
<div id="editAccountMsg" class="msg"></div>
</div>
</div>
<script src="/static/common.js"></script> <script src="/static/common.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>

View file

@ -219,3 +219,57 @@ nav {
.msg:empty { display: none; } .msg:empty { display: none; }
.msg.ok { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; } .msg.ok { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; }
.msg.err { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; } .msg.err { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }
/* ---- Login overlay ---- */
.login-overlay {
position: fixed;
inset: 0;
background: #1a1a2e;
display: flex;
align-items: center;
justify-content: center;
z-index: 500;
}
.login-card {
background: #fff;
border-radius: 12px;
padding: 40px;
width: 380px;
max-width: calc(100vw - 32px);
box-shadow: 0 20px 60px rgba(0,0,0,.4);
}
.login-card h1 { font-size: 1.6rem; margin-bottom: 4px; text-align: center; }
.login-sub { text-align: center; color: var(--muted); font-size: .9rem; margin-bottom: 24px; }
/* ---- Nav right (user + logout) ---- */
.nav-right { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.nav-user { color: #aaa; font-size: .88rem; }
.nav-logout {
background: transparent;
border: 1px solid #555;
color: #ccc;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: .85rem;
transition: background .15s;
}
.nav-logout:hover { background: rgba(255,255,255,.1); }
/* ---- Admin form extras ---- */
.label-hint { font-weight: 400; color: #aaa; font-size: .8rem; }
.form-row textarea {
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1rem;
outline: none;
resize: vertical;
font-family: inherit;
transition: border-color .15s;
}
.form-row textarea:focus { border-color: var(--primary); }
.form-row-check { flex-direction: row !important; align-items: center; gap: 8px; }
.form-row-check label { font-weight: 400; color: var(--text); font-size: 1rem; }
.panel-divider { border: none; border-top: 1px solid var(--border); margin: 20px 0; }
.sub-heading { font-size: 1rem; margin-bottom: 14px; color: var(--text); }