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)
|
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)}
|
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")
|
@app.get("/members/{member_id}/transactions")
|
||||||
def transactions(member_id: int, limit: int = 50, offset: int = 0,
|
def transactions(member_id: int, limit: int = 50, offset: int = 0,
|
||||||
user: dict = Depends(current_user)):
|
user: dict = Depends(current_user)):
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ async function startApp() {
|
||||||
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
|
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
|
||||||
document.getElementById('view-' + btn.dataset.view).classList.remove('hidden');
|
document.getElementById('view-' + btn.dataset.view).classList.remove('hidden');
|
||||||
if (btn.dataset.view === 'admin') loadAdminView();
|
if (btn.dataset.view === 'admin') loadAdminView();
|
||||||
|
if (btn.dataset.view === 'cashier') loadCashierStats();
|
||||||
// Close mobile menu after selection
|
// Close mobile menu after selection
|
||||||
navTabs.classList.remove('open');
|
navTabs.classList.remove('open');
|
||||||
hamburger.setAttribute('aria-expanded', 'false');
|
hamburger.setAttribute('aria-expanded', 'false');
|
||||||
|
|
@ -149,6 +150,10 @@ async function startApp() {
|
||||||
document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); });
|
document.getElementById('barSearch').addEventListener('keydown', e => { if (e.key === 'Enter') barSearchMembers(); });
|
||||||
|
|
||||||
searchMembers();
|
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); }
|
} 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
|
// Cashier view
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,58 @@
|
||||||
|
|
||||||
<!-- ===================== CASHIER VIEW ===================== -->
|
<!-- ===================== CASHIER VIEW ===================== -->
|
||||||
<div id="view-cashier" class="view hidden">
|
<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">
|
<div class="panel">
|
||||||
<h2>Top Up Account</h2>
|
<h2>Top Up Account</h2>
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
|
|
|
||||||
139
static/style.css
139
static/style.css
|
|
@ -573,3 +573,142 @@ select {
|
||||||
}
|
}
|
||||||
.nav-icon { font-size: 20px; }
|
.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