feat: admin-configurable default paper size for receipts/statements

Adds a Paper Size setting (A4/A5) to the General section of Admin
settings. Receipts and statements pre-select the configured size and
apply the correct @page margins; staff can still override per-print.

https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
This commit is contained in:
Claude 2026-05-30 17:29:59 +00:00
parent 6aa4c45616
commit 7b4e33254c
No known key found for this signature in database
3 changed files with 31 additions and 14 deletions

36
main.py
View file

@ -88,6 +88,8 @@ CONFIG = {
"receipt_footer_cashier": "", "receipt_footer_cashier": "",
# Timezone for display (IANA name); defaults to server local timezone # Timezone for display (IANA name); defaults to server local timezone
"timezone": _server_timezone(), "timezone": _server_timezone(),
# Default paper size for receipts and statements
"paper_size": "A4",
} }
DB_PATH = "clubledger.db" DB_PATH = "clubledger.db"
@ -499,6 +501,8 @@ class AppSettingsUpdate(BaseModel):
receipt_footer_cashier: Optional[str] = None receipt_footer_cashier: Optional[str] = None
# Timezone # Timezone
timezone: Optional[str] = None timezone: Optional[str] = None
# Default paper size
paper_size: Optional[str] = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Page routes # Page routes
@ -737,20 +741,24 @@ def transactions(member_id: int, limit: int = 50, offset: int = 0,
# Print views (no auth opened as new-tab popups) # Print views (no auth opened as new-tab popups)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _print_size_script(): def _print_size_script(s: dict):
return """<script> size = "A5" if (s.get("paper_size") or "A4").upper() == "A5" else "A4"
function setSize(s){ return f"""<script>
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'?'10mm':'16mm')+';}}';} el.textContent='@media print{{@page{{size:'+s+';margin:'+(s==='A5'?'10mm':'16mm')+';}}}}';}}
setSize('A4'); setSize('{size}');
</script>""" </script>"""
def _print_controls(): def _print_controls(s: dict):
return """<div class="no-print controls"> size = "A5" if (s.get("paper_size") or "A4").upper() == "A5" else "A4"
a4_chk = ' checked' if size == "A4" else ''
a5_chk = ' checked' if size == "A5" else ''
return f"""<div class="no-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"{a4_chk} 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"{a5_chk} onchange="setSize('A5')"> A5</label>
<button class="print-btn" onclick="window.print()">Print</button> <button class="print-btn" onclick="window.print()">Print</button>
</div>""" </div>"""
@ -905,7 +913,7 @@ def statement(member_id: int):
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 &mdash; {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(s)}
{_biz_header_html(s)} {_biz_header_html(s)}
<hr> <hr>
<h2>Account Statement</h2> <h2>Account Statement</h2>
@ -919,7 +927,7 @@ def statement(member_id: int):
</tr></thead><tbody>{rows_html}</tbody></table> </tr></thead><tbody>{rows_html}</tbody></table>
<div class="balance-box">Current Balance: {fmt(bal)}</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(s)}</body></html>"""
@app.get("/receipt/{entry_id}", response_class=HTMLResponse) @app.get("/receipt/{entry_id}", response_class=HTMLResponse)
def receipt(entry_id: int): def receipt(entry_id: int):
@ -1008,14 +1016,14 @@ def receipt(entry_id: int):
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 &mdash; {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(s)}
{_biz_header_html(s)} {_biz_header_html(s)}
<hr> <hr>
<div class="rx-title">{title}</div> <div class="rx-title">{title}</div>
{body_html} {body_html}
<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(s)}</body></html>"""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Products # Products

View file

@ -439,6 +439,7 @@ async function loadAdminSettings() {
document.getElementById('s-max-charge').value = ((s.max_charge || 0) / div).toFixed(2); document.getElementById('s-max-charge').value = ((s.max_charge || 0) / div).toFixed(2);
document.getElementById('s-overdraft-policy').value = s.overdraft_policy || 'never'; document.getElementById('s-overdraft-policy').value = s.overdraft_policy || 'never';
document.getElementById('s-timezone').value = s.timezone || ''; document.getElementById('s-timezone').value = s.timezone || '';
document.getElementById('s-paper-size').value = s.paper_size || 'A4';
document.getElementById('s-min-hint').textContent = `in ${majorUnit}`; document.getElementById('s-min-hint').textContent = `in ${majorUnit}`;
document.getElementById('s-max-hint').textContent = `in ${majorUnit}`; document.getElementById('s-max-hint').textContent = `in ${majorUnit}`;
document.getElementById('s-charge-hint').textContent= `in ${majorUnit}`; document.getElementById('s-charge-hint').textContent= `in ${majorUnit}`;
@ -502,6 +503,7 @@ async function saveSettings() {
max_charge: Math.round(parseFloat(_sv('s-max-charge')) * div), max_charge: Math.round(parseFloat(_sv('s-max-charge')) * div),
overdraft_policy: _sv('s-overdraft-policy'), overdraft_policy: _sv('s-overdraft-policy'),
timezone: _svt('s-timezone'), timezone: _svt('s-timezone'),
paper_size: _sv('s-paper-size'),
// Business address // Business address
biz_address1: _svt('s-biz-address1'), biz_address1: _svt('s-biz-address1'),
biz_address2: _svt('s-biz-address2'), biz_address2: _svt('s-biz-address2'),

View file

@ -218,6 +218,13 @@
<label>Timezone <span class="label-hint">(IANA name, e.g. Europe/London, Asia/Bangkok &mdash; default is server timezone)</span></label> <label>Timezone <span class="label-hint">(IANA name, e.g. Europe/London, Asia/Bangkok &mdash; default is server timezone)</span></label>
<input type="text" id="s-timezone" placeholder="e.g. Europe/London"> <input type="text" id="s-timezone" placeholder="e.g. Europe/London">
</div> </div>
<div class="form-row">
<label>Default paper size <span class="label-hint">(for receipts and statements)</span></label>
<select id="s-paper-size">
<option value="A4">A4</option>
<option value="A5">A5</option>
</select>
</div>
<div class="panel-divider"></div> <div class="panel-divider"></div>
<h3 class="sub-heading">Business Address</h3> <h3 class="sub-heading">Business Address</h3>