Redesign receipts/statement to match spec; add logo upload

Receipts:
- Font size raised to 11pt base (labels 9pt, amounts 13pt bold)
- Each field now shows LABEL (small, uppercase, gray) above VALUE —
  two cells per row in a 1fr/1fr CSS grid, matching the provided samples
- Business header: left column = address lines, right column = Tel/Email/Web
- Charge receipt: STAFF+TRANSACTION / CHARGE+TIME / AMOUNT+BALANCE
- Top-up/Withdrawal receipt: STAFF+TXN / TRANSFER_TYPE+TIME /
  AMOUNT+BALANCE / TRANSFER_TYPE+TRANSFER_REF
- Print button moved into the paper-size controls bar

Statement:
- Reduced from 9 to 7 columns: Date, Reference, Type, Venue, Staff,
  Amount (+/-), Balance — removes the separate Charge/Credit split
- Amount shown as "+ £X.XX" (green) or "- £X.XX" (red)
- Sub-row shows "Transfer type: X — Ref" for top-ups/withdrawals,
  or the note text for charges

Logo:
- New POST /admin/logo endpoint: accepts image upload, saves to
  static/logo.{ext}, auto-updates logo_url setting
- New logo_max_width / logo_max_height config fields (default 200×80px)
- Admin branding section: file upload input + max-width/height fields
- python-multipart added to requirements.txt (needed for file upload)

https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
This commit is contained in:
Claude 2026-05-30 16:55:23 +00:00
parent 79ae833fa9
commit 09df5efb07
No known key found for this signature in database
4 changed files with 251 additions and 138 deletions

337
main.py
View file

@ -13,7 +13,7 @@ from pathlib import Path
from typing import Optional from typing import Optional
import bcrypt 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.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
@ -43,6 +43,8 @@ CONFIG = {
# Branding # Branding
"logo_url": "", "logo_url": "",
"logo_align": "left", "logo_align": "left",
"logo_max_width": 200,
"logo_max_height": 80,
"bar_name": "Bar", "bar_name": "Bar",
"cashier_name": "Cashier", "cashier_name": "Cashier",
# Transactions # Transactions
@ -423,6 +425,8 @@ class AppSettingsUpdate(BaseModel):
# Branding # Branding
logo_url: Optional[str] = None logo_url: Optional[str] = None
logo_align: 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 bar_name: Optional[str] = None
cashier_name: Optional[str] = None cashier_name: Optional[str] = None
# Transactions # Transactions
@ -690,7 +694,7 @@ def _print_size_script():
function setSize(s){ function setSize(s){
var el=document.getElementById('psStyle'); var el=document.getElementById('psStyle');
if(!el){el=document.createElement('style');el.id='psStyle';document.head.appendChild(el);} 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'); setSize('A4');
</script>""" </script>"""
@ -699,6 +703,7 @@ def _print_controls():
<span class="size-label">Paper:</span> <span class="size-label">Paper:</span>
<label><input type="radio" name="ps" value="A4" checked onchange="setSize('A4')"> A4</label> <label><input type="radio" name="ps" value="A4" checked onchange="setSize('A4')"> A4</label>
<label><input type="radio" name="ps" value="A5" onchange="setSize('A5')"> A5</label> <label><input type="radio" name="ps" value="A5" onchange="setSize('A5')"> A5</label>
<button class="print-btn" onclick="window.print()">Print</button>
</div>""" </div>"""
def _txn_ref(entry_id: int, s: dict) -> str: 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() url = (s.get("logo_url") or "").strip()
if not url: if not url:
return "" return ""
align = s.get("logo_align", "left") align = s.get("logo_align", "left")
css_cls = f"biz-logo align-{align}" if align in ("left", "center", "right") else "biz-logo align-left" max_w = int(s.get("logo_max_width", 200) or 200)
return f'<img src="{url}" class="{css_cls}" alt="logo">' 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'<img src="{url}" class="{css_cl}" style="{style}" alt="logo">'
def _biz_header_html(s: dict) -> str: def _biz_header_html(s: dict) -> str:
parts = [_logo_html(s)] logo = _logo_html(s)
parts.append(f'<div class="biz-name">{s.get("club_name") or "ClubLedger"}</div>') name = 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()] addr = [( s.get(f"biz_address{i}") or "").strip() for i in range(1,5)]
if addr: addr += [(s.get("biz_country") or "").strip()]
parts.append('<div class="biz-address">' + "<br>".join(addr) + "</div>") addr = [l for l in addr if l]
contact = [x for x in [s.get("biz_phone",""), s.get("biz_email",""), s.get("biz_website","")] if x and x.strip()]
if contact: contacts = []
parts.append('<div class="biz-contact">' + " &nbsp;|&nbsp; ".join(contact) + "</div>") if (s.get("biz_phone") or "").strip(): contacts.append(f'Tel. &nbsp; {s["biz_phone"]}')
return '<div class="biz-header">' + "\n".join(p for p in parts if p) + "</div>" if (s.get("biz_email") or "").strip(): contacts.append(f'Email: {s["biz_email"]}')
if (s.get("biz_website") or "").strip(): contacts.append(f'Web: &nbsp; {s["biz_website"]}')
parts = []
if logo: parts.append(logo)
parts.append(f'<div class="biz-name">{name}</div>')
if addr and contacts:
parts.append(
f'<div class="biz-info-row">'
f'<div class="biz-addr">{"<br>".join(addr)}</div>'
f'<div class="biz-contacts">{"<br>".join(contacts)}</div>'
f'</div>'
)
elif addr:
parts.append(f'<div class="biz-addr">{"<br>".join(addr)}</div>')
elif contacts:
parts.append(f'<div class="biz-addr">{"<br>".join(contacts)}</div>')
return '<div class="biz-header">' + "\n".join(parts) + "</div>"
def _rx_cell(label: str, value: str, extra_cls: str = "") -> str:
val_cls = ("rx-val " + extra_cls).strip()
return f'<div class="rx-cell"><div class="rx-lbl">{label}</div><div class="{val_cls}">{value}</div></div>'
RECEIPT_CSS = """ RECEIPT_CSS = """
body{font-family:Arial,sans-serif;font-size:11px;color:#111;margin:24px;} body{font-family:Arial,sans-serif;font-size:11pt;color:#111;margin:28px;}
h2{font-size:14px;font-weight:bold;margin:10px 0 4px;} hr{border:none;border-top:1px solid #ccc;margin:12px 0;}
hr{border:none;border-top:1px solid #ccc;margin:10px 0;} .controls{display:flex;align-items:center;gap:12px;margin-bottom:16px;flex-wrap:wrap;}
.controls{display:flex;align-items:center;gap:12px;margin-bottom:14px;flex-wrap:wrap;} .size-label{font-size:10pt;color:#555;}
.size-label{font-size:12px;color:#555;} .controls label{font-size:12px;cursor:pointer;} .controls label{font-size:10pt;cursor:pointer;}
.print-btn{padding:7px 18px;font-size:13px;cursor:pointer;margin-left:auto;} .print-btn{padding:6px 16px;font-size:10pt;cursor:pointer;margin-left:auto;}
@media print{.no-print{display:none;}} @media print{.no-print{display:none;}}
.biz-header{margin-bottom:4px;} /* Business header */
.biz-logo{max-height:60px;max-width:200px;display:block;margin-bottom:4px;} .biz-logo{display:block;margin-bottom:8px;}
.biz-logo.align-center{margin-left:auto;margin-right:auto;} .biz-logo.align-center{margin-left:auto;margin-right:auto;}
.biz-logo.align-right{margin-left:auto;} .biz-logo.align-right{margin-left:auto;}
.biz-name{font-size:15px;font-weight:bold;margin:2px 0;} .biz-name{font-size:14pt;font-weight:bold;margin:4px 0 6px;}
.biz-address{color:#555;line-height:1.5;} .biz-info-row{display:flex;justify-content:space-between;align-items:flex-start;gap:24px;font-size:10pt;line-height:1.7;}
.biz-contact{color:#555;margin-top:3px;} .biz-addr{line-height:1.7;}
.receipt-title{font-size:13px;font-weight:bold;text-transform:uppercase;letter-spacing:.04em;margin:10px 0 6px;} .biz-contacts{text-align:right;white-space:nowrap;line-height:1.7;}
.member-section{display:flex;justify-content:space-between;align-items:baseline;margin:5px 0;} /* Receipt */
.member-name{font-size:13px;font-weight:bold;} .rx-title{font-size:13pt;font-weight:bold;text-transform:uppercase;letter-spacing:.06em;margin:14px 0 12px;}
.member-num{color:#555;} .rx-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 40px;margin:10px 0;}
.receipt-grid{display:grid;grid-template-columns:auto 1fr;gap:3px 14px;margin:6px 0;} .rx-cell{}
.rlbl{font-weight:600;color:#555;white-space:nowrap;font-size:10px;text-transform:uppercase;letter-spacing:.03em;} .rx-lbl{font-size:9pt;font-weight:700;color:#555;text-transform:uppercase;letter-spacing:.05em;margin-bottom:3px;}
.rval{text-align:right;} .rx-val{font-size:11pt;}
.section-head{grid-column:span 2;font-weight:bold;font-size:11px;text-transform:uppercase;letter-spacing:.04em;margin:6px 0 2px;} .rx-val.bold{font-weight:bold;}
.charge-val{font-size:20px;font-weight:bold;color:#c00;} .rx-val.large{font-size:13pt;font-weight:bold;}
.credit-val{font-size:20px;font-weight:bold;color:#080;} .rx-val.charge{color:#c00;}
.balance-val{font-size:14px;font-weight:bold;} .rx-val.credit{color:#080;}
table{width:100%;border-collapse:collapse;margin-top:10px;} .footer{margin-top:20px;font-size:10pt;color:#444;line-height:1.7;white-space:pre-wrap;}
th{background:#222;color:#fff;padding:5px 8px;text-align:left;font-size:10px;} /* Statement */
td{padding:4px 8px;border-bottom:1px solid #e0e0e0;} h2{font-size:13pt;font-weight:bold;margin:14px 0 4px;}
.num{text-align:right;font-variant-numeric:tabular-nums;} .stmt-info{font-size:10pt;color:#555;margin-bottom:12px;line-height:1.6;}
.red{color:#c00;} .grn{color:#080;} table{width:100%;border-collapse:collapse;margin-top:4px;font-size:10pt;}
.balance-box{margin-top:12px;text-align:right;font-size:14px;} th{border-bottom:2px solid #222;padding:6px 8px 6px 0;text-align:left;font-size:9pt;font-weight:700;white-space:nowrap;}
.balance-box span{font-weight:bold;font-size:18px;} td{padding:5px 8px 5px 0;border-bottom:1px solid #e0e0e0;vertical-align:top;}
.sub-row td{font-size:10px;color:#777;padding-top:0;border-bottom:none;padding-left:24px;} th.rnum,td.rnum{text-align:right;padding-right:0;}
.footer{margin-top:14px;font-size:10px;color:#888;text-align:center;white-space:pre-wrap;} .credit{color:#080;}
.debit{color:#c00;}
.sub-row td{font-size:10pt;color:#555;padding-top:0;border-bottom:none;padding-left:88px;}
.balance-box{margin-top:14px;text-align:right;font-size:11pt;font-weight:bold;}
""" """
@app.get("/members/{member_id}/statement", response_class=HTMLResponse) @app.get("/members/{member_id}/statement", response_class=HTMLResponse)
@ -775,55 +809,67 @@ 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)
footer = s.get("receipt_footer","") footer = s.get("receipt_footer","")
bar_name = s.get("bar_name","Bar") bar_name = s.get("bar_name","Bar")
cashier_name = s.get("cashier_name","Cashier") 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) txn_ref = _txn_ref(r["id"], s)
venue = bar_name if r["venue"] == "bar" else cashier_name
if r["type"] == "topup": if r["type"] == "topup":
running += r["amount"]; dr, cr = "", fmt(r["amount"]); type_lbl = "Top-up" running += r["amount"]
amt_html = f'<span class="credit">+ {fmt(r["amount"])}</span>'
type_lbl = "Top-up"
elif r["type"] == "withdrawal": elif r["type"] == "withdrawal":
running -= r["amount"]; dr, cr = fmt(r["amount"]), ""; type_lbl = "Withdrawal" running -= r["amount"]
amt_html = f'<span class="debit">- {fmt(r["amount"])}</span>'
type_lbl = "Withdrawal"
else: else:
running -= r["amount"]; dr, cr = fmt(r["amount"]), ""; type_lbl = "Charge" running -= r["amount"]
rows_html += (f"<tr><td>{r['created_at'][:16]}</td><td>{type_lbl}</td>" amt_html = f'<span class="debit">- {fmt(r["amount"])}</span>'
f"<td>{venue_label(r['venue'])}</td><td>{txn_ref}</td>" type_lbl = "Charge"
f"<td>{r['note'] or ''}</td><td>{r['staff_name']}</td>"
f"<td class='num red'>{dr}</td><td class='num grn'>{cr}</td>" rows_html += (
f"<td class='num'>{fmt(running)}</td></tr>") f"<tr><td>{r['created_at'][:16]}</td><td>{txn_ref}</td>"
if r["type"] in ("topup", "withdrawal"): f"<td>{type_lbl}</td><td>{venue}</td><td>{r['staff_name']}</td>"
f"<td class='rnum'>{amt_html}</td><td class='rnum'>{fmt(running)}</td></tr>"
)
# Detail sub-row
sub = ""
if r["type"] in ("topup","withdrawal"):
tf_type = r["transfer_type"] or "" tf_type = r["transfer_type"] or ""
tf_ref = r["transfer_ref"] or "" tf_ref = r["transfer_ref"] or ""
if tf_type or tf_ref: if tf_type and tf_ref:
sub = " &nbsp;·&nbsp; ".join(filter(None, [ sub = f"Transfer type: {tf_type} &mdash; {tf_ref}"
f"Type: {tf_type}" if tf_type else "", elif tf_type:
f"Ref: {tf_ref}" if tf_ref else "", sub = f"Transfer type: {tf_type}"
])) elif tf_ref:
rows_html += f'<tr class="sub-row"><td colspan="9">{sub}</td></tr>' sub = f"Ref: {tf_ref}"
elif r["note"]:
sub = r["note"]
if sub:
rows_html += f'<tr class="sub-row"><td colspan="7">{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>{RECEIPT_CSS}</style></head><body> <title>Statement &mdash; {member['name']}</title><style>{RECEIPT_CSS}</style></head><body>
{_print_controls()} {_print_controls()}
<div class="no-print controls" style="margin-top:0">
<button class="print-btn" onclick="window.print()">Print Statement</button>
</div>
{_biz_header_html(s)} {_biz_header_html(s)}
<hr> <hr>
<h2>Account Statement</h2> <h2>Account Statement</h2>
<div style="margin-bottom:10px;color:#555;font-size:11px;"> <div class="stmt-info">
Member: <strong>{member['name']}</strong> &nbsp;|&nbsp; #{member['member_number']} &nbsp;|&nbsp; Member: <strong>{member['name']}</strong> &mdash; #{member['member_number']} &mdash;
Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M')} UTC
</div> </div>
<table><thead><tr> <table><thead><tr>
<th>Date/Time</th><th>Type</th><th>Venue</th><th>Reference</th><th>Note</th> <th>Date and Time</th><th>Reference</th><th>Type</th><th>Venue</th>
<th>Staff</th><th class="num">Charge</th><th class="num">Credit</th><th class="num">Balance</th> <th>Staff</th><th class="rnum">Amount</th><th class="rnum">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: {fmt(bal)}</div>
{('<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>"""
@ -839,80 +885,86 @@ def receipt(entry_id: int):
FROM ledger_entries WHERE member_id=? AND id<=? FROM ledger_entries WHERE member_id=? AND id<=?
""", (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)
def fmt(p): return f"{sym}{p/div:.2f}" def fmt(p): return f"{sym}{p/div:.2f}"
txn_ref = _txn_ref(entry_id, s) txn_ref = _txn_ref(entry_id, s)
etype = entry["type"] etype = entry["type"]
venue_name = s.get("bar_name","Bar") if entry["venue"] == "bar" else s.get("cashier_name","Cashier") 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_staff = s.get("lbl_staff", "STAFF")
lbl_txn = s.get("lbl_transaction", "TRANSACTION") lbl_txn = s.get("lbl_transaction", "TRANSACTION")
lbl_txn_time = s.get("lbl_txn_time", "TRANSACTION TIME") lbl_txn_time = s.get("lbl_txn_time", "TRANSACTION TIME")
lbl_remaining = s.get("lbl_remaining_balance", "REMAINING BALANCE") lbl_remaining = s.get("lbl_remaining_balance", "REMAINING BALANCE")
if etype == "topup": if etype == "topup":
title = s.get("lbl_topup_receipt", "TOP-UP RECEIPT") title = s.get("lbl_topup_receipt", "TOP-UP RECEIPT")
footer = s.get("receipt_footer_cashier") or s.get("receipt_footer", "") 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": elif etype == "withdrawal":
title = s.get("lbl_withdrawal_receipt", "WITHDRAWAL RECEIPT") title = s.get("lbl_withdrawal_receipt","WITHDRAWAL RECEIPT")
footer = s.get("receipt_footer_cashier") or s.get("receipt_footer", "") 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: else:
title = s.get("lbl_receipt", "RECEIPT") title = s.get("lbl_receipt", "RECEIPT")
footer = s.get("receipt_footer_charge") or s.get("receipt_footer", "") 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": if etype == "charge":
lbl_charge = s.get("lbl_charge_venue", "CHARGE") body_html = f"""<div class="rx-grid">
lbl_amount = s.get("lbl_amount_charged", "AMOUNT CHARGED") {_rx_cell(lbl_staff, entry['staff_name'])}
grid_details = ( {_rx_cell(lbl_txn, txn_ref)}
f'<div class="rlbl">{lbl_staff}</div><div class="rval">{entry["staff_name"]}</div>' </div>
f'<div class="rlbl">{lbl_txn}</div><div class="rval">{txn_ref}</div>' <hr>
f'<div class="rlbl">{lbl_charge}</div><div class="rval">{venue_name}</div>' <div class="rx-grid">
f'<div class="rlbl">{lbl_txn_time}</div><div class="rval">{entry["created_at"][:16]} UTC</div>' {_rx_cell(lbl_charge, venue_name)}
) {_rx_cell(lbl_txn_time, timestamp)}
grid_amounts = ( </div>
f'<div class="rlbl">{lbl_amount}</div><div class="rval charge-val">{fmt(entry["amount"])}</div>' <hr>
f'<div class="rlbl">{lbl_remaining}</div><div class="rval balance-val">{fmt(bal_after)}</div>' <div class="rx-grid">
) {_rx_cell(lbl_amount, fmt(entry['amount']), 'large charge')}
{_rx_cell(lbl_remaining, fmt(bal_after), 'large')}
</div>"""
else: else:
lbl_tf_section = s.get("lbl_balance_transfer", "BALANCE TRANSFER") lbl_tf_type = s.get("lbl_transfer_type", "TRANSFER TYPE")
lbl_tf_type = s.get("lbl_transfer_type", "TRANSFER TYPE") lbl_tf_ref = s.get("lbl_transfer_ref", "TRANSFER REFERENCE")
lbl_tf_ref = s.get("lbl_transfer_ref", "TRANSFER REFERENCE") body_html = f"""<div class="rx-grid">
lbl_amount = s.get("lbl_amount_topup", "AMOUNT TOPPED-UP") if etype == "topup" else s.get("lbl_amount_withdrawal", "AMOUNT WITHDRAWN") {_rx_cell(lbl_staff, entry['staff_name'])}
tf_type = entry["transfer_type"] or "" {_rx_cell(lbl_txn, txn_ref)}
tf_ref = entry["transfer_ref"] or "" </div>
grid_details = ( <hr>
f'<div class="rlbl">{lbl_staff}</div><div class="rval">{entry["staff_name"]}</div>' <div class="rx-grid">
f'<div class="rlbl">{lbl_txn}</div><div class="rval">{txn_ref}</div>' {_rx_cell(lbl_tf_sec, tf_label)}
f'<div class="rlbl">{lbl_txn_time}</div><div class="rval">{entry["created_at"][:16]} UTC</div>' {_rx_cell(lbl_txn_time, timestamp)}
) </div>
tf_rows = "" <hr>
if tf_type: tf_rows += f'<div class="rlbl">{lbl_tf_type}</div><div class="rval">{tf_type}</div>' <div class="rx-grid">
if tf_ref: tf_rows += f'<div class="rlbl">{lbl_tf_ref}</div><div class="rval">{tf_ref}</div>' {_rx_cell(lbl_amount, fmt(entry['amount']), amount_cls)}
grid_amounts = ( {_rx_cell(lbl_remaining, fmt(bal_after), 'large')}
f'<div class="section-head">{lbl_tf_section}</div>' </div>
f'{tf_rows}' <hr>
f'<div class="rlbl">{lbl_amount}</div><div class="rval credit-val">{fmt(entry["amount"])}</div>' <div class="rx-grid">
f'<div class="rlbl">{lbl_remaining}</div><div class="rval balance-val">{fmt(bal_after)}</div>' {_rx_cell(lbl_tf_type, tf_type or '&mdash;')}
) {_rx_cell(lbl_tf_ref, tf_ref or '&mdash;')}
</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>{RECEIPT_CSS}</style></head><body> <title>Receipt &mdash; {member['name']}</title><style>{RECEIPT_CSS}</style></head><body>
{_print_controls()} {_print_controls()}
<div class="no-print controls" style="margin-top:0">
<button class="print-btn" onclick="window.print()">Print Receipt</button>
</div>
{_biz_header_html(s)} {_biz_header_html(s)}
<hr> <hr>
<div class="receipt-title">{title}</div> <div class="rx-title">{title}</div>
<div class="member-section"> {body_html}
<div class="member-name">{member['name']}</div>
<div class="member-num">#{member['member_number']}</div>
</div>
<hr>
<div class="receipt-grid">{grid_details}</div>
<hr>
<div class="receipt-grid">{grid_amounts}</div>
<hr> <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>"""
@ -1062,6 +1114,27 @@ def update_admin_settings(body: AppSettingsUpdate, user: dict = Depends(admin_us
refresh_settings() refresh_settings()
return _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) # Config (public loaded by frontend before login screen shows)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -1,3 +1,4 @@
fastapi fastapi
uvicorn[standard] uvicorn[standard]
bcrypt bcrypt
python-multipart

View file

@ -398,6 +398,29 @@ async function doCharge() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function loadAdminView() { async function loadAdminView() {
await Promise.all([loadAdminSettings(), loadStaffAccounts()]); 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() { async function loadAdminSettings() {
@ -428,8 +451,10 @@ async function loadAdminSettings() {
document.getElementById('s-biz-email').value = s.biz_email || ''; document.getElementById('s-biz-email').value = s.biz_email || '';
document.getElementById('s-biz-website').value = s.biz_website || ''; document.getElementById('s-biz-website').value = s.biz_website || '';
// Branding // Branding
document.getElementById('s-logo-url').value = s.logo_url || ''; document.getElementById('s-logo-url').value = s.logo_url || '';
document.getElementById('s-logo-align').value = s.logo_align || 'left'; 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-bar-name').value = s.bar_name || '';
document.getElementById('s-cashier-name').value = s.cashier_name || ''; document.getElementById('s-cashier-name').value = s.cashier_name || '';
// Transactions // Transactions
@ -485,8 +510,10 @@ async function saveSettings() {
biz_email: _svt('s-biz-email'), biz_email: _svt('s-biz-email'),
biz_website: _svt('s-biz-website'), biz_website: _svt('s-biz-website'),
// Branding // Branding
logo_url: _svt('s-logo-url'), logo_url: _svt('s-logo-url'),
logo_align: _sv('s-logo-align'), 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'), bar_name: _svt('s-bar-name'),
cashier_name: _svt('s-cashier-name'), cashier_name: _svt('s-cashier-name'),
// Transactions // Transactions

View file

@ -228,15 +228,27 @@
<div class="panel-divider"></div> <div class="panel-divider"></div>
<h3 class="sub-heading">Branding</h3> <h3 class="sub-heading">Branding</h3>
<div class="form-row"><label>Logo URL <span class="label-hint">(optional)</span></label> <div class="form-row">
<label>Logo <span class="label-hint">(upload image file)</span></label>
<input type="file" id="s-logo-upload" accept="image/*" style="padding:4px 0;border:none;background:none;">
<div id="logoUploadMsg" class="msg" style="margin-top:4px"></div>
</div>
<div class="form-row"><label>Logo URL <span class="label-hint">(or paste URL; upload above sets this automatically)</span></label>
<input type="text" id="s-logo-url" placeholder="https://..."></div> <input type="text" id="s-logo-url" placeholder="https://..."></div>
<div class="form-row"><label>Logo alignment</label> <div class="form-row">
<label>Logo alignment</label>
<select id="s-logo-align"> <select id="s-logo-align">
<option value="left">Left</option> <option value="left">Left</option>
<option value="center">Center</option> <option value="center">Center</option>
<option value="right">Right</option> <option value="right">Right</option>
</select> </select>
</div> </div>
<div class="form-row" style="flex-direction:row;gap:24px;align-items:flex-end">
<div style="flex:1"><label>Logo max width (px)</label>
<input type="number" id="s-logo-max-width" min="20" step="10" placeholder="200"></div>
<div style="flex:1"><label>Logo max height (px)</label>
<input type="number" id="s-logo-max-height" min="20" step="10" placeholder="80"></div>
</div>
<div class="form-row"><label>Bar venue name</label> <div class="form-row"><label>Bar venue name</label>
<input type="text" id="s-bar-name" placeholder="Bar"></div> <input type="text" id="s-bar-name" placeholder="Bar"></div>
<div class="form-row"><label>Cashier venue name</label> <div class="form-row"><label>Cashier venue name</label>