Add store-credit web app (FastAPI + SQLite)

- main.py: single-file backend with Member, LedgerEntry, Product models;
  endpoints for register, list/search members, topup (cashier), charge (bar,
  PIN-verified), transaction history, printable HTML statement, product CRUD.
  All monetary values stored as integers (pence). bcrypt PIN hashing.
  Admin-tunable CONFIG dict at top of file.
- static/index.html + style.css + app.js: three-view SPA (Members, Cashier,
  Bar) with member search, product search with member-price support, and
  XSS-safe rendering throughout.
- requirements.txt: fastapi, uvicorn, bcrypt only.

https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
This commit is contained in:
Claude 2026-05-30 05:21:01 +00:00
parent 68f3ae1538
commit fa4884bdb4
No known key found for this signature in database
5 changed files with 1019 additions and 0 deletions

454
main.py Normal file
View file

@ -0,0 +1,454 @@
"""
ClubLedger - Store Credit Web App
Admin configuration: edit the CONFIG dict below.
"""
import sqlite3
import hashlib
import time
import os
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
import bcrypt
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, field_validator
from typing import Optional
# ---------------------------------------------------------------------------
# Admin configuration
# ---------------------------------------------------------------------------
CONFIG = {
"club_name": "ClubLedger",
"currency_symbol": "£",
"currency_unit": "pence", # smallest unit stored as integer
"currency_divisor": 100, # divide stored int by this for display
"db_path": "clubledger.db",
"allow_negative_balance": False, # set True to allow overdraft at bar
"min_topup_amount": 100, # minimum top-up in pence (£1.00)
"max_topup_amount": 100_000, # maximum top-up in pence (£1000.00)
"max_charge_amount": 50_000, # maximum single charge in pence
}
DB_PATH = CONFIG["db_path"]
# ---------------------------------------------------------------------------
# Database
# ---------------------------------------------------------------------------
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
@contextmanager
def db_conn():
conn = get_db()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def init_db():
with db_conn() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
member_number TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
pin_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS ledger_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
member_id INTEGER NOT NULL REFERENCES members(id),
amount INTEGER NOT NULL,
type TEXT NOT NULL CHECK(type IN ('topup','charge')),
venue TEXT NOT NULL CHECK(venue IN ('cashier','bar')),
note TEXT,
staff_name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
brand TEXT,
price INTEGER NOT NULL,
member_price INTEGER,
search_tags TEXT,
active INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_ledger_member
ON ledger_entries(member_id);
""")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def hash_pin(pin: str) -> str:
return bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode()
def verify_pin(pin: str, hashed: str) -> bool:
return bcrypt.checkpw(pin.encode(), hashed.encode())
def member_balance(conn, member_id: int) -> int:
row = conn.execute("""
SELECT COALESCE(
SUM(CASE WHEN type='topup' THEN amount ELSE -amount END), 0
) AS balance
FROM ledger_entries WHERE member_id=?
""", (member_id,)).fetchone()
return row["balance"] if row else 0
def format_amount(pence: int) -> str:
sym = CONFIG["currency_symbol"]
div = CONFIG["currency_divisor"]
return f"{sym}{pence / div:.2f}"
# ---------------------------------------------------------------------------
# FastAPI app
# ---------------------------------------------------------------------------
app = FastAPI(title=CONFIG["club_name"])
# Serve static files
static_dir = Path(__file__).parent / "static"
static_dir.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
@app.on_event("startup")
def on_startup():
init_db()
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class MemberCreate(BaseModel):
member_number: str
name: str
pin: str
@field_validator("pin")
@classmethod
def pin_length(cls, v):
if len(v) < 4:
raise ValueError("PIN must be at least 4 characters")
return v
@field_validator("member_number")
@classmethod
def member_number_nonempty(cls, v):
v = v.strip()
if not v:
raise ValueError("member_number cannot be empty")
return v
class TopupRequest(BaseModel):
member_id: int
amount: int # pence
staff_name: str
note: Optional[str] = None
class ChargeRequest(BaseModel):
member_id: int
amount: int # pence
pin: str
staff_name: str
note: Optional[str] = None
class ProductCreate(BaseModel):
name: str
brand: Optional[str] = None
price: int
member_price: Optional[int] = None
search_tags: Optional[str] = None
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
async def root():
return (static_dir / "index.html").read_text()
@app.post("/members")
def create_member(body: MemberCreate):
with db_conn() as conn:
existing = conn.execute(
"SELECT id FROM members WHERE member_number=?",
(body.member_number.strip(),)
).fetchone()
if existing:
raise HTTPException(400, "Member number already exists")
pin_hash = hash_pin(body.pin)
cur = conn.execute(
"INSERT INTO members (member_number, name, pin_hash) VALUES (?,?,?)",
(body.member_number.strip(), body.name.strip(), pin_hash)
)
member_id = cur.lastrowid
with db_conn() as conn:
row = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
return {
"id": row["id"],
"member_number": row["member_number"],
"name": row["name"],
"created_at": row["created_at"],
}
@app.get("/members")
def list_members(q: Optional[str] = None):
with db_conn() as conn:
if q:
pattern = f"%{q}%"
rows = conn.execute(
"SELECT * FROM members WHERE name LIKE ? OR member_number LIKE ? ORDER BY name",
(pattern, pattern)
).fetchall()
else:
rows = conn.execute("SELECT * FROM members ORDER BY name").fetchall()
result = []
for r in rows:
balance = member_balance(conn, r["id"])
result.append({
"id": r["id"],
"member_number": r["member_number"],
"name": r["name"],
"balance": balance,
"balance_display": format_amount(balance),
"created_at": r["created_at"],
})
return result
@app.post("/topup")
def topup(body: TopupRequest):
if body.amount < CONFIG["min_topup_amount"]:
raise HTTPException(400, f"Minimum top-up is {format_amount(CONFIG['min_topup_amount'])}")
if body.amount > CONFIG["max_topup_amount"]:
raise HTTPException(400, f"Maximum top-up is {format_amount(CONFIG['max_topup_amount'])}")
with db_conn() as conn:
member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone()
if not member:
raise HTTPException(404, "Member not found")
conn.execute(
"INSERT INTO ledger_entries (member_id, amount, type, venue, note, staff_name) VALUES (?,?,?,?,?,?)",
(body.member_id, body.amount, "topup", "cashier", body.note, body.staff_name)
)
balance = member_balance(conn, body.member_id)
return {
"ok": True,
"new_balance": balance,
"new_balance_display": format_amount(balance),
}
@app.post("/charge")
def charge(body: ChargeRequest):
if body.amount <= 0:
raise HTTPException(400, "Amount must be positive")
if body.amount > CONFIG["max_charge_amount"]:
raise HTTPException(400, f"Maximum single charge is {format_amount(CONFIG['max_charge_amount'])}")
with db_conn() as conn:
member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone()
if not member:
raise HTTPException(404, "Member not found")
if not verify_pin(body.pin, member["pin_hash"]):
raise HTTPException(403, "Incorrect PIN")
balance = member_balance(conn, body.member_id)
if not CONFIG["allow_negative_balance"] and balance < body.amount:
raise HTTPException(400, f"Insufficient balance ({format_amount(balance)})")
conn.execute(
"INSERT INTO ledger_entries (member_id, amount, type, venue, note, staff_name) VALUES (?,?,?,?,?,?)",
(body.member_id, body.amount, "charge", "bar", body.note, body.staff_name)
)
new_balance = member_balance(conn, body.member_id)
return {
"ok": True,
"new_balance": new_balance,
"new_balance_display": format_amount(new_balance),
}
@app.get("/members/{member_id}/transactions")
def transactions(member_id: int, limit: int = 50, offset: int = 0):
with db_conn() as conn:
member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
if not member:
raise HTTPException(404, "Member not found")
rows = conn.execute("""
SELECT * FROM ledger_entries
WHERE member_id=?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""", (member_id, limit, offset)).fetchall()
balance = member_balance(conn, member_id)
return {
"member": {
"id": member["id"],
"member_number": member["member_number"],
"name": member["name"],
},
"balance": balance,
"balance_display": format_amount(balance),
"transactions": [
{
"id": r["id"],
"amount": r["amount"],
"amount_display": format_amount(r["amount"]),
"type": r["type"],
"venue": r["venue"],
"note": r["note"],
"staff_name": r["staff_name"],
"created_at": r["created_at"],
}
for r in rows
],
}
@app.get("/members/{member_id}/statement", response_class=HTMLResponse)
def statement(member_id: int):
with db_conn() as conn:
member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
if not member:
raise HTTPException(404, "Member not found")
rows = conn.execute("""
SELECT * FROM ledger_entries WHERE member_id=?
ORDER BY created_at ASC
""", (member_id,)).fetchall()
balance = member_balance(conn, member_id)
sym = CONFIG["currency_symbol"]
div = CONFIG["currency_divisor"]
club = CONFIG["club_name"]
def fmt(p):
return f"{sym}{p/div:.2f}"
rows_html = ""
running = 0
for r in rows:
if r["type"] == "topup":
running += r["amount"]
dr, cr = "", fmt(r["amount"])
else:
running -= r["amount"]
dr, cr = fmt(r["amount"]), ""
rows_html += f"""
<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>"""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Statement {member['name']}</title>
<style>
@media print {{
@page {{ size: A4; margin: 15mm; }}
@page :first {{ size: A5; margin: 10mm; }}
.no-print {{ display: none; }}
}}
body {{ font-family: Arial, sans-serif; font-size: 11px; color: #111; margin: 20px; }}
h1 {{ font-size: 18px; margin-bottom: 2px; }}
h2 {{ font-size: 13px; font-weight: normal; color: #555; margin-top: 0; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 16px; }}
th {{ background: #222; color: #fff; padding: 5px 8px; text-align: left; }}
td {{ padding: 4px 8px; border-bottom: 1px solid #e0e0e0; }}
.num {{ text-align: right; font-variant-numeric: tabular-nums; }}
.red {{ color: #c00; }}
.grn {{ color: #080; }}
.cap {{ text-transform: capitalize; }}
.balance-box {{ margin-top: 12px; text-align: right; font-size: 14px; }}
.balance-box span {{ font-weight: bold; font-size: 18px; }}
.print-btn {{ margin-top: 12px; padding: 8px 18px; font-size: 13px; cursor: pointer; }}
</style>
</head>
<body>
<div class="no-print">
<button class="print-btn" onclick="window.print()">Print Statement</button>
</div>
<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>
<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>
</body>
</html>"""
# ---------------------------------------------------------------------------
# Products endpoints
# ---------------------------------------------------------------------------
@app.get("/products")
def list_products(q: Optional[str] = None, active_only: bool = True):
with db_conn() as conn:
base = "SELECT * FROM products"
conds, params = [], []
if active_only:
conds.append("active=1")
if q:
conds.append("(name LIKE ? OR brand LIKE ? OR search_tags LIKE ?)")
p = f"%{q}%"
params += [p, p, p]
if conds:
base += " WHERE " + " AND ".join(conds)
base += " ORDER BY name"
rows = conn.execute(base, params).fetchall()
return [
{
"id": r["id"],
"name": r["name"],
"brand": r["brand"],
"price": r["price"],
"price_display": format_amount(r["price"]),
"member_price": r["member_price"],
"member_price_display": format_amount(r["member_price"]) if r["member_price"] else None,
"search_tags": r["search_tags"],
"active": bool(r["active"]),
}
for r in rows
]
@app.post("/products")
def create_product(body: ProductCreate):
with db_conn() as conn:
cur = conn.execute(
"INSERT INTO products (name, brand, price, member_price, search_tags) VALUES (?,?,?,?,?)",
(body.name, body.brand, body.price, body.member_price, body.search_tags)
)
return {"id": cur.lastrowid, "ok": True}
@app.get("/config")
def get_config():
safe = {k: v for k, v in CONFIG.items() if k != "db_path"}
return safe
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
fastapi
uvicorn[standard]
bcrypt

285
static/app.js Normal file
View file

@ -0,0 +1,285 @@
/* ClubLedger frontend */
let cfg = { currency_unit: 'pence', currency_symbol: '£', currency_divisor: 100, club_name: 'ClubLedger' };
let cashierMember = null;
let barMember = null;
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
(async function init() {
try {
const r = await fetch('/config');
cfg = await r.json();
document.getElementById('navBrand').textContent = cfg.club_name;
document.title = cfg.club_name;
document.querySelectorAll('.currency-unit').forEach(el => {
el.textContent = cfg.currency_unit;
});
} catch (e) { /* use defaults */ }
// Nav
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
document.getElementById('view-' + btn.dataset.view).classList.remove('hidden');
});
});
// Register form
document.getElementById('registerForm').addEventListener('submit', async e => {
e.preventDefault();
await registerMember();
});
// Enter key on search fields
document.getElementById('memberSearch').addEventListener('keydown', e => { if (e.key === 'Enter') searchMembers(); });
document.getElementById('cashierSearch').addEventListener('keydown', e => { if (e.key === 'Enter') cashierSearchMembers(); });
document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); });
searchMembers();
})();
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function fmtAmount(pence) {
return cfg.currency_symbol + (pence / cfg.currency_divisor).toFixed(2);
}
function balanceClass(v) {
return v < 0 ? 'balance-neg' : 'balance-pos';
}
function setMsg(id, text, type) {
const el = document.getElementById(id);
el.textContent = text;
el.className = 'msg ' + (type || '');
}
async function apiFetch(url, opts) {
const r = await fetch(url, opts);
const json = await r.json();
if (!r.ok) throw new Error(json.detail || 'Server error');
return json;
}
// ---------------------------------------------------------------------------
// Members view
// ---------------------------------------------------------------------------
async function registerMember() {
const number = document.getElementById('reg-number').value.trim();
const name = document.getElementById('reg-name').value.trim();
const pin = document.getElementById('reg-pin').value;
if (!number || !name || !pin) { setMsg('registerMsg', 'All fields required.', 'err'); return; }
try {
const m = await apiFetch('/members', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_number: number, name, pin })
});
setMsg('registerMsg', `Registered: ${m.name} (#${m.member_number})`, 'ok');
document.getElementById('registerForm').reset();
searchMembers();
} catch (e) {
setMsg('registerMsg', e.message, 'err');
}
}
async function searchMembers() {
const q = document.getElementById('memberSearch').value.trim();
const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members';
try {
const members = await apiFetch(url);
renderMemberTable(members);
} catch (e) {
console.error(e);
}
}
function renderMemberTable(members) {
const tbody = document.querySelector('#memberTable tbody');
if (!members.length) { tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#888">No members found</td></tr>'; return; }
tbody.innerHTML = members.map(m => `
<tr>
<td>${esc(m.member_number)}</td>
<td>${esc(m.name)}</td>
<td class="num ${balanceClass(m.balance)}">${esc(m.balance_display)}</td>
<td>${m.created_at ? m.created_at.slice(0,10) : ''}</td>
<td>
<a href="/members/${m.id}/statement" target="_blank" class="btn" style="padding:4px 10px;font-size:.82rem">Statement</a>
</td>
</tr>`).join('');
}
// ---------------------------------------------------------------------------
// Cashier view
// ---------------------------------------------------------------------------
async function cashierSearchMembers() {
const q = document.getElementById('cashierSearch').value.trim();
const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members';
try {
const members = await apiFetch(url);
const list = document.getElementById('cashierMemberList');
list.innerHTML = members.map(m => `
<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 class="${balanceClass(m.balance)}">${esc(m.balance_display)}</div>
</div>`).join('');
} catch (e) { console.error(e); }
}
function selectCashierMember(id, name, number, balance, balanceDisplay) {
cashierMember = { id, name, number };
document.getElementById('cashierMemberList').innerHTML = '';
document.getElementById('cashierSelected').innerHTML =
`<strong>${esc(name)}</strong> &nbsp; #${esc(number)} &nbsp; Balance: <span class="${balanceClass(balance)}">${esc(balanceDisplay)}</span>`;
document.getElementById('cashierForm').classList.remove('hidden');
setMsg('cashierMsg', '', '');
}
function clearCashierSelection() {
cashierMember = null;
document.getElementById('cashierForm').classList.add('hidden');
document.getElementById('cashierAmount').value = '';
document.getElementById('cashierStaff').value = '';
document.getElementById('cashierNote').value = '';
setMsg('cashierMsg', '', '');
}
async function doTopup() {
if (!cashierMember) return;
const amount = parseInt(document.getElementById('cashierAmount').value, 10);
const staff = document.getElementById('cashierStaff').value.trim();
const note = document.getElementById('cashierNote').value.trim();
if (!amount || isNaN(amount) || amount <= 0) { setMsg('cashierMsg', 'Enter a valid amount.', 'err'); return; }
if (!staff) { setMsg('cashierMsg', 'Staff name required.', 'err'); return; }
try {
const r = await apiFetch('/topup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_id: cashierMember.id, amount, staff_name: staff, note: note || null })
});
setMsg('cashierMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok');
clearCashierSelection();
searchMembers();
} catch (e) {
setMsg('cashierMsg', e.message, 'err');
}
}
// ---------------------------------------------------------------------------
// Bar view
// ---------------------------------------------------------------------------
async function barSearchMembers() {
const q = document.getElementById('barSearch').value.trim();
const url = q ? `/members?q=${encodeURIComponent(q)}` : '/members';
try {
const members = await apiFetch(url);
const list = document.getElementById('barMemberList');
list.innerHTML = members.map(m => `
<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 class="${balanceClass(m.balance)}">${esc(m.balance_display)}</div>
</div>`).join('');
} catch (e) { console.error(e); }
}
function selectBarMember(id, name, number, balance, balanceDisplay) {
barMember = { id, name, number };
document.getElementById('barMemberList').innerHTML = '';
document.getElementById('barSelected').innerHTML =
`<strong>${esc(name)}</strong> &nbsp; #${esc(number)} &nbsp; Balance: <span class="${balanceClass(balance)}">${esc(balanceDisplay)}</span>`;
document.getElementById('barForm').classList.remove('hidden');
document.getElementById('barProductSearch').value = '';
document.getElementById('barProductResults').innerHTML = '';
setMsg('barMsg', '', '');
}
function clearBarSelection() {
barMember = null;
document.getElementById('barForm').classList.add('hidden');
document.getElementById('barAmount').value = '';
document.getElementById('barPin').value = '';
document.getElementById('barStaff').value = '';
document.getElementById('barNote').value = '';
document.getElementById('barProductSearch').value = '';
document.getElementById('barProductResults').innerHTML = '';
setMsg('barMsg', '', '');
}
let productTimer = null;
async function barProductLookup() {
clearTimeout(productTimer);
productTimer = setTimeout(async () => {
const q = document.getElementById('barProductSearch').value.trim();
if (!q) { document.getElementById('barProductResults').innerHTML = ''; return; }
try {
const products = await apiFetch(`/products?q=${encodeURIComponent(q)}`);
const div = document.getElementById('barProductResults');
if (!products.length) { div.innerHTML = '<div style="color:#888;font-size:.88rem;padding:4px">No products found</div>'; return; }
div.innerHTML = products.map(p => `
<div class="product-item" onclick="selectProduct(${p.price}, ${p.member_price || p.price}, '${esc(p.name)}${p.brand ? ' '+esc(p.brand) : ''}')">
<div>
<strong>${esc(p.name)}</strong>${p.brand ? ` <span style="color:#888"> ${esc(p.brand)}</span>` : ''}
${p.search_tags ? `<div style="font-size:.78rem;color:#aaa">${esc(p.search_tags)}</div>` : ''}
</div>
<div>
<span class="product-price">${esc(p.price_display)}</span>
${p.member_price_display ? `<span style="font-size:.82rem;color:#34d399;margin-left:6px">mbr: ${esc(p.member_price_display)}</span>` : ''}
</div>
</div>`).join('');
} catch (e) { console.error(e); }
}, 250);
}
function selectProduct(price, memberPrice, label) {
document.getElementById('barAmount').value = memberPrice;
document.getElementById('barNote').value = label;
document.getElementById('barProductResults').innerHTML = '';
document.getElementById('barProductSearch').value = '';
}
async function doCharge() {
if (!barMember) return;
const amount = parseInt(document.getElementById('barAmount').value, 10);
const pin = document.getElementById('barPin').value;
const staff = document.getElementById('barStaff').value.trim();
const note = document.getElementById('barNote').value.trim();
if (!amount || isNaN(amount) || amount <= 0) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; }
if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; }
if (!staff) { setMsg('barMsg', 'Staff name required.', 'err'); return; }
try {
const r = await apiFetch('/charge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ member_id: barMember.id, amount, pin, staff_name: staff, note: note || null })
});
setMsg('barMsg', `Charge complete. New balance: ${r.new_balance_display}`, 'ok');
clearBarSelection();
searchMembers();
} catch (e) {
setMsg('barMsg', e.message, 'err');
}
}
// ---------------------------------------------------------------------------
// XSS-safe escape
// ---------------------------------------------------------------------------
function esc(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

129
static/index.html Normal file
View file

@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ClubLedger</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<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>
</nav>
<!-- ===================== MEMBERS VIEW ===================== -->
<div id="view-members" class="view">
<div class="panel">
<h2>Register New Member</h2>
<form id="registerForm">
<div class="form-row">
<label>Member Number</label>
<input type="text" id="reg-number" placeholder="e.g. 001" required>
</div>
<div class="form-row">
<label>Full Name</label>
<input type="text" id="reg-name" placeholder="Name" required>
</div>
<div class="form-row">
<label>PIN</label>
<input type="password" id="reg-pin" placeholder="Min 4 digits" required>
</div>
<button type="submit" class="btn btn-primary">Register</button>
</form>
<div id="registerMsg" class="msg"></div>
</div>
<div class="panel">
<h2>Member Search</h2>
<div class="search-row">
<input type="text" id="memberSearch" placeholder="Search name or number…">
<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></th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<!-- ===================== CASHIER VIEW ===================== -->
<div id="view-cashier" class="view hidden">
<div class="panel">
<h2>Top Up Account</h2>
<div class="search-row">
<input type="text" id="cashierSearch" placeholder="Search member…">
<button class="btn" onclick="cashierSearchMembers()">Search</button>
</div>
<div id="cashierMemberList" class="member-pick-list"></div>
<div id="cashierForm" class="hidden">
<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 Name</label>
<input type="text" id="cashierStaff" placeholder="Your name">
</div>
<div class="form-row">
<label>Note (optional)</label>
<input type="text" id="cashierNote" placeholder="">
</div>
<button class="btn btn-primary" onclick="doTopup()">Top Up</button>
<button class="btn" onclick="clearCashierSelection()">Cancel</button>
</div>
<div id="cashierMsg" class="msg"></div>
</div>
</div>
<!-- ===================== BAR VIEW ===================== -->
<div id="view-bar" class="view hidden">
<div class="panel">
<h2>Charge Account</h2>
<div class="search-row">
<input type="text" id="barSearch" placeholder="Search member…">
<button class="btn" onclick="barSearchMembers()">Search</button>
</div>
<div id="barMemberList" class="member-pick-list"></div>
<div id="barForm" class="hidden">
<div class="selected-member-box" id="barSelected"></div>
<!-- Product search -->
<div class="form-row">
<label>Product Search</label>
<input type="text" id="barProductSearch" placeholder="Search products…" oninput="barProductLookup()">
</div>
<div id="barProductResults" class="product-results"></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">
</div>
<div class="form-row">
<label>PIN</label>
<input type="password" id="barPin" placeholder="Member PIN" maxlength="20">
</div>
<div class="form-row">
<label>Staff Name</label>
<input type="text" id="barStaff" placeholder="Your name">
</div>
<div class="form-row">
<label>Note (optional)</label>
<input type="text" id="barNote" placeholder="">
</div>
<button class="btn btn-danger" onclick="doCharge()">Charge</button>
<button class="btn" onclick="clearBarSelection()">Cancel</button>
</div>
<div id="barMsg" class="msg"></div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

148
static/style.css Normal file
View file

@ -0,0 +1,148 @@
/* ClubLedger main styles */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #1a56db;
--primary-dark: #1140a6;
--danger: #d63b3b;
--danger-dark: #a82e2e;
--success: #1a7f3c;
--bg: #f4f6fb;
--panel-bg: #ffffff;
--border: #d1d5db;
--text: #111827;
--muted: #6b7280;
--nav-bg: #1a1a2e;
--nav-text: #e0e0e0;
}
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
/* ---- Nav ---- */
nav {
background: var(--nav-bg);
color: var(--nav-text);
display: flex;
align-items: center;
padding: 0 20px;
height: 52px;
gap: 8px;
position: sticky;
top: 0;
z-index: 100;
}
.brand { font-size: 1.2rem; font-weight: 700; letter-spacing: .5px; margin-right: 16px; color: #fff; }
.nav-btn {
background: transparent;
border: 2px solid transparent;
color: var(--nav-text);
padding: 6px 16px;
border-radius: 6px;
cursor: pointer;
font-size: .95rem;
transition: background .15s, border-color .15s;
}
.nav-btn:hover { background: rgba(255,255,255,.1); }
.nav-btn.active { border-color: #4a9eff; color: #fff; }
/* ---- Views ---- */
.view { max-width: 900px; margin: 28px auto; padding: 0 16px; display: flex; flex-direction: column; gap: 24px; }
.hidden { display: none !important; }
/* ---- Panel ---- */
.panel { background: var(--panel-bg); border: 1px solid var(--border); border-radius: 10px; padding: 24px; }
.panel h2 { font-size: 1.15rem; margin-bottom: 18px; color: var(--text); }
/* ---- Forms ---- */
.form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 14px; }
.form-row label { font-size: .85rem; font-weight: 600; color: var(--muted); }
.form-row input {
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1rem;
outline: none;
transition: border-color .15s;
}
.form-row input:focus { border-color: var(--primary); }
.search-row { display: flex; gap: 8px; margin-bottom: 14px; }
.search-row input { flex: 1; padding: 9px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 1rem; outline: none; }
.search-row input:focus { border-color: var(--primary); }
/* ---- Buttons ---- */
.btn {
padding: 9px 20px;
border: 1px solid var(--border);
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: .95rem;
transition: background .15s;
margin-right: 6px;
}
.btn:hover { background: #f0f0f0; }
.btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
.btn-primary:hover { background: var(--primary-dark); }
.btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
.btn-danger:hover { background: var(--danger-dark); }
/* ---- Data table ---- */
.data-table { width: 100%; border-collapse: collapse; font-size: .93rem; margin-top: 8px; }
.data-table th { background: #f0f2f7; padding: 8px 10px; text-align: left; font-weight: 600; border-bottom: 2px solid var(--border); }
.data-table td { padding: 8px 10px; border-bottom: 1px solid #eee; }
.data-table tr:last-child td { border-bottom: none; }
.data-table tr:hover td { background: #f9f9ff; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.balance-pos { color: var(--success); font-weight: 600; }
.balance-neg { color: var(--danger); font-weight: 600; }
/* ---- Member pick list ---- */
.member-pick-list { margin-bottom: 14px; }
.member-pick-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 6px;
cursor: pointer;
transition: background .12s, border-color .12s;
}
.member-pick-item:hover { background: #eef2ff; border-color: var(--primary); }
.member-pick-name { font-weight: 600; }
.member-pick-sub { font-size: .83rem; color: var(--muted); }
/* ---- Selected member box ---- */
.selected-member-box {
background: #eef2ff;
border: 1px solid #c7d2f7;
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 16px;
font-size: .95rem;
}
.selected-member-box strong { font-size: 1.05rem; }
/* ---- Product results ---- */
.product-results { margin-bottom: 14px; }
.product-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 5px;
cursor: pointer;
transition: background .12s;
}
.product-item:hover { background: #f0fff4; border-color: #34d399; }
.product-price { font-weight: 700; color: var(--primary); }
/* ---- Messages ---- */
.msg { margin-top: 12px; padding: 10px 14px; border-radius: 6px; font-size: .93rem; }
.msg:empty { display: none; }
.msg.ok { background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7; }
.msg.err { background: #fee2e2; color: #991b1b; border: 1px solid #fca5a5; }