mirror of
https://github.com/kbenestad/ClubLedger.git
synced 2026-06-18 09:44:33 +00:00
Add business branding, transaction references, and redesigned receipts
- CONFIG: add business address/contact, logo URL+alignment, venue names, transaction ref prefix, transfer types list, and 14 localizable receipt labels plus per-receipt-type footer fields - DB: add transfer_type and transfer_ref columns to ledger_entries (init_db + migrate_db); fix duplicate return in pos_user - Pydantic: extend AppSettingsUpdate with all new settings; add transfer_type/transfer_ref to TopupRequest and WithdrawalRequest - /topup and /withdrawal: persist transfer_type and transfer_ref - /config: return transfer_types as parsed array for frontend dropdowns - New helpers: _txn_ref(), _logo_html(), _biz_header_html() - New RECEIPT_CSS with 2-column grid layout; receipt() fully redesigned with business header, auto-generated TXN reference, separate charge vs top-up/withdrawal layouts; statement() adds Reference column and transfer-detail sub-rows - index.html: Transfer Type + Transfer Reference fields on cashier/ withdrawal panels; admin settings expanded into organized sections (General, Business Address, Branding, Transactions, Receipt Labels, Receipt Footers) - app.js: populateTransferTypes() called on startup and after settings save; doTopup/doWithdrawal send transfer fields; clearCashierSelection clears new fields; loadAdminSettings/saveSettings handle all new fields https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
This commit is contained in:
parent
21b6791f4c
commit
8616ff1d49
3 changed files with 489 additions and 115 deletions
314
main.py
314
main.py
|
|
@ -24,14 +24,49 @@ from pydantic import BaseModel, field_validator
|
||||||
CONFIG = {
|
CONFIG = {
|
||||||
"club_name": "ClubLedger",
|
"club_name": "ClubLedger",
|
||||||
"currency_symbol": "£",
|
"currency_symbol": "£",
|
||||||
"currency_major": "pounds", # label for major unit (what users enter)
|
"currency_major": "pounds",
|
||||||
"currency_minor": "pence", # label for stored minor unit
|
"currency_minor": "pence",
|
||||||
"currency_divisor": 100, # minor units per major unit
|
"currency_divisor": 100,
|
||||||
"overdraft_policy": "never", # never|always|staff_override|admin_override|staff_block
|
"overdraft_policy": "never",
|
||||||
"min_topup": 100, # minor units
|
"min_topup": 100,
|
||||||
"max_topup": 100_000,
|
"max_topup": 100_000,
|
||||||
"max_charge": 50_000,
|
"max_charge": 50_000,
|
||||||
|
# Business contact
|
||||||
|
"biz_address1": "",
|
||||||
|
"biz_address2": "",
|
||||||
|
"biz_address3": "",
|
||||||
|
"biz_address4": "",
|
||||||
|
"biz_country": "",
|
||||||
|
"biz_phone": "",
|
||||||
|
"biz_email": "",
|
||||||
|
"biz_website": "",
|
||||||
|
# Branding
|
||||||
|
"logo_url": "",
|
||||||
|
"logo_align": "left",
|
||||||
|
"bar_name": "Bar",
|
||||||
|
"cashier_name": "Cashier",
|
||||||
|
# Transactions
|
||||||
|
"txn_ref_prefix": "TXN",
|
||||||
|
"transfer_types": "Bank Transfer,Cash,QR",
|
||||||
|
# Receipt labels (localizable)
|
||||||
|
"lbl_receipt": "RECEIPT",
|
||||||
|
"lbl_topup_receipt": "TOP-UP RECEIPT",
|
||||||
|
"lbl_withdrawal_receipt": "WITHDRAWAL RECEIPT",
|
||||||
|
"lbl_staff": "STAFF",
|
||||||
|
"lbl_transaction": "TRANSACTION",
|
||||||
|
"lbl_charge_venue": "CHARGE",
|
||||||
|
"lbl_txn_time": "TRANSACTION TIME",
|
||||||
|
"lbl_amount_charged": "AMOUNT CHARGED",
|
||||||
|
"lbl_remaining_balance": "REMAINING BALANCE",
|
||||||
|
"lbl_balance_transfer": "BALANCE TRANSFER",
|
||||||
|
"lbl_amount_topup": "AMOUNT TOPPED-UP",
|
||||||
|
"lbl_amount_withdrawal": "AMOUNT WITHDRAWN",
|
||||||
|
"lbl_transfer_type": "TRANSFER TYPE",
|
||||||
|
"lbl_transfer_ref": "TRANSFER REFERENCE",
|
||||||
|
# Receipt footers
|
||||||
"receipt_footer": "",
|
"receipt_footer": "",
|
||||||
|
"receipt_footer_charge": "",
|
||||||
|
"receipt_footer_cashier": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
DB_PATH = "clubledger.db"
|
DB_PATH = "clubledger.db"
|
||||||
|
|
@ -86,6 +121,8 @@ def init_db():
|
||||||
venue TEXT NOT NULL CHECK(venue IN ('cashier','bar')),
|
venue TEXT NOT NULL CHECK(venue IN ('cashier','bar')),
|
||||||
note TEXT,
|
note TEXT,
|
||||||
staff_name TEXT NOT NULL,
|
staff_name TEXT NOT NULL,
|
||||||
|
transfer_type TEXT,
|
||||||
|
transfer_ref TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS products (
|
CREATE TABLE IF NOT EXISTS products (
|
||||||
|
|
@ -184,6 +221,13 @@ def migrate_db():
|
||||||
)
|
)
|
||||||
conn.execute("DELETE FROM app_settings WHERE key='allow_negative_balance'")
|
conn.execute("DELETE FROM app_settings WHERE key='allow_negative_balance'")
|
||||||
|
|
||||||
|
# --- ledger_entries: add transfer_type and transfer_ref columns ---
|
||||||
|
le_cols = [r[1] for r in conn.execute("PRAGMA table_info(ledger_entries)").fetchall()]
|
||||||
|
if "transfer_type" not in le_cols:
|
||||||
|
conn.execute("ALTER TABLE ledger_entries ADD COLUMN transfer_type TEXT")
|
||||||
|
if "transfer_ref" not in le_cols:
|
||||||
|
conn.execute("ALTER TABLE ledger_entries ADD COLUMN transfer_ref TEXT")
|
||||||
|
|
||||||
def seed_admin():
|
def seed_admin():
|
||||||
with db_conn() as conn:
|
with db_conn() as conn:
|
||||||
if conn.execute("SELECT COUNT(*) FROM staff_accounts WHERE role='admin'").fetchone()[0] == 0:
|
if conn.execute("SELECT COUNT(*) FROM staff_accounts WHERE role='admin'").fetchone()[0] == 0:
|
||||||
|
|
@ -262,7 +306,6 @@ def pos_user(user: dict = Depends(current_user)):
|
||||||
if user["role"] not in ("pos-staff", "admin"):
|
if user["role"] not in ("pos-staff", "admin"):
|
||||||
raise HTTPException(403, "POS staff access required")
|
raise HTTPException(403, "POS staff access required")
|
||||||
return user
|
return user
|
||||||
return user
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# App
|
# App
|
||||||
|
|
@ -312,8 +355,10 @@ class MemberUpdate(BaseModel):
|
||||||
|
|
||||||
class TopupRequest(BaseModel):
|
class TopupRequest(BaseModel):
|
||||||
member_id: int
|
member_id: int
|
||||||
amount: int # minor units
|
amount: int
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
|
transfer_type: Optional[str] = None
|
||||||
|
transfer_ref: Optional[str] = None
|
||||||
|
|
||||||
class ChargeRequest(BaseModel):
|
class ChargeRequest(BaseModel):
|
||||||
member_id: int
|
member_id: int
|
||||||
|
|
@ -323,9 +368,11 @@ class ChargeRequest(BaseModel):
|
||||||
|
|
||||||
class WithdrawalRequest(BaseModel):
|
class WithdrawalRequest(BaseModel):
|
||||||
member_id: int
|
member_id: int
|
||||||
amount: int # minor units
|
amount: int
|
||||||
pin: str
|
pin: str
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
|
transfer_type: Optional[str] = None
|
||||||
|
transfer_ref: Optional[str] = None
|
||||||
|
|
||||||
class ProductCreate(BaseModel):
|
class ProductCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|
@ -364,7 +411,42 @@ class AppSettingsUpdate(BaseModel):
|
||||||
min_topup: Optional[int] = None
|
min_topup: Optional[int] = None
|
||||||
max_topup: Optional[int] = None
|
max_topup: Optional[int] = None
|
||||||
max_charge: Optional[int] = None
|
max_charge: Optional[int] = None
|
||||||
|
# Business contact
|
||||||
|
biz_address1: Optional[str] = None
|
||||||
|
biz_address2: Optional[str] = None
|
||||||
|
biz_address3: Optional[str] = None
|
||||||
|
biz_address4: Optional[str] = None
|
||||||
|
biz_country: Optional[str] = None
|
||||||
|
biz_phone: Optional[str] = None
|
||||||
|
biz_email: Optional[str] = None
|
||||||
|
biz_website: Optional[str] = None
|
||||||
|
# Branding
|
||||||
|
logo_url: Optional[str] = None
|
||||||
|
logo_align: Optional[str] = None
|
||||||
|
bar_name: Optional[str] = None
|
||||||
|
cashier_name: Optional[str] = None
|
||||||
|
# Transactions
|
||||||
|
txn_ref_prefix: Optional[str] = None
|
||||||
|
transfer_types: Optional[str] = None
|
||||||
|
# Receipt labels
|
||||||
|
lbl_receipt: Optional[str] = None
|
||||||
|
lbl_topup_receipt: Optional[str] = None
|
||||||
|
lbl_withdrawal_receipt: Optional[str] = None
|
||||||
|
lbl_staff: Optional[str] = None
|
||||||
|
lbl_transaction: Optional[str] = None
|
||||||
|
lbl_charge_venue: Optional[str] = None
|
||||||
|
lbl_txn_time: Optional[str] = None
|
||||||
|
lbl_amount_charged: Optional[str] = None
|
||||||
|
lbl_remaining_balance: Optional[str] = None
|
||||||
|
lbl_balance_transfer: Optional[str] = None
|
||||||
|
lbl_amount_topup: Optional[str] = None
|
||||||
|
lbl_amount_withdrawal: Optional[str] = None
|
||||||
|
lbl_transfer_type: Optional[str] = None
|
||||||
|
lbl_transfer_ref: Optional[str] = None
|
||||||
|
# Receipt footers
|
||||||
receipt_footer: Optional[str] = None
|
receipt_footer: Optional[str] = None
|
||||||
|
receipt_footer_charge: Optional[str] = None
|
||||||
|
receipt_footer_cashier: Optional[str] = None
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Page routes
|
# Page routes
|
||||||
|
|
@ -510,8 +592,9 @@ def topup(body: TopupRequest, user: dict = Depends(cashier_user)):
|
||||||
if not conn.execute("SELECT id FROM members WHERE id=?", (body.member_id,)).fetchone():
|
if not conn.execute("SELECT id FROM members WHERE id=?", (body.member_id,)).fetchone():
|
||||||
raise HTTPException(404, "Member not found")
|
raise HTTPException(404, "Member not found")
|
||||||
cur = 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,transfer_type,transfer_ref) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
(body.member_id, body.amount, "topup", "cashier", body.note, user["name"])
|
(body.member_id, body.amount, "topup", "cashier", body.note, user["name"],
|
||||||
|
body.transfer_type, body.transfer_ref)
|
||||||
)
|
)
|
||||||
eid = cur.lastrowid
|
eid = cur.lastrowid
|
||||||
bal = member_balance(conn, body.member_id)
|
bal = member_balance(conn, body.member_id)
|
||||||
|
|
@ -567,8 +650,9 @@ def withdrawal(body: WithdrawalRequest, user: dict = Depends(cashier_user)):
|
||||||
if bal < body.amount:
|
if bal < body.amount:
|
||||||
raise HTTPException(400, f"Insufficient balance ({format_amount(bal)})")
|
raise HTTPException(400, f"Insufficient balance ({format_amount(bal)})")
|
||||||
cur = 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,transfer_type,transfer_ref) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
(body.member_id, body.amount, "withdrawal", "cashier", body.note, user["name"])
|
(body.member_id, body.amount, "withdrawal", "cashier", body.note, user["name"],
|
||||||
|
body.transfer_type, body.transfer_ref)
|
||||||
)
|
)
|
||||||
eid = cur.lastrowid
|
eid = cur.lastrowid
|
||||||
new_bal = member_balance(conn, body.member_id)
|
new_bal = member_balance(conn, body.member_id)
|
||||||
|
|
@ -617,14 +701,65 @@ def _print_controls():
|
||||||
<label><input type="radio" name="ps" value="A5" onchange="setSize('A5')"> A5</label>
|
<label><input type="radio" name="ps" value="A5" onchange="setSize('A5')"> A5</label>
|
||||||
</div>"""
|
</div>"""
|
||||||
|
|
||||||
PRINT_CSS = """
|
def _txn_ref(entry_id: int, s: dict) -> str:
|
||||||
|
prefix = (s.get("txn_ref_prefix") or "TXN").strip()
|
||||||
|
return f"{prefix}{entry_id:07d}"
|
||||||
|
|
||||||
|
def _logo_html(s: dict) -> str:
|
||||||
|
url = (s.get("logo_url") or "").strip()
|
||||||
|
if not url:
|
||||||
|
return ""
|
||||||
|
align = s.get("logo_align", "left")
|
||||||
|
css_cls = f"biz-logo align-{align}" if align in ("left", "center", "right") else "biz-logo align-left"
|
||||||
|
return f'<img src="{url}" class="{css_cls}" alt="logo">'
|
||||||
|
|
||||||
|
def _biz_header_html(s: dict) -> str:
|
||||||
|
parts = [_logo_html(s)]
|
||||||
|
parts.append(f'<div class="biz-name">{s.get("club_name") or "ClubLedger"}</div>')
|
||||||
|
addr = [s.get(f"biz_address{i}", "") for i in range(1, 5)] + [s.get("biz_country", "")]
|
||||||
|
addr = [l.strip() for l in addr if l and l.strip()]
|
||||||
|
if addr:
|
||||||
|
parts.append('<div class="biz-address">' + "<br>".join(addr) + "</div>")
|
||||||
|
contact = [x for x in [s.get("biz_phone",""), s.get("biz_email",""), s.get("biz_website","")] if x and x.strip()]
|
||||||
|
if contact:
|
||||||
|
parts.append('<div class="biz-contact">' + " | ".join(contact) + "</div>")
|
||||||
|
return '<div class="biz-header">' + "\n".join(p for p in parts if p) + "</div>"
|
||||||
|
|
||||||
|
RECEIPT_CSS = """
|
||||||
body{font-family:Arial,sans-serif;font-size:11px;color:#111;margin:24px;}
|
body{font-family:Arial,sans-serif;font-size:11px;color:#111;margin:24px;}
|
||||||
h1{font-size:18px;margin-bottom:2px;} h2{font-size:13px;font-weight:normal;color:#555;margin:0 0 16px;}
|
h2{font-size:14px;font-weight:bold;margin:10px 0 4px;}
|
||||||
|
hr{border:none;border-top:1px solid #ccc;margin:10px 0;}
|
||||||
.controls{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;}
|
.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;}
|
.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;}
|
.print-btn{padding:7px 18px;font-size:13px;cursor:pointer;margin-left:auto;}
|
||||||
.footer{margin-top:16px;font-size:10px;color:#888;text-align:center;white-space:pre-wrap;}
|
|
||||||
@media print{.no-print{display:none;}}
|
@media print{.no-print{display:none;}}
|
||||||
|
.biz-header{margin-bottom:4px;}
|
||||||
|
.biz-logo{max-height:60px;max-width:200px;display:block;margin-bottom:4px;}
|
||||||
|
.biz-logo.align-center{margin-left:auto;margin-right:auto;}
|
||||||
|
.biz-logo.align-right{margin-left:auto;}
|
||||||
|
.biz-name{font-size:15px;font-weight:bold;margin:2px 0;}
|
||||||
|
.biz-address{color:#555;line-height:1.5;}
|
||||||
|
.biz-contact{color:#555;margin-top:3px;}
|
||||||
|
.receipt-title{font-size:13px;font-weight:bold;text-transform:uppercase;letter-spacing:.04em;margin:10px 0 6px;}
|
||||||
|
.member-section{display:flex;justify-content:space-between;align-items:baseline;margin:5px 0;}
|
||||||
|
.member-name{font-size:13px;font-weight:bold;}
|
||||||
|
.member-num{color:#555;}
|
||||||
|
.receipt-grid{display:grid;grid-template-columns:auto 1fr;gap:3px 14px;margin:6px 0;}
|
||||||
|
.rlbl{font-weight:600;color:#555;white-space:nowrap;font-size:10px;text-transform:uppercase;letter-spacing:.03em;}
|
||||||
|
.rval{text-align:right;}
|
||||||
|
.section-head{grid-column:span 2;font-weight:bold;font-size:11px;text-transform:uppercase;letter-spacing:.04em;margin:6px 0 2px;}
|
||||||
|
.charge-val{font-size:20px;font-weight:bold;color:#c00;}
|
||||||
|
.credit-val{font-size:20px;font-weight:bold;color:#080;}
|
||||||
|
.balance-val{font-size:14px;font-weight:bold;}
|
||||||
|
table{width:100%;border-collapse:collapse;margin-top:10px;}
|
||||||
|
th{background:#222;color:#fff;padding:5px 8px;text-align:left;font-size:10px;}
|
||||||
|
td{padding:4px 8px;border-bottom:1px solid #e0e0e0;}
|
||||||
|
.num{text-align:right;font-variant-numeric:tabular-nums;}
|
||||||
|
.red{color:#c00;} .grn{color:#080;}
|
||||||
|
.balance-box{margin-top:12px;text-align:right;font-size:14px;}
|
||||||
|
.balance-box span{font-weight:bold;font-size:18px;}
|
||||||
|
.sub-row td{font-size:10px;color:#777;padding-top:0;border-bottom:none;padding-left:24px;}
|
||||||
|
.footer{margin-top:14px;font-size:10px;color:#888;text-align:center;white-space:pre-wrap;}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@app.get("/members/{member_id}/statement", response_class=HTMLResponse)
|
@app.get("/members/{member_id}/statement", response_class=HTMLResponse)
|
||||||
|
|
@ -640,42 +775,53 @@ def statement(member_id: int):
|
||||||
bal = member_balance(conn, member_id)
|
bal = member_balance(conn, member_id)
|
||||||
|
|
||||||
sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100)
|
sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100)
|
||||||
club, footer = s.get("club_name","ClubLedger"), s.get("receipt_footer","")
|
footer = s.get("receipt_footer","")
|
||||||
|
bar_name = s.get("bar_name","Bar")
|
||||||
|
cashier_name = s.get("cashier_name","Cashier")
|
||||||
|
|
||||||
def fmt(p): return f"{sym}{p/div:.2f}"
|
def fmt(p): return f"{sym}{p/div:.2f}"
|
||||||
|
def venue_label(v): return bar_name if v == "bar" else cashier_name
|
||||||
|
|
||||||
rows_html, running = "", 0
|
rows_html, running = "", 0
|
||||||
for r in rows:
|
for r in rows:
|
||||||
|
txn_ref = _txn_ref(r["id"], s)
|
||||||
if r["type"] == "topup":
|
if r["type"] == "topup":
|
||||||
running += r["amount"]; dr, cr = "", fmt(r["amount"])
|
running += r["amount"]; dr, cr = "", fmt(r["amount"]); type_lbl = "Top-up"
|
||||||
|
elif r["type"] == "withdrawal":
|
||||||
|
running -= r["amount"]; dr, cr = fmt(r["amount"]), ""; type_lbl = "Withdrawal"
|
||||||
else:
|
else:
|
||||||
running -= r["amount"]; dr, cr = fmt(r["amount"]), ""
|
running -= r["amount"]; dr, cr = fmt(r["amount"]), ""; type_lbl = "Charge"
|
||||||
rows_html += (f"<tr><td>{r['created_at'][:16]}</td><td class='cap'>{r['type']}</td>"
|
rows_html += (f"<tr><td>{r['created_at'][:16]}</td><td>{type_lbl}</td>"
|
||||||
f"<td class='cap'>{r['venue']}</td><td>{r['note'] or ''}</td>"
|
f"<td>{venue_label(r['venue'])}</td><td>{txn_ref}</td>"
|
||||||
f"<td>{r['staff_name']}</td><td class='num red'>{dr}</td>"
|
f"<td>{r['note'] or ''}</td><td>{r['staff_name']}</td>"
|
||||||
f"<td class='num grn'>{cr}</td><td class='num'>{fmt(running)}</td></tr>")
|
f"<td class='num red'>{dr}</td><td class='num grn'>{cr}</td>"
|
||||||
|
f"<td class='num'>{fmt(running)}</td></tr>")
|
||||||
|
if r["type"] in ("topup", "withdrawal"):
|
||||||
|
tf_type = r["transfer_type"] or ""
|
||||||
|
tf_ref = r["transfer_ref"] or ""
|
||||||
|
if tf_type or tf_ref:
|
||||||
|
sub = " · ".join(filter(None, [
|
||||||
|
f"Type: {tf_type}" if tf_type else "",
|
||||||
|
f"Ref: {tf_ref}" if tf_ref else "",
|
||||||
|
]))
|
||||||
|
rows_html += f'<tr class="sub-row"><td colspan="9">{sub}</td></tr>'
|
||||||
|
|
||||||
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||||
<title>Statement – {member['name']}</title><style>
|
<title>Statement – {member['name']}</title><style>{RECEIPT_CSS}</style></head><body>
|
||||||
{PRINT_CSS}
|
|
||||||
table{{width:100%;border-collapse:collapse;margin-top:16px;}}
|
|
||||||
th{{background:#222;color:#fff;padding:5px 8px;text-align:left;}}
|
|
||||||
td{{padding:4px 8px;border-bottom:1px solid #e0e0e0;}}
|
|
||||||
.num{{text-align:right;font-variant-numeric:tabular-nums;}}
|
|
||||||
.red{{color:#c00;}} .grn{{color:#080;}} .cap{{text-transform:capitalize;}}
|
|
||||||
.balance-box{{margin-top:12px;text-align:right;font-size:14px;}}
|
|
||||||
.balance-box span{{font-weight:bold;font-size:18px;}}
|
|
||||||
</style></head><body>
|
|
||||||
{_print_controls()}
|
{_print_controls()}
|
||||||
<div class="no-print controls" style="margin-top:0">
|
<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>
|
{_biz_header_html(s)}
|
||||||
<h2>Member: {member['name']} | #{member['member_number']} |
|
<hr>
|
||||||
Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC</h2>
|
<h2>Account Statement</h2>
|
||||||
|
<div style="margin-bottom:10px;color:#555;font-size:11px;">
|
||||||
|
Member: <strong>{member['name']}</strong> | #{member['member_number']} |
|
||||||
|
Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC
|
||||||
|
</div>
|
||||||
<table><thead><tr>
|
<table><thead><tr>
|
||||||
<th>Date/Time</th><th>Type</th><th>Venue</th><th>Note</th>
|
<th>Date/Time</th><th>Type</th><th>Venue</th><th>Reference</th><th>Note</th>
|
||||||
<th>Staff</th><th class="num">Charge</th><th class="num">Top-up</th><th class="num">Balance</th>
|
<th>Staff</th><th class="num">Charge</th><th class="num">Credit</th><th class="num">Balance</th>
|
||||||
</tr></thead><tbody>{rows_html}</tbody></table>
|
</tr></thead><tbody>{rows_html}</tbody></table>
|
||||||
<div class="balance-box">Current Balance: <span>{fmt(bal)}</span></div>
|
<div class="balance-box">Current Balance: <span>{fmt(bal)}</span></div>
|
||||||
{('<div class="footer">' + footer + '</div>') if footer else ''}
|
{('<div class="footer">' + footer + '</div>') if footer else ''}
|
||||||
|
|
@ -694,39 +840,80 @@ def receipt(entry_id: int):
|
||||||
""", (entry["member_id"], entry_id)).fetchone()[0]
|
""", (entry["member_id"], entry_id)).fetchone()[0]
|
||||||
|
|
||||||
sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100)
|
sym, div = s.get("currency_symbol","£"), s.get("currency_divisor",100)
|
||||||
club, footer = s.get("club_name","ClubLedger"), s.get("receipt_footer","")
|
|
||||||
|
|
||||||
def fmt(p): return f"{sym}{p/div:.2f}"
|
def fmt(p): return f"{sym}{p/div:.2f}"
|
||||||
|
|
||||||
type_label = {"topup": "Top-up", "charge": "Charge", "withdrawal": "Withdrawal"}.get(entry["type"], entry["type"].capitalize())
|
txn_ref = _txn_ref(entry_id, s)
|
||||||
colour = "#080" if entry["type"] == "topup" else "#c00"
|
etype = entry["type"]
|
||||||
|
venue_name = s.get("bar_name","Bar") if entry["venue"] == "bar" else s.get("cashier_name","Cashier")
|
||||||
|
|
||||||
|
lbl_staff = s.get("lbl_staff", "STAFF")
|
||||||
|
lbl_txn = s.get("lbl_transaction", "TRANSACTION")
|
||||||
|
lbl_txn_time = s.get("lbl_txn_time", "TRANSACTION TIME")
|
||||||
|
lbl_remaining = s.get("lbl_remaining_balance", "REMAINING BALANCE")
|
||||||
|
|
||||||
|
if etype == "topup":
|
||||||
|
title = s.get("lbl_topup_receipt", "TOP-UP RECEIPT")
|
||||||
|
footer = s.get("receipt_footer_cashier") or s.get("receipt_footer", "")
|
||||||
|
elif etype == "withdrawal":
|
||||||
|
title = s.get("lbl_withdrawal_receipt", "WITHDRAWAL RECEIPT")
|
||||||
|
footer = s.get("receipt_footer_cashier") or s.get("receipt_footer", "")
|
||||||
|
else:
|
||||||
|
title = s.get("lbl_receipt", "RECEIPT")
|
||||||
|
footer = s.get("receipt_footer_charge") or s.get("receipt_footer", "")
|
||||||
|
|
||||||
|
if etype == "charge":
|
||||||
|
lbl_charge = s.get("lbl_charge_venue", "CHARGE")
|
||||||
|
lbl_amount = s.get("lbl_amount_charged", "AMOUNT CHARGED")
|
||||||
|
grid_details = (
|
||||||
|
f'<div class="rlbl">{lbl_staff}</div><div class="rval">{entry["staff_name"]}</div>'
|
||||||
|
f'<div class="rlbl">{lbl_txn}</div><div class="rval">{txn_ref}</div>'
|
||||||
|
f'<div class="rlbl">{lbl_charge}</div><div class="rval">{venue_name}</div>'
|
||||||
|
f'<div class="rlbl">{lbl_txn_time}</div><div class="rval">{entry["created_at"][:16]} UTC</div>'
|
||||||
|
)
|
||||||
|
grid_amounts = (
|
||||||
|
f'<div class="rlbl">{lbl_amount}</div><div class="rval charge-val">{fmt(entry["amount"])}</div>'
|
||||||
|
f'<div class="rlbl">{lbl_remaining}</div><div class="rval balance-val">{fmt(bal_after)}</div>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lbl_tf_section = s.get("lbl_balance_transfer", "BALANCE TRANSFER")
|
||||||
|
lbl_tf_type = s.get("lbl_transfer_type", "TRANSFER TYPE")
|
||||||
|
lbl_tf_ref = s.get("lbl_transfer_ref", "TRANSFER REFERENCE")
|
||||||
|
lbl_amount = s.get("lbl_amount_topup", "AMOUNT TOPPED-UP") if etype == "topup" else s.get("lbl_amount_withdrawal", "AMOUNT WITHDRAWN")
|
||||||
|
tf_type = entry["transfer_type"] or ""
|
||||||
|
tf_ref = entry["transfer_ref"] or ""
|
||||||
|
grid_details = (
|
||||||
|
f'<div class="rlbl">{lbl_staff}</div><div class="rval">{entry["staff_name"]}</div>'
|
||||||
|
f'<div class="rlbl">{lbl_txn}</div><div class="rval">{txn_ref}</div>'
|
||||||
|
f'<div class="rlbl">{lbl_txn_time}</div><div class="rval">{entry["created_at"][:16]} UTC</div>'
|
||||||
|
)
|
||||||
|
tf_rows = ""
|
||||||
|
if tf_type: tf_rows += f'<div class="rlbl">{lbl_tf_type}</div><div class="rval">{tf_type}</div>'
|
||||||
|
if tf_ref: tf_rows += f'<div class="rlbl">{lbl_tf_ref}</div><div class="rval">{tf_ref}</div>'
|
||||||
|
grid_amounts = (
|
||||||
|
f'<div class="section-head">{lbl_tf_section}</div>'
|
||||||
|
f'{tf_rows}'
|
||||||
|
f'<div class="rlbl">{lbl_amount}</div><div class="rval credit-val">{fmt(entry["amount"])}</div>'
|
||||||
|
f'<div class="rlbl">{lbl_remaining}</div><div class="rval balance-val">{fmt(bal_after)}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
return f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
|
||||||
<title>Receipt – {member['name']}</title><style>
|
<title>Receipt – {member['name']}</title><style>{RECEIPT_CSS}</style></head><body>
|
||||||
{PRINT_CSS}
|
|
||||||
.sub{{font-size:15px;color:#555;margin-bottom:20px;}}
|
|
||||||
table{{border-collapse:collapse;}}
|
|
||||||
td{{padding:5px 16px 5px 0;vertical-align:top;}}
|
|
||||||
td:first-child{{font-weight:600;color:#555;white-space:nowrap;min-width:110px;}}
|
|
||||||
.amount{{font-size:24px;font-weight:bold;color:{colour};}}
|
|
||||||
.bal{{font-size:20px;font-weight:bold;}}
|
|
||||||
hr{{border:none;border-top:1px solid #ccc;margin:16px 0;}}
|
|
||||||
</style></head><body>
|
|
||||||
{_print_controls()}
|
{_print_controls()}
|
||||||
<div class="no-print controls" style="margin-top:0">
|
<div class="no-print controls" style="margin-top:0">
|
||||||
<button class="print-btn" onclick="window.print()">Print Receipt</button>
|
<button class="print-btn" onclick="window.print()">Print Receipt</button>
|
||||||
</div>
|
</div>
|
||||||
<h1>{club}</h1><div class="sub">{type_label} Receipt</div><hr>
|
{_biz_header_html(s)}
|
||||||
<table>
|
<hr>
|
||||||
<tr><td>Member</td><td><strong>{member['name']}</strong></td></tr>
|
<div class="receipt-title">{title}</div>
|
||||||
<tr><td>Member #</td><td>{member['member_number']}</td></tr>
|
<div class="member-section">
|
||||||
<tr><td>Type</td><td>{type_label}</td></tr>
|
<div class="member-name">{member['name']}</div>
|
||||||
<tr><td>Amount</td><td class="amount">{fmt(entry['amount'])}</td></tr>
|
<div class="member-num">#{member['member_number']}</div>
|
||||||
<tr><td>Balance after</td><td class="bal">{fmt(bal_after)}</td></tr>
|
</div>
|
||||||
<tr><td>Staff</td><td>{entry['staff_name']}</td></tr>
|
<hr>
|
||||||
<tr><td>Note</td><td>{entry['note'] or '—'}</td></tr>
|
<div class="receipt-grid">{grid_details}</div>
|
||||||
<tr><td>Date / Time</td><td>{entry['created_at']} UTC</td></tr>
|
<hr>
|
||||||
</table>
|
<div class="receipt-grid">{grid_amounts}</div>
|
||||||
|
<hr>
|
||||||
{('<div class="footer">' + footer + '</div>') if footer else ''}
|
{('<div class="footer">' + footer + '</div>') if footer else ''}
|
||||||
{_print_size_script()}</body></html>"""
|
{_print_size_script()}</body></html>"""
|
||||||
|
|
||||||
|
|
@ -882,8 +1069,9 @@ def update_admin_settings(body: AppSettingsUpdate, user: dict = Depends(admin_us
|
||||||
@app.get("/config")
|
@app.get("/config")
|
||||||
def get_config():
|
def get_config():
|
||||||
s = dict(_settings)
|
s = dict(_settings)
|
||||||
# expose currency_major as currency_unit so common.js .currency-unit spans still work
|
|
||||||
s["currency_unit"] = s.get("currency_major", "pounds")
|
s["currency_unit"] = s.get("currency_major", "pounds")
|
||||||
|
raw_tt = s.get("transfer_types", "Bank Transfer,Cash,QR")
|
||||||
|
s["transfer_types"] = [t.strip() for t in raw_tt.split(",") if t.strip()]
|
||||||
return s
|
return s
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
145
static/app.js
145
static/app.js
|
|
@ -61,9 +61,22 @@ async function doLogout() {
|
||||||
showLogin();
|
showLogin();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function populateTransferTypes() {
|
||||||
|
const types = Array.isArray(cfg.transfer_types) ? cfg.transfer_types : [];
|
||||||
|
['cashierTransferType', 'withdrawalTransferType'].forEach(id => {
|
||||||
|
const sel = document.getElementById(id);
|
||||||
|
if (!sel) return;
|
||||||
|
const prev = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">— select —</option>' +
|
||||||
|
types.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
|
||||||
|
if (prev && types.includes(prev)) sel.value = prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function startApp() {
|
async function startApp() {
|
||||||
document.getElementById('loginOverlay').classList.add('hidden');
|
document.getElementById('loginOverlay').classList.add('hidden');
|
||||||
await loadConfig();
|
await loadConfig();
|
||||||
|
populateTransferTypes();
|
||||||
|
|
||||||
const brand = document.getElementById('navBrand');
|
const brand = document.getElementById('navBrand');
|
||||||
if (brand) brand.textContent = cfg.club_name;
|
if (brand) brand.textContent = cfg.club_name;
|
||||||
|
|
@ -269,9 +282,13 @@ function clearCashierSelection() {
|
||||||
cashierMember = null;
|
cashierMember = null;
|
||||||
document.getElementById('cashierForm').classList.add('hidden');
|
document.getElementById('cashierForm').classList.add('hidden');
|
||||||
document.getElementById('cashierAmount').value = '';
|
document.getElementById('cashierAmount').value = '';
|
||||||
|
document.getElementById('cashierTransferType').value = '';
|
||||||
|
document.getElementById('cashierTransferRef').value = '';
|
||||||
document.getElementById('cashierNote').value = '';
|
document.getElementById('cashierNote').value = '';
|
||||||
document.getElementById('withdrawalAmount').value = '';
|
document.getElementById('withdrawalAmount').value = '';
|
||||||
document.getElementById('withdrawalPin').value = '';
|
document.getElementById('withdrawalPin').value = '';
|
||||||
|
document.getElementById('withdrawalTransferType').value = '';
|
||||||
|
document.getElementById('withdrawalTransferRef').value = '';
|
||||||
document.getElementById('withdrawalNote').value = '';
|
document.getElementById('withdrawalNote').value = '';
|
||||||
setMsg('cashierTopupMsg', '', '');
|
setMsg('cashierTopupMsg', '', '');
|
||||||
setMsg('cashierWithdrawalMsg', '', '');
|
setMsg('cashierWithdrawalMsg', '', '');
|
||||||
|
|
@ -281,15 +298,20 @@ function clearCashierSelection() {
|
||||||
async function doTopup() {
|
async function doTopup() {
|
||||||
if (!cashierMember) return;
|
if (!cashierMember) return;
|
||||||
const amount = toMinor('cashierAmount');
|
const amount = toMinor('cashierAmount');
|
||||||
const note = document.getElementById('cashierNote').value.trim();
|
const transferType = document.getElementById('cashierTransferType').value || null;
|
||||||
|
const transferRef = document.getElementById('cashierTransferRef').value.trim() || null;
|
||||||
|
const note = document.getElementById('cashierNote').value.trim() || null;
|
||||||
if (!amount) { setMsg('cashierTopupMsg', 'Enter a valid amount.', 'err'); return; }
|
if (!amount) { setMsg('cashierTopupMsg', 'Enter a valid amount.', 'err'); return; }
|
||||||
try {
|
try {
|
||||||
const r = await apiFetch('/topup', {
|
const r = await apiFetch('/topup', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ member_id: cashierMember.id, amount, note: note || null })
|
body: JSON.stringify({ member_id: cashierMember.id, amount, note,
|
||||||
|
transfer_type: transferType, transfer_ref: transferRef })
|
||||||
});
|
});
|
||||||
window.open(`/receipt/${r.entry_id}`, '_blank');
|
window.open(`/receipt/${r.entry_id}`, '_blank');
|
||||||
document.getElementById('cashierAmount').value = '';
|
document.getElementById('cashierAmount').value = '';
|
||||||
|
document.getElementById('cashierTransferType').value = '';
|
||||||
|
document.getElementById('cashierTransferRef').value = '';
|
||||||
document.getElementById('cashierNote').value = '';
|
document.getElementById('cashierNote').value = '';
|
||||||
setMsg('cashierTopupMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok');
|
setMsg('cashierTopupMsg', `Top-up complete. New balance: ${r.new_balance_display}`, 'ok');
|
||||||
} catch (err) { setMsg('cashierTopupMsg', err.message, 'err'); }
|
} catch (err) { setMsg('cashierTopupMsg', err.message, 'err'); }
|
||||||
|
|
@ -299,17 +321,22 @@ async function doWithdrawal() {
|
||||||
if (!cashierMember) return;
|
if (!cashierMember) return;
|
||||||
const amount = toMinor('withdrawalAmount');
|
const amount = toMinor('withdrawalAmount');
|
||||||
const pin = document.getElementById('withdrawalPin').value;
|
const pin = document.getElementById('withdrawalPin').value;
|
||||||
const note = document.getElementById('withdrawalNote').value.trim();
|
const transferType = document.getElementById('withdrawalTransferType').value || null;
|
||||||
|
const transferRef = document.getElementById('withdrawalTransferRef').value.trim() || null;
|
||||||
|
const note = document.getElementById('withdrawalNote').value.trim() || null;
|
||||||
if (!amount) { setMsg('cashierWithdrawalMsg', 'Enter a valid amount.', 'err'); return; }
|
if (!amount) { setMsg('cashierWithdrawalMsg', 'Enter a valid amount.', 'err'); return; }
|
||||||
if (!pin) { setMsg('cashierWithdrawalMsg', 'PIN is required.', 'err'); return; }
|
if (!pin) { setMsg('cashierWithdrawalMsg', 'PIN is required.', 'err'); return; }
|
||||||
try {
|
try {
|
||||||
const r = await apiFetch('/withdrawal', {
|
const r = await apiFetch('/withdrawal', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ member_id: cashierMember.id, amount, pin, note: note || null })
|
body: JSON.stringify({ member_id: cashierMember.id, amount, pin, note,
|
||||||
|
transfer_type: transferType, transfer_ref: transferRef })
|
||||||
});
|
});
|
||||||
window.open(`/receipt/${r.entry_id}`, '_blank');
|
window.open(`/receipt/${r.entry_id}`, '_blank');
|
||||||
document.getElementById('withdrawalAmount').value = '';
|
document.getElementById('withdrawalAmount').value = '';
|
||||||
document.getElementById('withdrawalPin').value = '';
|
document.getElementById('withdrawalPin').value = '';
|
||||||
|
document.getElementById('withdrawalTransferType').value = '';
|
||||||
|
document.getElementById('withdrawalTransferRef').value = '';
|
||||||
document.getElementById('withdrawalNote').value = '';
|
document.getElementById('withdrawalNote').value = '';
|
||||||
setMsg('cashierWithdrawalMsg', `Withdrawal complete. New balance: ${r.new_balance_display}`, 'ok');
|
setMsg('cashierWithdrawalMsg', `Withdrawal complete. New balance: ${r.new_balance_display}`, 'ok');
|
||||||
} catch (err) { setMsg('cashierWithdrawalMsg', err.message, 'err'); }
|
} catch (err) { setMsg('cashierWithdrawalMsg', err.message, 'err'); }
|
||||||
|
|
@ -377,6 +404,8 @@ async function loadAdminSettings() {
|
||||||
try {
|
try {
|
||||||
const s = await apiFetch('/admin/settings');
|
const s = await apiFetch('/admin/settings');
|
||||||
const div = s.currency_divisor || 100;
|
const div = s.currency_divisor || 100;
|
||||||
|
const majorUnit = s.currency_major || 'major units';
|
||||||
|
// General
|
||||||
document.getElementById('s-club-name').value = s.club_name || '';
|
document.getElementById('s-club-name').value = s.club_name || '';
|
||||||
document.getElementById('s-currency-symbol').value = s.currency_symbol || '';
|
document.getElementById('s-currency-symbol').value = s.currency_symbol || '';
|
||||||
document.getElementById('s-currency-major').value = s.currency_major || '';
|
document.getElementById('s-currency-major').value = s.currency_major || '';
|
||||||
|
|
@ -385,28 +414,103 @@ async function loadAdminSettings() {
|
||||||
document.getElementById('s-min-topup').value = ((s.min_topup || 0) / div).toFixed(2);
|
document.getElementById('s-min-topup').value = ((s.min_topup || 0) / div).toFixed(2);
|
||||||
document.getElementById('s-max-topup').value = ((s.max_topup || 0) / div).toFixed(2);
|
document.getElementById('s-max-topup').value = ((s.max_topup || 0) / div).toFixed(2);
|
||||||
document.getElementById('s-max-charge').value = ((s.max_charge || 0) / div).toFixed(2);
|
document.getElementById('s-max-charge').value = ((s.max_charge || 0) / div).toFixed(2);
|
||||||
document.getElementById('s-receipt-footer').value = s.receipt_footer || '';
|
|
||||||
document.getElementById('s-overdraft-policy').value = s.overdraft_policy || 'never';
|
document.getElementById('s-overdraft-policy').value = s.overdraft_policy || 'never';
|
||||||
const sym = s.currency_symbol || '';
|
document.getElementById('s-min-hint').textContent = `in ${majorUnit}`;
|
||||||
document.getElementById('s-min-hint').textContent = `in ${s.currency_major || 'major units'}`;
|
document.getElementById('s-max-hint').textContent = `in ${majorUnit}`;
|
||||||
document.getElementById('s-max-hint').textContent = `in ${s.currency_major || 'major units'}`;
|
document.getElementById('s-charge-hint').textContent= `in ${majorUnit}`;
|
||||||
document.getElementById('s-charge-hint').textContent= `in ${s.currency_major || 'major units'}`;
|
// Business address
|
||||||
|
document.getElementById('s-biz-address1').value = s.biz_address1 || '';
|
||||||
|
document.getElementById('s-biz-address2').value = s.biz_address2 || '';
|
||||||
|
document.getElementById('s-biz-address3').value = s.biz_address3 || '';
|
||||||
|
document.getElementById('s-biz-address4').value = s.biz_address4 || '';
|
||||||
|
document.getElementById('s-biz-country').value = s.biz_country || '';
|
||||||
|
document.getElementById('s-biz-phone').value = s.biz_phone || '';
|
||||||
|
document.getElementById('s-biz-email').value = s.biz_email || '';
|
||||||
|
document.getElementById('s-biz-website').value = s.biz_website || '';
|
||||||
|
// Branding
|
||||||
|
document.getElementById('s-logo-url').value = s.logo_url || '';
|
||||||
|
document.getElementById('s-logo-align').value = s.logo_align || 'left';
|
||||||
|
document.getElementById('s-bar-name').value = s.bar_name || '';
|
||||||
|
document.getElementById('s-cashier-name').value = s.cashier_name || '';
|
||||||
|
// Transactions
|
||||||
|
document.getElementById('s-txn-ref-prefix').value = s.txn_ref_prefix || '';
|
||||||
|
// transfer_types from /admin/settings is the raw comma-separated string
|
||||||
|
const rawTT = Array.isArray(s.transfer_types) ? s.transfer_types.join(',') : (s.transfer_types || '');
|
||||||
|
document.getElementById('s-transfer-types').value = rawTT;
|
||||||
|
// Receipt labels
|
||||||
|
document.getElementById('s-lbl-receipt').value = s.lbl_receipt || '';
|
||||||
|
document.getElementById('s-lbl-topup-receipt').value = s.lbl_topup_receipt || '';
|
||||||
|
document.getElementById('s-lbl-withdrawal-receipt').value = s.lbl_withdrawal_receipt || '';
|
||||||
|
document.getElementById('s-lbl-staff').value = s.lbl_staff || '';
|
||||||
|
document.getElementById('s-lbl-transaction').value = s.lbl_transaction || '';
|
||||||
|
document.getElementById('s-lbl-charge').value = s.lbl_charge_venue || '';
|
||||||
|
document.getElementById('s-lbl-txn-time').value = s.lbl_txn_time || '';
|
||||||
|
document.getElementById('s-lbl-amount-charged').value = s.lbl_amount_charged || '';
|
||||||
|
document.getElementById('s-lbl-remaining-balance').value = s.lbl_remaining_balance || '';
|
||||||
|
document.getElementById('s-lbl-balance-transfer').value = s.lbl_balance_transfer || '';
|
||||||
|
document.getElementById('s-lbl-amount-topup').value = s.lbl_amount_topup || '';
|
||||||
|
document.getElementById('s-lbl-amount-withdrawal').value = s.lbl_amount_withdrawal || '';
|
||||||
|
document.getElementById('s-lbl-transfer-type').value = s.lbl_transfer_type || '';
|
||||||
|
document.getElementById('s-lbl-transfer-ref').value = s.lbl_transfer_ref || '';
|
||||||
|
// Footers
|
||||||
|
document.getElementById('s-receipt-footer').value = s.receipt_footer || '';
|
||||||
|
document.getElementById('s-receipt-footer-charge').value = s.receipt_footer_charge || '';
|
||||||
|
document.getElementById('s-receipt-footer-cashier').value = s.receipt_footer_cashier || '';
|
||||||
} catch (err) { setMsg('settingsMsg', err.message, 'err'); }
|
} catch (err) { setMsg('settingsMsg', err.message, 'err'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _sv(id) { return document.getElementById(id).value; }
|
||||||
|
function _svt(id) { return _sv(id).trim(); }
|
||||||
|
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
const div = parseInt(document.getElementById('s-currency-divisor').value, 10) || 100;
|
const div = parseInt(_sv('s-currency-divisor'), 10) || 100;
|
||||||
const body = {
|
const body = {
|
||||||
club_name: document.getElementById('s-club-name').value.trim(),
|
// General
|
||||||
currency_symbol: document.getElementById('s-currency-symbol').value.trim(),
|
club_name: _svt('s-club-name'),
|
||||||
currency_major: document.getElementById('s-currency-major').value.trim(),
|
currency_symbol: _svt('s-currency-symbol'),
|
||||||
currency_minor: document.getElementById('s-currency-minor').value.trim(),
|
currency_major: _svt('s-currency-major'),
|
||||||
|
currency_minor: _svt('s-currency-minor'),
|
||||||
currency_divisor: div,
|
currency_divisor: div,
|
||||||
min_topup: Math.round(parseFloat(document.getElementById('s-min-topup').value) * div),
|
min_topup: Math.round(parseFloat(_sv('s-min-topup')) * div),
|
||||||
max_topup: Math.round(parseFloat(document.getElementById('s-max-topup').value) * div),
|
max_topup: Math.round(parseFloat(_sv('s-max-topup')) * div),
|
||||||
max_charge: Math.round(parseFloat(document.getElementById('s-max-charge').value) * div),
|
max_charge: Math.round(parseFloat(_sv('s-max-charge')) * div),
|
||||||
receipt_footer: document.getElementById('s-receipt-footer').value,
|
overdraft_policy: _sv('s-overdraft-policy'),
|
||||||
overdraft_policy: document.getElementById('s-overdraft-policy').value,
|
// Business address
|
||||||
|
biz_address1: _svt('s-biz-address1'),
|
||||||
|
biz_address2: _svt('s-biz-address2'),
|
||||||
|
biz_address3: _svt('s-biz-address3'),
|
||||||
|
biz_address4: _svt('s-biz-address4'),
|
||||||
|
biz_country: _svt('s-biz-country'),
|
||||||
|
biz_phone: _svt('s-biz-phone'),
|
||||||
|
biz_email: _svt('s-biz-email'),
|
||||||
|
biz_website: _svt('s-biz-website'),
|
||||||
|
// Branding
|
||||||
|
logo_url: _svt('s-logo-url'),
|
||||||
|
logo_align: _sv('s-logo-align'),
|
||||||
|
bar_name: _svt('s-bar-name'),
|
||||||
|
cashier_name: _svt('s-cashier-name'),
|
||||||
|
// Transactions
|
||||||
|
txn_ref_prefix: _svt('s-txn-ref-prefix'),
|
||||||
|
transfer_types: _svt('s-transfer-types'),
|
||||||
|
// Receipt labels
|
||||||
|
lbl_receipt: _svt('s-lbl-receipt'),
|
||||||
|
lbl_topup_receipt: _svt('s-lbl-topup-receipt'),
|
||||||
|
lbl_withdrawal_receipt: _svt('s-lbl-withdrawal-receipt'),
|
||||||
|
lbl_staff: _svt('s-lbl-staff'),
|
||||||
|
lbl_transaction: _svt('s-lbl-transaction'),
|
||||||
|
lbl_charge_venue: _svt('s-lbl-charge'),
|
||||||
|
lbl_txn_time: _svt('s-lbl-txn-time'),
|
||||||
|
lbl_amount_charged: _svt('s-lbl-amount-charged'),
|
||||||
|
lbl_remaining_balance: _svt('s-lbl-remaining-balance'),
|
||||||
|
lbl_balance_transfer: _svt('s-lbl-balance-transfer'),
|
||||||
|
lbl_amount_topup: _svt('s-lbl-amount-topup'),
|
||||||
|
lbl_amount_withdrawal: _svt('s-lbl-amount-withdrawal'),
|
||||||
|
lbl_transfer_type: _svt('s-lbl-transfer-type'),
|
||||||
|
lbl_transfer_ref: _svt('s-lbl-transfer-ref'),
|
||||||
|
// Footers
|
||||||
|
receipt_footer: _sv('s-receipt-footer'),
|
||||||
|
receipt_footer_charge: _sv('s-receipt-footer-charge'),
|
||||||
|
receipt_footer_cashier: _sv('s-receipt-footer-cashier'),
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await apiFetch('/admin/settings', {
|
await apiFetch('/admin/settings', {
|
||||||
|
|
@ -414,7 +518,8 @@ async function saveSettings() {
|
||||||
body: JSON.stringify(body)
|
body: JSON.stringify(body)
|
||||||
});
|
});
|
||||||
setMsg('settingsMsg', 'Settings saved.', 'ok');
|
setMsg('settingsMsg', 'Settings saved.', 'ok');
|
||||||
await loadConfig(); // refresh frontend cfg
|
await loadConfig();
|
||||||
|
populateTransferTypes();
|
||||||
document.querySelectorAll('.currency-unit').forEach(el => { el.textContent = cfg.currency_major || cfg.currency_unit; });
|
document.querySelectorAll('.currency-unit').forEach(el => { el.textContent = cfg.currency_major || cfg.currency_unit; });
|
||||||
if (document.getElementById('navBrand'))
|
if (document.getElementById('navBrand'))
|
||||||
document.getElementById('navBrand').textContent = cfg.club_name;
|
document.getElementById('navBrand').textContent = cfg.club_name;
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,17 @@
|
||||||
<input type="number" id="cashierAmount" placeholder="e.g. 10.00" min="0.01" step="0.01">
|
<input type="number" id="cashierAmount" placeholder="e.g. 10.00" min="0.01" step="0.01">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Note (optional)</label>
|
<label>Transfer Type</label>
|
||||||
|
<select id="cashierTransferType">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Transfer Reference <span class="label-hint">(optional)</span></label>
|
||||||
|
<input type="text" id="cashierTransferRef" placeholder="">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Note <span class="label-hint">(optional)</span></label>
|
||||||
<input type="text" id="cashierNote" placeholder="">
|
<input type="text" id="cashierNote" placeholder="">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" onclick="doTopup()">Top Up</button>
|
<button class="btn btn-primary" onclick="doTopup()">Top Up</button>
|
||||||
|
|
@ -116,7 +126,17 @@
|
||||||
<input type="password" id="withdrawalPin" placeholder="" autocomplete="off">
|
<input type="password" id="withdrawalPin" placeholder="" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Note (optional)</label>
|
<label>Transfer Type</label>
|
||||||
|
<select id="withdrawalTransferType">
|
||||||
|
<option value="">— select —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Transfer Reference <span class="label-hint">(optional)</span></label>
|
||||||
|
<input type="text" id="withdrawalTransferRef" placeholder="">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Note <span class="label-hint">(optional)</span></label>
|
||||||
<input type="text" id="withdrawalNote" placeholder="">
|
<input type="text" id="withdrawalNote" placeholder="">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-danger" onclick="doWithdrawal()">Withdraw</button>
|
<button class="btn btn-danger" onclick="doWithdrawal()">Withdraw</button>
|
||||||
|
|
@ -166,6 +186,8 @@
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>App Settings</h2>
|
<h2>App Settings</h2>
|
||||||
<form id="settingsForm">
|
<form id="settingsForm">
|
||||||
|
|
||||||
|
<h3 class="sub-heading">General</h3>
|
||||||
<div class="form-row"><label>Club Name</label>
|
<div class="form-row"><label>Club Name</label>
|
||||||
<input type="text" id="s-club-name"></div>
|
<input type="text" id="s-club-name"></div>
|
||||||
<div class="form-row"><label>Currency Symbol</label>
|
<div class="form-row"><label>Currency Symbol</label>
|
||||||
|
|
@ -182,8 +204,6 @@
|
||||||
<input type="number" id="s-max-topup" step="0.01"></div>
|
<input type="number" id="s-max-topup" step="0.01"></div>
|
||||||
<div class="form-row"><label>Maximum single charge <span class="label-hint" id="s-charge-hint"></span></label>
|
<div class="form-row"><label>Maximum single charge <span class="label-hint" id="s-charge-hint"></span></label>
|
||||||
<input type="number" id="s-max-charge" step="0.01"></div>
|
<input type="number" id="s-max-charge" step="0.01"></div>
|
||||||
<div class="form-row"><label>Receipt footer text <span class="label-hint">(optional)</span></label>
|
|
||||||
<textarea id="s-receipt-footer" rows="2" placeholder="Printed at the bottom of every receipt and statement"></textarea></div>
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Overdraft (bar charges)</label>
|
<label>Overdraft (bar charges)</label>
|
||||||
<select id="s-overdraft-policy">
|
<select id="s-overdraft-policy">
|
||||||
|
|
@ -194,7 +214,68 @@
|
||||||
<option value="staff_block">Default allowed — staff may block per charge</option>
|
<option value="staff_block">Default allowed — staff may block per charge</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">Save Settings</button>
|
|
||||||
|
<div class="panel-divider"></div>
|
||||||
|
<h3 class="sub-heading">Business Address</h3>
|
||||||
|
<div class="form-row"><label>Address line 1</label><input type="text" id="s-biz-address1"></div>
|
||||||
|
<div class="form-row"><label>Address line 2</label><input type="text" id="s-biz-address2"></div>
|
||||||
|
<div class="form-row"><label>Address line 3</label><input type="text" id="s-biz-address3"></div>
|
||||||
|
<div class="form-row"><label>Address line 4</label><input type="text" id="s-biz-address4"></div>
|
||||||
|
<div class="form-row"><label>Country</label><input type="text" id="s-biz-country"></div>
|
||||||
|
<div class="form-row"><label>Phone</label><input type="text" id="s-biz-phone"></div>
|
||||||
|
<div class="form-row"><label>Email</label><input type="text" id="s-biz-email"></div>
|
||||||
|
<div class="form-row"><label>Website</label><input type="text" id="s-biz-website"></div>
|
||||||
|
|
||||||
|
<div class="panel-divider"></div>
|
||||||
|
<h3 class="sub-heading">Branding</h3>
|
||||||
|
<div class="form-row"><label>Logo URL <span class="label-hint">(optional)</span></label>
|
||||||
|
<input type="text" id="s-logo-url" placeholder="https://..."></div>
|
||||||
|
<div class="form-row"><label>Logo alignment</label>
|
||||||
|
<select id="s-logo-align">
|
||||||
|
<option value="left">Left</option>
|
||||||
|
<option value="center">Center</option>
|
||||||
|
<option value="right">Right</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row"><label>Bar venue name</label>
|
||||||
|
<input type="text" id="s-bar-name" placeholder="Bar"></div>
|
||||||
|
<div class="form-row"><label>Cashier venue name</label>
|
||||||
|
<input type="text" id="s-cashier-name" placeholder="Cashier"></div>
|
||||||
|
|
||||||
|
<div class="panel-divider"></div>
|
||||||
|
<h3 class="sub-heading">Transactions</h3>
|
||||||
|
<div class="form-row"><label>Transaction reference prefix</label>
|
||||||
|
<input type="text" id="s-txn-ref-prefix" placeholder="TXN" style="max-width:120px"></div>
|
||||||
|
<div class="form-row"><label>Transfer types <span class="label-hint">(comma-separated)</span></label>
|
||||||
|
<input type="text" id="s-transfer-types" placeholder="Bank Transfer,Cash,QR"></div>
|
||||||
|
|
||||||
|
<div class="panel-divider"></div>
|
||||||
|
<h3 class="sub-heading">Receipt Labels <span class="label-hint">(for localisation)</span></h3>
|
||||||
|
<div class="form-row"><label>Receipt title (charge)</label><input type="text" id="s-lbl-receipt" placeholder="RECEIPT"></div>
|
||||||
|
<div class="form-row"><label>Receipt title (top-up)</label><input type="text" id="s-lbl-topup-receipt" placeholder="TOP-UP RECEIPT"></div>
|
||||||
|
<div class="form-row"><label>Receipt title (withdrawal)</label><input type="text" id="s-lbl-withdrawal-receipt" placeholder="WITHDRAWAL RECEIPT"></div>
|
||||||
|
<div class="form-row"><label>Staff label</label><input type="text" id="s-lbl-staff" placeholder="STAFF"></div>
|
||||||
|
<div class="form-row"><label>Transaction label</label><input type="text" id="s-lbl-transaction" placeholder="TRANSACTION"></div>
|
||||||
|
<div class="form-row"><label>Charge/venue label</label><input type="text" id="s-lbl-charge" placeholder="CHARGE"></div>
|
||||||
|
<div class="form-row"><label>Transaction time label</label><input type="text" id="s-lbl-txn-time" placeholder="TRANSACTION TIME"></div>
|
||||||
|
<div class="form-row"><label>Amount charged label</label><input type="text" id="s-lbl-amount-charged" placeholder="AMOUNT CHARGED"></div>
|
||||||
|
<div class="form-row"><label>Remaining balance label</label><input type="text" id="s-lbl-remaining-balance" placeholder="REMAINING BALANCE"></div>
|
||||||
|
<div class="form-row"><label>Balance transfer section header</label><input type="text" id="s-lbl-balance-transfer" placeholder="BALANCE TRANSFER"></div>
|
||||||
|
<div class="form-row"><label>Amount topped-up label</label><input type="text" id="s-lbl-amount-topup" placeholder="AMOUNT TOPPED-UP"></div>
|
||||||
|
<div class="form-row"><label>Amount withdrawn label</label><input type="text" id="s-lbl-amount-withdrawal" placeholder="AMOUNT WITHDRAWN"></div>
|
||||||
|
<div class="form-row"><label>Transfer type label</label><input type="text" id="s-lbl-transfer-type" placeholder="TRANSFER TYPE"></div>
|
||||||
|
<div class="form-row"><label>Transfer reference label</label><input type="text" id="s-lbl-transfer-ref" placeholder="TRANSFER REFERENCE"></div>
|
||||||
|
|
||||||
|
<div class="panel-divider"></div>
|
||||||
|
<h3 class="sub-heading">Receipt Footers</h3>
|
||||||
|
<div class="form-row"><label>Footer — all <span class="label-hint">(fallback for all receipts and statement)</span></label>
|
||||||
|
<textarea id="s-receipt-footer" rows="2" placeholder="Printed at the bottom of every receipt and statement"></textarea></div>
|
||||||
|
<div class="form-row"><label>Footer — charge receipts <span class="label-hint">(overrides all-footer for bar charges)</span></label>
|
||||||
|
<textarea id="s-receipt-footer-charge" rows="2"></textarea></div>
|
||||||
|
<div class="form-row"><label>Footer — cashier receipts <span class="label-hint">(overrides all-footer for top-ups and withdrawals)</span></label>
|
||||||
|
<textarea id="s-receipt-footer-cashier" rows="2"></textarea></div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" style="margin-top:8px">Save Settings</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="settingsMsg" class="msg"></div>
|
<div id="settingsMsg" class="msg"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue