From 3377092a3f21ff6ef2ac4c2638f38bd4a79de381 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Wed, 11 Mar 2026 13:00:21 +0100 Subject: [PATCH] feat: Add Adults and Juniors dashboards with concise layout, totals, tooltips and unified navigation Co-authored-by: Antigravity --- app.py | 307 +++++++++++ templates/adults.html | 891 +++++++++++++++++++++++++++++++ templates/fees-juniors.html | 42 +- templates/fees.html | 42 +- templates/juniors.html | 872 ++++++++++++++++++++++++++++++ templates/payments.html | 42 +- templates/reconcile-juniors.html | 42 +- templates/reconcile.html | 42 +- tests/test_app.py | 60 +++ 9 files changed, 2315 insertions(+), 25 deletions(-) create mode 100644 templates/adults.html create mode 100644 templates/juniors.html diff --git a/app.py b/app.py index 49686d0..9f3652f 100644 --- a/app.py +++ b/app.py @@ -253,6 +253,141 @@ def fees_juniors(): payments_url=payments_url ) +@app.route("/adults") +def adults_view(): + attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit" + payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit" + credentials_path = CREDENTIALS_PATH + + members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees) + record_step("fetch_members") + if not members_data: + return "No data." + members, sorted_months = members_data + + transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path) + record_step("fetch_payments") + exceptions = get_cached_data( + "exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions, + PAYMENTS_SHEET_ID, credentials_path, + serialize=lambda d: [[list(k), v] for k, v in d.items()], + deserialize=lambda c: {tuple(k): v for k, v in c}, + ) + record_step("fetch_exceptions") + result = reconcile(members, sorted_months, transactions, exceptions) + record_step("reconcile") + + month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS) + adult_names = sorted([name for name, tier, _ in members if tier == "A"]) + + monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months} + formatted_results = [] + for name in adult_names: + data = result["members"][name] + row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""} + unpaid_months = [] + for m in sorted_months: + mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None}) + expected = mdata.get("expected", 0) + original_expected = mdata.get("original_expected", 0) + count = mdata.get("attendance_count", 0) + paid = int(mdata.get("paid", 0)) + exception_info = mdata.get("exception", None) + + monthly_totals[m]["expected"] += expected + monthly_totals[m]["paid"] += paid + + override_amount = exception_info["amount"] if exception_info else None + + if override_amount is not None and override_amount != original_expected: + is_overridden = True + fee_display = f"{override_amount} ({original_expected}) CZK ({count})" if count > 0 else f"{override_amount} ({original_expected}) CZK" + else: + is_overridden = False + fee_display = f"{expected} CZK ({count})" if count > 0 else f"{expected} CZK" + + status = "empty" + cell_text = "-" + amount_to_pay = 0 + + if expected > 0: + amount_to_pay = max(0, expected - paid) + if paid >= expected: + status = "ok" + cell_text = f"{paid}/{fee_display}" + elif paid > 0: + status = "partial" + cell_text = f"{paid}/{fee_display}" + unpaid_months.append(month_labels[m]) + else: + status = "unpaid" + cell_text = f"0/{fee_display}" + unpaid_months.append(month_labels[m]) + elif paid > 0: + status = "surplus" + cell_text = f"PAID {paid}" + else: + cell_text = "-" + amount_to_pay = 0 + + if expected > 0 or paid > 0: + tooltip = f"Received: {paid}, Expected: {expected}" + else: + tooltip = "" + + row["months"].append({ + "text": cell_text, + "overridden": is_overridden, + "status": status, + "amount": amount_to_pay, + "month": month_labels[m], + "tooltip": tooltip + }) + + row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "") + row["balance"] = data["total_balance"] + formatted_results.append(row) + + formatted_totals = [] + for m in sorted_months: + t = monthly_totals[m] + status = "empty" + if t["expected"] > 0 or t["paid"] > 0: + if t["paid"] == t["expected"]: + status = "ok" + elif t["paid"] < t["expected"]: + status = "unpaid" + else: + status = "surplus" + + formatted_totals.append({ + "text": f"{t['paid']} / {t['expected']} CZK", + "status": status + }) + + credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0 and n in adult_names], key=lambda x: x["name"]) + debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0 and n in adult_names], key=lambda x: x["name"]) + unmatched = result["unmatched"] + import json + + record_step("process_data") + + return render_template( + "adults.html", + months=[month_labels[m] for m in sorted_months], + raw_months=sorted_months, + results=formatted_results, + totals=formatted_totals, + member_data=json.dumps(result["members"]), + month_labels_json=json.dumps(month_labels), + credits=credits, + debts=debts, + unmatched=unmatched, + attendance_url=attendance_url, + payments_url=payments_url, + bank_account=BANK_ACCOUNT + ) + @app.route("/reconcile") def reconcile_view(): attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit" @@ -352,6 +487,178 @@ def reconcile_view(): bank_account=BANK_ACCOUNT ) +@app.route("/juniors") +def juniors_view(): + attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}" + payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit" + + credentials_path = CREDENTIALS_PATH + + junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees) + record_step("fetch_junior_members") + if not junior_members_data: + return "No data." + junior_members, sorted_months = junior_members_data + + transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path) + record_step("fetch_payments") + exceptions = get_cached_data( + "exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions, + PAYMENTS_SHEET_ID, credentials_path, + serialize=lambda d: [[list(k), v] for k, v in d.items()], + deserialize=lambda c: {tuple(k): v for k, v in c}, + ) + record_step("fetch_exceptions") + + # Adapt junior tuple format (name, tier, {month: (fee, total_count, adult_count, junior_count)}) + # to what match_payments expects: (name, tier, {month: (expected_fee, attendance_count)}) + adapted_members = [] + for name, tier, fees_dict in junior_members: + adapted_fees = {} + for m, fee_data in fees_dict.items(): + if len(fee_data) == 4: + fee, total_count, _, _ = fee_data + adapted_fees[m] = (fee, total_count) + else: + fee, count = fee_data + adapted_fees[m] = (fee, count) + adapted_members.append((name, tier, adapted_fees)) + + result = reconcile(adapted_members, sorted_months, transactions, exceptions) + record_step("reconcile") + + # Format month labels + month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS) + junior_names = sorted([name for name, tier, _ in adapted_members]) + junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members} + + monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months} + formatted_results = [] + for name in junior_names: + data = result["members"][name] + row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""} + unpaid_months = [] + for m in sorted_months: + mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None}) + expected = mdata.get("expected", 0) + original_expected = mdata.get("original_expected", 0) + count = mdata.get("attendance_count", 0) + paid = int(mdata.get("paid", 0)) + exception_info = mdata.get("exception", None) + + if expected != "?" and isinstance(expected, int): + monthly_totals[m]["expected"] += expected + monthly_totals[m]["paid"] += paid + + orig_fee_data = junior_members_dict.get(name, {}).get(m) + adult_count = 0 + junior_count = 0 + if orig_fee_data and len(orig_fee_data) == 4: + _, _, adult_count, junior_count = orig_fee_data + + breakdown = "" + if adult_count > 0 and junior_count > 0: + breakdown = f":{junior_count}J,{adult_count}A" + elif junior_count > 0: + breakdown = f":{junior_count}J" + elif adult_count > 0: + breakdown = f":{adult_count}A" + + count_str = f" ({count}{breakdown})" if count > 0 else "" + + override_amount = exception_info["amount"] if exception_info else None + + if override_amount is not None and override_amount != original_expected: + is_overridden = True + fee_display = f"{override_amount} ({original_expected}) CZK{count_str}" + else: + is_overridden = False + fee_display = f"{expected} CZK{count_str}" + + status = "empty" + cell_text = "-" + amount_to_pay = 0 + + if expected == "?" or (isinstance(expected, int) and expected > 0): + if expected == "?": + status = "empty" + cell_text = "?" + elif paid >= expected: + status = "ok" + cell_text = f"{paid}/{fee_display}" + elif paid > 0: + status = "partial" + cell_text = f"{paid}/{fee_display}" + amount_to_pay = expected - paid + unpaid_months.append(month_labels[m]) + else: + status = "unpaid" + cell_text = f"0/{fee_display}" + amount_to_pay = expected + unpaid_months.append(month_labels[m]) + elif paid > 0: + status = "surplus" + cell_text = f"PAID {paid}" + + if (isinstance(expected, int) and expected > 0) or paid > 0: + tooltip = f"Received: {paid}, Expected: {expected}" + else: + tooltip = "" + + row["months"].append({ + "text": cell_text, + "overridden": is_overridden, + "status": status, + "amount": amount_to_pay, + "month": month_labels[m], + "tooltip": tooltip + }) + + row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "") + row["balance"] = data["total_balance"] + formatted_results.append(row) + + formatted_totals = [] + for m in sorted_months: + t = monthly_totals[m] + status = "empty" + if t["expected"] > 0 or t["paid"] > 0: + if t["paid"] == t["expected"]: + status = "ok" + elif t["paid"] < t["expected"]: + status = "unpaid" + else: + status = "surplus" + + formatted_totals.append({ + "text": f"{t['paid']} / {t['expected']} CZK", + "status": status + }) + + # Format credits and debts + credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0], key=lambda x: x["name"]) + debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0], key=lambda x: x["name"]) + unmatched = result["unmatched"] + import json + + record_step("process_data") + + return render_template( + "juniors.html", + months=[month_labels[m] for m in sorted_months], + raw_months=sorted_months, + results=formatted_results, + totals=formatted_totals, + member_data=json.dumps(result["members"]), + month_labels_json=json.dumps(month_labels), + credits=credits, + debts=debts, + unmatched=unmatched, + attendance_url=attendance_url, + payments_url=payments_url, + bank_account=BANK_ACCOUNT + ) + @app.route("/reconcile-juniors") def reconcile_juniors_view(): attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}" diff --git a/templates/adults.html b/templates/adults.html new file mode 100644 index 0000000..f48eae0 --- /dev/null +++ b/templates/adults.html @@ -0,0 +1,891 @@ + + + + + + + FUJ Adults Dashboard + + + + + + +

Adults Dashboard

+ +
+ Balances calculated by matching Google Sheet payments against attendance fees.
+ Source: Attendance Sheet | + Payments Ledger +
+ +
+ search member: + +
+ +
+ + + + + {% for m in months %} + + {% endfor %} + + + + + {% for row in results %} + + + {% for cell in row.months %} + + {% endfor %} + + + {% endfor %} + + + {% for t in totals %} + + {% endfor %} + + + +
Member{{ m }}Balance
+ {{ row.name }} + [i] + + {{ cell.text }} + {% if cell.status == 'unpaid' or cell.status == 'partial' %} + + {% endif %} + + {{ "%+d"|format(row.balance) if row.balance != 0 else "0" }} + {% if row.balance < 0 %} + + {% endif %} +
+ TOTAL + + received / expected + {{ t.text }} +
+
+ + {% if credits %} +

Credits (Advance Payments / Surplus)

+
+ {% for item in credits %} +
+ {{ item.name }} + {{ item.amount }} CZK +
+ {% endfor %} +
+ {% endif %} + + {% if debts %} +

Debts (Missing Payments)

+
+ {% for item in debts %} +
+ {{ item.name }} + {{ item.amount }} CZK +
+ {% endfor %} +
+ {% endif %} + + {% if unmatched %} +

Unmatched Transactions

+
+
+ Date + Amount + Sender + Message +
+ {% for tx in unmatched %} +
+ {{ tx.date }} + {{ tx.amount }} + {{ tx.sender }} + {{ tx.message }} +
+ {% endfor %} +
+ {% endif %} + + + + +
+ +
+ + {% set rt = get_render_time() %} + + + + + + +``` \ No newline at end of file diff --git a/templates/fees-juniors.html b/templates/fees-juniors.html index 85b7a6b..3368413 100644 --- a/templates/fees-juniors.html +++ b/templates/fees-juniors.html @@ -96,8 +96,16 @@ margin-bottom: 20px; font-size: 12px; color: #555; + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + } + + .nav > div { display: flex; gap: 15px; + align-items: center; } .nav a { @@ -118,6 +126,23 @@ border-color: #555; } + .nav-archived a { + font-size: 10px; + color: #666; + border-color: #222; + } + + .nav-archived a.active { + color: #ccc; + background-color: #333; + border-color: #555; + } + + .nav-archived a:hover { + color: #999; + border-color: #444; + } + .description { margin-bottom: 20px; text-align: center; @@ -155,11 +180,18 @@

FUJ Junior Fees Dashboard

diff --git a/templates/fees.html b/templates/fees.html index 5e65d1d..d265817 100644 --- a/templates/fees.html +++ b/templates/fees.html @@ -111,8 +111,16 @@ margin-bottom: 20px; font-size: 12px; color: #555; + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + } + + .nav > div { display: flex; gap: 15px; + align-items: center; } .nav a { @@ -133,6 +141,23 @@ border-color: #555; } + .nav-archived a { + font-size: 10px; + color: #666; + border-color: #222; + } + + .nav-archived a.active { + color: #ccc; + background-color: #333; + border-color: #555; + } + + .nav-archived a:hover { + color: #999; + border-color: #444; + } + .description { margin-bottom: 20px; text-align: center; @@ -170,11 +195,18 @@

FUJ Fees Dashboard

diff --git a/templates/juniors.html b/templates/juniors.html new file mode 100644 index 0000000..76fe26b --- /dev/null +++ b/templates/juniors.html @@ -0,0 +1,872 @@ + + + + + + + FUJ Juniors Dashboard + + + + + + +

Juniors Dashboard

+ +
+ Balances calculated by matching Google Sheet payments against attendance fees.
+ Source: Attendance Sheet | + Payments Ledger +
+ +
+ search member: + +
+ +
+ + + + + {% for m in months %} + + {% endfor %} + + + + + {% for row in results %} + + + {% for cell in row.months %} + + {% endfor %} + + + {% endfor %} + + + {% for t in totals %} + + {% endfor %} + + + +
Member{{ m }}Balance
+ {{ row.name }} + [i] + + {{ cell.text }} + {% if cell.status == 'unpaid' or cell.status == 'partial' %} + + {% endif %} + + {{ "%+d"|format(row.balance) if row.balance != 0 else "0" }} + {% if row.balance < 0 %} + + {% endif %} +
+ TOTAL + + received / expected + {{ t.text }} +
+
+ + {% if credits %} +

Credits (Advance Payments / Surplus)

+
+ {% for item in credits %} +
+ {{ item.name }} + {{ item.amount }} CZK +
+ {% endfor %} +
+ {% endif %} + + {% if debts %} +

Debts (Missing Payments)

+
+ {% for item in debts %} +
+ {{ item.name }} + {{ item.amount }} CZK +
+ {% endfor %} +
+ {% endif %} + + + + + +
+ +
+ + {% set rt = get_render_time() %} + + + + + + +``` \ No newline at end of file diff --git a/templates/payments.html b/templates/payments.html index 161b911..ca8ec89 100644 --- a/templates/payments.html +++ b/templates/payments.html @@ -45,8 +45,16 @@ margin-bottom: 20px; font-size: 12px; color: #555; + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + } + + .nav > div { display: flex; gap: 15px; + align-items: center; } .nav a { @@ -67,6 +75,23 @@ border-color: #555; } + .nav-archived a { + font-size: 10px; + color: #666; + border-color: #222; + } + + .nav-archived a.active { + color: #ccc; + background-color: #333; + border-color: #555; + } + + .nav-archived a:hover { + color: #999; + border-color: #444; + } + .description { margin-bottom: 20px; text-align: center; @@ -159,11 +184,18 @@

Payments Ledger

diff --git a/templates/reconcile-juniors.html b/templates/reconcile-juniors.html index a5d7086..177e2cd 100644 --- a/templates/reconcile-juniors.html +++ b/templates/reconcile-juniors.html @@ -45,8 +45,16 @@ margin-bottom: 20px; font-size: 12px; color: #555; + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + } + + .nav > div { display: flex; gap: 15px; + align-items: center; } .nav a { @@ -67,6 +75,23 @@ border-color: #555; } + .nav-archived a { + font-size: 10px; + color: #666; + border-color: #222; + } + + .nav-archived a.active { + color: #ccc; + background-color: #333; + border-color: #555; + } + + .nav-archived a:hover { + color: #999; + border-color: #444; + } + .description { margin-bottom: 20px; text-align: center; @@ -423,11 +448,18 @@

Junior Payment Reconciliation

diff --git a/templates/reconcile.html b/templates/reconcile.html index aad9de4..74abbe6 100644 --- a/templates/reconcile.html +++ b/templates/reconcile.html @@ -45,8 +45,16 @@ margin-bottom: 20px; font-size: 12px; color: #555; + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; + } + + .nav > div { display: flex; gap: 15px; + align-items: center; } .nav a { @@ -67,6 +75,23 @@ border-color: #555; } + .nav-archived a { + font-size: 10px; + color: #666; + border-color: #222; + } + + .nav-archived a.active { + color: #ccc; + background-color: #333; + border-color: #555; + } + + .nav-archived a:hover { + color: #999; + border-color: #444; + } + .description { margin-bottom: 20px; text-align: center; @@ -423,11 +448,18 @@

Payment Reconciliation

diff --git a/tests/test_app.py b/tests/test_app.py index 02276fc..2d5ac23 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -130,5 +130,65 @@ class TestWebApp(unittest.TestCase): self.assertIn(b'OK', response.data) self.assertIn(b'?', response.data) + @patch('app.get_cached_data', side_effect=_bypass_cache) + @patch('app.fetch_sheet_data') + @patch('app.fetch_exceptions', return_value={}) + @patch('app.get_members_with_fees') + def test_adults_route(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache): + """Test that /adults returns 200 and shows combined matches""" + mock_get_members.return_value = ( + [('Test Member', 'A', {'2026-01': (750, 4)})], + ['2026-01'] + ) + mock_fetch_sheet.return_value = [{ + 'date': '2026-01-01', + 'amount': 750, + 'person': 'Test Member', + 'purpose': '2026-01', + 'message': 'test payment', + 'sender': 'External Bank User', + 'inferred_amount': 750 + }] + + response = self.client.get('/adults') + self.assertEqual(response.status_code, 200) + self.assertIn(b'Adults Dashboard', response.data) + self.assertIn(b'Test Member', response.data) + self.assertNotIn(b'OK', response.data) + self.assertIn(b'750/750 CZK (4)', response.data) + + @patch('app.get_cached_data', side_effect=_bypass_cache) + @patch('app.fetch_sheet_data') + @patch('app.fetch_exceptions', return_value={}) + @patch('app.get_junior_members_with_fees') + def test_juniors_route(self, mock_get_junior_members, mock_exceptions, mock_fetch_sheet, mock_cache): + """Test that /juniors returns 200, uses single line format, and displays '?' properly""" + mock_get_junior_members.return_value = ( + [ + ('Junior One', 'J', {'2026-01': (500, 3, 0, 3)}), + ('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)}) + ], + ['2026-01'] + ) + mock_exceptions.return_value = {} + mock_fetch_sheet.return_value = [{ + 'date': '2026-01-15', + 'amount': 500, + 'person': 'Junior One', + 'purpose': '2026-01', + 'message': '', + 'sender': 'Parent', + 'inferred_amount': 500 + }] + + response = self.client.get('/juniors') + self.assertEqual(response.status_code, 200) + self.assertIn(b'Juniors Dashboard', response.data) + self.assertIn(b'Junior One', response.data) + self.assertIn(b'Junior Two', response.data) + self.assertNotIn(b'OK', response.data) + self.assertIn(b'500/500 CZK', response.data) + self.assertIn(b'?', response.data) + if __name__ == '__main__': unittest.main()