9 Commits
0.17 ... 0.26

Author SHA1 Message Date
54762cd421 feat: Change QR payment message separator from "/" to ":"
Some checks failed
Deploy to K8s / deploy (push) Failing after 6s
Build and Push / build (push) Successful in 6s
Format is now "Name: MM/YYYY+MM/YYYY" instead of "Name / MM/YYYY+MM/YYYY"
for clearer readability when multiple months are included.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:35:46 +01:00
b2aaca5df9 feat: Add /sync-bank endpoint to trigger bank sync and inference from web UI
Some checks failed
Deploy to K8s / deploy (push) Failing after 6s
Build and Push / build (push) Successful in 6s
Adds a new GET /sync-bank route that runs sync_to_sheets (2026) + infer_payments + flush_cache,
capturing all output and displaying it on a styled results page. Adds "Tools: [Sync Bank Data]"
nav link to all templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:24:41 +01:00
883bc4489e feat: Add per-month rate override for adult fees
Some checks failed
Deploy to K8s / deploy (push) Failing after 5s
Build and Push / build (push) Successful in 6s
Mirror the junior fee override mechanism (JUNIOR_MONTHLY_RATE) for adults
via ADULT_FEE_MONTHLY_RATE. Set 2026-03 override to 350 CZK. Rename
FEE_FULL/FEE_SINGLE to ADULT_FEE_DEFAULT/ADULT_FEE_SINGLE for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 16:54:00 +01:00
3ad4a21f5b feat: Pass build metadata args in Gitea CI pipeline
Some checks failed
Deploy to K8s / deploy (push) Failing after 6s
Build and Push / build (push) Successful in 5s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 16:34:34 +01:00
3c1604c7af feat: Bake build metadata (git tag, commit, date) into OCI image and display in web UI
Some checks failed
Deploy to K8s / deploy (push) Failing after 10s
Build and Push / build (push) Successful in 6s
Store git tag, commit hash, and build date as OCI-standard labels and a
build_meta.json file inside the Docker image. The Flask app reads this at
startup and displays version info in all page footers. Adds /version JSON
endpoint for programmatic access. Falls back to dev@local outside Docker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 16:30:20 +01:00
8b3223f865 feat: Add POST /flush-cache endpoint to clear all cached data and reset timers
Some checks failed
Deploy to K8s / deploy (push) Failing after 7s
Build and Push / build (push) Successful in 6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 10:45:47 +01:00
276e18a9c8 feat: Show attendance breakdown for single-visit junior fees
All checks were successful
Build and Push / build (push) Successful in 8s
When a junior attended only once in a month (fee = "?"), the dashboard
cells now display the attendance details (e.g., "? (1:1J)") instead of
a bare "?". Applied to both /juniors and /reconcile-juniors routes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 10:41:45 +01:00
61f2126c1b feat: Change default redirect to Adults dashboard
All checks were successful
Build and Push / build (push) Successful in 8s
Deploy to K8s / deploy (push) Successful in 12s
Co-authored-by: Antigravity <antigravity@google.com>
2026-03-11 13:13:05 +01:00
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
15 changed files with 2680 additions and 66 deletions

View File

@@ -31,5 +31,9 @@ jobs:
TAG=${{ inputs.tag }} TAG=${{ inputs.tag }}
fi fi
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
docker build -f build/Dockerfile -t $IMAGE . docker build -f build/Dockerfile \
--build-arg GIT_TAG=$TAG \
--build-arg GIT_COMMIT=${{ github.sha }} \
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
-t $IMAGE .
docker push $IMAGE docker push $IMAGE

View File

@@ -45,7 +45,11 @@ web-debug: $(PYTHON)
FLASK_DEBUG=1 $(PYTHON) app.py FLASK_DEBUG=1 $(PYTHON) app.py
image: image:
docker build -t fuj-management:latest -f build/Dockerfile . docker build -t fuj-management:latest \
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
--build-arg GIT_COMMIT=$$(git rev-parse --short HEAD) \
--build-arg BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ) \
-f build/Dockerfile .
run: run:
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest

429
app.py
View File

@@ -7,7 +7,7 @@ import os
import io import io
import qrcode import qrcode
import logging import logging
from flask import Flask, render_template, g, send_file, request from flask import Flask, render_template, g, send_file, request, jsonify
# Configure logging, allowing override via LOG_LEVEL environment variable # Configure logging, allowing override via LOG_LEVEL environment variable
log_level = os.environ.get("LOG_LEVEL", "INFO").upper() log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
@@ -23,7 +23,9 @@ from config import (
) )
from attendance import get_members_with_fees, get_junior_members_with_fees, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS from attendance import get_members_with_fees, get_junior_members_with_fees, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache
from sync_fio_to_sheets import sync_to_sheets
from infer_payments import infer_payments
def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs): def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
mod_time = get_sheet_modified_time(cache_key) mod_time = get_sheet_modified_time(cache_key)
@@ -72,6 +74,13 @@ def warmup_cache():
logger.info("Cache warmup complete.") logger.info("Cache warmup complete.")
app = Flask(__name__) app = Flask(__name__)
import json as _json
_meta_path = Path(__file__).parent / "build_meta.json"
BUILD_META = _json.loads(_meta_path.read_text()) if _meta_path.exists() else {
"tag": "dev", "commit": "local", "build_date": ""
}
warmup_cache() warmup_cache()
@app.before_request @app.before_request
@@ -101,12 +110,50 @@ def inject_render_time():
"total": f"{total:.3f}", "total": f"{total:.3f}",
"breakdown": " | ".join(breakdown) "breakdown": " | ".join(breakdown)
} }
return dict(get_render_time=get_render_time) return dict(get_render_time=get_render_time, build_meta=BUILD_META)
@app.route("/") @app.route("/")
def index(): def index():
# Redirect root to /fees for convenience while there are no other apps # Redirect root to /adults for convenience while there are no other apps
return '<meta http-equiv="refresh" content="0; url=/fees" />' return '<meta http-equiv="refresh" content="0; url=/adults" />'
@app.route("/flush-cache", methods=["POST"])
def flush_cache_endpoint():
deleted = flush_cache()
return jsonify({"status": "ok", "deleted_files": deleted})
@app.route("/sync-bank")
def sync_bank():
import contextlib
output = io.StringIO()
success = True
try:
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
# sync_to_sheets: equivalent of make sync-2026
output.write("=== Syncing Fio transactions (2026) ===\n")
sync_to_sheets(
spreadsheet_id=PAYMENTS_SHEET_ID,
credentials_path=CREDENTIALS_PATH,
date_from_str="2026-01-01",
date_to_str="2026-12-31",
sort_by_date=True,
)
output.write("\n=== Inferring payment details ===\n")
infer_payments(PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
output.write("\n=== Flushing cache ===\n")
deleted = flush_cache()
output.write(f"Deleted {deleted} cache files.\n")
output.write("\n=== Done ===\n")
except Exception as e:
import traceback
output.write(f"\n!!! Error: {e}\n")
output.write(traceback.format_exc())
success = False
return render_template("sync.html", output=output.getvalue(), success=success)
@app.route("/version")
def version():
return BUILD_META
@app.route("/fees") @app.route("/fees")
def fees(): def fees():
@@ -253,6 +300,146 @@ def fees_juniors():
payments_url=payments_url 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": "", "raw_unpaid_periods": ""}
unpaid_months = []
raw_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])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
else:
status = "unpaid"
cell_text = f"0/{fee_display}"
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
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],
"raw_month": m,
"tooltip": tooltip
})
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
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") @app.route("/reconcile")
def reconcile_view(): def reconcile_view():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit" attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
@@ -288,17 +475,18 @@ def reconcile_view():
formatted_results = [] formatted_results = []
for name in adult_names: for name in adult_names:
data = result["members"][name] data = result["members"][name]
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""} row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
unpaid_months = [] unpaid_months = []
raw_unpaid_months = []
for m in sorted_months: for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0}) mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0})
expected = mdata["expected"] expected = mdata["expected"]
paid = int(mdata["paid"]) paid = int(mdata["paid"])
status = "empty" status = "empty"
cell_text = "-" cell_text = "-"
amount_to_pay = 0 amount_to_pay = 0
if expected > 0: if expected > 0:
if paid >= expected: if paid >= expected:
status = "ok" status = "ok"
@@ -308,23 +496,27 @@ def reconcile_view():
cell_text = f"{paid}/{expected}" cell_text = f"{paid}/{expected}"
amount_to_pay = expected - paid amount_to_pay = expected - paid
unpaid_months.append(month_labels[m]) unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
else: else:
status = "unpaid" status = "unpaid"
cell_text = f"UNPAID {expected}" cell_text = f"UNPAID {expected}"
amount_to_pay = expected amount_to_pay = expected
unpaid_months.append(month_labels[m]) unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
elif paid > 0: elif paid > 0:
status = "surplus" status = "surplus"
cell_text = f"PAID {paid}" cell_text = f"PAID {paid}"
row["months"].append({ row["months"].append({
"text": cell_text, "text": cell_text,
"status": status, "status": status,
"amount": amount_to_pay, "amount": amount_to_pay,
"month": month_labels[m] "month": month_labels[m],
"raw_month": m
}) })
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "") row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
row["balance"] = data["total_balance"] # Updated to use total_balance row["balance"] = data["total_balance"] # Updated to use total_balance
formatted_results.append(row) formatted_results.append(row)
@@ -352,6 +544,183 @@ def reconcile_view():
bank_account=BANK_ACCOUNT 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": "", "raw_unpaid_periods": ""}
unpaid_months = []
raw_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 = f"?{count_str}"
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])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
else:
status = "unpaid"
cell_text = f"0/{fee_display}"
amount_to_pay = expected
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
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],
"raw_month": m,
"tooltip": tooltip
})
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
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") @app.route("/reconcile-juniors")
def reconcile_juniors_view(): def reconcile_juniors_view():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}" attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
@@ -398,24 +767,44 @@ def reconcile_juniors_view():
# Filter to juniors for the main table # Filter to juniors for the main table
junior_names = sorted([name for name, tier, _ in adapted_members]) junior_names = sorted([name for name, tier, _ in adapted_members])
junior_members_dict_rc = {name: fees_dict for name, _, fees_dict in junior_members}
formatted_results = [] formatted_results = []
for name in junior_names: for name in junior_names:
data = result["members"][name] data = result["members"][name]
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""} row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
unpaid_months = [] unpaid_months = []
raw_unpaid_months = []
for m in sorted_months: for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0}) mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0})
expected = mdata["expected"] expected = mdata["expected"]
paid = int(mdata["paid"]) paid = int(mdata["paid"])
orig_fee_data = junior_members_dict_rc.get(name, {}).get(m)
adult_count = 0
junior_count = 0
att_count = 0
if orig_fee_data and len(orig_fee_data) == 4:
_, att_count, 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" ({att_count}{breakdown})" if att_count > 0 else ""
status = "empty" status = "empty"
cell_text = "-" cell_text = "-"
amount_to_pay = 0 amount_to_pay = 0
if expected == "?" or (isinstance(expected, int) and expected > 0): if expected == "?" or (isinstance(expected, int) and expected > 0):
if expected == "?": if expected == "?":
status = "empty" status = "empty"
cell_text = "?" cell_text = f"?{count_str}"
elif paid >= expected: elif paid >= expected:
status = "ok" status = "ok"
cell_text = "OK" cell_text = "OK"
@@ -424,23 +813,27 @@ def reconcile_juniors_view():
cell_text = f"{paid}/{expected}" cell_text = f"{paid}/{expected}"
amount_to_pay = expected - paid amount_to_pay = expected - paid
unpaid_months.append(month_labels[m]) unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
else: else:
status = "unpaid" status = "unpaid"
cell_text = f"UNPAID {expected}" cell_text = f"UNPAID {expected}"
amount_to_pay = expected amount_to_pay = expected
unpaid_months.append(month_labels[m]) unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
elif paid > 0: elif paid > 0:
status = "surplus" status = "surplus"
cell_text = f"PAID {paid}" cell_text = f"PAID {paid}"
row["months"].append({ row["months"].append({
"text": cell_text, "text": cell_text,
"status": status, "status": status,
"amount": amount_to_pay, "amount": amount_to_pay,
"month": month_labels[m] "month": month_labels[m],
"raw_month": m
}) })
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "") row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
row["balance"] = data["total_balance"] row["balance"] = data["total_balance"]
formatted_results.append(row) formatted_results.append(row)

View File

@@ -24,6 +24,17 @@ COPY templates/ ./templates/
COPY build/entrypoint.sh /entrypoint.sh COPY build/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
ARG GIT_TAG=unknown
ARG GIT_COMMIT=unknown
ARG BUILD_DATE=unknown
LABEL org.opencontainers.image.version="${GIT_TAG}" \
org.opencontainers.image.revision="${GIT_COMMIT}" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.title="fuj-management"
RUN echo "{\"tag\": \"${GIT_TAG}\", \"commit\": \"${GIT_COMMIT}\", \"build_date\": \"${BUILD_DATE}\"}" > /app/build_meta.json
EXPOSE 5001 EXPOSE 5001
HEALTHCHECK --interval=60s --timeout=5s --start-period=5s \ HEALTHCHECK --interval=60s --timeout=5s --start-period=5s \

View File

@@ -10,8 +10,11 @@ from config import ATTENDANCE_SHEET_ID as SHEET_ID, JUNIOR_SHEET_GID
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0" EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0"
JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}" JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}"
FEE_FULL = 750 # CZK, for 2+ practices in a month ADULT_FEE_DEFAULT = 750 # CZK, for 2+ practices in a month
FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month ADULT_FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
ADULT_FEE_MONTHLY_RATE = {
"2026-03": 350
}
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
JUNIOR_MONTHLY_RATE = { JUNIOR_MONTHLY_RATE = {
@@ -76,13 +79,13 @@ def group_by_month(dates: list[tuple[int, datetime]], merged_months: dict[str, s
return months return months
def calculate_fee(attendance_count: int) -> int: def calculate_fee(attendance_count: int, month_key: str) -> int:
"""Apply fee rules: 0 → 0, 1 → 200, 2+ → 750.""" """Apply fee rules: 0 → 0, 1 → 200, 2+ → configured rate (default 750)."""
if attendance_count == 0: if attendance_count == 0:
return 0 return 0
if attendance_count == 1: if attendance_count == 1:
return FEE_SINGLE return ADULT_FEE_SINGLE
return FEE_FULL return ADULT_FEE_MONTHLY_RATE.get(month_key, ADULT_FEE_DEFAULT)
def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int: def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int:
@@ -186,7 +189,7 @@ def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list
for c in cols for c in cols
if c < len(row) and row[c].strip().upper() == "TRUE" if c < len(row) and row[c].strip().upper() == "TRUE"
) )
fee = calculate_fee(count) if tier == "A" else 0 fee = calculate_fee(count, month_key) if tier == "A" else 0
month_fees[month_key] = (fee, count) month_fees[month_key] = (fee, count)
members.append((name, tier, month_fees)) members.append((name, tier, month_fees))

View File

@@ -155,3 +155,19 @@ def write_cache(sheet_id: str, modified_time: str, data: list | dict) -> None:
logger.info(f"Wrote cache for {sheet_id}") logger.info(f"Wrote cache for {sheet_id}")
except Exception as e: except Exception as e:
logger.error(f"Failed to write cache {sheet_id}: {e}") logger.error(f"Failed to write cache {sheet_id}: {e}")
def flush_cache():
"""Delete all cache files and reset in-memory state. Returns count of deleted files."""
global _DRIVE_SERVICE
_LAST_CHECKED.clear()
_DRIVE_SERVICE = None
deleted = 0
if CACHE_DIR.exists():
for f in CACHE_DIR.glob("*_cache.json"):
f.unlink()
deleted += 1
logger.info(f"Deleted cache file: {f.name}")
logger.info(f"Cache flushed: {deleted} files deleted, timers reset")
return deleted

899
templates/adults.html Normal file
View File

@@ -0,0 +1,899 @@
<!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 class="nav-archived">
<span style="color: #666; margin-right: 5px;">Tools:</span>
<a href="/sync-bank">[Sync Bank Data]</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 }}', '{{ cell.raw_month }}')">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 }}', '{{ row.raw_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')">
{{ build_meta.tag }}@{{ build_meta.commit }} | 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, rawMonth) {
const account = "{{ bank_account }}";
// Convert YYYY-MM to MM/YYYY for infer_payments.py compatibility
const numericMonth = rawMonth.includes('+')
? rawMonth.split('+').map(p => p.replace(/(\d{4})-(\d{2})/, '$2/$1')).join('+')
: rawMonth.replace(/(\d{4})-(\d{2})/, '$2/$1');
const message = `${name}: ${numericMonth}`;
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; margin-bottom: 20px;
font-size: 12px; font-size: 12px;
color: #555; color: #555;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.nav > div {
display: flex; display: flex;
gap: 15px; gap: 15px;
align-items: center;
} }
.nav a { .nav a {
@@ -118,6 +126,23 @@
border-color: #555; 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 { .description {
margin-bottom: 20px; margin-bottom: 20px;
text-align: center; text-align: center;
@@ -155,11 +180,22 @@
<body> <body>
<div class="nav"> <div class="nav">
<a href="/fees">[Adult - Attendance/Fees]</a> <div>
<a href="/fees-juniors" class="active">[Junior Attendance/Fees]</a> <a href="/adults">[Adults]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a> <a href="/juniors">[Juniors]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a> </div>
<a href="/payments">[Payments Ledger]</a> <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 class="nav-archived">
<span style="color: #666; margin-right: 5px;">Tools:</span>
<a href="/sync-bank">[Sync Bank Data]</a>
</div>
</div> </div>
<h1>FUJ Junior Fees Dashboard</h1> <h1>FUJ Junior Fees Dashboard</h1>
@@ -206,7 +242,7 @@
{% set rt = get_render_time() %} {% set rt = get_render_time() %}
<div class="footer" <div class="footer"
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')"> onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
render time: {{ rt.total }}s {{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
<div id="perf-details" class="perf-breakdown"> <div id="perf-details" class="perf-breakdown">
{{ rt.breakdown }} {{ rt.breakdown }}
</div> </div>

View File

@@ -111,8 +111,16 @@
margin-bottom: 20px; margin-bottom: 20px;
font-size: 12px; font-size: 12px;
color: #555; color: #555;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.nav > div {
display: flex; display: flex;
gap: 15px; gap: 15px;
align-items: center;
} }
.nav a { .nav a {
@@ -133,6 +141,23 @@
border-color: #555; 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 { .description {
margin-bottom: 20px; margin-bottom: 20px;
text-align: center; text-align: center;
@@ -170,11 +195,22 @@
<body> <body>
<div class="nav"> <div class="nav">
<a href="/fees" class="active">[Adult - Attendance/Fees]</a> <div>
<a href="/fees-juniors">[Junior Attendance/Fees]</a> <a href="/adults">[Adults]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a> <a href="/juniors">[Juniors]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a> </div>
<a href="/payments">[Payments Ledger]</a> <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 class="nav-archived">
<span style="color: #666; margin-right: 5px;">Tools:</span>
<a href="/sync-bank">[Sync Bank Data]</a>
</div>
</div> </div>
<h1>FUJ Fees Dashboard</h1> <h1>FUJ Fees Dashboard</h1>
@@ -221,7 +257,7 @@
{% set rt = get_render_time() %} {% set rt = get_render_time() %}
<div class="footer" <div class="footer"
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')"> onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
render time: {{ rt.total }}s {{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
<div id="perf-details" class="perf-breakdown"> <div id="perf-details" class="perf-breakdown">
{{ rt.breakdown }} {{ rt.breakdown }}
</div> </div>

880
templates/juniors.html Normal file
View File

@@ -0,0 +1,880 @@
<!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 class="nav-archived">
<span style="color: #666; margin-right: 5px;">Tools:</span>
<a href="/sync-bank">[Sync Bank Data]</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 }}', '{{ cell.raw_month }}')">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 }}', '{{ row.raw_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')">
{{ build_meta.tag }}@{{ build_meta.commit }} | 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, rawMonth) {
const account = "{{ bank_account }}";
// Convert YYYY-MM to MM/YYYY for infer_payments.py compatibility
const numericMonth = rawMonth.includes('+')
? rawMonth.split('+').map(p => p.replace(/(\d{4})-(\d{2})/, '$2/$1')).join('+')
: rawMonth.replace(/(\d{4})-(\d{2})/, '$2/$1');
const message = `${name}: ${numericMonth}`;
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; margin-bottom: 20px;
font-size: 12px; font-size: 12px;
color: #555; color: #555;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.nav > div {
display: flex; display: flex;
gap: 15px; gap: 15px;
align-items: center;
} }
.nav a { .nav a {
@@ -67,6 +75,23 @@
border-color: #555; 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 { .description {
margin-bottom: 20px; margin-bottom: 20px;
text-align: center; text-align: center;
@@ -159,11 +184,22 @@
<body> <body>
<div class="nav"> <div class="nav">
<a href="/fees">[Adult - Attendance/Fees]</a> <div>
<a href="/fees-juniors">[Junior Attendance/Fees]</a> <a href="/adults">[Adults]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a> <a href="/juniors">[Juniors]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a> </div>
<a href="/payments" class="active">[Payments Ledger]</a> <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 class="nav-archived">
<span style="color: #666; margin-right: 5px;">Tools:</span>
<a href="/sync-bank">[Sync Bank Data]</a>
</div>
</div> </div>
<h1>Payments Ledger</h1> <h1>Payments Ledger</h1>
@@ -205,7 +241,7 @@
{% set rt = get_render_time() %} {% set rt = get_render_time() %}
<div class="footer" <div class="footer"
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')"> onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
render time: {{ rt.total }}s {{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
<div id="perf-details" class="perf-breakdown"> <div id="perf-details" class="perf-breakdown">
{{ rt.breakdown }} {{ rt.breakdown }}
</div> </div>

View File

@@ -45,8 +45,16 @@
margin-bottom: 20px; margin-bottom: 20px;
font-size: 12px; font-size: 12px;
color: #555; color: #555;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.nav > div {
display: flex; display: flex;
gap: 15px; gap: 15px;
align-items: center;
} }
.nav a { .nav a {
@@ -67,6 +75,23 @@
border-color: #555; 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 { .description {
margin-bottom: 20px; margin-bottom: 20px;
text-align: center; text-align: center;
@@ -423,11 +448,22 @@
<body> <body>
<div class="nav"> <div class="nav">
<a href="/fees">[Adult - Attendance/Fees]</a> <div>
<a href="/fees-juniors">[Junior Attendance/Fees]</a> <a href="/adults">[Adults]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a> <a href="/juniors">[Juniors]</a>
<a href="/reconcile-juniors" class="active">[Junior Payment Reconciliation]</a> </div>
<a href="/payments">[Payments Ledger]</a> <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 class="nav-archived">
<span style="color: #666; margin-right: 5px;">Tools:</span>
<a href="/sync-bank">[Sync Bank Data]</a>
</div>
</div> </div>
<h1>Junior Payment Reconciliation</h1> <h1>Junior Payment Reconciliation</h1>
@@ -467,7 +503,7 @@
{{ cell.text }} {{ cell.text }}
{% if cell.status == 'unpaid' or cell.status == 'partial' %} {% if cell.status == 'unpaid' or cell.status == 'partial' %}
<button class="pay-btn" <button class="pay-btn"
onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}')">Pay</button> onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}', '{{ cell.raw_month }}')">Pay</button>
{% endif %} {% endif %}
</td> </td>
{% endfor %} {% endfor %}
@@ -475,7 +511,7 @@
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }} {{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
{% if row.balance < 0 %} {% if row.balance < 0 %}
<button class="pay-btn" <button class="pay-btn"
onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}')">Pay All</button> onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}', '{{ row.raw_unpaid_periods|e }}')">Pay All</button>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@@ -599,7 +635,7 @@
{% set rt = get_render_time() %} {% set rt = get_render_time() %}
<div class="footer" <div class="footer"
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')"> onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
render time: {{ rt.total }}s {{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
<div id="perf-details" class="perf-breakdown"> <div id="perf-details" class="perf-breakdown">
{{ rt.breakdown }} {{ rt.breakdown }}
</div> </div>
@@ -809,9 +845,13 @@
showMemberDetails(nextName); showMemberDetails(nextName);
} }
} }
function showPayQR(name, amount, month) { function showPayQR(name, amount, month, rawMonth) {
const account = "{{ bank_account }}"; const account = "{{ bank_account }}";
const message = `${name} / ${month}`; // Convert YYYY-MM to MM/YYYY for infer_payments.py compatibility
const numericMonth = rawMonth.includes('+')
? rawMonth.split('+').map(p => p.replace(/(\d{4})-(\d{2})/, '$2/$1')).join('+')
: rawMonth.replace(/(\d{4})-(\d{2})/, '$2/$1');
const message = `${name}: ${numericMonth}`;
const qrTitle = document.getElementById('qrTitle'); const qrTitle = document.getElementById('qrTitle');
const qrImg = document.getElementById('qrImg'); const qrImg = document.getElementById('qrImg');
const qrAccount = document.getElementById('qrAccount'); const qrAccount = document.getElementById('qrAccount');

View File

@@ -45,8 +45,16 @@
margin-bottom: 20px; margin-bottom: 20px;
font-size: 12px; font-size: 12px;
color: #555; color: #555;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.nav > div {
display: flex; display: flex;
gap: 15px; gap: 15px;
align-items: center;
} }
.nav a { .nav a {
@@ -67,6 +75,23 @@
border-color: #555; 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 { .description {
margin-bottom: 20px; margin-bottom: 20px;
text-align: center; text-align: center;
@@ -423,11 +448,22 @@
<body> <body>
<div class="nav"> <div class="nav">
<a href="/fees">[Adult - Attendance/Fees]</a> <div>
<a href="/fees-juniors">[Junior Attendance/Fees]</a> <a href="/adults">[Adults]</a>
<a href="/reconcile" class="active">[Adult Payment Reconciliation]</a> <a href="/juniors">[Juniors]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a> </div>
<a href="/payments">[Payments Ledger]</a> <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 class="nav-archived">
<span style="color: #666; margin-right: 5px;">Tools:</span>
<a href="/sync-bank">[Sync Bank Data]</a>
</div>
</div> </div>
<h1>Payment Reconciliation</h1> <h1>Payment Reconciliation</h1>
@@ -467,7 +503,7 @@
{{ cell.text }} {{ cell.text }}
{% if cell.status == 'unpaid' or cell.status == 'partial' %} {% if cell.status == 'unpaid' or cell.status == 'partial' %}
<button class="pay-btn" <button class="pay-btn"
onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}')">Pay</button> onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}', '{{ cell.raw_month }}')">Pay</button>
{% endif %} {% endif %}
</td> </td>
{% endfor %} {% endfor %}
@@ -475,7 +511,7 @@
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }} {{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
{% if row.balance < 0 %} {% if row.balance < 0 %}
<button class="pay-btn" <button class="pay-btn"
onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}')">Pay All</button> onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}', '{{ row.raw_unpaid_periods|e }}')">Pay All</button>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@@ -599,7 +635,7 @@
{% set rt = get_render_time() %} {% set rt = get_render_time() %}
<div class="footer" <div class="footer"
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')"> onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
render time: {{ rt.total }}s {{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
<div id="perf-details" class="perf-breakdown"> <div id="perf-details" class="perf-breakdown">
{{ rt.breakdown }} {{ rt.breakdown }}
</div> </div>
@@ -809,9 +845,13 @@
showMemberDetails(nextName); showMemberDetails(nextName);
} }
} }
function showPayQR(name, amount, month) { function showPayQR(name, amount, month, rawMonth) {
const account = "{{ bank_account }}"; const account = "{{ bank_account }}";
const message = `${name} / ${month}`; // Convert YYYY-MM to MM/YYYY for infer_payments.py compatibility
const numericMonth = rawMonth.includes('+')
? rawMonth.split('+').map(p => p.replace(/(\d{4})-(\d{2})/, '$2/$1')).join('+')
: rawMonth.replace(/(\d{4})-(\d{2})/, '$2/$1');
const message = `${name}: ${numericMonth}`;
const qrTitle = document.getElementById('qrTitle'); const qrTitle = document.getElementById('qrTitle');
const qrImg = document.getElementById('qrImg'); const qrImg = document.getElementById('qrImg');
const qrAccount = document.getElementById('qrAccount'); const qrAccount = document.getElementById('qrAccount');

156
templates/sync.html Normal file
View File

@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FUJ - Sync Bank Data</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;
}
.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;
}
.output-container {
background-color: #111;
border: 1px solid #333;
padding: 15px;
width: 100%;
max-width: 1200px;
margin-bottom: 30px;
overflow-x: auto;
}
.output-container pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
color: {% if success %}#cccccc{% else %}#ff6666{% endif %};
}
.status {
margin-bottom: 15px;
font-size: 12px;
}
.status-ok { color: #00ff00; }
.status-error { color: #ff6666; }
.footer {
text-align: center;
color: #333;
margin-top: 20px;
font-size: 10px;
}
</style>
</head>
<body>
<div class="nav">
<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">[Payments Ledger]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Tools:</span>
<a href="/sync-bank" class="active">[Sync Bank Data]</a>
</div>
</div>
<h1>Sync Bank Data</h1>
<div class="status">
{% if success %}
<span class="status-ok">Sync completed successfully.</span>
{% else %}
<span class="status-error">Sync failed - see output below.</span>
{% endif %}
</div>
<div class="output-container">
<pre>{{ output }}</pre>
</div>
<div class="footer">
{{ build_meta.tag }} | {{ build_meta.commit }} | {{ build_meta.build_date }}
</div>
</body>
</html>

View File

@@ -17,7 +17,7 @@ class TestWebApp(unittest.TestCase):
"""Test that / returns the refresh meta tag""" """Test that / returns the refresh meta tag"""
response = self.client.get('/') response = self.client.get('/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn(b'url=/fees', response.data) self.assertIn(b'url=/adults', response.data)
@patch('app.get_cached_data', side_effect=_bypass_cache) @patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.get_members_with_fees') @patch('app.get_members_with_fees')
@@ -130,5 +130,65 @@ class TestWebApp(unittest.TestCase):
self.assertIn(b'OK', response.data) self.assertIn(b'OK', response.data)
self.assertIn(b'?', 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__': if __name__ == '__main__':
unittest.main() unittest.main()