mirror of
https://github.com/kbenestad/ClubLedger.git
synced 2026-06-18 09:44:33 +00:00
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:
parent
68f3ae1538
commit
fa4884bdb4
5 changed files with 1019 additions and 0 deletions
454
main.py
Normal file
454
main.py
Normal 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']} | #{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>
|
||||||
|
</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
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
bcrypt
|
||||||
285
static/app.js
Normal file
285
static/app.js
Normal 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> #${esc(number)} 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> #${esc(number)} 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
129
static/index.html
Normal file
129
static/index.html
Normal 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
148
static/style.css
Normal 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; }
|
||||||
Loading…
Reference in a new issue