mirror of
https://github.com/kbenestad/ClubLedger.git
synced 2026-06-18 09:44:33 +00:00
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:
parent
79ae833fa9
commit
09df5efb07
4 changed files with 251 additions and 138 deletions
337
main.py
337
main.py
|
|
@ -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">' + " | ".join(contact) + "</div>")
|
if (s.get("biz_phone") or "").strip(): contacts.append(f'Tel. {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: {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 = " · ".join(filter(None, [
|
sub = f"Transfer type: {tf_type} — {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 — {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> | #{member['member_number']} |
|
Member: <strong>{member['name']}</strong> — #{member['member_number']} —
|
||||||
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 '—')}
|
||||||
)
|
{_rx_cell(lbl_tf_ref, tf_ref or '—')}
|
||||||
|
</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 — {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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
fastapi
|
fastapi
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
bcrypt
|
bcrypt
|
||||||
|
python-multipart
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue