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:
Claude 2026-05-30 15:44:27 +00:00
parent 21b6791f4c
commit 8616ff1d49
No known key found for this signature in database
3 changed files with 489 additions and 115 deletions

340
main.py
View file

@ -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"
@ -79,14 +114,16 @@ def init_db():
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS ledger_entries ( CREATE TABLE IF NOT EXISTS ledger_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
member_id INTEGER NOT NULL REFERENCES members(id), member_id INTEGER NOT NULL REFERENCES members(id),
amount INTEGER NOT NULL, amount INTEGER NOT NULL,
type TEXT NOT NULL CHECK(type IN ('topup','charge','withdrawal')), type TEXT NOT NULL CHECK(type IN ('topup','charge','withdrawal')),
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,
created_at TEXT NOT NULL DEFAULT (datetime('now')) transfer_type TEXT,
transfer_ref TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS products ( CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -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
@ -311,9 +354,11 @@ class MemberUpdate(BaseModel):
overdraft_override: Optional[int] = None # NULL=default, 1=allow, 0=block overdraft_override: Optional[int] = None # NULL=default, 1=allow, 0=block
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
@ -322,10 +367,12 @@ class ChargeRequest(BaseModel):
note: Optional[str] = None note: Optional[str] = None
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">' + " &nbsp;|&nbsp; ".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 = " &nbsp;·&nbsp; ".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']} &nbsp;|&nbsp; #{member['member_number']} &nbsp;|&nbsp; <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> &nbsp;|&nbsp; #{member['member_number']} &nbsp;|&nbsp;
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__":

View file

@ -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;
@ -268,11 +281,15 @@ function selectCashierMember(id, name, number, balance, balanceDisplay) {
function clearCashierSelection() { 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('cashierNote').value = ''; document.getElementById('cashierTransferType').value = '';
document.getElementById('withdrawalAmount').value = ''; document.getElementById('cashierTransferRef').value = '';
document.getElementById('withdrawalPin').value = ''; document.getElementById('cashierNote').value = '';
document.getElementById('withdrawalNote').value = ''; document.getElementById('withdrawalAmount').value = '';
document.getElementById('withdrawalPin').value = '';
document.getElementById('withdrawalTransferType').value = '';
document.getElementById('withdrawalTransferRef').value = '';
document.getElementById('withdrawalNote').value = '';
setMsg('cashierTopupMsg', '', ''); setMsg('cashierTopupMsg', '', '');
setMsg('cashierWithdrawalMsg', '', ''); setMsg('cashierWithdrawalMsg', '', '');
setMsg('cashierMsg', '', ''); setMsg('cashierMsg', '', '');
@ -280,37 +297,47 @@ 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('cashierNote').value = ''; document.getElementById('cashierTransferType').value = '';
document.getElementById('cashierTransferRef').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'); }
} }
async function doWithdrawal() { 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('withdrawalNote').value = ''; document.getElementById('withdrawalTransferType').value = '';
document.getElementById('withdrawalTransferRef').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'; document.getElementById('s-min-hint').textContent = `in ${majorUnit}`;
const sym = s.currency_symbol || ''; document.getElementById('s-max-hint').textContent = `in ${majorUnit}`;
document.getElementById('s-min-hint').textContent = `in ${s.currency_major || 'major units'}`; document.getElementById('s-charge-hint').textContent= `in ${majorUnit}`;
document.getElementById('s-max-hint').textContent = `in ${s.currency_major || 'major units'}`; // Business address
document.getElementById('s-charge-hint').textContent= `in ${s.currency_major || 'major units'}`; 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;

View file

@ -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>