mirror of
https://github.com/kbenestad/ClubLedger.git
synced 2026-06-18 09:44:33 +00:00
feat: period filter on statement page; add last_* periods everywhere
Statement page gains a period selector (same options as cashier widget) in the print controls bar. Changing period reloads the page; custom shows date pickers. Balance column reflects actual account balance at each transaction by computing an opening balance before the period. Period label shown in statement header. Cashier stats widget gains: All time, Last week, Last month, Last quarter, Last year options. _period_bounds extended with all last_* variants and returns None for 'all' (callers skip the WHERE clause). https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
This commit is contained in:
parent
7dfe66f5a1
commit
8450da6176
2 changed files with 140 additions and 31 deletions
166
main.py
166
main.py
|
|
@ -721,40 +721,68 @@ def withdrawal(body: WithdrawalRequest, user: dict = Depends(cashier_user)):
|
||||||
def _period_bounds(period: str, s: dict,
|
def _period_bounds(period: str, s: dict,
|
||||||
from_date: Optional[str] = None,
|
from_date: Optional[str] = None,
|
||||||
to_date: Optional[str] = None):
|
to_date: Optional[str] = None):
|
||||||
"""Return (start_utc_str, end_utc_str) for the requested period."""
|
"""Return (start_utc_str, end_utc_str), or None for 'all' (no filter)."""
|
||||||
from datetime import timedelta, date as date_type
|
from datetime import timedelta, date as date_type
|
||||||
tz = _display_tz(s)
|
|
||||||
now = datetime.now(timezone.utc).astimezone(tz)
|
if period == "all":
|
||||||
|
return None
|
||||||
|
|
||||||
|
tz = _display_tz(s)
|
||||||
|
now = datetime.now(timezone.utc).astimezone(tz)
|
||||||
today = now.date()
|
today = now.date()
|
||||||
|
|
||||||
def _local(d: date_type):
|
def _local(d: date_type):
|
||||||
return datetime(d.year, d.month, d.day, tzinfo=tz)
|
return datetime(d.year, d.month, d.day, tzinfo=tz)
|
||||||
|
|
||||||
if period == "week":
|
def _next_month(y: int, m: int):
|
||||||
start = _local(today - timedelta(days=today.weekday())) # Monday
|
return (y, m % 12 + 1) if m < 12 else (y + 1, 1)
|
||||||
|
|
||||||
|
def _prev_month_start(d: date_type) -> date_type:
|
||||||
|
first = d.replace(day=1)
|
||||||
|
return (first - timedelta(days=1)).replace(day=1)
|
||||||
|
|
||||||
|
if period == "today":
|
||||||
|
start = _local(today); end = start + timedelta(days=1)
|
||||||
|
elif period == "week":
|
||||||
|
start = _local(today - timedelta(days=today.weekday()))
|
||||||
end = start + timedelta(days=7)
|
end = start + timedelta(days=7)
|
||||||
|
elif period == "last_week":
|
||||||
|
mon_this = today - timedelta(days=today.weekday())
|
||||||
|
start = _local(mon_this - timedelta(days=7)); end = _local(mon_this)
|
||||||
elif period == "month":
|
elif period == "month":
|
||||||
start = _local(today.replace(day=1))
|
start = _local(today.replace(day=1))
|
||||||
m = today.month % 12 + 1
|
ny, nm = _next_month(today.year, today.month)
|
||||||
y = today.year + (1 if today.month == 12 else 0)
|
end = _local(date_type(ny, nm, 1))
|
||||||
end = _local(date_type(y, m, 1))
|
elif period == "last_month":
|
||||||
|
lm_start = _prev_month_start(today)
|
||||||
|
lm_end = today.replace(day=1)
|
||||||
|
start = _local(lm_start); end = _local(lm_end)
|
||||||
elif period == "quarter":
|
elif period == "quarter":
|
||||||
qm = ((today.month - 1) // 3) * 3 + 1
|
qm = ((today.month - 1) // 3) * 3 + 1
|
||||||
start = _local(date_type(today.year, qm, 1))
|
start = _local(date_type(today.year, qm, 1))
|
||||||
em, ey = (qm + 3, today.year) if qm <= 9 else (qm - 9, today.year + 1)
|
ey, em = _next_month(today.year, qm + 2)
|
||||||
end = _local(date_type(ey, em, 1))
|
end = _local(date_type(ey, em, 1))
|
||||||
|
elif period == "last_quarter":
|
||||||
|
qm = ((today.month - 1) // 3) * 3 + 1 # start of this quarter
|
||||||
|
lqm = qm - 3
|
||||||
|
lqy = today.year if lqm > 0 else today.year - 1
|
||||||
|
lqm = lqm if lqm > 0 else lqm + 12
|
||||||
|
start = _local(date_type(lqy, lqm, 1))
|
||||||
|
end = _local(date_type(today.year, qm, 1))
|
||||||
elif period == "year":
|
elif period == "year":
|
||||||
start = _local(date_type(today.year, 1, 1))
|
start = _local(date_type(today.year, 1, 1))
|
||||||
end = _local(date_type(today.year + 1, 1, 1))
|
end = _local(date_type(today.year + 1, 1, 1))
|
||||||
|
elif period == "last_year":
|
||||||
|
start = _local(date_type(today.year - 1, 1, 1))
|
||||||
|
end = _local(date_type(today.year, 1, 1))
|
||||||
elif period == "custom" and from_date and to_date:
|
elif period == "custom" and from_date and to_date:
|
||||||
try:
|
try:
|
||||||
fd = date_type.fromisoformat(from_date)
|
fd = date_type.fromisoformat(from_date)
|
||||||
td = date_type.fromisoformat(to_date)
|
td = date_type.fromisoformat(to_date)
|
||||||
start = _local(fd)
|
start = _local(fd); end = _local(td) + timedelta(days=1)
|
||||||
end = _local(td) + timedelta(days=1)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
start = _local(today); end = start + timedelta(days=1)
|
start = _local(today); end = start + timedelta(days=1)
|
||||||
else: # today (default)
|
else: # fallback → today
|
||||||
start = _local(today); end = start + timedelta(days=1)
|
start = _local(today); end = start + timedelta(days=1)
|
||||||
|
|
||||||
fmt = "%Y-%m-%d %H:%M:%S"
|
fmt = "%Y-%m-%d %H:%M:%S"
|
||||||
|
|
@ -774,20 +802,26 @@ def cashier_stats(period: str = "today",
|
||||||
def fmt(v: int) -> str:
|
def fmt(v: int) -> str:
|
||||||
return f"{sym}{v / div:.2f}"
|
return f"{sym}{v / div:.2f}"
|
||||||
|
|
||||||
start_utc, end_utc = _period_bounds(period, s, from_date, to_date)
|
bounds = _period_bounds(period, s, from_date, to_date)
|
||||||
|
|
||||||
with db_conn() as conn:
|
with db_conn() as conn:
|
||||||
credit = conn.execute(
|
credit = conn.execute(
|
||||||
"SELECT COALESCE(SUM(CASE WHEN type='topup' THEN amount ELSE -amount END),0) FROM ledger_entries"
|
"SELECT COALESCE(SUM(CASE WHEN type='topup' THEN amount ELSE -amount END),0) FROM ledger_entries"
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
|
|
||||||
rows = conn.execute(
|
if bounds:
|
||||||
"""SELECT type, COUNT(*) cnt, COALESCE(SUM(amount),0) total
|
rows = conn.execute(
|
||||||
FROM ledger_entries
|
"""SELECT type, COUNT(*) cnt, COALESCE(SUM(amount),0) total
|
||||||
WHERE created_at >= ? AND created_at < ?
|
FROM ledger_entries
|
||||||
GROUP BY type""",
|
WHERE created_at >= ? AND created_at < ?
|
||||||
(start_utc, end_utc)
|
GROUP BY type""",
|
||||||
).fetchall()
|
bounds
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT type, COUNT(*) cnt, COALESCE(SUM(amount),0) total
|
||||||
|
FROM ledger_entries GROUP BY type"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
by_type = {r["type"]: {"count": r["cnt"], "total": r["total"]} for r in rows}
|
by_type = {r["type"]: {"count": r["cnt"], "total": r["total"]} for r in rows}
|
||||||
|
|
||||||
|
|
@ -848,7 +882,7 @@ function setSize(s){{
|
||||||
setSize('{size}');
|
setSize('{size}');
|
||||||
</script>"""
|
</script>"""
|
||||||
|
|
||||||
def _print_controls(s: dict):
|
def _print_controls(s: dict, extra: str = "") -> str:
|
||||||
size = "A5" if (s.get("paper_size") or "A4").upper() == "A5" else "A4"
|
size = "A5" if (s.get("paper_size") or "A4").upper() == "A5" else "A4"
|
||||||
a4_chk = ' checked' if size == "A4" else ''
|
a4_chk = ' checked' if size == "A4" else ''
|
||||||
a5_chk = ' checked' if size == "A5" else ''
|
a5_chk = ' checked' if size == "A5" else ''
|
||||||
|
|
@ -856,7 +890,7 @@ def _print_controls(s: dict):
|
||||||
<span class="size-label">Paper:</span>
|
<span class="size-label">Paper:</span>
|
||||||
<label><input type="radio" name="ps" value="A4"{a4_chk} 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"{a5_chk} 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>
|
{extra}<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:
|
||||||
|
|
@ -949,16 +983,81 @@ RECEIPT_CSS = """
|
||||||
.balance-box{margin-top:14px;text-align:right;font-size:11pt;font-weight:bold;}
|
.balance-box{margin-top:14px;text-align:right;font-size:11pt;font-weight:bold;}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_STMT_PERIODS = [
|
||||||
|
("all", "All time"),
|
||||||
|
("today", "Today"),
|
||||||
|
("week", "This week"),
|
||||||
|
("last_week", "Last week"),
|
||||||
|
("month", "This month"),
|
||||||
|
("last_month", "Last month"),
|
||||||
|
("quarter", "This quarter"),
|
||||||
|
("last_quarter", "Last quarter"),
|
||||||
|
("year", "This year"),
|
||||||
|
("last_year", "Last year"),
|
||||||
|
("custom", "Custom…"),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _stmt_period_selector(period: str, from_date: str, to_date: str) -> str:
|
||||||
|
opts = "".join(
|
||||||
|
f'<option value="{v}"{" selected" if v == period else ""}>{lbl}</option>'
|
||||||
|
for v, lbl in _STMT_PERIODS
|
||||||
|
)
|
||||||
|
fd = from_date or ""; td = to_date or ""
|
||||||
|
vis = "inline-flex" if period == "custom" else "none"
|
||||||
|
return (
|
||||||
|
f'<select onchange="stmtPeriod(this.value)" style="margin-right:8px">{opts}</select>'
|
||||||
|
f'<span id="stmtCR" style="display:{vis};align-items:center;gap:4px;margin-right:8px">'
|
||||||
|
f'<input type="date" id="stmtF" value="{fd}">'
|
||||||
|
f'<span>to</span>'
|
||||||
|
f'<input type="date" id="stmtT" value="{td}">'
|
||||||
|
f'<button onclick="stmtGo()" style="margin-left:4px">Go</button>'
|
||||||
|
f'</span>'
|
||||||
|
)
|
||||||
|
|
||||||
|
_STMT_SCRIPT = """<script>
|
||||||
|
function stmtPeriod(p){
|
||||||
|
if(p==='custom'){document.getElementById('stmtCR').style.display='inline-flex';return;}
|
||||||
|
var u=new URL(window.location.href);
|
||||||
|
u.searchParams.set('period',p);
|
||||||
|
u.searchParams.delete('from_date');u.searchParams.delete('to_date');
|
||||||
|
window.location=u;
|
||||||
|
}
|
||||||
|
function stmtGo(){
|
||||||
|
var f=document.getElementById('stmtF').value,t=document.getElementById('stmtT').value;
|
||||||
|
if(!f||!t)return;
|
||||||
|
var u=new URL(window.location.href);
|
||||||
|
u.searchParams.set('period','custom');
|
||||||
|
u.searchParams.set('from_date',f);u.searchParams.set('to_date',t);
|
||||||
|
window.location=u;
|
||||||
|
}
|
||||||
|
</script>"""
|
||||||
|
|
||||||
@app.get("/members/{member_id}/statement", response_class=HTMLResponse)
|
@app.get("/members/{member_id}/statement", response_class=HTMLResponse)
|
||||||
def statement(member_id: int):
|
def statement(member_id: int,
|
||||||
|
period: str = "all",
|
||||||
|
from_date: Optional[str] = None,
|
||||||
|
to_date: Optional[str] = None):
|
||||||
s = _settings
|
s = _settings
|
||||||
|
bounds = _period_bounds(period, s, from_date, to_date)
|
||||||
with db_conn() as conn:
|
with db_conn() as conn:
|
||||||
member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
|
member = conn.execute("SELECT * FROM members WHERE id=?", (member_id,)).fetchone()
|
||||||
if not member: raise HTTPException(404, "Member not found")
|
if not member: raise HTTPException(404, "Member not found")
|
||||||
rows = conn.execute(
|
if bounds:
|
||||||
"SELECT * FROM ledger_entries WHERE member_id=? ORDER BY created_at ASC",
|
opening_bal = conn.execute(
|
||||||
(member_id,)
|
"""SELECT COALESCE(SUM(CASE WHEN type='topup' THEN amount ELSE -amount END),0)
|
||||||
).fetchall()
|
FROM ledger_entries WHERE member_id=? AND created_at < ?""",
|
||||||
|
(member_id, bounds[0])
|
||||||
|
).fetchone()[0]
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM ledger_entries WHERE member_id=? AND created_at>=? AND created_at<? ORDER BY created_at ASC",
|
||||||
|
(member_id, bounds[0], bounds[1])
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
opening_bal = 0
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM ledger_entries WHERE member_id=? ORDER BY created_at ASC",
|
||||||
|
(member_id,)
|
||||||
|
).fetchall()
|
||||||
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)
|
||||||
|
|
@ -968,7 +1067,7 @@ def statement(member_id: int):
|
||||||
|
|
||||||
def fmt(p): return f"{sym}{p/div:.2f}"
|
def fmt(p): return f"{sym}{p/div:.2f}"
|
||||||
|
|
||||||
rows_html, running = "", 0
|
rows_html, running = "", opening_bal
|
||||||
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
|
venue = bar_name if r["venue"] == "bar" else cashier_name
|
||||||
|
|
@ -1008,15 +1107,19 @@ def statement(member_id: int):
|
||||||
if sub:
|
if sub:
|
||||||
rows_html += f'<tr class="sub-row"><td colspan="7">{sub}</td></tr>'
|
rows_html += f'<tr class="sub-row"><td colspan="7">{sub}</td></tr>'
|
||||||
|
|
||||||
|
period_lbl = dict(_STMT_PERIODS).get(period, period)
|
||||||
|
if period == "custom" and bounds:
|
||||||
|
period_lbl = f"{bounds[0][:10]} to {bounds[1][:10]}"
|
||||||
|
|
||||||
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(s)}
|
{_print_controls(s, extra=_stmt_period_selector(period, from_date or '', to_date or ''))}
|
||||||
{_biz_header_html(s)}
|
{_biz_header_html(s)}
|
||||||
<hr>
|
<hr>
|
||||||
<h2>Account Statement</h2>
|
<h2>Account Statement</h2>
|
||||||
<div class="stmt-info">
|
<div class="stmt-info">
|
||||||
Member: <strong>{member['name']}</strong> — #{member['member_number']} —
|
Member: <strong>{member['name']}</strong> — #{member['member_number']}<br>
|
||||||
Generated: {_now_display(s)}
|
Period: {period_lbl} — Generated: {_now_display(s)}
|
||||||
</div>
|
</div>
|
||||||
<table><thead><tr>
|
<table><thead><tr>
|
||||||
<th>Date and Time</th><th>Reference</th><th>Type</th><th>Venue</th>
|
<th>Date and Time</th><th>Reference</th><th>Type</th><th>Venue</th>
|
||||||
|
|
@ -1024,6 +1127,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 ''}
|
||||||
|
{_STMT_SCRIPT}
|
||||||
{_print_size_script(s)}</body></html>"""
|
{_print_size_script(s)}</body></html>"""
|
||||||
|
|
||||||
@app.get("/receipt/{entry_id}", response_class=HTMLResponse)
|
@app.get("/receipt/{entry_id}", response_class=HTMLResponse)
|
||||||
|
|
|
||||||
|
|
@ -108,11 +108,16 @@
|
||||||
<h2>Transaction Summary</h2>
|
<h2>Transaction Summary</h2>
|
||||||
<div class="stats-period-row">
|
<div class="stats-period-row">
|
||||||
<select id="statsPeriod" onchange="onStatsPeriodChange()">
|
<select id="statsPeriod" onchange="onStatsPeriodChange()">
|
||||||
|
<option value="all">All time</option>
|
||||||
<option value="today">Today</option>
|
<option value="today">Today</option>
|
||||||
<option value="week">This week</option>
|
<option value="week">This week</option>
|
||||||
|
<option value="last_week">Last week</option>
|
||||||
<option value="month">This month</option>
|
<option value="month">This month</option>
|
||||||
|
<option value="last_month">Last month</option>
|
||||||
<option value="quarter">This quarter</option>
|
<option value="quarter">This quarter</option>
|
||||||
|
<option value="last_quarter">Last quarter</option>
|
||||||
<option value="year">This year</option>
|
<option value="year">This year</option>
|
||||||
|
<option value="last_year">Last year</option>
|
||||||
<option value="custom">Custom…</option>
|
<option value="custom">Custom…</option>
|
||||||
</select>
|
</select>
|
||||||
<span id="statsCustomRange" class="stats-custom hidden">
|
<span id="statsCustomRange" class="stats-custom hidden">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue