1 Commits
0.17 ... 0.18

Author SHA1 Message Date
3377092a3f feat: Add Adults and Juniors dashboards with concise layout, totals, tooltips and unified navigation
All checks were successful
Build and Push / build (push) Successful in 8s
Deploy to K8s / deploy (push) Successful in 8s
Co-authored-by: Antigravity <antigravity@google.com>
2026-03-11 13:01:18 +01:00
9 changed files with 2315 additions and 25 deletions

307
app.py
View File

@@ -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}"

891
templates/adults.html Normal file
View File

@@ -0,0 +1,891 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FUJ Adults Dashboard</title>
<style>
body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background-color: #0c0c0c;
color: #cccccc;
padding: 10px;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
font-size: 11px;
line-height: 1.2;
}
h1 {
color: #00ff00;
font-family: inherit;
margin-top: 10px;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
}
h2 {
color: #00ff00;
font-size: 12px;
margin-top: 30px;
margin-bottom: 10px;
text-transform: uppercase;
width: 100%;
max-width: 1200px;
border-bottom: 1px solid #333;
padding-bottom: 5px;
}
.nav {
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 {
color: #00ff00;
text-decoration: none;
padding: 2px 8px;
border: 1px solid #333;
}
.nav a.active {
color: #000;
background-color: #00ff00;
border-color: #00ff00;
}
.nav a:hover {
color: #fff;
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;
color: #888;
max-width: 800px;
}
.description a {
color: #00ff00;
text-decoration: none;
}
.description a:hover {
text-decoration: underline;
}
.table-container {
background-color: transparent;
border: 1px solid #333;
box-shadow: none;
overflow-x: auto;
width: 100%;
max-width: 1200px;
margin-bottom: 30px;
}
table {
border-collapse: collapse;
width: 100%;
table-layout: auto;
}
th,
td {
padding: 2px 8px;
text-align: right;
border-bottom: 1px dashed #222;
white-space: nowrap;
}
th:first-child,
td:first-child {
text-align: left;
}
th {
background-color: transparent;
color: #888888;
font-weight: normal;
border-bottom: 1px solid #555;
text-transform: lowercase;
}
tr:hover {
background-color: #1a1a1a;
}
.balance-pos {
color: #00ff00;
}
.balance-neg {
color: #ff3333;
}
.cell-ok {
color: #00ff00;
}
.cell-unpaid {
color: #ff3333;
background-color: rgba(255, 51, 51, 0.05);
position: relative;
}
.cell-overridden {
color: #ffa500 !important;
}
.pay-btn {
display: none;
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
background: #ff3333;
color: white;
border: none;
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
font-weight: bold;
}
.member-row:hover .pay-btn {
display: inline-block;
}
.cell-empty {
color: #444444;
}
.list-container {
width: 100%;
max-width: 1200px;
color: #888;
margin-bottom: 40px;
}
.list-item {
display: flex;
justify-content: flex-start;
gap: 20px;
padding: 1px 0;
border-bottom: 1px dashed #222;
}
.list-item-name {
color: #ccc;
min-width: 200px;
}
.list-item-val {
color: #00ff00;
}
.unmatched-row {
font-family: inherit;
display: grid;
grid-template-columns: 100px 100px 200px 1fr;
gap: 15px;
color: #888;
padding: 2px 0;
border-bottom: 1px dashed #222;
}
.unmatched-header {
color: #555;
border-bottom: 1px solid #333;
margin-bottom: 5px;
}
.filter-container {
width: 100%;
max-width: 1200px;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.filter-input {
background-color: #1a1a1a;
border: 1px solid #333;
color: #00ff00;
font-family: inherit;
font-size: 11px;
padding: 4px 8px;
width: 250px;
outline: none;
}
.filter-input:focus {
border-color: #00ff00;
}
.filter-label {
color: #888;
text-transform: lowercase;
}
.info-icon {
color: #00ff00;
cursor: pointer;
margin-left: 5px;
font-size: 10px;
opacity: 0.5;
}
.info-icon:hover {
opacity: 1;
}
/* Modal Styles */
#memberModal {
display: none !important;
/* Force hide by default */
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.9);
z-index: 9999;
justify-content: center;
align-items: center;
}
#memberModal.active {
display: flex !important;
}
.modal-content {
background-color: #0c0c0c;
border: 1px solid #00ff00;
width: 90%;
max-width: 800px;
max-height: 85vh;
overflow-y: auto;
padding: 20px;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.2);
position: relative;
}
.modal-header {
border-bottom: 1px solid #333;
margin-bottom: 20px;
padding-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
color: #00ff00;
font-size: 14px;
text-transform: uppercase;
}
.close-btn {
color: #ff3333;
cursor: pointer;
font-size: 14px;
text-transform: lowercase;
}
.modal-section {
margin-bottom: 25px;
}
.modal-section-title {
color: #555;
text-transform: uppercase;
font-size: 10px;
margin-bottom: 8px;
border-bottom: 1px dashed #222;
}
.modal-table {
width: 100%;
border-collapse: collapse;
}
.modal-table th,
.modal-table td {
text-align: left;
padding: 4px 0;
border-bottom: 1px dashed #1a1a1a;
}
.modal-table th {
color: #666;
font-weight: normal;
font-size: 10px;
}
.tx-list {
list-style: none;
padding: 0;
margin: 0;
}
.tx-item {
padding: 8px 0;
border-bottom: 1px dashed #222;
}
.tx-meta {
color: #555;
font-size: 10px;
margin-bottom: 4px;
}
.tx-main {
display: flex;
justify-content: space-between;
gap: 20px;
}
.tx-amount {
color: #00ff00;
}
.tx-sender {
color: #ccc;
}
.tx-msg {
color: #888;
font-style: italic;
}
.footer {
margin-top: 50px;
margin-bottom: 20px;
color: #333;
font-size: 9px;
text-align: center;
width: 100%;
cursor: pointer;
user-select: none;
}
.perf-breakdown {
display: none;
margin-top: 5px;
color: #222;
}
/* QR Modal styles */
#qrModal .modal-content {
max-width: 400px;
text-align: center;
}
.qr-image {
background: white;
padding: 10px;
border-radius: 5px;
margin: 20px 0;
display: inline-block;
}
.qr-image img {
display: block;
width: 250px;
height: 250px;
}
.qr-details {
text-align: left;
margin-top: 15px;
font-size: 14px;
color: #ccc;
}
.qr-details div {
margin-bottom: 5px;
}
.qr-details span {
color: #00ff00;
font-family: monospace;
}
</style>
</head>
<body>
<div class="nav">
<div>
<a href="/adults" class="active">[Adults]</a>
<a href="/juniors">[Juniors]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Archived:</span>
<a href="/fees">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
</div>
</div>
<h1>Adults Dashboard</h1>
<div class="description">
Balances calculated by matching Google Sheet payments against attendance fees.<br>
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
</div>
<div class="filter-container">
<span class="filter-label">search member:</span>
<input type="text" id="nameFilter" class="filter-input" placeholder="..." autocomplete="off">
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Member</th>
{% for m in months %}
<th>{{ m }}</th>
{% endfor %}
<th>Balance</th>
</tr>
</thead>
<tbody id="reconcileBody">
{% for row in results %}
<tr class="member-row">
<td class="member-name">
{{ row.name }}
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
</td>
{% for cell in row.months %}
<td title="{{ cell.tooltip }}"
class="{% if cell.status == 'empty' %}cell-empty{% elif cell.status == 'unpaid' or cell.status == 'partial' %}cell-unpaid{% elif cell.status == 'ok' %}cell-ok{% endif %}{% if cell.overridden %} cell-overridden{% endif %}">
{{ cell.text }}
{% if cell.status == 'unpaid' or cell.status == 'partial' %}
<button class="pay-btn"
onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}')">Pay</button>
{% endif %}
</td>
{% endfor %}
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}" style="position: relative;">
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
{% if row.balance < 0 %}
<button class="pay-btn"
onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}')">Pay All</button>
{% endif %}
</td>
</tr>
{% endfor %}
<tr class="totals-row" style="font-weight: bold; background-color: #111; border-top: 2px solid #333;">
<td style="text-align: left; padding: 6px 8px;">
TOTAL
</td>
{% for t in totals %}
<td class="{% if t.status == 'ok' %}cell-ok{% elif t.status == 'unpaid' %}cell-unpaid{% elif t.status == 'surplus' %}cell-overridden{% endif %}" style="padding-top: 4px; padding-bottom: 4px;">
<span style="font-size: 0.6em; font-weight: normal; color: #666; text-transform: lowercase; display: block; margin-bottom: 2px;">received / expected</span>
{{ t.text }}
</td>
{% endfor %}
<td></td>
</tr>
</tbody>
</table>
</div>
{% if credits %}
<h2>Credits (Advance Payments / Surplus)</h2>
<div class="list-container">
{% for item in credits %}
<div class="list-item">
<span class="list-item-name">{{ item.name }}</span>
<span class="list-item-val">{{ item.amount }} CZK</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if debts %}
<h2>Debts (Missing Payments)</h2>
<div class="list-container">
{% for item in debts %}
<div class="list-item">
<span class="list-item-name">{{ item.name }}</span>
<span class="list-item-val" style="color: #ff3333;">{{ item.amount }} CZK</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if unmatched %}
<h2>Unmatched Transactions</h2>
<div class="list-container">
<div class="unmatched-row unmatched-header">
<span>Date</span>
<span>Amount</span>
<span>Sender</span>
<span>Message</span>
</div>
{% for tx in unmatched %}
<div class="unmatched-row">
<span>{{ tx.date }}</span>
<span>{{ tx.amount }}</span>
<span>{{ tx.sender }}</span>
<span>{{ tx.message }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<!-- QR Code Modal -->
<div id="qrModal" class="modal"
style="display:none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgba(0, 0, 0, 0.9); z-index: 9999; justify-content: center; align-items: center;">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="qrTitle">Payment for ...</div>
<div class="close-btn" onclick="closeModal('qrModal')">[close]</div>
</div>
<div class="qr-image">
<img id="qrImg" src="" alt="Payment QR Code">
</div>
<div class="qr-details">
<div>Account: <span id="qrAccount"></span></div>
<div>Amount: <span id="qrAmount"></span> CZK</div>
<div>Message: <span id="qrMessage"></span></div>
</div>
</div>
</div>
<div id="memberModal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="modalMemberName">Member Name</div>
<div class="close-btn" onclick="closeModal()">[close]</div>
</div>
<div class="modal-section">
<div class="modal-section-title">Status Summary</div>
<div id="modalTier" style="margin-bottom: 10px; color: #888;">Tier: -</div>
<table class="modal-table">
<thead>
<tr>
<th>Month</th>
<th style="text-align: center;">Att.</th>
<th style="text-align: center;">Expected</th>
<th style="text-align: center;">Paid</th>
<th style="text-align: right;">Status</th>
</tr>
</thead>
<tbody id="modalStatusBody">
<!-- Filled by JS -->
</tbody>
</table>
</div>
<div class="modal-section" id="modalExceptionSection" style="display: none;">
<div class="modal-section-title">Fee Exceptions</div>
<div id="modalExceptionList" class="tx-list">
<!-- Filled by JS -->
</div>
</div>
<div class="modal-section" id="modalOtherSection" style="display: none;">
<div class="modal-section-title">Other Transactions</div>
<div id="modalOtherList" class="tx-list">
<!-- Filled by JS -->
</div>
</div>
<div class="modal-section">
<div class="modal-section-title">Payment History</div>
<div id="modalTxList" class="tx-list">
<!-- Filled by JS -->
</div>
</div>
</div>
</div>
{% set rt = get_render_time() %}
<div class="footer"
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
render time: {{ rt.total }}s
<div id="perf-details" class="perf-breakdown">
{{ rt.breakdown }}
</div>
</div>
<script>
const memberData = {{ member_data| safe }};
const sortedMonths = {{ raw_months| tojson }};
const monthLabels = {{ month_labels_json| safe }};
let currentMemberName = null;
function showMemberDetails(name) {
currentMemberName = name;
const data = memberData[name];
if (!data) return;
document.getElementById('modalMemberName').textContent = name;
document.getElementById('modalTier').textContent = 'Tier: ' + data.tier;
const statusBody = document.getElementById('modalStatusBody');
statusBody.innerHTML = '';
// Collect all transactions for listing
const allTransactions = [];
// We need to iterate over months in reverse to show newest first
const monthKeys = Object.keys(data.months).sort().reverse();
monthKeys.forEach(m => {
const mdata = data.months[m];
const expected = mdata.expected || 0;
const paid = mdata.paid || 0;
const attendance = mdata.attendance_count || 0;
const originalExpected = mdata.original_expected;
let status = '-';
let statusClass = '';
if (expected > 0 || paid > 0) {
if (paid >= expected && expected > 0) { status = paid + '/' + expected; statusClass = 'cell-ok'; }
else if (paid > 0) { status = paid + '/' + expected; }
else { status = '0/' + expected; statusClass = 'cell-unpaid'; }
}
const expectedCell = mdata.exception
? `<span style="color: #ffaa00;" title="Overridden from ${originalExpected}">${expected}*</span>`
: expected;
const displayMonth = monthLabels[m] || m;
const row = document.createElement('tr');
row.innerHTML = `
<td style="color: #888;">${displayMonth}</td>
<td style="text-align: center; color: #ccc;">${attendance}</td>
<td style="text-align: center; color: #ccc;">${expectedCell}</td>
<td style="text-align: center; color: #ccc;">${paid}</td>
<td style="text-align: right;" class="${statusClass}">${status}</td>
`;
statusBody.appendChild(row);
if (mdata.transactions) {
mdata.transactions.forEach(tx => {
allTransactions.push({ month: m, ...tx });
});
}
});
const exList = document.getElementById('modalExceptionList');
const exSection = document.getElementById('modalExceptionSection');
exList.innerHTML = '';
const exceptions = [];
monthKeys.forEach(m => {
if (data.months[m].exception) {
exceptions.push({ month: m, ...data.months[m].exception });
}
});
if (exceptions.length > 0) {
exSection.style.display = 'block';
exceptions.forEach(ex => {
const displayMonth = monthLabels[ex.month] || ex.month;
const item = document.createElement('div');
item.className = 'tx-item'; // Reuse style
item.innerHTML = `
<div class="tx-meta">${displayMonth}</div>
<div class="tx-main">
<span class="tx-amount" style="color: #ffaa00;">${ex.amount} CZK</span>
</div>
<div class="tx-msg">${ex.note || 'No details provided.'}</div>
`;
exList.appendChild(item);
});
} else {
exSection.style.display = 'none';
}
const otherList = document.getElementById('modalOtherList');
const otherSection = document.getElementById('modalOtherSection');
otherList.innerHTML = '';
if (data.other_transactions && data.other_transactions.length > 0) {
otherSection.style.display = 'block';
data.other_transactions.forEach(tx => {
const displayPurpose = tx.purpose || 'Other';
const item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML = `
<div class="tx-meta">${tx.date} | ${displayPurpose}</div>
<div class="tx-main">
<span class="tx-amount" style="color: #66ccff;">${tx.amount} CZK</span>
<span class="tx-sender">${tx.sender}</span>
</div>
<div class="tx-msg">${tx.message || ''}</div>
`;
otherList.appendChild(item);
});
} else {
otherSection.style.display = 'none';
}
const txList = document.getElementById('modalTxList');
txList.innerHTML = '';
if (allTransactions.length === 0) {
txList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No transactions matched to this member.</div>';
} else {
allTransactions.sort((a, b) => b.date.localeCompare(a.date)).forEach(tx => {
const displayMonth = monthLabels[tx.month] || tx.month;
const item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML = `
<div class="tx-meta">${tx.date} | matched to ${displayMonth}</div>
<div class="tx-main">
<span class="tx-amount">${tx.amount} CZK</span>
<span class="tx-sender">${tx.sender}</span>
</div>
<div class="tx-msg">${tx.message || ''}</div>
`;
txList.appendChild(item);
});
}
document.getElementById('memberModal').classList.add('active');
}
function closeModal(id) {
if (id) {
document.getElementById(id).style.display = 'none';
if (id === 'qrModal') {
document.getElementById(id).style.display = 'none';
}
} else {
document.getElementById('memberModal').classList.remove('active');
}
}
// Existing filter script
document.getElementById('nameFilter').addEventListener('input', function (e) {
const filterValue = e.target.value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
const rows = document.querySelectorAll('.member-row');
rows.forEach(row => {
const nameNode = row.querySelector('.member-name');
const name = nameNode.childNodes[0].textContent.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
if (name.includes(filterValue)) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
});
// Close on Esc and Navigate with Arrows
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
closeModal();
closeModal('qrModal');
}
const modal = document.getElementById('memberModal');
if (modal.classList.contains('active')) {
if (e.key === 'ArrowUp') {
e.preventDefault();
navigateMember(-1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
navigateMember(1);
}
}
});
function navigateMember(direction) {
const rows = Array.from(document.querySelectorAll('.member-row'));
const visibleRows = rows.filter(row => row.style.display !== 'none');
let currentIndex = visibleRows.findIndex(row => {
const nameNode = row.querySelector('.member-name');
const name = nameNode.childNodes[0].textContent.trim();
return name === currentMemberName;
});
if (currentIndex === -1) return;
let nextIndex = currentIndex + direction;
if (nextIndex >= 0 && nextIndex < visibleRows.length) {
const nextRow = visibleRows[nextIndex];
const nextName = nextRow.querySelector('.member-name').childNodes[0].textContent.trim();
showMemberDetails(nextName);
}
}
function showPayQR(name, amount, month) {
const account = "{{ bank_account }}";
const message = `${name} / ${month}`;
const qrTitle = document.getElementById('qrTitle');
const qrImg = document.getElementById('qrImg');
const qrAccount = document.getElementById('qrAccount');
const qrAmount = document.getElementById('qrAmount');
const qrMessage = document.getElementById('qrMessage');
qrTitle.innerText = `Payment for ${month}`;
qrAccount.innerText = account;
qrAmount.innerText = amount;
qrMessage.innerText = message;
const encodedMessage = encodeURIComponent(message);
const qrUrl = `/qr?account=${encodeURIComponent(account)}&amount=${amount}&message=${encodedMessage}`;
qrImg.src = qrUrl;
document.getElementById('qrModal').style.display = 'block';
}
// Close modal when clicking outside
window.onclick = function (event) {
if (event.target.className === 'modal') {
event.target.style.display = 'none';
}
}
</script>
</body>
</html>
```

View File

@@ -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 @@
<body>
<div class="nav">
<a href="/fees">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors" class="active">[Junior Attendance/Fees]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
<div>
<a href="/adults">[Adults]</a>
<a href="/juniors">[Juniors]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Archived:</span>
<a href="/fees">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors" class="active">[Junior Attendance/Fees]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
</div>
</div>
<h1>FUJ Junior Fees Dashboard</h1>

View File

@@ -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 @@
<body>
<div class="nav">
<a href="/fees" class="active">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
<div>
<a href="/adults">[Adults]</a>
<a href="/juniors">[Juniors]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Archived:</span>
<a href="/fees" class="active">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
</div>
</div>
<h1>FUJ Fees Dashboard</h1>

872
templates/juniors.html Normal file
View File

@@ -0,0 +1,872 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FUJ Juniors Dashboard</title>
<style>
body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background-color: #0c0c0c;
color: #cccccc;
padding: 10px;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
font-size: 11px;
line-height: 1.2;
}
h1 {
color: #00ff00;
font-family: inherit;
margin-top: 10px;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
}
h2 {
color: #00ff00;
font-size: 12px;
margin-top: 30px;
margin-bottom: 10px;
text-transform: uppercase;
width: 100%;
max-width: 1200px;
border-bottom: 1px solid #333;
padding-bottom: 5px;
}
.nav {
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 {
color: #00ff00;
text-decoration: none;
padding: 2px 8px;
border: 1px solid #333;
}
.nav a.active {
color: #000;
background-color: #00ff00;
border-color: #00ff00;
}
.nav a:hover {
color: #fff;
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;
color: #888;
max-width: 800px;
}
.description a {
color: #00ff00;
text-decoration: none;
}
.description a:hover {
text-decoration: underline;
}
.table-container {
background-color: transparent;
border: 1px solid #333;
box-shadow: none;
overflow-x: auto;
width: 100%;
max-width: 1200px;
margin-bottom: 30px;
}
table {
border-collapse: collapse;
width: 100%;
table-layout: auto;
}
th,
td {
padding: 2px 8px;
text-align: right;
border-bottom: 1px dashed #222;
white-space: nowrap;
}
th:first-child,
td:first-child {
text-align: left;
}
th {
background-color: transparent;
color: #888888;
font-weight: normal;
border-bottom: 1px solid #555;
text-transform: lowercase;
}
tr:hover {
background-color: #1a1a1a;
}
.balance-pos {
color: #00ff00;
}
.balance-neg {
color: #ff3333;
}
.cell-ok {
color: #00ff00;
}
.cell-unpaid {
color: #ff3333;
background-color: rgba(255, 51, 51, 0.05);
position: relative;
}
.cell-overridden {
color: #ffa500 !important;
}
.pay-btn {
display: none;
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
background: #ff3333;
color: white;
border: none;
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
font-weight: bold;
}
.member-row:hover .pay-btn {
display: inline-block;
}
.cell-empty {
color: #444444;
}
.list-container {
width: 100%;
max-width: 1200px;
color: #888;
margin-bottom: 40px;
}
.list-item {
display: flex;
justify-content: flex-start;
gap: 20px;
padding: 1px 0;
border-bottom: 1px dashed #222;
}
.list-item-name {
color: #ccc;
min-width: 200px;
}
.list-item-val {
color: #00ff00;
}
.unmatched-row {
font-family: inherit;
display: grid;
grid-template-columns: 100px 100px 200px 1fr;
gap: 15px;
color: #888;
padding: 2px 0;
border-bottom: 1px dashed #222;
}
.unmatched-header {
color: #555;
border-bottom: 1px solid #333;
margin-bottom: 5px;
}
.filter-container {
width: 100%;
max-width: 1200px;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.filter-input {
background-color: #1a1a1a;
border: 1px solid #333;
color: #00ff00;
font-family: inherit;
font-size: 11px;
padding: 4px 8px;
width: 250px;
outline: none;
}
.filter-input:focus {
border-color: #00ff00;
}
.filter-label {
color: #888;
text-transform: lowercase;
}
.info-icon {
color: #00ff00;
cursor: pointer;
margin-left: 5px;
font-size: 10px;
opacity: 0.5;
}
.info-icon:hover {
opacity: 1;
}
/* Modal Styles */
#memberModal {
display: none !important;
/* Force hide by default */
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.9);
z-index: 9999;
justify-content: center;
align-items: center;
}
#memberModal.active {
display: flex !important;
}
.modal-content {
background-color: #0c0c0c;
border: 1px solid #00ff00;
width: 90%;
max-width: 800px;
max-height: 85vh;
overflow-y: auto;
padding: 20px;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.2);
position: relative;
}
.modal-header {
border-bottom: 1px solid #333;
margin-bottom: 20px;
padding-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-title {
color: #00ff00;
font-size: 14px;
text-transform: uppercase;
}
.close-btn {
color: #ff3333;
cursor: pointer;
font-size: 14px;
text-transform: lowercase;
}
.modal-section {
margin-bottom: 25px;
}
.modal-section-title {
color: #555;
text-transform: uppercase;
font-size: 10px;
margin-bottom: 8px;
border-bottom: 1px dashed #222;
}
.modal-table {
width: 100%;
border-collapse: collapse;
}
.modal-table th,
.modal-table td {
text-align: left;
padding: 4px 0;
border-bottom: 1px dashed #1a1a1a;
}
.modal-table th {
color: #666;
font-weight: normal;
font-size: 10px;
}
.tx-list {
list-style: none;
padding: 0;
margin: 0;
}
.tx-item {
padding: 8px 0;
border-bottom: 1px dashed #222;
}
.tx-meta {
color: #555;
font-size: 10px;
margin-bottom: 4px;
}
.tx-main {
display: flex;
justify-content: space-between;
gap: 20px;
}
.tx-amount {
color: #00ff00;
}
.tx-sender {
color: #ccc;
}
.tx-msg {
color: #888;
font-style: italic;
}
.footer {
margin-top: 50px;
margin-bottom: 20px;
color: #333;
font-size: 9px;
text-align: center;
width: 100%;
cursor: pointer;
user-select: none;
}
.perf-breakdown {
display: none;
margin-top: 5px;
color: #222;
}
/* QR Modal styles */
#qrModal .modal-content {
max-width: 400px;
text-align: center;
}
.qr-image {
background: white;
padding: 10px;
border-radius: 5px;
margin: 20px 0;
display: inline-block;
}
.qr-image img {
display: block;
width: 250px;
height: 250px;
}
.qr-details {
text-align: left;
margin-top: 15px;
font-size: 14px;
color: #ccc;
}
.qr-details div {
margin-bottom: 5px;
}
.qr-details span {
color: #00ff00;
font-family: monospace;
}
</style>
</head>
<body>
<div class="nav">
<div>
<a href="/adults">[Adults]</a>
<a href="/juniors" class="active">[Juniors]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Archived:</span>
<a href="/fees">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
</div>
</div>
<h1>Juniors Dashboard</h1>
<div class="description">
Balances calculated by matching Google Sheet payments against attendance fees.<br>
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
</div>
<div class="filter-container">
<span class="filter-label">search member:</span>
<input type="text" id="nameFilter" class="filter-input" placeholder="..." autocomplete="off">
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Member</th>
{% for m in months %}
<th>{{ m }}</th>
{% endfor %}
<th>Balance</th>
</tr>
</thead>
<tbody id="reconcileBody">
{% for row in results %}
<tr class="member-row">
<td class="member-name">
{{ row.name }}
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
</td>
{% for cell in row.months %}
<td title="{{ cell.tooltip }}"
class="{% if cell.status == 'empty' %}cell-empty{% elif cell.status == 'unpaid' or cell.status == 'partial' %}cell-unpaid{% elif cell.status == 'ok' %}cell-ok{% endif %}{% if cell.overridden %} cell-overridden{% endif %}">
{{ cell.text }}
{% if cell.status == 'unpaid' or cell.status == 'partial' %}
<button class="pay-btn"
onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}')">Pay</button>
{% endif %}
</td>
{% endfor %}
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}" style="position: relative;">
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
{% if row.balance < 0 %}
<button class="pay-btn"
onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}')">Pay All</button>
{% endif %}
</td>
</tr>
{% endfor %}
<tr class="totals-row" style="font-weight: bold; background-color: #111; border-top: 2px solid #333;">
<td style="text-align: left; padding: 6px 8px;">
TOTAL
</td>
{% for t in totals %}
<td class="{% if t.status == 'ok' %}cell-ok{% elif t.status == 'unpaid' %}cell-unpaid{% elif t.status == 'surplus' %}cell-overridden{% endif %}" style="padding-top: 4px; padding-bottom: 4px;">
<span style="font-size: 0.6em; font-weight: normal; color: #666; text-transform: lowercase; display: block; margin-bottom: 2px;">received / expected</span>
{{ t.text }}
</td>
{% endfor %}
<td></td>
</tr>
</tbody>
</table>
</div>
{% if credits %}
<h2>Credits (Advance Payments / Surplus)</h2>
<div class="list-container">
{% for item in credits %}
<div class="list-item">
<span class="list-item-name">{{ item.name }}</span>
<span class="list-item-val">{{ item.amount }} CZK</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if debts %}
<h2>Debts (Missing Payments)</h2>
<div class="list-container">
{% for item in debts %}
<div class="list-item">
<span class="list-item-name">{{ item.name }}</span>
<span class="list-item-val" style="color: #ff3333;">{{ item.amount }} CZK</span>
</div>
{% endfor %}
</div>
{% endif %}
<!-- QR Code Modal -->
<div id="qrModal" class="modal"
style="display:none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgba(0, 0, 0, 0.9); z-index: 9999; justify-content: center; align-items: center;">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="qrTitle">Payment for ...</div>
<div class="close-btn" onclick="closeModal('qrModal')">[close]</div>
</div>
<div class="qr-image">
<img id="qrImg" src="" alt="Payment QR Code">
</div>
<div class="qr-details">
<div>Account: <span id="qrAccount"></span></div>
<div>Amount: <span id="qrAmount"></span> CZK</div>
<div>Message: <span id="qrMessage"></span></div>
</div>
</div>
</div>
<div id="memberModal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="modalMemberName">Member Name</div>
<div class="close-btn" onclick="closeModal()">[close]</div>
</div>
<div class="modal-section">
<div class="modal-section-title">Status Summary</div>
<div id="modalTier" style="margin-bottom: 10px; color: #888;">Tier: -</div>
<table class="modal-table">
<thead>
<tr>
<th>Month</th>
<th style="text-align: center;">Att.</th>
<th style="text-align: center;">Expected</th>
<th style="text-align: center;">Paid</th>
<th style="text-align: right;">Status</th>
</tr>
</thead>
<tbody id="modalStatusBody">
<!-- Filled by JS -->
</tbody>
</table>
</div>
<div class="modal-section" id="modalExceptionSection" style="display: none;">
<div class="modal-section-title">Fee Exceptions</div>
<div id="modalExceptionList" class="tx-list">
<!-- Filled by JS -->
</div>
</div>
<div class="modal-section" id="modalOtherSection" style="display: none;">
<div class="modal-section-title">Other Transactions</div>
<div id="modalOtherList" class="tx-list">
<!-- Filled by JS -->
</div>
</div>
<div class="modal-section">
<div class="modal-section-title">Payment History</div>
<div id="modalTxList" class="tx-list">
<!-- Filled by JS -->
</div>
</div>
</div>
</div>
{% set rt = get_render_time() %}
<div class="footer"
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
render time: {{ rt.total }}s
<div id="perf-details" class="perf-breakdown">
{{ rt.breakdown }}
</div>
</div>
<script>
const memberData = {{ member_data| safe }};
const sortedMonths = {{ raw_months| tojson }};
const monthLabels = {{ month_labels_json| safe }};
let currentMemberName = null;
function showMemberDetails(name) {
currentMemberName = name;
const data = memberData[name];
if (!data) return;
document.getElementById('modalMemberName').textContent = name;
document.getElementById('modalTier').textContent = 'Tier: ' + data.tier;
const statusBody = document.getElementById('modalStatusBody');
statusBody.innerHTML = '';
// Collect all transactions for listing
const allTransactions = [];
// We need to iterate over months in reverse to show newest first
const monthKeys = Object.keys(data.months).sort().reverse();
monthKeys.forEach(m => {
const mdata = data.months[m];
const expected = mdata.expected || 0;
const paid = mdata.paid || 0;
const attendance = mdata.attendance_count || 0;
const originalExpected = mdata.original_expected;
let status = '-';
let statusClass = '';
if (expected > 0 || paid > 0) {
if (paid >= expected && expected > 0) { status = paid + '/' + expected; statusClass = 'cell-ok'; }
else if (paid > 0) { status = paid + '/' + expected; }
else { status = '0/' + expected; statusClass = 'cell-unpaid'; }
}
const expectedCell = mdata.exception
? `<span style="color: #ffaa00;" title="Overridden from ${originalExpected}">${expected}*</span>`
: expected;
const displayMonth = monthLabels[m] || m;
const row = document.createElement('tr');
row.innerHTML = `
<td style="color: #888;">${displayMonth}</td>
<td style="text-align: center; color: #ccc;">${attendance}</td>
<td style="text-align: center; color: #ccc;">${expectedCell}</td>
<td style="text-align: center; color: #ccc;">${paid}</td>
<td style="text-align: right;" class="${statusClass}">${status}</td>
`;
statusBody.appendChild(row);
if (mdata.transactions) {
mdata.transactions.forEach(tx => {
allTransactions.push({ month: m, ...tx });
});
}
});
const exList = document.getElementById('modalExceptionList');
const exSection = document.getElementById('modalExceptionSection');
exList.innerHTML = '';
const exceptions = [];
monthKeys.forEach(m => {
if (data.months[m].exception) {
exceptions.push({ month: m, ...data.months[m].exception });
}
});
if (exceptions.length > 0) {
exSection.style.display = 'block';
exceptions.forEach(ex => {
const displayMonth = monthLabels[ex.month] || ex.month;
const item = document.createElement('div');
item.className = 'tx-item'; // Reuse style
item.innerHTML = `
<div class="tx-meta">${displayMonth}</div>
<div class="tx-main">
<span class="tx-amount" style="color: #ffaa00;">${ex.amount} CZK</span>
</div>
<div class="tx-msg">${ex.note || 'No details provided.'}</div>
`;
exList.appendChild(item);
});
} else {
exSection.style.display = 'none';
}
const otherList = document.getElementById('modalOtherList');
const otherSection = document.getElementById('modalOtherSection');
otherList.innerHTML = '';
if (data.other_transactions && data.other_transactions.length > 0) {
otherSection.style.display = 'block';
data.other_transactions.forEach(tx => {
const displayPurpose = tx.purpose || 'Other';
const item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML = `
<div class="tx-meta">${tx.date} | ${displayPurpose}</div>
<div class="tx-main">
<span class="tx-amount" style="color: #66ccff;">${tx.amount} CZK</span>
<span class="tx-sender">${tx.sender}</span>
</div>
<div class="tx-msg">${tx.message || ''}</div>
`;
otherList.appendChild(item);
});
} else {
otherSection.style.display = 'none';
}
const txList = document.getElementById('modalTxList');
txList.innerHTML = '';
if (allTransactions.length === 0) {
txList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No transactions matched to this member.</div>';
} else {
allTransactions.sort((a, b) => b.date.localeCompare(a.date)).forEach(tx => {
const displayMonth = monthLabels[tx.month] || tx.month;
const item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML = `
<div class="tx-meta">${tx.date} | matched to ${displayMonth}</div>
<div class="tx-main">
<span class="tx-amount">${tx.amount} CZK</span>
<span class="tx-sender">${tx.sender}</span>
</div>
<div class="tx-msg">${tx.message || ''}</div>
`;
txList.appendChild(item);
});
}
document.getElementById('memberModal').classList.add('active');
}
function closeModal(id) {
if (id) {
document.getElementById(id).style.display = 'none';
if (id === 'qrModal') {
document.getElementById(id).style.display = 'none';
}
} else {
document.getElementById('memberModal').classList.remove('active');
}
}
// Existing filter script
document.getElementById('nameFilter').addEventListener('input', function (e) {
const filterValue = e.target.value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
const rows = document.querySelectorAll('.member-row');
rows.forEach(row => {
const nameNode = row.querySelector('.member-name');
const name = nameNode.childNodes[0].textContent.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
if (name.includes(filterValue)) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
});
// Close on Esc and Navigate with Arrows
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
closeModal();
closeModal('qrModal');
}
const modal = document.getElementById('memberModal');
if (modal.classList.contains('active')) {
if (e.key === 'ArrowUp') {
e.preventDefault();
navigateMember(-1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
navigateMember(1);
}
}
});
function navigateMember(direction) {
const rows = Array.from(document.querySelectorAll('.member-row'));
const visibleRows = rows.filter(row => row.style.display !== 'none');
let currentIndex = visibleRows.findIndex(row => {
const nameNode = row.querySelector('.member-name');
const name = nameNode.childNodes[0].textContent.trim();
return name === currentMemberName;
});
if (currentIndex === -1) return;
let nextIndex = currentIndex + direction;
if (nextIndex >= 0 && nextIndex < visibleRows.length) {
const nextRow = visibleRows[nextIndex];
const nextName = nextRow.querySelector('.member-name').childNodes[0].textContent.trim();
showMemberDetails(nextName);
}
}
function showPayQR(name, amount, month) {
const account = "{{ bank_account }}";
const message = `${name} / ${month}`;
const qrTitle = document.getElementById('qrTitle');
const qrImg = document.getElementById('qrImg');
const qrAccount = document.getElementById('qrAccount');
const qrAmount = document.getElementById('qrAmount');
const qrMessage = document.getElementById('qrMessage');
qrTitle.innerText = `Payment for ${month}`;
qrAccount.innerText = account;
qrAmount.innerText = amount;
qrMessage.innerText = message;
const encodedMessage = encodeURIComponent(message);
const qrUrl = `/qr?account=${encodeURIComponent(account)}&amount=${amount}&message=${encodedMessage}`;
qrImg.src = qrUrl;
document.getElementById('qrModal').style.display = 'block';
}
// Close modal when clicking outside
window.onclick = function (event) {
if (event.target.className === 'modal') {
event.target.style.display = 'none';
}
}
</script>
</body>
</html>
```

View File

@@ -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 @@
<body>
<div class="nav">
<a href="/fees">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
<a href="/payments" class="active">[Payments Ledger]</a>
<div>
<a href="/adults">[Adults]</a>
<a href="/juniors">[Juniors]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Archived:</span>
<a href="/fees">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
<a href="/payments" class="active">[Payments Ledger]</a>
</div>
</div>
<h1>Payments Ledger</h1>

View File

@@ -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 @@
<body>
<div class="nav">
<a href="/fees">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors" class="active">[Junior Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
<div>
<a href="/adults">[Adults]</a>
<a href="/juniors">[Juniors]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Archived:</span>
<a href="/fees">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors" class="active">[Junior Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
</div>
</div>
<h1>Junior Payment Reconciliation</h1>

View File

@@ -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 @@
<body>
<div class="nav">
<a href="/fees">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
<a href="/reconcile" class="active">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
<div>
<a href="/adults">[Adults]</a>
<a href="/juniors">[Juniors]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Archived:</span>
<a href="/fees">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
<a href="/reconcile" class="active">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
</div>
</div>
<h1>Payment Reconciliation</h1>

View File

@@ -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()