Fix 1-4: staff dropdown, split pages, print size toggle, receipts

Fix 1: Replace free-text staff name with a dropdown populated from staff.json
  via GET/POST/DELETE /staff endpoints. Staff management panel on cashier page
  (type name, Add button, chip list with × remove). Dropdown remembers last
  selection per session via sessionStorage.

Fix 2: Split single-page app into /cashier (register + top-up + member list +
  staff management) and /bar (charge only). Each page is its own HTML file
  with two plain <a> nav links; / redirects to /cashier. Shared helpers
  extracted to common.js; page logic in cashier.js and bar.js.

Fix 3: Statement view gains an A4/A5 radio toggle that rewrites a dynamic
  <style> @page rule before the browser print dialog opens. Defaults to A4.

Fix 4: POST /topup and POST /charge now return entry_id. Each successful
  transaction opens /receipt/{entry_id} in a new tab — server-rendered HTML
  showing member name/number, type, amount, balance-after (computed as running
  sum up to that entry), staff, note, timestamp. Same A4/A5 print toggle.

https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
This commit is contained in:
Claude 2026-05-30 06:28:54 +00:00
parent 7af0dd0496
commit 34b3e88fe2
No known key found for this signature in database
7 changed files with 736 additions and 24 deletions

191
main.py
View file

@ -4,14 +4,15 @@ Admin configuration: edit the CONFIG dict below.
"""
import sqlite3
import json
import os
from contextlib import contextmanager, asynccontextmanager
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 import FastAPI, HTTPException
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, field_validator
from typing import Optional
@ -32,6 +33,8 @@ CONFIG = {
}
DB_PATH = CONFIG["db_path"]
static_dir = Path(__file__).parent / "static"
STAFF_FILE = Path(__file__).parent / "staff.json"
# ---------------------------------------------------------------------------
# Database
@ -116,19 +119,25 @@ def format_amount(pence: int) -> str:
div = CONFIG["currency_divisor"]
return f"{sym}{pence / div:.2f}"
def load_staff() -> list:
if STAFF_FILE.exists():
return json.loads(STAFF_FILE.read_text()).get("staff", [])
return []
def save_staff(names: list):
STAFF_FILE.write_text(json.dumps({"staff": sorted(set(names))}, indent=2))
# ---------------------------------------------------------------------------
# FastAPI app
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
async def lifespan(app):
init_db()
yield
app = FastAPI(title=CONFIG["club_name"], lifespan=lifespan)
# 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")
@ -158,13 +167,13 @@ class MemberCreate(BaseModel):
class TopupRequest(BaseModel):
member_id: int
amount: int # pence
amount: int
staff_name: str
note: Optional[str] = None
class ChargeRequest(BaseModel):
member_id: int
amount: int # pence
amount: int
pin: str
staff_name: str
note: Optional[str] = None
@ -176,13 +185,28 @@ class ProductCreate(BaseModel):
member_price: Optional[int] = None
search_tags: Optional[str] = None
class StaffAdd(BaseModel):
name: str
# ---------------------------------------------------------------------------
# Endpoints
# Page routes
# ---------------------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
@app.get("/", response_class=RedirectResponse)
async def root():
return (static_dir / "index.html").read_text()
return RedirectResponse(url="/cashier", status_code=302)
@app.get("/cashier", response_class=HTMLResponse)
async def cashier_page():
return (static_dir / "cashier.html").read_text()
@app.get("/bar", response_class=HTMLResponse)
async def bar_page():
return (static_dir / "bar.html").read_text()
# ---------------------------------------------------------------------------
# API endpoints members
# ---------------------------------------------------------------------------
@app.post("/members")
def create_member(body: MemberCreate):
@ -242,13 +266,15 @@ def topup(body: TopupRequest):
member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone()
if not member:
raise HTTPException(404, "Member not found")
conn.execute(
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)
)
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),
}
@ -268,13 +294,15 @@ def charge(body: ChargeRequest):
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(
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)
)
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),
}
@ -315,6 +343,39 @@ def transactions(member_id: int, limit: int = 50, offset: int = 0):
],
}
# ---------------------------------------------------------------------------
# Print views
# ---------------------------------------------------------------------------
def _print_size_script() -> str:
return """
<script>
function setSize(s) {
var el = document.getElementById('psStyle');
if (!el) { el = document.createElement('style'); el.id = 'psStyle'; document.head.appendChild(el); }
el.textContent = '@media print { @page { size: ' + s + '; margin: ' + (s === 'A5' ? '8mm' : '14mm') + '; } }';
}
setSize('A4');
</script>"""
def _print_controls(extra_class: str = "") -> str:
return f"""<div class="no-print controls {extra_class}">
<span class="size-label">Paper size:</span>
<label><input type="radio" name="ps" value="A4" checked onchange="setSize('A4')"> A4</label>
<label><input type="radio" name="ps" value="A5" onchange="setSize('A5')"> A5</label>
</div>"""
PRINT_CSS = """
body { font-family: Arial, sans-serif; font-size: 11px; color: #111; margin: 24px; }
h1 { font-size: 18px; margin-bottom: 2px; }
h2 { font-size: 13px; font-weight: normal; color: #555; margin-top: 0; margin-bottom: 16px; }
.controls { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.size-label { font-size: 12px; color: #555; }
.controls label { font-size: 12px; cursor: pointer; }
.print-btn { padding: 7px 18px; font-size: 13px; cursor: pointer; margin-left: auto; }
@media print { .no-print { display: none; } }
"""
@app.get("/members/{member_id}/statement", response_class=HTMLResponse)
def statement(member_id: int):
with db_conn() as conn:
@ -361,14 +422,7 @@ def statement(member_id: int):
<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; }}
{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; }}
@ -378,11 +432,11 @@ def statement(member_id: int):
.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">
{_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>
@ -397,6 +451,67 @@ def statement(member_id: int):
<tbody>{rows_html}</tbody>
</table>
<div class="balance-box">Current Balance: <span>{fmt(balance)}</span></div>
{_print_size_script()}
</body>
</html>"""
@app.get("/receipt/{entry_id}", response_class=HTMLResponse)
def receipt(entry_id: int):
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")
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)
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"]
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"
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>
{_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>
<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>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>"""
@ -443,10 +558,38 @@ def create_product(body: ProductCreate):
)
return {"id": cur.lastrowid, "ok": True}
# ---------------------------------------------------------------------------
# Staff endpoints
# ---------------------------------------------------------------------------
@app.get("/staff")
def get_staff():
return {"staff": load_staff()}
@app.post("/staff")
def add_staff(body: StaffAdd):
name = body.name.strip()
if not name:
raise HTTPException(400, "Name cannot be empty")
staff = load_staff()
if name not in staff:
staff.append(name)
save_staff(staff)
return {"staff": sorted(staff)}
@app.delete("/staff/{name}")
def remove_staff(name: str):
staff = [s for s in load_staff() if s != name]
save_staff(staff)
return {"staff": staff}
# ---------------------------------------------------------------------------
# Config endpoint
# ---------------------------------------------------------------------------
@app.get("/config")
def get_config():
safe = {k: v for k, v in CONFIG.items() if k != "db_path"}
return safe
return {k: v for k, v in CONFIG.items() if k != "db_path"}
if __name__ == "__main__":
import uvicorn

63
static/bar.html Normal file
View file

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bar ClubLedger</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav>
<span class="brand" id="navBrand">ClubLedger</span>
<a class="nav-link" href="/cashier">Cashier</a>
<a class="nav-link active" href="/bar">Bar</a>
</nav>
<div class="view">
<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>
<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</label>
<select id="barStaff"></select>
</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/common.js"></script>
<script src="/static/bar.js"></script>
</body>
</html>

116
static/bar.js Normal file
View file

@ -0,0 +1,116 @@
/* ClubLedger bar page */
let barMember = null;
(async function init() {
await loadConfig();
await loadStaffInto('barStaff');
document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); });
})();
// ---------------------------------------------------------------------------
// Member selection
// ---------------------------------------------------------------------------
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('barNote').value = '';
document.getElementById('barProductSearch').value = '';
document.getElementById('barProductResults').innerHTML = '';
setMsg('barMsg', '', '');
}
// ---------------------------------------------------------------------------
// Product search
// ---------------------------------------------------------------------------
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 = '';
}
// ---------------------------------------------------------------------------
// Charge
// ---------------------------------------------------------------------------
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;
const note = document.getElementById('barNote').value.trim();
if (!amount || isNaN(amount) || amount <= 0) { setMsg('barMsg', 'Enter a valid amount.', 'err'); return; }
if (!pin) { setMsg('barMsg', 'PIN required.', 'err'); return; }
if (!staff) { setMsg('barMsg', 'Select a staff member.', 'err'); return; }
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 })
});
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');
}
}

98
static/cashier.html Normal file
View file

@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cashier ClubLedger</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav>
<span class="brand" id="navBrand">ClubLedger</span>
<a class="nav-link active" href="/cashier">Cashier</a>
<a class="nav-link" href="/bar">Bar</a>
</nav>
<div class="view">
<!-- Register -->
<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>
<!-- Top Up -->
<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</label>
<select id="cashierStaff"></select>
</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>
<!-- Member list -->
<div class="panel">
<h2>Members</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>
<!-- Staff management -->
<div class="panel">
<h2>Staff</h2>
<div class="search-row">
<input type="text" id="staffNameInput" placeholder="Staff name" id="staffNameInput">
<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>
<script src="/static/common.js"></script>
<script src="/static/cashier.js"></script>
</body>
</html>

132
static/cashier.js Normal file
View file

@ -0,0 +1,132 @@
/* ClubLedger cashier page */
let cashierMember = null;
(async function init() {
await loadConfig();
await loadStaffInto('cashierStaff');
// load initial staff chips
try {
const data = await apiFetch('/staff');
renderStaffChips(data.staff);
} catch (e) { /* ignore */ }
document.getElementById('registerForm').addEventListener('submit', async e => {
e.preventDefault();
await registerMember();
});
document.getElementById('memberSearch').addEventListener('keydown', e => { if (e.key === 'Enter') searchMembers(); });
document.getElementById('cashierSearch').addEventListener('keydown', e => { if (e.key === 'Enter') cashierSearchMembers(); });
document.getElementById('staffNameInput').addEventListener('keydown', e => { if (e.key === 'Enter') addStaff(); });
searchMembers();
})();
// ---------------------------------------------------------------------------
// Register
// ---------------------------------------------------------------------------
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');
}
}
// ---------------------------------------------------------------------------
// Member list
// ---------------------------------------------------------------------------
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 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('');
} catch (e) { console.error(e); }
}
// ---------------------------------------------------------------------------
// Top-up
// ---------------------------------------------------------------------------
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('cashierNote').value = '';
setMsg('cashierMsg', '', '');
}
async function doTopup() {
if (!cashierMember) return;
const amount = parseInt(document.getElementById('cashierAmount').value, 10);
const staff = document.getElementById('cashierStaff').value;
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; }
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 })
});
window.open(`/receipt/${r.entry_id}`, '_blank');
setMsg('cashierMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok');
clearCashierSelection();
searchMembers();
} catch (e) {
setMsg('cashierMsg', e.message, 'err');
}
}

112
static/common.js Normal file
View file

@ -0,0 +1,112 @@
/* ClubLedger shared helpers */
let cfg = { currency_unit: 'pence', currency_symbol: '£', currency_divisor: 100, club_name: 'ClubLedger' };
async function loadConfig() {
try {
const r = await fetch('/config');
cfg = await r.json();
const brand = document.getElementById('navBrand');
if (brand) brand.textContent = cfg.club_name;
document.title = document.title.replace('ClubLedger', cfg.club_name);
document.querySelectorAll('.currency-unit').forEach(el => { el.textContent = cfg.currency_unit; });
} catch (e) { /* use defaults */ }
}
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;
}
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;');
}
// ---------------------------------------------------------------------------
// Staff dropdown
// ---------------------------------------------------------------------------
async function loadStaffInto(selectId) {
const sel = document.getElementById(selectId);
if (!sel) return;
try {
const data = await apiFetch('/staff');
const saved = sessionStorage.getItem('lastStaff') || '';
sel.innerHTML = '<option value="">— select staff —</option>' +
data.staff.map(n => `<option value="${esc(n)}"${n === saved ? ' selected' : ''}>${esc(n)}</option>`).join('');
sel.addEventListener('change', () => {
if (sel.value) sessionStorage.setItem('lastStaff', sel.value);
});
} catch (e) { console.error('Could not load staff', e); }
}
async function refreshAllStaffDropdowns() {
try {
const data = await apiFetch('/staff');
const saved = sessionStorage.getItem('lastStaff') || '';
document.querySelectorAll('select[id$="Staff"]').forEach(sel => {
sel.innerHTML = '<option value="">— select staff —</option>' +
data.staff.map(n => `<option value="${esc(n)}"${n === saved ? ' selected' : ''}>${esc(n)}</option>`).join('');
});
renderStaffChips(data.staff);
} catch (e) { console.error(e); }
}
function renderStaffChips(staffList) {
const div = document.getElementById('staffChips');
if (!div) return;
div.innerHTML = staffList.map(n => `
<span class="staff-chip">
${esc(n)}
<button class="chip-del" onclick="removeStaff('${esc(n)}')" title="Remove">&times;</button>
</span>`).join('');
}
async function addStaff() {
const input = document.getElementById('staffNameInput');
const name = input.value.trim();
if (!name) return;
try {
const data = await apiFetch('/staff', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
input.value = '';
setMsg('staffMsg', `Added: ${name}`, 'ok');
renderStaffChips(data.staff);
await refreshAllStaffDropdowns();
} catch (e) {
setMsg('staffMsg', e.message, 'err');
}
}
async function removeStaff(name) {
try {
const data = await apiFetch(`/staff/${encodeURIComponent(name)}`, { method: 'DELETE' });
renderStaffChips(data.staff);
await refreshAllStaffDropdowns();
} catch (e) { console.error(e); }
}

View file

@ -45,6 +45,18 @@ nav {
.nav-btn:hover { background: rgba(255,255,255,.1); }
.nav-btn.active { border-color: #4a9eff; color: #fff; }
.nav-link {
color: var(--nav-text);
text-decoration: none;
padding: 6px 16px;
border-radius: 6px;
border: 2px solid transparent;
font-size: .95rem;
transition: background .15s, border-color .15s;
}
.nav-link:hover { background: rgba(255,255,255,.1); }
.nav-link.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; }
@ -141,6 +153,42 @@ nav {
.product-item:hover { background: #f0fff4; border-color: #34d399; }
.product-price { font-weight: 700; color: var(--primary); }
/* ---- Staff chips ---- */
.staff-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.staff-chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: #eef2ff;
border: 1px solid #c7d2f7;
border-radius: 20px;
padding: 4px 10px 4px 12px;
font-size: .88rem;
}
.chip-del {
background: none;
border: none;
cursor: pointer;
color: #888;
font-size: 1rem;
line-height: 1;
padding: 0;
}
.chip-del:hover { color: var(--danger); }
/* ---- Select / dropdown ---- */
.form-row select {
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1rem;
outline: none;
background: #fff;
cursor: pointer;
transition: border-color .15s;
}
.form-row select:focus { border-color: var(--primary); }
/* ---- Messages ---- */
.msg { margin-top: 12px; padding: 10px 14px; border-radius: 6px; font-size: .93rem; }
.msg:empty { display: none; }