mirror of
https://github.com/kbenestad/ClubLedger.git
synced 2026-06-18 09:44:33 +00:00
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:
parent
79d51973cd
commit
a5c9af1ca6
4 changed files with 876 additions and 462 deletions
742
main.py
742
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
|
||||
"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
|
||||
|
|
@ -69,7 +77,6 @@ def init_db():
|
|||
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),
|
||||
|
|
@ -80,7 +87,6 @@ def init_db():
|
|||
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,
|
||||
|
|
@ -90,11 +96,44 @@ def init_db():
|
|||
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,17 +223,20 @@ 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
|
||||
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
|
||||
|
||||
class ProductCreate(BaseModel):
|
||||
|
|
@ -188,10 +249,34 @@ class ProductCreate(BaseModel):
|
|||
class StaffAdd(BaseModel):
|
||||
name: str
|
||||
|
||||
class MemberUpdate(BaseModel):
|
||||
member_number: 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
|
||||
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
|
||||
|
|
@ -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 """
|
||||
<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):
|
||||
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 """<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 = ""
|
||||
running = 0
|
||||
def _print_controls():
|
||||
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:
|
||||
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"""
|
||||
<tr>
|
||||
<td>{r['created_at'][:16]}</td>
|
||||
<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>"""
|
||||
running -= r["amount"]; dr, cr = fmt(r["amount"]), ""
|
||||
rows_html += (f"<tr><td>{r['created_at'][:16]}</td><td class='cap'>{r['type']}</td>"
|
||||
f"<td class='cap'>{r['venue']}</td><td>{r['note'] or ''}</td>"
|
||||
f"<td>{r['staff_name']}</td><td class='num red'>{dr}</td>"
|
||||
f"<td class='num grn'>{cr}</td><td class='num'>{fmt(running)}</td></tr>")
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Statement – {member['name']}</title>
|
||||
<style>
|
||||
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||
<title>Statement – {member['name']}</title><style>
|
||||
{PRINT_CSS}
|
||||
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; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
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;}}
|
||||
</style></head><body>
|
||||
{_print_controls()}
|
||||
<div class="no-print controls" style="margin-top:0">
|
||||
<button class="print-btn" onclick="window.print()">Print Statement</button>
|
||||
</div>
|
||||
<h1>{club} – Account Statement</h1>
|
||||
<h2>Member: {member['name']} | #{member['member_number']} | Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<h2>Member: {member['name']} | #{member['member_number']} |
|
||||
Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC</h2>
|
||||
<table><thead><tr>
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{rows_html}</tbody>
|
||||
</table>
|
||||
<div class="balance-box">Current Balance: <span>{fmt(balance)}</span></div>
|
||||
{_print_size_script()}
|
||||
</body>
|
||||
</html>"""
|
||||
</tr></thead><tbody>{rows_html}</tbody></table>
|
||||
<div class="balance-box">Current Balance: <span>{fmt(bal)}</span></div>
|
||||
{('<div class="footer">' + footer + '</div>') if footer else ''}
|
||||
{_print_size_script()}</body></html>"""
|
||||
|
||||
@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"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Receipt – {member['name']}</title>
|
||||
<style>
|
||||
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||
<title>Receipt – {member['name']}</title><style>
|
||||
{PRINT_CSS}
|
||||
.receipt-title {{ 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: {amount_colour}; }}
|
||||
.balance {{ font-size: 20px; font-weight: bold; }}
|
||||
hr {{ border: none; border-top: 1px solid #ccc; margin: 16px 0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
.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;}}
|
||||
</style></head><body>
|
||||
{_print_controls()}
|
||||
<div class="no-print controls" style="margin-top:0">
|
||||
<button class="print-btn" onclick="window.print()">Print Receipt</button>
|
||||
</div>
|
||||
<h1>{club}</h1>
|
||||
<div class="receipt-title">{type_label} Receipt</div>
|
||||
<hr>
|
||||
<h1>{club}</h1><div class="sub">{type_label} Receipt</div><hr>
|
||||
<table>
|
||||
<tr><td>Member</td><td><strong>{member['name']}</strong></td></tr>
|
||||
<tr><td>Member #</td><td>{member['member_number']}</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>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>Note</td><td>{entry['note'] or '—'}</td></tr>
|
||||
<tr><td>Date / Time</td><td>{entry['created_at']} UTC</td></tr>
|
||||
</table>
|
||||
{_print_size_script()}
|
||||
</body>
|
||||
</html>"""
|
||||
{('<div class="footer">' + footer + '</div>') if footer else ''}
|
||||
{_print_size_script()}</body></html>"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"]),
|
||||
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
|
||||
]
|
||||
"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
|
||||
|
|
|
|||
323
static/app.js
323
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) {
|
|||
</tr>`).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 body = {
|
||||
member_number: document.getElementById('edit-number').value.trim(),
|
||||
name: document.getElementById('edit-name').value.trim(),
|
||||
};
|
||||
const pin = document.getElementById('edit-pin').value;
|
||||
const body = { member_number: number, name };
|
||||
if (pin) body.pin = pin;
|
||||
try {
|
||||
await apiFetch(`/members/${editMemberId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
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 => `
|
||||
<div class="member-pick-item" onclick="selectCashierMember(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${m.balance},'${esc(m.balance_display)}')">
|
||||
<div>
|
||||
<div class="member-pick-name">${esc(m.name)}</div>
|
||||
<div class="member-pick-sub">#${esc(m.member_number)}</div>
|
||||
</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>`).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 => `
|
||||
<div class="member-pick-item" onclick="selectBarMember(${m.id},'${esc(m.name)}','${esc(m.member_number)}',${m.balance},'${esc(m.balance_display)}')">
|
||||
<div>
|
||||
<div class="member-pick-name">${esc(m.name)}</div>
|
||||
<div class="member-pick-sub">#${esc(m.member_number)}</div>
|
||||
</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>`).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 (!amount) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; }
|
||||
if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; }
|
||||
if (!staff) { setMsg('barMsg', 'Select a staff member.', 'err'); return; }
|
||||
try {
|
||||
const r = await apiFetch('/charge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ member_id: barMember.id, amount, pin, staff_name: staff, note: note || null })
|
||||
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 => `
|
||||
<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); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,37 @@
|
|||
</head>
|
||||
<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>
|
||||
<span class="brand" id="navBrand">ClubLedger</span>
|
||||
<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="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>
|
||||
|
||||
<!-- ===================== MEMBERS VIEW ===================== -->
|
||||
|
|
@ -45,23 +71,11 @@
|
|||
<button class="btn" onclick="searchMembers()">Search</button>
|
||||
</div>
|
||||
<table id="memberTable" class="data-table">
|
||||
<thead>
|
||||
<tr><th>#</th><th>Name</th><th>Balance</th><th>Joined</th><th class="actions-col"></th></tr>
|
||||
</thead>
|
||||
<thead><tr><th>#</th><th>Name</th><th>Balance</th><th>Joined</th><th class="actions-col"></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
<!-- ===================== CASHIER VIEW ===================== -->
|
||||
|
|
@ -78,11 +92,7 @@
|
|||
<div class="selected-member-box" id="cashierSelected"></div>
|
||||
<div class="form-row">
|
||||
<label>Amount (<span class="currency-unit"></span>)</label>
|
||||
<input type="number" id="cashierAmount" placeholder="e.g. 1000" min="1" step="1">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Staff</label>
|
||||
<select id="cashierStaff"></select>
|
||||
<input type="number" id="cashierAmount" placeholder="e.g. 10.00" min="0.01" step="0.01">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Note (optional)</label>
|
||||
|
|
@ -107,19 +117,14 @@
|
|||
|
||||
<div id="barForm" class="hidden">
|
||||
<div class="selected-member-box" id="barSelected"></div>
|
||||
|
||||
<div class="form-row">
|
||||
<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 class="form-row">
|
||||
<label>PIN</label>
|
||||
<label>Member PIN</label>
|
||||
<input type="password" id="barPin" placeholder="Member PIN" maxlength="20">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Staff</label>
|
||||
<select id="barStaff"></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Note (optional)</label>
|
||||
<input type="text" id="barNote" placeholder="">
|
||||
|
|
@ -131,21 +136,70 @@
|
|||
</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 class="modal">
|
||||
<h3>Edit Member</h3>
|
||||
<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">
|
||||
<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">
|
||||
<label>New PIN <span style="font-weight:400;color:#aaa">(leave blank to keep current)</span></label>
|
||||
<label>New PIN <span class="label-hint">(leave blank to keep current)</span></label>
|
||||
<input type="password" id="edit-pin" placeholder="Leave blank to keep">
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
|
|
@ -157,6 +211,33 @@
|
|||
</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/app.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -219,3 +219,57 @@ nav {
|
|||
.msg:empty { display: none; }
|
||||
.msg.ok { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; }
|
||||
.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); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue