diff --git a/main.py b/main.py
index bf7e710..6fce780 100644
--- a/main.py
+++ b/main.py
@@ -13,7 +13,7 @@ from pathlib import Path
from typing import Optional
import bcrypt
-from fastapi import FastAPI, HTTPException, Cookie, Depends, Response
+from fastapi import FastAPI, HTTPException, Cookie, Depends, Response, UploadFile, File
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, field_validator
@@ -43,6 +43,8 @@ CONFIG = {
# Branding
"logo_url": "",
"logo_align": "left",
+ "logo_max_width": 200,
+ "logo_max_height": 80,
"bar_name": "Bar",
"cashier_name": "Cashier",
# Transactions
@@ -423,6 +425,8 @@ class AppSettingsUpdate(BaseModel):
# Branding
logo_url: Optional[str] = None
logo_align: Optional[str] = None
+ logo_max_width: Optional[int] = None
+ logo_max_height: Optional[int] = None
bar_name: Optional[str] = None
cashier_name: Optional[str] = None
# Transactions
@@ -690,7 +694,7 @@ def _print_size_script():
function setSize(s){
var el=document.getElementById('psStyle');
if(!el){el=document.createElement('style');el.id='psStyle';document.head.appendChild(el);}
- el.textContent='@media print{@page{size:'+s+';margin:'+(s==='A5'?'8mm':'14mm')+';}}';}
+ el.textContent='@media print{@page{size:'+s+';margin:'+(s==='A5'?'10mm':'16mm')+';}}';}
setSize('A4');
"""
@@ -699,6 +703,7 @@ def _print_controls():
Paper:
A4
A5
+ Print
"""
def _txn_ref(entry_id: int, s: dict) -> str:
@@ -709,57 +714,86 @@ 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' '
+ align = s.get("logo_align", "left")
+ max_w = int(s.get("logo_max_width", 200) or 200)
+ max_h = int(s.get("logo_max_height", 80) or 80)
+ style = f"max-width:{max_w}px;max-height:{max_h}px;"
+ css_cl = f"biz-logo align-{align}" if align in ("left","center","right") else "biz-logo"
+ return f' '
def _biz_header_html(s: dict) -> str:
- parts = [_logo_html(s)]
- parts.append(f'
- Member:
{member['name']} | #{member['member_number']} |
+
+ Member: {member['name']} — #{member['member_number']} —
Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC
- Date/Time Type Venue Reference Note
- Staff Charge Credit Balance
+ Date and Time Reference Type Venue
+ Staff Amount Balance
{rows_html}
-
Current Balance: {fmt(bal)}
+
Current Balance: {fmt(bal)}
{('') if footer else ''}
{_print_size_script()}"""
@@ -839,80 +885,86 @@ def receipt(entry_id: int):
FROM ledger_entries WHERE member_id=? AND id<=?
""", (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)
def fmt(p): return f"{sym}{p/div:.2f}"
- 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")
+ 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")
+ tf_type = entry["transfer_type"] or ""
+ tf_ref = entry["transfer_ref"] or ""
+ timestamp = entry["created_at"][:16] + " UTC"
- 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_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", "")
+ title = s.get("lbl_topup_receipt", "TOP-UP RECEIPT")
+ footer = s.get("receipt_footer_cashier") or s.get("receipt_footer","")
+ lbl_tf_sec = s.get("lbl_balance_transfer", "BALANCE TRANSFER")
+ lbl_amount = s.get("lbl_amount_topup", "AMOUNT TOPPED-UP")
+ tf_label = "Top-up"
+ amount_cls = "large credit"
elif etype == "withdrawal":
- title = s.get("lbl_withdrawal_receipt", "WITHDRAWAL RECEIPT")
- footer = s.get("receipt_footer_cashier") or s.get("receipt_footer", "")
+ title = s.get("lbl_withdrawal_receipt","WITHDRAWAL RECEIPT")
+ footer = s.get("receipt_footer_cashier") or s.get("receipt_footer","")
+ lbl_tf_sec = s.get("lbl_balance_transfer", "BALANCE TRANSFER")
+ lbl_amount = s.get("lbl_amount_withdrawal","AMOUNT WITHDRAWN")
+ tf_label = "Withdrawal"
+ amount_cls = "large charge"
else:
- title = s.get("lbl_receipt", "RECEIPT")
- footer = s.get("receipt_footer_charge") or s.get("receipt_footer", "")
+ title = s.get("lbl_receipt", "RECEIPT")
+ footer = s.get("receipt_footer_charge") or s.get("receipt_footer","")
+ lbl_charge = s.get("lbl_charge_venue", "CHARGE")
+ lbl_amount = s.get("lbl_amount_charged", "AMOUNT CHARGED")
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)}
'
- )
+ body_html = f"""
+ {_rx_cell(lbl_staff, entry['staff_name'])}
+ {_rx_cell(lbl_txn, txn_ref)}
+
+
+
+ {_rx_cell(lbl_charge, venue_name)}
+ {_rx_cell(lbl_txn_time, timestamp)}
+
+
+
+ {_rx_cell(lbl_amount, fmt(entry['amount']), 'large charge')}
+ {_rx_cell(lbl_remaining, fmt(bal_after), 'large')}
+
"""
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)}
'
- )
+ lbl_tf_type = s.get("lbl_transfer_type", "TRANSFER TYPE")
+ lbl_tf_ref = s.get("lbl_transfer_ref", "TRANSFER REFERENCE")
+ body_html = f"""
+ {_rx_cell(lbl_staff, entry['staff_name'])}
+ {_rx_cell(lbl_txn, txn_ref)}
+
+
+
+ {_rx_cell(lbl_tf_sec, tf_label)}
+ {_rx_cell(lbl_txn_time, timestamp)}
+
+
+
+ {_rx_cell(lbl_amount, fmt(entry['amount']), amount_cls)}
+ {_rx_cell(lbl_remaining, fmt(bal_after), 'large')}
+
+
+
+ {_rx_cell(lbl_tf_type, tf_type or '—')}
+ {_rx_cell(lbl_tf_ref, tf_ref or '—')}
+
"""
return f"""
-
Receipt – {member['name']}
+
Receipt — {member['name']}
{_print_controls()}
-
- Print Receipt
-
{_biz_header_html(s)}
-
{title}
-
-
{member['name']}
-
#{member['member_number']}
-
-
-
{grid_details}
-
-
{grid_amounts}
+
{title}
+{body_html}
{('') if footer else ''}
{_print_size_script()}"""
@@ -1062,6 +1114,27 @@ def update_admin_settings(body: AppSettingsUpdate, user: dict = Depends(admin_us
refresh_settings()
return _settings
+# ---------------------------------------------------------------------------
+# Admin – logo upload
+# ---------------------------------------------------------------------------
+
+@app.post("/admin/logo")
+async def upload_logo(file: UploadFile = File(...), user: dict = Depends(admin_user)):
+ content_type = file.content_type or ""
+ if not content_type.startswith("image/"):
+ raise HTTPException(400, "Only image files are allowed")
+ suffix = Path(file.filename or "logo.png").suffix.lower()
+ if suffix not in (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"):
+ suffix = ".png"
+ dest = static_dir / f"logo{suffix}"
+ dest.write_bytes(await file.read())
+ url = f"/static/logo{suffix}"
+ with db_conn() as conn:
+ conn.execute("INSERT OR REPLACE INTO app_settings (key,value) VALUES (?,?)",
+ ("logo_url", json.dumps(url)))
+ refresh_settings()
+ return {"url": url}
+
# ---------------------------------------------------------------------------
# Config (public – loaded by frontend before login screen shows)
# ---------------------------------------------------------------------------
diff --git a/requirements.txt b/requirements.txt
index d52f40e..d36de0e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
fastapi
uvicorn[standard]
bcrypt
+python-multipart
diff --git a/static/app.js b/static/app.js
index 4791b48..5f081a3 100644
--- a/static/app.js
+++ b/static/app.js
@@ -398,6 +398,29 @@ async function doCharge() {
// ---------------------------------------------------------------------------
async function loadAdminView() {
await Promise.all([loadAdminSettings(), loadStaffAccounts()]);
+ setupLogoUpload();
+}
+
+let _logoUploadWired = false;
+function setupLogoUpload() {
+ if (_logoUploadWired) return;
+ const input = document.getElementById('s-logo-upload');
+ if (!input) return;
+ _logoUploadWired = true;
+ input.addEventListener('change', async function() {
+ const file = this.files[0];
+ if (!file) return;
+ const fd = new FormData();
+ fd.append('file', file);
+ try {
+ const r = await fetch('/admin/logo', { method: 'POST', body: fd });
+ const json = await r.json();
+ if (!r.ok) throw new Error(json.detail || 'Upload failed');
+ document.getElementById('s-logo-url').value = json.url;
+ setMsg('logoUploadMsg', 'Logo uploaded.', 'ok');
+ } catch (e) { setMsg('logoUploadMsg', e.message, 'err'); }
+ this.value = '';
+ });
}
async function loadAdminSettings() {
@@ -428,8 +451,10 @@ async function loadAdminSettings() {
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-logo-url').value = s.logo_url || '';
+ document.getElementById('s-logo-align').value = s.logo_align || 'left';
+ document.getElementById('s-logo-max-width').value = s.logo_max_width || '';
+ document.getElementById('s-logo-max-height').value = s.logo_max_height || '';
document.getElementById('s-bar-name').value = s.bar_name || '';
document.getElementById('s-cashier-name').value = s.cashier_name || '';
// Transactions
@@ -485,8 +510,10 @@ async function saveSettings() {
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'),
+ logo_url: _svt('s-logo-url'),
+ logo_align: _sv('s-logo-align'),
+ logo_max_width: parseInt(_sv('s-logo-max-width'), 10) || null,
+ logo_max_height: parseInt(_sv('s-logo-max-height'), 10) || null,
bar_name: _svt('s-bar-name'),
cashier_name: _svt('s-cashier-name'),
// Transactions
diff --git a/static/index.html b/static/index.html
index 5247332..e81659c 100644
--- a/static/index.html
+++ b/static/index.html
@@ -228,15 +228,27 @@
Branding
-