feat: cashier stat widgets — outstanding credit and period summary

Adds two widgets at the top of the Cashier view:
- Total outstanding credit across all member accounts
- Transaction summary (top-ups / withdrawals / charges / net) with
  period selector: today, this week, month, quarter, year, or custom
  date range. Backed by new GET /cashier/stats endpoint that respects
  the configured display timezone for period boundaries.

https://claude.ai/code/session_01JuRTR5Xjx8emQsyerBgGU7
This commit is contained in:
Claude 2026-05-31 03:37:15 +00:00
parent f31d4cd282
commit 7dfe66f5a1
No known key found for this signature in database
4 changed files with 344 additions and 1 deletions

97
main.py
View file

@ -714,6 +714,103 @@ def withdrawal(body: WithdrawalRequest, user: dict = Depends(cashier_user)):
new_bal = member_balance(conn, body.member_id)
return {"ok": True, "entry_id": eid, "new_balance": new_bal, "new_balance_display": format_amount(new_bal)}
# ---------------------------------------------------------------------------
# Cashier stats
# ---------------------------------------------------------------------------
def _period_bounds(period: str, s: dict,
from_date: Optional[str] = None,
to_date: Optional[str] = None):
"""Return (start_utc_str, end_utc_str) for the requested period."""
from datetime import timedelta, date as date_type
tz = _display_tz(s)
now = datetime.now(timezone.utc).astimezone(tz)
today = now.date()
def _local(d: date_type):
return datetime(d.year, d.month, d.day, tzinfo=tz)
if period == "week":
start = _local(today - timedelta(days=today.weekday())) # Monday
end = start + timedelta(days=7)
elif period == "month":
start = _local(today.replace(day=1))
m = today.month % 12 + 1
y = today.year + (1 if today.month == 12 else 0)
end = _local(date_type(y, m, 1))
elif period == "quarter":
qm = ((today.month - 1) // 3) * 3 + 1
start = _local(date_type(today.year, qm, 1))
em, ey = (qm + 3, today.year) if qm <= 9 else (qm - 9, today.year + 1)
end = _local(date_type(ey, em, 1))
elif period == "year":
start = _local(date_type(today.year, 1, 1))
end = _local(date_type(today.year + 1, 1, 1))
elif period == "custom" and from_date and to_date:
try:
fd = date_type.fromisoformat(from_date)
td = date_type.fromisoformat(to_date)
start = _local(fd)
end = _local(td) + timedelta(days=1)
except ValueError:
start = _local(today); end = start + timedelta(days=1)
else: # today (default)
start = _local(today); end = start + timedelta(days=1)
fmt = "%Y-%m-%d %H:%M:%S"
return (start.astimezone(timezone.utc).strftime(fmt),
end.astimezone(timezone.utc).strftime(fmt))
@app.get("/cashier/stats")
def cashier_stats(period: str = "today",
from_date: Optional[str] = None,
to_date: Optional[str] = None,
user: dict = Depends(cashier_user)):
s = _settings
sym = s.get("currency_symbol", "£")
div = int(s.get("currency_divisor") or 100)
def fmt(v: int) -> str:
return f"{sym}{v / div:.2f}"
start_utc, end_utc = _period_bounds(period, s, from_date, to_date)
with db_conn() as conn:
credit = conn.execute(
"SELECT COALESCE(SUM(CASE WHEN type='topup' THEN amount ELSE -amount END),0) FROM ledger_entries"
).fetchone()[0]
rows = conn.execute(
"""SELECT type, COUNT(*) cnt, COALESCE(SUM(amount),0) total
FROM ledger_entries
WHERE created_at >= ? AND created_at < ?
GROUP BY type""",
(start_utc, end_utc)
).fetchall()
by_type = {r["type"]: {"count": r["cnt"], "total": r["total"]} for r in rows}
def stat(t):
d = by_type.get(t, {"count": 0, "total": 0})
return {"count": d["count"], "total": d["total"], "display": fmt(d["total"])}
tu = by_type.get("topup", {"total": 0})["total"]
wd = by_type.get("withdrawal", {"total": 0})["total"]
ch = by_type.get("charge", {"total": 0})["total"]
net = tu - wd - ch
return {
"outstanding_credit": credit,
"outstanding_credit_display": fmt(credit),
"topups": stat("topup"),
"withdrawals": stat("withdrawal"),
"charges": stat("charge"),
"net": {"total": net, "display": fmt(abs(net)), "negative": net < 0},
"period_from": start_utc[:10],
"period_to": end_utc[:10],
}
@app.get("/members/{member_id}/transactions")
def transactions(member_id: int, limit: int = 50, offset: int = 0,
user: dict = Depends(current_user)):

View file

@ -129,6 +129,7 @@ async function startApp() {
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
document.getElementById('view-' + btn.dataset.view).classList.remove('hidden');
if (btn.dataset.view === 'admin') loadAdminView();
if (btn.dataset.view === 'cashier') loadCashierStats();
// Close mobile menu after selection
navTabs.classList.remove('open');
hamburger.setAttribute('aria-expanded', 'false');
@ -149,6 +150,10 @@ async function startApp() {
document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); });
searchMembers();
// Pre-load cashier stats if current user can see the cashier tab
if (currentUser.role === 'cashier' || currentUser.role === 'admin') {
loadCashierStats();
}
}
// ---------------------------------------------------------------------------
@ -284,6 +289,56 @@ async function deleteMember(id, name) {
} catch (err) { alert(err.message); }
}
// ---------------------------------------------------------------------------
// Cashier stats widgets
// ---------------------------------------------------------------------------
function onStatsPeriodChange() {
const period = document.getElementById('statsPeriod').value;
const custom = document.getElementById('statsCustomRange');
if (period === 'custom') {
custom.classList.remove('hidden');
// default custom range to current month
const today = new Date();
const y = today.getFullYear(), m = String(today.getMonth()+1).padStart(2,'0');
if (!document.getElementById('statsFrom').value)
document.getElementById('statsFrom').value = `${y}-${m}-01`;
if (!document.getElementById('statsTo').value)
document.getElementById('statsTo').value = today.toISOString().slice(0,10);
} else {
custom.classList.add('hidden');
loadCashierStats();
}
}
async function loadCashierStats() {
const period = document.getElementById('statsPeriod').value;
let url = `/cashier/stats?period=${period}`;
if (period === 'custom') {
const from = document.getElementById('statsFrom').value;
const to = document.getElementById('statsTo').value;
if (!from || !to) return;
url += `&from_date=${from}&to_date=${to}`;
}
try {
const d = await apiFetch(url);
document.getElementById('statCredit').textContent = d.outstanding_credit_display;
const setCol = (valId, cntId, stat, cls) => {
const el = document.getElementById(valId);
el.textContent = stat.display;
el.className = 'stats-col-value' + (cls ? ' ' + cls : '');
if (cntId) document.getElementById(cntId).textContent =
stat.count === 1 ? '1 transaction' : `${stat.count} transactions`;
};
setCol('statsTopups', 'statsTopupsCount', d.topups, 'stats-positive');
setCol('statsWithdrawals', 'statsWithdrawalsCount', d.withdrawals, 'stats-negative');
setCol('statsCharges', 'statsChargesCount', d.charges, 'stats-negative');
const netEl = document.getElementById('statsNet');
netEl.textContent = (d.net.negative ? '' : '+') + d.net.display;
netEl.className = 'stats-col-value ' + (d.net.negative ? 'stats-negative' : 'stats-positive');
} catch (e) { /* silently ignore — widgets are non-critical */ }
}
// ---------------------------------------------------------------------------
// Cashier view
// ---------------------------------------------------------------------------

View file

@ -94,6 +94,58 @@
<!-- ===================== CASHIER VIEW ===================== -->
<div id="view-cashier" class="view hidden">
<!-- Stat widgets -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">Total outstanding credit</div>
<div class="stat-value" id="statCredit">&mdash;</div>
</div>
</div>
<div class="panel stats-panel">
<div class="stats-header">
<h2>Transaction Summary</h2>
<div class="stats-period-row">
<select id="statsPeriod" onchange="onStatsPeriodChange()">
<option value="today">Today</option>
<option value="week">This week</option>
<option value="month">This month</option>
<option value="quarter">This quarter</option>
<option value="year">This year</option>
<option value="custom">Custom&hellip;</option>
</select>
<span id="statsCustomRange" class="stats-custom hidden">
<input type="date" id="statsFrom">
<span>to</span>
<input type="date" id="statsTo">
<button class="btn" onclick="loadCashierStats()">Go</button>
</span>
</div>
</div>
<div id="statsSummary" class="stats-summary">
<div class="stats-col">
<div class="stats-col-label">Top-ups</div>
<div class="stats-col-value" id="statsTopups">&mdash;</div>
<div class="stats-col-count" id="statsTopupsCount"></div>
</div>
<div class="stats-col">
<div class="stats-col-label">Withdrawals</div>
<div class="stats-col-value" id="statsWithdrawals">&mdash;</div>
<div class="stats-col-count" id="statsWithdrawalsCount"></div>
</div>
<div class="stats-col">
<div class="stats-col-label">Charges</div>
<div class="stats-col-value" id="statsCharges">&mdash;</div>
<div class="stats-col-count" id="statsChargesCount"></div>
</div>
<div class="stats-col stats-col-net">
<div class="stats-col-label">Net</div>
<div class="stats-col-value" id="statsNet">&mdash;</div>
</div>
</div>
</div>
<div class="panel">
<h2>Top Up Account</h2>
<div class="search-row">

View file

@ -573,3 +573,142 @@ select {
}
.nav-icon { font-size: 20px; }
}
/* ---- Cashier stat widgets ---- */
.stats-row {
display: flex;
gap: 16px;
}
.stat-card {
flex: 1;
background: var(--canvas);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 20px;
}
.stat-label {
font-size: .75rem;
font-weight: 600;
color: var(--fg-muted);
text-transform: uppercase;
letter-spacing: .04em;
margin-bottom: 6px;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
color: var(--fg);
font-variant-numeric: tabular-nums;
}
.stats-panel h2 {
margin-bottom: 0;
border-bottom: none;
padding-bottom: 0;
}
.stats-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
padding-bottom: 14px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.stats-period-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.stats-period-row select {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: .8125rem;
font-family: inherit;
background: var(--canvas);
cursor: pointer;
}
.stats-custom {
display: flex;
align-items: center;
gap: 6px;
font-size: .8125rem;
color: var(--fg-muted);
}
.stats-custom input[type="date"] {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: .8125rem;
font-family: inherit;
background: var(--canvas);
color: var(--fg);
}
.stats-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.stats-col {
background: var(--canvas);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.stats-col-net {
background: var(--canvas-subtle);
}
.stats-col-label {
font-size: .6875rem;
font-weight: 600;
color: var(--fg-muted);
text-transform: uppercase;
letter-spacing: .05em;
}
.stats-col-value {
font-size: 1.125rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--fg);
}
.stats-col-count {
font-size: .75rem;
color: var(--fg-subtle);
}
.stats-positive { color: var(--success); }
.stats-negative { color: var(--danger); }
@media (max-width: 600px) {
.stats-summary {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 380px) {
.stats-summary {
grid-template-columns: 1fr;
}
}