mirror of
https://github.com/kbenestad/ClubLedger.git
synced 2026-06-18 09:44:33 +00:00
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:
parent
f31d4cd282
commit
7dfe66f5a1
4 changed files with 344 additions and 1 deletions
97
main.py
97
main.py
|
|
@ -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)):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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">—</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…</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">—</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">—</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">—</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">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Top Up Account</h2>
|
||||
<div class="search-row">
|
||||
|
|
|
|||
139
static/style.css
139
static/style.css
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue