From 8616ff1d493c87a4a1f2e6291a1d892a6e6d8c08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 15:44:27 +0000 Subject: [PATCH] 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 --- main.py | 340 +++++++++++++++++++++++++++++++++++----------- static/app.js | 173 ++++++++++++++++++----- static/index.html | 91 ++++++++++++- 3 files changed, 489 insertions(+), 115 deletions(-) diff --git a/main.py b/main.py index 183facb..bf7e710 100644 --- a/main.py +++ b/main.py @@ -24,14 +24,49 @@ from pydantic import BaseModel, field_validator CONFIG = { "club_name": "ClubLedger", "currency_symbol": "£", - "currency_major": "pounds", # label for major unit (what users enter) - "currency_minor": "pence", # label for stored minor unit - "currency_divisor": 100, # minor units per major unit - "overdraft_policy": "never", # never|always|staff_override|admin_override|staff_block - "min_topup": 100, # minor units + "currency_major": "pounds", + "currency_minor": "pence", + "currency_divisor": 100, + "overdraft_policy": "never", + "min_topup": 100, "max_topup": 100_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_charge": "", + "receipt_footer_cashier": "", } DB_PATH = "clubledger.db" @@ -79,14 +114,16 @@ def init_db(): created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS ledger_entries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - member_id INTEGER NOT NULL REFERENCES members(id), - amount INTEGER NOT NULL, - type TEXT NOT NULL CHECK(type IN ('topup','charge','withdrawal')), - venue TEXT NOT NULL CHECK(venue IN ('cashier','bar')), - note TEXT, - staff_name TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) + id INTEGER PRIMARY KEY AUTOINCREMENT, + member_id INTEGER NOT NULL REFERENCES members(id), + amount INTEGER NOT NULL, + type TEXT NOT NULL CHECK(type IN ('topup','charge','withdrawal')), + venue TEXT NOT NULL CHECK(venue IN ('cashier','bar')), + note TEXT, + staff_name TEXT NOT NULL, + transfer_type TEXT, + transfer_ref TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -184,6 +221,13 @@ def migrate_db(): ) 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(): with db_conn() as conn: 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"): raise HTTPException(403, "POS staff access required") return user - return user # --------------------------------------------------------------------------- # App @@ -311,9 +354,11 @@ class MemberUpdate(BaseModel): overdraft_override: Optional[int] = None # NULL=default, 1=allow, 0=block class TopupRequest(BaseModel): - member_id: int - amount: int # minor units - note: Optional[str] = None + member_id: int + amount: int + note: Optional[str] = None + transfer_type: Optional[str] = None + transfer_ref: Optional[str] = None class ChargeRequest(BaseModel): member_id: int @@ -322,10 +367,12 @@ class ChargeRequest(BaseModel): note: Optional[str] = None class WithdrawalRequest(BaseModel): - member_id: int - amount: int # minor units - pin: str - note: Optional[str] = None + member_id: int + amount: int + pin: str + note: Optional[str] = None + transfer_type: Optional[str] = None + transfer_ref: Optional[str] = None class ProductCreate(BaseModel): name: str @@ -364,7 +411,42 @@ class AppSettingsUpdate(BaseModel): min_topup: Optional[int] = None max_topup: 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_charge: Optional[str] = None + receipt_footer_cashier: Optional[str] = None # --------------------------------------------------------------------------- # 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(): raise HTTPException(404, "Member not found") cur = conn.execute( - "INSERT INTO ledger_entries (member_id,amount,type,venue,note,staff_name) VALUES (?,?,?,?,?,?)", - (body.member_id, body.amount, "topup", "cashier", body.note, user["name"]) + "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.transfer_type, body.transfer_ref) ) eid = cur.lastrowid bal = member_balance(conn, body.member_id) @@ -567,8 +650,9 @@ def withdrawal(body: WithdrawalRequest, user: dict = Depends(cashier_user)): if bal < body.amount: raise HTTPException(400, f"Insufficient balance ({format_amount(bal)})") cur = conn.execute( - "INSERT INTO ledger_entries (member_id,amount,type,venue,note,staff_name) VALUES (?,?,?,?,?,?)", - (body.member_id, body.amount, "withdrawal", "cashier", body.note, user["name"]) + "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.transfer_type, body.transfer_ref) ) eid = cur.lastrowid new_bal = member_balance(conn, body.member_id) @@ -617,14 +701,65 @@ def _print_controls(): """ -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'logo' + +def _biz_header_html(s: dict) -> str: + parts = [_logo_html(s)] + parts.append(f'
{s.get("club_name") or "ClubLedger"}
') + 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('
' + "
".join(addr) + "
") + 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('
' + "  |  ".join(contact) + "
") + return '
' + "\n".join(p for p in parts if p) + "
" + +RECEIPT_CSS = """ body{font-family:Arial,sans-serif;font-size:11px;color:#111;margin:24px;} - h1{font-size:18px;margin-bottom:2px;} h2{font-size:13px;font-weight:normal;color:#555;margin: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;} .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;} - .footer{margin-top:16px;font-size:10px;color:#888;text-align:center;white-space:pre-wrap;} @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) @@ -640,42 +775,53 @@ def statement(member_id: int): bal = member_balance(conn, member_id) 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 venue_label(v): return bar_name if v == "bar" else cashier_name rows_html, running = "", 0 for r in rows: + txn_ref = _txn_ref(r["id"], s) 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: - running -= r["amount"]; dr, cr = fmt(r["amount"]), "" - rows_html += (f"{r['created_at'][:16]}{r['type']}" - f"{r['venue']}{r['note'] or ''}" - f"{r['staff_name']}{dr}" - f"{cr}{fmt(running)}") + running -= r["amount"]; dr, cr = fmt(r["amount"]), ""; type_lbl = "Charge" + rows_html += (f"{r['created_at'][:16]}{type_lbl}" + f"{venue_label(r['venue'])}{txn_ref}" + f"{r['note'] or ''}{r['staff_name']}" + f"{dr}{cr}" + f"{fmt(running)}") + 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'{sub}' return f""" -Statement – {member['name']} +Statement – {member['name']} {_print_controls()}
-

{club} – Account Statement

-

Member: {member['name']}  |  #{member['member_number']}  |  -Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC

+{_biz_header_html(s)} +
+

Account Statement

+
+ Member: {member['name']}  |  #{member['member_number']}  |  + Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC +
- - + + {rows_html}
Date/TimeTypeVenueNoteStaffChargeTop-upBalanceDate/TimeTypeVenueReferenceNoteStaffChargeCreditBalance
Current Balance: {fmt(bal)}
{('') if footer else ''} @@ -694,39 +840,80 @@ def receipt(entry_id: int): """, (entry["member_id"], entry_id)).fetchone()[0] 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}" - type_label = {"topup": "Top-up", "charge": "Charge", "withdrawal": "Withdrawal"}.get(entry["type"], entry["type"].capitalize()) - colour = "#080" if entry["type"] == "topup" else "#c00" + txn_ref = _txn_ref(entry_id, s) + 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'
{lbl_staff}
{entry["staff_name"]}
' + f'
{lbl_txn}
{txn_ref}
' + f'
{lbl_charge}
{venue_name}
' + f'
{lbl_txn_time}
{entry["created_at"][:16]} UTC
' + ) + grid_amounts = ( + f'
{lbl_amount}
{fmt(entry["amount"])}
' + f'
{lbl_remaining}
{fmt(bal_after)}
' + ) + 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'
{lbl_staff}
{entry["staff_name"]}
' + f'
{lbl_txn}
{txn_ref}
' + f'
{lbl_txn_time}
{entry["created_at"][:16]} UTC
' + ) + tf_rows = "" + if tf_type: tf_rows += f'
{lbl_tf_type}
{tf_type}
' + if tf_ref: tf_rows += f'
{lbl_tf_ref}
{tf_ref}
' + grid_amounts = ( + f'
{lbl_tf_section}
' + f'{tf_rows}' + f'
{lbl_amount}
{fmt(entry["amount"])}
' + f'
{lbl_remaining}
{fmt(bal_after)}
' + ) return f""" -Receipt – {member['name']} +Receipt – {member['name']} {_print_controls()}
-

{club}

{type_label} Receipt

- - - - - - - - - -
Member{member['name']}
Member #{member['member_number']}
Type{type_label}
Amount{fmt(entry['amount'])}
Balance after{fmt(bal_after)}
Staff{entry['staff_name']}
Note{entry['note'] or '—'}
Date / Time{entry['created_at']} UTC
+{_biz_header_html(s)} +
+
{title}
+
+
{member['name']}
+
#{member['member_number']}
+
+
+
{grid_details}
+
+
{grid_amounts}
+
{('') if footer else ''} {_print_size_script()}""" @@ -882,8 +1069,9 @@ def update_admin_settings(body: AppSettingsUpdate, user: dict = Depends(admin_us @app.get("/config") def get_config(): 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") + 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 if __name__ == "__main__": diff --git a/static/app.js b/static/app.js index 51f1c74..3f62ef4 100644 --- a/static/app.js +++ b/static/app.js @@ -61,9 +61,22 @@ async function doLogout() { 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 = '' + + types.map(t => ``).join(''); + if (prev && types.includes(prev)) sel.value = prev; + }); +} + async function startApp() { document.getElementById('loginOverlay').classList.add('hidden'); await loadConfig(); + populateTransferTypes(); const brand = document.getElementById('navBrand'); if (brand) brand.textContent = cfg.club_name; @@ -268,11 +281,15 @@ function selectCashierMember(id, name, number, balance, balanceDisplay) { function clearCashierSelection() { cashierMember = null; document.getElementById('cashierForm').classList.add('hidden'); - document.getElementById('cashierAmount').value = ''; - document.getElementById('cashierNote').value = ''; - document.getElementById('withdrawalAmount').value = ''; - document.getElementById('withdrawalPin').value = ''; - document.getElementById('withdrawalNote').value = ''; + document.getElementById('cashierAmount').value = ''; + document.getElementById('cashierTransferType').value = ''; + document.getElementById('cashierTransferRef').value = ''; + document.getElementById('cashierNote').value = ''; + document.getElementById('withdrawalAmount').value = ''; + document.getElementById('withdrawalPin').value = ''; + document.getElementById('withdrawalTransferType').value = ''; + document.getElementById('withdrawalTransferRef').value = ''; + document.getElementById('withdrawalNote').value = ''; setMsg('cashierTopupMsg', '', ''); setMsg('cashierWithdrawalMsg', '', ''); setMsg('cashierMsg', '', ''); @@ -280,37 +297,47 @@ function clearCashierSelection() { async function doTopup() { if (!cashierMember) return; - const amount = toMinor('cashierAmount'); - const note = document.getElementById('cashierNote').value.trim(); + const amount = toMinor('cashierAmount'); + 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; } try { const r = await apiFetch('/topup', { 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'); - document.getElementById('cashierAmount').value = ''; - document.getElementById('cashierNote').value = ''; + document.getElementById('cashierAmount').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'); } catch (err) { setMsg('cashierTopupMsg', err.message, 'err'); } } async function doWithdrawal() { if (!cashierMember) return; - const amount = toMinor('withdrawalAmount'); - const pin = document.getElementById('withdrawalPin').value; - const note = document.getElementById('withdrawalNote').value.trim(); + const amount = toMinor('withdrawalAmount'); + const pin = document.getElementById('withdrawalPin').value; + 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 (!pin) { setMsg('cashierWithdrawalMsg', 'PIN is required.', 'err'); return; } try { const r = await apiFetch('/withdrawal', { 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'); - document.getElementById('withdrawalAmount').value = ''; - document.getElementById('withdrawalPin').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('cashierWithdrawalMsg', `Withdrawal complete. New balance: ${r.new_balance_display}`, 'ok'); } catch (err) { setMsg('cashierWithdrawalMsg', err.message, 'err'); } } @@ -377,6 +404,8 @@ async function loadAdminSettings() { try { const s = await apiFetch('/admin/settings'); 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-currency-symbol').value = s.currency_symbol || ''; 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-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-receipt-footer').value = s.receipt_footer || ''; - document.getElementById('s-overdraft-policy').value = s.overdraft_policy || 'never'; - const sym = s.currency_symbol || ''; - document.getElementById('s-min-hint').textContent = `in ${s.currency_major || 'major units'}`; - document.getElementById('s-max-hint').textContent = `in ${s.currency_major || 'major units'}`; - document.getElementById('s-charge-hint').textContent= `in ${s.currency_major || 'major units'}`; + document.getElementById('s-overdraft-policy').value = s.overdraft_policy || 'never'; + document.getElementById('s-min-hint').textContent = `in ${majorUnit}`; + document.getElementById('s-max-hint').textContent = `in ${majorUnit}`; + document.getElementById('s-charge-hint').textContent= `in ${majorUnit}`; + // 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'); } } +function _sv(id) { return document.getElementById(id).value; } +function _svt(id) { return _sv(id).trim(); } + 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 = { - club_name: document.getElementById('s-club-name').value.trim(), - currency_symbol: document.getElementById('s-currency-symbol').value.trim(), - currency_major: document.getElementById('s-currency-major').value.trim(), - currency_minor: document.getElementById('s-currency-minor').value.trim(), + // General + club_name: _svt('s-club-name'), + currency_symbol: _svt('s-currency-symbol'), + currency_major: _svt('s-currency-major'), + currency_minor: _svt('s-currency-minor'), currency_divisor: div, - min_topup: Math.round(parseFloat(document.getElementById('s-min-topup').value) * div), - max_topup: Math.round(parseFloat(document.getElementById('s-max-topup').value) * div), - max_charge: Math.round(parseFloat(document.getElementById('s-max-charge').value) * div), - receipt_footer: document.getElementById('s-receipt-footer').value, - overdraft_policy: document.getElementById('s-overdraft-policy').value, + min_topup: Math.round(parseFloat(_sv('s-min-topup')) * div), + max_topup: Math.round(parseFloat(_sv('s-max-topup')) * div), + max_charge: Math.round(parseFloat(_sv('s-max-charge')) * div), + overdraft_policy: _sv('s-overdraft-policy'), + // 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 { await apiFetch('/admin/settings', { @@ -414,7 +518,8 @@ async function saveSettings() { body: JSON.stringify(body) }); 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; }); if (document.getElementById('navBrand')) document.getElementById('navBrand').textContent = cfg.club_name; diff --git a/static/index.html b/static/index.html index b2d786b..0999ecb 100644 --- a/static/index.html +++ b/static/index.html @@ -98,7 +98,17 @@
- + + +
+
+ + +
+
+
@@ -116,7 +126,17 @@
- + + +
+
+ + +
+
+
@@ -166,6 +186,8 @@

App Settings

+ +

General

@@ -182,8 +204,6 @@
-
-
- + +
+

Business Address

+
+
+
+
+
+
+
+
+ +
+

Branding

+
+
+
+ +
+
+
+
+
+ +
+

Transactions

+
+
+
+
+ +
+

Receipt Labels (for localisation)

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+

Receipt Footers

+
+
+
+
+
+
+ +