mirror of
https://github.com/kbenestad/ClubLedger.git
synced 2026-06-18 09:44:33 +00:00
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:
parent
7af0dd0496
commit
34b3e88fe2
7 changed files with 736 additions and 24 deletions
191
main.py
191
main.py
|
|
@ -4,14 +4,15 @@ Admin configuration: edit the CONFIG dict below.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from contextlib import contextmanager, asynccontextmanager
|
from contextlib import contextmanager, asynccontextmanager
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
@ -32,6 +33,8 @@ CONFIG = {
|
||||||
}
|
}
|
||||||
|
|
||||||
DB_PATH = CONFIG["db_path"]
|
DB_PATH = CONFIG["db_path"]
|
||||||
|
static_dir = Path(__file__).parent / "static"
|
||||||
|
STAFF_FILE = Path(__file__).parent / "staff.json"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Database
|
# Database
|
||||||
|
|
@ -116,19 +119,25 @@ def format_amount(pence: int) -> str:
|
||||||
div = CONFIG["currency_divisor"]
|
div = CONFIG["currency_divisor"]
|
||||||
return f"{sym}{pence / div:.2f}"
|
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
|
# FastAPI app
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app):
|
||||||
init_db()
|
init_db()
|
||||||
yield
|
yield
|
||||||
|
|
||||||
app = FastAPI(title=CONFIG["club_name"], lifespan=lifespan)
|
app = FastAPI(title=CONFIG["club_name"], lifespan=lifespan)
|
||||||
|
|
||||||
# Serve static files
|
|
||||||
static_dir = Path(__file__).parent / "static"
|
|
||||||
static_dir.mkdir(exist_ok=True)
|
static_dir.mkdir(exist_ok=True)
|
||||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
|
|
||||||
|
|
@ -158,13 +167,13 @@ class MemberCreate(BaseModel):
|
||||||
|
|
||||||
class TopupRequest(BaseModel):
|
class TopupRequest(BaseModel):
|
||||||
member_id: int
|
member_id: int
|
||||||
amount: int # pence
|
amount: int
|
||||||
staff_name: str
|
staff_name: str
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
|
|
||||||
class ChargeRequest(BaseModel):
|
class ChargeRequest(BaseModel):
|
||||||
member_id: int
|
member_id: int
|
||||||
amount: int # pence
|
amount: int
|
||||||
pin: str
|
pin: str
|
||||||
staff_name: str
|
staff_name: str
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
|
|
@ -176,13 +185,28 @@ class ProductCreate(BaseModel):
|
||||||
member_price: Optional[int] = None
|
member_price: Optional[int] = None
|
||||||
search_tags: Optional[str] = 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():
|
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")
|
@app.post("/members")
|
||||||
def create_member(body: MemberCreate):
|
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()
|
member = conn.execute("SELECT * FROM members WHERE id=?", (body.member_id,)).fetchone()
|
||||||
if not member:
|
if not member:
|
||||||
raise HTTPException(404, "Member not found")
|
raise HTTPException(404, "Member not found")
|
||||||
conn.execute(
|
cur = conn.execute(
|
||||||
"INSERT INTO ledger_entries (member_id, amount, type, venue, note, staff_name) VALUES (?,?,?,?,?,?)",
|
"INSERT INTO ledger_entries (member_id, amount, type, venue, note, staff_name) VALUES (?,?,?,?,?,?)",
|
||||||
(body.member_id, body.amount, "topup", "cashier", body.note, body.staff_name)
|
(body.member_id, body.amount, "topup", "cashier", body.note, body.staff_name)
|
||||||
)
|
)
|
||||||
|
entry_id = cur.lastrowid
|
||||||
balance = member_balance(conn, body.member_id)
|
balance = member_balance(conn, body.member_id)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
"entry_id": entry_id,
|
||||||
"new_balance": balance,
|
"new_balance": balance,
|
||||||
"new_balance_display": format_amount(balance),
|
"new_balance_display": format_amount(balance),
|
||||||
}
|
}
|
||||||
|
|
@ -268,13 +294,15 @@ def charge(body: ChargeRequest):
|
||||||
balance = member_balance(conn, body.member_id)
|
balance = member_balance(conn, body.member_id)
|
||||||
if not CONFIG["allow_negative_balance"] and balance < body.amount:
|
if not CONFIG["allow_negative_balance"] and balance < body.amount:
|
||||||
raise HTTPException(400, f"Insufficient balance ({format_amount(balance)})")
|
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 (?,?,?,?,?,?)",
|
"INSERT INTO ledger_entries (member_id, amount, type, venue, note, staff_name) VALUES (?,?,?,?,?,?)",
|
||||||
(body.member_id, body.amount, "charge", "bar", body.note, body.staff_name)
|
(body.member_id, body.amount, "charge", "bar", body.note, body.staff_name)
|
||||||
)
|
)
|
||||||
|
entry_id = cur.lastrowid
|
||||||
new_balance = member_balance(conn, body.member_id)
|
new_balance = member_balance(conn, body.member_id)
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
"entry_id": entry_id,
|
||||||
"new_balance": new_balance,
|
"new_balance": new_balance,
|
||||||
"new_balance_display": format_amount(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)
|
@app.get("/members/{member_id}/statement", response_class=HTMLResponse)
|
||||||
def statement(member_id: int):
|
def statement(member_id: int):
|
||||||
with db_conn() as conn:
|
with db_conn() as conn:
|
||||||
|
|
@ -361,14 +422,7 @@ def statement(member_id: int):
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>Statement – {member['name']}</title>
|
<title>Statement – {member['name']}</title>
|
||||||
<style>
|
<style>
|
||||||
@media print {{
|
{PRINT_CSS}
|
||||||
@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; }}
|
table {{ width: 100%; border-collapse: collapse; margin-top: 16px; }}
|
||||||
th {{ background: #222; color: #fff; padding: 5px 8px; text-align: left; }}
|
th {{ background: #222; color: #fff; padding: 5px 8px; text-align: left; }}
|
||||||
td {{ padding: 4px 8px; border-bottom: 1px solid #e0e0e0; }}
|
td {{ padding: 4px 8px; border-bottom: 1px solid #e0e0e0; }}
|
||||||
|
|
@ -378,11 +432,11 @@ def statement(member_id: int):
|
||||||
.cap {{ text-transform: capitalize; }}
|
.cap {{ text-transform: capitalize; }}
|
||||||
.balance-box {{ margin-top: 12px; text-align: right; font-size: 14px; }}
|
.balance-box {{ margin-top: 12px; text-align: right; font-size: 14px; }}
|
||||||
.balance-box span {{ font-weight: bold; font-size: 18px; }}
|
.balance-box span {{ font-weight: bold; font-size: 18px; }}
|
||||||
.print-btn {{ margin-top: 12px; padding: 8px 18px; font-size: 13px; cursor: pointer; }}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<button class="print-btn" onclick="window.print()">Print Statement</button>
|
||||||
</div>
|
</div>
|
||||||
<h1>{club} – Account Statement</h1>
|
<h1>{club} – Account Statement</h1>
|
||||||
|
|
@ -397,6 +451,67 @@ def statement(member_id: int):
|
||||||
<tbody>{rows_html}</tbody>
|
<tbody>{rows_html}</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="balance-box">Current Balance: <span>{fmt(balance)}</span></div>
|
<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>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
|
|
||||||
|
|
@ -443,10 +558,38 @@ def create_product(body: ProductCreate):
|
||||||
)
|
)
|
||||||
return {"id": cur.lastrowid, "ok": True}
|
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")
|
@app.get("/config")
|
||||||
def get_config():
|
def get_config():
|
||||||
safe = {k: v for k, v in CONFIG.items() if k != "db_path"}
|
return {k: v for k, v in CONFIG.items() if k != "db_path"}
|
||||||
return safe
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
|
||||||
63
static/bar.html
Normal file
63
static/bar.html
Normal 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
116
static/bar.js
Normal 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> #${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('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
98
static/cashier.html
Normal 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
132
static/cashier.js
Normal 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> #${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('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
112
static/common.js
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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">×</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); }
|
||||||
|
}
|
||||||
|
|
@ -45,6 +45,18 @@ nav {
|
||||||
.nav-btn:hover { background: rgba(255,255,255,.1); }
|
.nav-btn:hover { background: rgba(255,255,255,.1); }
|
||||||
.nav-btn.active { border-color: #4a9eff; color: #fff; }
|
.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 ---- */
|
/* ---- Views ---- */
|
||||||
.view { max-width: 900px; margin: 28px auto; padding: 0 16px; display: flex; flex-direction: column; gap: 24px; }
|
.view { max-width: 900px; margin: 28px auto; padding: 0 16px; display: flex; flex-direction: column; gap: 24px; }
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
|
|
@ -141,6 +153,42 @@ nav {
|
||||||
.product-item:hover { background: #f0fff4; border-color: #34d399; }
|
.product-item:hover { background: #f0fff4; border-color: #34d399; }
|
||||||
.product-price { font-weight: 700; color: var(--primary); }
|
.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 ---- */
|
/* ---- Messages ---- */
|
||||||
.msg { margin-top: 12px; padding: 10px 14px; border-radius: 6px; font-size: .93rem; }
|
.msg { margin-top: 12px; padding: 10px 14px; border-radius: 6px; font-size: .93rem; }
|
||||||
.msg:empty { display: none; }
|
.msg:empty { display: none; }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue